From 8392cdd8614ee999a0e61d9c30e73c50083b51c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Sun, 22 Feb 2026 18:55:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 17 + .gitignore | 25 + README.md | 87 ++ changes.md | 856 ++++++++++++++++++ easy-agents-bom/pom.xml | 232 +++++ .../easy-agents-chat-deepseek/pom.xml | 30 + .../llm/deepseek/DeepseekChatModel.java | 52 ++ .../llm/deepseek/DeepseekConfig.java | 36 + .../easyagents/llm/deepseek/DeepseekTest.java | 92 ++ .../easy-agents-chat-ollama/pom.xml | 34 + .../llm/ollama/OllamaChatConfig.java | 32 + .../llm/ollama/OllamaChatModel.java | 56 ++ .../llm/ollama/OllamaRequestSpecBuilder.java | 21 + .../llm/ollama/OllamaChatModelTest.java | 94 ++ .../llm/ollama/WeatherFunctions.java | 22 + .../easy-agents-chat-openai/pom.xml | 32 + .../llm/openai/OpenAIChatConfig.java | 214 +++++ .../llm/openai/OpenAIChatModel.java | 62 ++ .../llm/openai/ChatModelTestUtils.java | 86 ++ .../llm/openai/GiteeAiImageTest.java | 53 ++ .../llm/openai/OpenAIChatModelTest.java | 500 ++++++++++ .../llm/openai/WeatherFunctions.java | 23 + .../easy-agents-chat-qwen/pom.xml | 33 + .../easyagents/llm/qwen/QwenChatConfig.java | 32 + .../easyagents/llm/qwen/QwenChatModel.java | 55 ++ .../easyagents/llm/qwen/QwenChatOptions.java | 303 +++++++ .../llm/qwen/QwenRequestSpecBuilder.java | 30 + .../easyagents/llm/qwen/test/QwenTest.java | 113 +++ .../llm/qwen/test/WeatherFunctions.java | 14 + easy-agents-chat/pom.xml | 29 + easy-agents-core/pom.xml | 120 +++ .../main/java/com/easyagents/core/Consts.java | 21 + .../com/easyagents/core/agent/IAgent.java | 22 + .../core/agent/react/ReActAgent.java | 564 ++++++++++++ .../core/agent/react/ReActAgentListener.java | 149 +++ .../core/agent/react/ReActAgentState.java | 112 +++ .../core/agent/react/ReActAgentTool.java | 123 +++ .../core/agent/react/ReActMessageBuilder.java | 105 +++ .../core/agent/react/ReActStep.java | 66 ++ .../core/agent/react/ReActStepParser.java | 137 +++ .../com/easyagents/core/agent/react/Util.java | 179 ++++ .../core/agent/route/RoutingAgent.java | 166 ++++ .../core/agent/route/RoutingAgentFactory.java | 28 + .../agent/route/RoutingAgentRegistry.java | 75 ++ .../core/convert/BigDecimalConverter.java | 24 + .../core/convert/BigIntegerConverter.java | 23 + .../core/convert/BooleanConverter.java | 30 + .../core/convert/ByteArrayConverter.java | 23 + .../core/convert/ByteConverter.java | 23 + .../core/convert/ConvertException.java | 19 + .../core/convert/ConvertService.java | 98 ++ .../core/convert/DoubleConverter.java | 23 + .../core/convert/FloatConverter.java | 23 + .../easyagents/core/convert/IConverter.java | 27 + .../core/convert/IntegerConverter.java | 23 + .../core/convert/LongConverter.java | 24 + .../core/convert/ShortConverter.java | 23 + .../easyagents/core/document/Document.java | 100 ++ .../core/document/DocumentSplitter.java | 41 + .../core/document/id/DocumentIdGenerator.java | 29 + .../id/DocumentIdGeneratorFactory.java | 47 + .../core/document/id/MD5IdGenerator.java | 33 + .../core/document/id/RandomIdGenerator.java | 34 + .../document/splitter/AIDocumentSplitter.java | 176 ++++ .../splitter/MarkdownHeaderSplitter.java | 245 +++++ .../splitter/RegexDocumentSplitter.java | 56 ++ .../splitter/SimpleDocumentSplitter.java | 97 ++ .../splitter/SimpleTokenizeSplitter.java | 149 +++ .../core/file2text/File2TextService.java | 132 +++ .../core/file2text/File2TextUtil.java | 62 ++ .../extractor/ExtractorRegistry.java | 67 ++ .../file2text/extractor/FileExtractor.java | 39 + .../extractor/impl/DocExtractor.java | 103 +++ .../extractor/impl/DocxExtractor.java | 151 +++ .../extractor/impl/HtmlExtractor.java | 339 +++++++ .../extractor/impl/PdfTextExtractor.java | 86 ++ .../extractor/impl/PlainTextExtractor.java | 109 +++ .../extractor/impl/PptxExtractor.java | 146 +++ .../source/ByteArrayDocumentSource.java | 50 + .../source/ByteStreamDocumentSource.java | 54 ++ .../core/file2text/source/DocumentSource.java | 30 + .../file2text/source/FileDocumentSource.java | 55 ++ .../file2text/source/HttpDocumentSource.java | 281 ++++++ .../TemporaryFileStreamDocumentSource.java | 149 +++ .../core/file2text/util/IOUtils.java | 48 + .../easyagents/core/memory/ChatMemory.java | 36 + .../core/memory/DefaultChatMemory.java | 65 ++ .../com/easyagents/core/memory/Memory.java | 24 + .../easyagents/core/memory/package-info.java | 20 + .../core/message/AbstractTextMessage.java | 45 + .../easyagents/core/message/AiMessage.java | 351 +++++++ .../com/easyagents/core/message/Message.java | 44 + .../core/message/SystemMessage.java | 55 ++ .../com/easyagents/core/message/ToolCall.java | 115 +++ .../easyagents/core/message/ToolMessage.java | 57 ++ .../easyagents/core/message/UserMessage.java | 210 +++++ .../easyagents/core/message/package-info.java | 20 + .../core/model/chat/BaseChatModel.java | 271 ++++++ .../core/model/chat/ChatConfig.java | 203 +++++ .../core/model/chat/ChatContext.java | 80 ++ .../core/model/chat/ChatContextHolder.java | 99 ++ .../core/model/chat/ChatInterceptor.java | 37 + .../easyagents/core/model/chat/ChatModel.java | 60 ++ .../chat/ChatObservabilityInterceptor.java | 210 +++++ .../core/model/chat/ChatOptions.java | 453 +++++++++ .../model/chat/GlobalChatInterceptors.java | 114 +++ .../core/model/chat/MessageResponse.java | 22 + .../model/chat/OpenAICompatibleChatModel.java | 71 ++ .../core/model/chat/StreamChain.java | 21 + .../model/chat/StreamResponseListener.java | 39 + .../easyagents/core/model/chat/SyncChain.java | 23 + .../model/chat/log/ChatMessageLogger.java | 40 + .../chat/log/DefaultChatMessageLogger.java | 65 ++ .../model/chat/log/IChatMessageLogger.java | 24 + .../core/model/chat/package-info.java | 20 + .../response/AbstractBaseMessageResponse.java | 59 ++ .../chat/response/AiMessageResponse.java | 163 ++++ .../core/model/chat/tool/BaseTool.java | 52 ++ .../core/model/chat/tool/FunctionTool.java | 40 + .../chat/tool/GlobalToolInterceptors.java | 49 + .../core/model/chat/tool/MethodParameter.java | 29 + .../core/model/chat/tool/MethodTool.java | 97 ++ .../core/model/chat/tool/Parameter.java | 167 ++++ .../easyagents/core/model/chat/tool/Tool.java | 72 ++ .../core/model/chat/tool/ToolChain.java | 20 + .../core/model/chat/tool/ToolContext.java | 65 ++ .../model/chat/tool/ToolContextHolder.java | 60 ++ .../core/model/chat/tool/ToolExecutor.java | 117 +++ .../core/model/chat/tool/ToolInterceptor.java | 34 + .../tool/ToolObservabilityInterceptor.java | 206 +++++ .../core/model/chat/tool/ToolScanner.java | 78 ++ .../model/chat/tool/annotation/ToolDef.java | 26 + .../model/chat/tool/annotation/ToolParam.java | 32 + .../client/BaseStreamClientListener.java | 142 +++ .../core/model/client/ChatClient.java | 33 + .../model/client/ChatMessageSerializer.java | 54 ++ .../core/model/client/ChatRequestSpec.java | 91 ++ .../model/client/ChatRequestSpecBuilder.java | 24 + .../core/model/client/HttpClient.java | 328 +++++++ .../core/model/client/OkHttpClientUtil.java | 253 ++++++ .../core/model/client/OpenAIChatClient.java | 143 +++ .../client/OpenAIChatMessageSerializer.java | 292 ++++++ .../client/OpenAIChatRequestSpecBuilder.java | 140 +++ .../core/model/client/StreamClient.java | 27 + .../model/client/StreamClientListener.java | 28 + .../core/model/client/StreamContext.java | 68 ++ .../core/model/client/impl/DnjsonClient.java | 176 ++++ .../core/model/client/impl/SseClient.java | 133 +++ .../core/model/client/impl/Util.java | 54 ++ .../model/client/impl/WebSocketClient.java | 158 ++++ .../core/model/config/BaseModelConfig.java | 153 ++++ .../model/embedding/BaseEmbeddingModel.java | 31 + .../core/model/embedding/EmbeddingModel.java | 45 + .../model/embedding/EmbeddingOptions.java | 97 ++ .../core/model/exception/ModelException.java | 92 ++ .../core/model/image/BaseImageModel.java | 31 + .../core/model/image/BaseImageRequest.java | 113 +++ .../core/model/image/EditImageRequest.java | 38 + .../model/image/GenerateImageRequest.java | 56 ++ .../easyagents/core/model/image/Image.java | 106 +++ .../core/model/image/ImageModel.java | 28 + .../core/model/image/ImageResponse.java | 87 ++ .../core/model/image/VaryImageRequest.java | 29 + .../core/model/rerank/BaseRerankModel.java | 35 + .../core/model/rerank/RerankConfig.java | 65 ++ .../core/model/rerank/RerankException.java | 92 ++ .../core/model/rerank/RerankModel.java | 31 + .../core/model/rerank/RerankOptions.java | 54 ++ .../core/observability/Observability.java | 343 +++++++ .../core/parser/AiMessageParser.java | 23 + .../core/parser/JSONArrayParser.java | 22 + .../core/parser/JSONObjectParser.java | 22 + .../com/easyagents/core/parser/Parser.java | 33 + .../parser/impl/DefaultAiMessageParser.java | 266 ++++++ .../easyagents/core/prompt/MemoryPrompt.java | 188 ++++ .../com/easyagents/core/prompt/Prompt.java | 28 + .../core/prompt/PromptTemplate.java | 347 +++++++ .../easyagents/core/prompt/SimplePrompt.java | 175 ++++ .../easyagents/core/prompt/package-info.java | 20 + .../easyagents/core/store/DocumentStore.java | 150 +++ .../core/store/DocumentStoreConfig.java | 23 + .../easyagents/core/store/SearchWrapper.java | 340 +++++++ .../easyagents/core/store/StoreOptions.java | 132 +++ .../easyagents/core/store/StoreResult.java | 80 ++ .../com/easyagents/core/store/VectorData.java | 122 +++ .../easyagents/core/store/VectorStore.java | 172 ++++ .../core/store/condition/Condition.java | 161 ++++ .../core/store/condition/ConditionType.java | 39 + .../core/store/condition/Connector.java | 65 ++ .../store/condition/ExpressionAdaptor.java | 79 ++ .../core/store/condition/Group.java | 71 ++ .../easyagents/core/store/condition/Key.java | 38 + .../easyagents/core/store/condition/Not.java | 24 + .../core/store/condition/Operand.java | 23 + .../core/store/condition/Value.java | 54 ++ .../core/store/exception/StoreException.java | 34 + .../com/easyagents/core/util/ArrayUtil.java | 132 +++ .../com/easyagents/core/util/ClassUtil.java | 208 +++++ .../easyagents/core/util/CollectionUtil.java | 50 + .../com/easyagents/core/util/Copyable.java | 31 + .../com/easyagents/core/util/HashUtil.java | 70 ++ .../java/com/easyagents/core/util/IOUtil.java | 73 ++ .../com/easyagents/core/util/ImageUtil.java | 113 +++ .../com/easyagents/core/util/JSONUtil.java | 133 +++ .../core/util/LocalTokenCounter.java | 129 +++ .../com/easyagents/core/util/MapUtil.java | 70 ++ .../java/com/easyagents/core/util/Maps.java | 155 ++++ .../com/easyagents/core/util/MessageUtil.java | 38 + .../com/easyagents/core/util/Metadata.java | 66 ++ .../core/util/NamedThreadFactory.java | 59 ++ .../core/util/NamedThreadPools.java | 61 ++ .../easyagents/core/util/RetryException.java | 92 ++ .../com/easyagents/core/util/Retryer.java | 216 +++++ .../com/easyagents/core/util/StringUtil.java | 72 ++ .../easyagents/core/util/package-info.java | 20 + .../easyagents/core/message/ToolCallTest.java | 86 ++ .../core/test/PromptTemplateTest.java | 77 ++ .../core/test/SearchWrapperTest.java | 71 ++ .../splitter/MarkdownHeaderSplitterTest.java | 37 + .../splitter/SimpleDocumentSplitterTest.java | 73 ++ .../splitter/SimpleTokenizeSplitterTest.java | 73 ++ .../easyagents/core/test/tool/ToolTest.java | 24 + .../core/test/util/HttpClientTest.java | 15 + .../easyagents/core/test/util/MapsTest.java | 19 + .../easy-agents-embedding-ollama/pom.xml | 27 + .../ollama/OllamaEmbeddingConfig.java | 34 + .../ollama/OllamaEmbeddingModel.java | 92 ++ .../easy-agents-embedding-openai/pom.xml | 27 + .../openai/OpenAIEmbeddingConfig.java | 75 ++ .../openai/OpenAIEmbeddingModel.java | 87 ++ .../easy-agents-embedding-qwen/pom.xml | 27 + .../embedding/qwen/QwenEmbeddingConfig.java | 33 + .../embedding/qwen/QwenEmbeddingModel.java | 87 ++ easy-agents-embedding/pom.xml | 27 + easy-agents-flow/pom.xml | 67 ++ .../com/easyagents/flow/core/chain/Chain.java | 748 +++++++++++++++ .../flow/core/chain/ChainConsts.java | 24 + .../flow/core/chain/ChainDefinition.java | 212 +++++ .../flow/core/chain/ChainException.java | 91 ++ .../flow/core/chain/ChainState.java | 486 ++++++++++ .../flow/core/chain/ChainStatus.java | 151 +++ .../core/chain/ChainSuspendException.java | 32 + .../chain/ChainUpdateTimeoutException.java | 92 ++ .../easyagents/flow/core/chain/DataType.java | 56 ++ .../com/easyagents/flow/core/chain/Edge.java | 64 ++ .../flow/core/chain/EdgeCondition.java | 25 + .../com/easyagents/flow/core/chain/Event.java | 19 + .../flow/core/chain/EventManager.java | 135 +++ .../flow/core/chain/ExceptionSummary.java | 137 +++ .../flow/core/chain/JsCodeCondition.java | 58 ++ .../com/easyagents/flow/core/chain/Node.java | 245 +++++ .../flow/core/chain/NodeCondition.java | 25 + .../easyagents/flow/core/chain/NodeState.java | 193 ++++ .../flow/core/chain/NodeStatus.java | 45 + .../flow/core/chain/NodeValidResult.java | 213 +++++ .../flow/core/chain/NodeValidator.java | 21 + .../easyagents/flow/core/chain/Parameter.java | 304 +++++++ .../easyagents/flow/core/chain/RefType.java | 50 + .../flow/core/chain/event/BaseEvent.java | 33 + .../flow/core/chain/event/ChainEndEvent.java | 33 + .../core/chain/event/ChainResumeEvent.java | 43 + .../core/chain/event/ChainStartEvent.java | 43 + .../chain/event/ChainStatusChangeEvent.java | 50 + .../event/EdgeConditionCheckFailedEvent.java | 48 + .../core/chain/event/EdgeTriggerEvent.java | 33 + .../flow/core/chain/event/NodeEndEvent.java | 58 ++ .../flow/core/chain/event/NodeStartEvent.java | 43 + .../chain/listener/ChainErrorListener.java | 23 + .../chain/listener/ChainEventListener.java | 24 + .../chain/listener/ChainOutputListener.java | 24 + .../chain/listener/NodeErrorListener.java | 26 + .../flow/core/chain/package-info.java | 20 + .../repository/ChainDefinitionRepository.java | 23 + .../flow/core/chain/repository/ChainLock.java | 33 + .../repository/ChainLockTimeoutException.java | 673 ++++++++++++++ .../chain/repository/ChainStateField.java | 38 + .../chain/repository/ChainStateModifier.java | 25 + .../repository/ChainStateRepository.java | 41 + .../InMemoryChainStateRepository.java | 42 + .../InMemoryNodeStateRepository.java | 45 + .../core/chain/repository/LocalChainLock.java | 98 ++ .../core/chain/repository/NodeStateField.java | 31 + .../chain/repository/NodeStateModifier.java | 25 + .../chain/repository/NodeStateRepository.java | 27 + .../core/chain/runtime/ChainExecutor.java | 310 +++++++ .../flow/core/chain/runtime/ChainRuntime.java | 58 ++ .../chain/runtime/InMemoryTriggerStore.java | 57 ++ .../flow/core/chain/runtime/Trigger.java | 92 ++ .../core/chain/runtime/TriggerContext.java | 29 + .../core/chain/runtime/TriggerScheduler.java | 246 +++++ .../flow/core/chain/runtime/TriggerStore.java | 30 + .../flow/core/chain/runtime/TriggerType.java | 31 + .../flow/core/code/CodeRuntimeEngine.java | 25 + .../core/code/CodeRuntimeEngineManager.java | 62 ++ .../core/code/CodeRuntimeEngineProvider.java | 20 + .../code/impl/GraalvmToFastJSONUtils.java | 144 +++ .../code/impl/JavascriptRuntimeEngine.java | 81 ++ .../flow/core/filestoreage/FileStorage.java | 28 + .../core/filestoreage/FileStorageManager.java | 53 ++ .../filestoreage/FileStorageProvider.java | 21 + .../flow/core/knowledge/Knowledge.java | 26 + .../flow/core/knowledge/KnowledgeManager.java | 54 ++ .../core/knowledge/KnowledgeProvider.java | 20 + .../com/easyagents/flow/core/llm/Llm.java | 206 +++++ .../easyagents/flow/core/llm/LlmManager.java | 53 ++ .../easyagents/flow/core/llm/LlmProvider.java | 23 + .../easyagents/flow/core/node/BaseNode.java | 67 ++ .../easyagents/flow/core/node/CodeNode.java | 63 ++ .../flow/core/node/ConfirmNode.java | 145 +++ .../easyagents/flow/core/node/EndNode.java | 105 +++ .../easyagents/flow/core/node/HttpNode.java | 370 ++++++++ .../flow/core/node/KnowledgeNode.java | 111 +++ .../easyagents/flow/core/node/LlmNode.java | 214 +++++ .../easyagents/flow/core/node/LoopNode.java | 253 ++++++ .../flow/core/node/SearchEngineNode.java | 112 +++ .../easyagents/flow/core/node/StartNode.java | 33 + .../flow/core/node/TemplateNode.java | 91 ++ .../flow/core/parser/BaseNodeParser.java | 205 +++++ .../flow/core/parser/ChainParser.java | 201 ++++ .../flow/core/parser/DefaultNodeParsers.java | 53 ++ .../flow/core/parser/NodeParser.java | 27 + .../flow/core/parser/impl/CodeNodeParser.java | 32 + .../core/parser/impl/ConfirmNodeParser.java | 40 + .../flow/core/parser/impl/EndNodeParser.java | 30 + .../flow/core/parser/impl/HttpNodeParser.java | 48 + .../core/parser/impl/KnowledgeNodeParser.java | 33 + .../flow/core/parser/impl/LlmNodeParser.java | 40 + .../flow/core/parser/impl/LoopNodeParser.java | 47 + .../parser/impl/SearchEngineNodeParser.java | 40 + .../core/parser/impl/StartNodeParser.java | 28 + .../core/parser/impl/TemplateNodeParser.java | 30 + .../core/searchengine/BaseSearchEngine.java | 67 ++ .../flow/core/searchengine/SearchEngine.java | 28 + .../searchengine/SearchEngineManager.java | 62 ++ .../searchengine/SearchEngineProvider.java | 20 + .../impl/BochaaiSearchEngineImpl.java | 75 ++ .../flow/core/util/CollectionUtil.java | 50 + .../com/easyagents/flow/core/util/IOUtil.java | 72 ++ .../flow/core/util/IterableUtil.java | 64 ++ .../flow/core/util/JsConditionUtil.java | 190 ++++ .../easyagents/flow/core/util/MapUtil.java | 130 +++ .../com/easyagents/flow/core/util/Maps.java | 156 ++++ .../flow/core/util/NamedThreadFactory.java | 59 ++ .../flow/core/util/NamedThreadPools.java | 61 ++ .../flow/core/util/OKHttpClientWrapper.java | 199 ++++ .../flow/core/util/OkHttpClientUtil.java | 165 ++++ .../easyagents/flow/core/util/StringUtil.java | 78 ++ .../flow/core/util/TextTemplate.java | 356 ++++++++ .../core/util/graalvm/JsInteropUtils.java | 101 +++ .../flow/core/util/graalvm/ProxyList.java | 60 ++ .../flow/core/util/graalvm/ProxyMap.java | 56 ++ .../flow/core/test/ChainAsyncStringTest.java | 72 ++ .../flow/core/test/ChainEvnTest.java | 20 + .../flow/core/test/TinyflowTest.java | 71 ++ .../easy-agents-image-gitee/pom.xml | 33 + .../image/gitee/GiteeImageModel.java | 87 ++ .../image/gitee/GiteeImageModelConfig.java | 49 + .../image/test/GiteeImageModelTest.java | 51 ++ .../easy-agents-image-openai/pom.xml | 33 + .../image/openai/OpenAIImageModel.java | 89 ++ .../image/openai/OpenAIImageModelConfig.java | 29 + .../image/test/OpenAIImageModelTest.java | 38 + .../easy-agents-image-qianfan/pom.xml | 45 + .../image/qianfan/QianfanImageModel.java | 85 ++ .../qianfan/QianfanImageModelConfig.java | 64 ++ .../image/test/QianfanImageModelTest.java | 22 + .../easy-agents-image-qwen/pom.xml | 38 + .../easyagents/image/qwen/QwenImageModel.java | 80 ++ .../image/qwen/QwenImageModelConfig.java | 49 + .../image/test/QwenImageModelTest.java | 54 ++ .../easy-agents-image-siliconflow/pom.xml | 35 + .../image/siliconflow/SiliconImageModel.java | 92 ++ .../SiliconflowImageModelConfig.java | 77 ++ .../siliconflow/SiliconflowImageModels.java | 39 + .../test/SiliconflowImageModelTest.java | 53 ++ .../easy-agents-image-stability/pom.xml | 32 + .../image/stability/StabilityImageModel.java | 78 ++ .../stability/StabilityImageModelConfig.java | 37 + .../image/test/StabilityImageModelTest.java | 38 + .../easy-agents-image-tencent/pom.xml | 37 + .../image/tencent/TencentImageModel.java | 237 +++++ .../tencent/TencentImageModelConfig.java | 78 ++ .../image/test/TencentImageModelTest.java | 59 ++ .../easy-agents-image-volcengine/pom.xml | 54 ++ .../volcengine/VolcengineImageModel.java | 80 ++ .../VolcengineImageModelConfig.java | 40 + .../src/test/java/VolcengineImageTest.java | 99 ++ .../src/test/java/VolcengineTest.java | 52 ++ easy-agents-image/pom.xml | 32 + easy-agents-mcp/pom.xml | 66 ++ .../mcp/client/CloseableTransport.java | 24 + .../mcp/client/HttpSseTransportFactory.java | 53 ++ .../client/HttpStreamTransportFactory.java | 51 ++ .../mcp/client/McpCallException.java | 78 ++ .../mcp/client/McpClientDescriptor.java | 233 +++++ .../mcp/client/McpClientManager.java | 236 +++++ .../com/easyagents/mcp/client/McpConfig.java | 121 +++ .../com/easyagents/mcp/client/McpTool.java | 131 +++ .../mcp/client/McpTransportFactory.java | 23 + .../mcp/client/StdioTransportFactory.java | 93 ++ .../src/main/resources/mcp-servers.json | 11 + .../mcp/client/McpClientManagerTest.java | 397 ++++++++ .../easy-agents-rerank-default/pom.xml | 33 + .../easyagents/rerank/DefaultRerankModel.java | 130 +++ .../rerank/DefaultRerankModelConfig.java | 51 ++ .../rereank/gitee/DefaultRerankModelTest.java | 51 ++ .../easy-agents-rerank-gitee/pom.xml | 39 + .../rerank/gitee/GiteeRerankModel.java | 26 + .../rerank/gitee/GiteeRerankModelConfig.java | 32 + .../rereank/gitee/GiteeRerankModelTest.java | 48 + easy-agents-rerank/pom.xml | 27 + .../easy-agents-helloworld/.gitignore | 39 + .../easy-agents-helloworld/pom.xml | 25 + .../src/main/java/com/easyagents/Main.java | 20 + easy-agents-samples/readme.md | 3 + .../easy-agents-search-engine-es/pom.xml | 42 + .../com/easyagents/engine/es/ESConfig.java | 56 ++ .../easyagents/engine/es/ElasticSearcher.java | 223 +++++ .../engines/es/ElasticSearcherTest.java | 42 + .../easy-agents-search-engine-lucene/pom.xml | 55 ++ .../search/engine/lucene/LuceneConfig.java | 29 + .../search/engine/lucene/LuceneSearcher.java | 212 +++++ .../engines/test/TestLuceneCRUD.java | 96 ++ .../easy-agents-search-engine-service/pom.xml | 26 + .../engine/service/DocumentSearcher.java | 35 + easy-agents-search-engine/pom.xml | 26 + easy-agents-spring-boot-starter/pom.xml | 56 ++ .../deepseek/DeepSeekAutoConfiguration.java | 30 + .../boot/llm/deepseek/DeepSeekProperties.java | 36 + .../llm/ollama/OllamaAutoConfiguration.java | 33 + .../boot/llm/ollama/OllamaProperties.java | 49 + .../llm/openai/OpenAIAutoConfiguration.java | 33 + .../boot/llm/openai/OpenAIProperties.java | 48 + .../boot/llm/qwen/QwenAutoConfiguration.java | 32 + .../spring/boot/llm/qwen/QwenProperties.java | 40 + .../store/aliyun/AliyunAutoConfiguration.java | 31 + .../boot/store/aliyun/AliyunProperties.java | 49 + .../store/chroma/ChromaAutoConfiguration.java | 51 ++ .../boot/store/chroma/ChromaProperties.java | 97 ++ .../ElasticSearchAutoConfiguration.java | 52 ++ .../ElasticSearchProperties.java | 76 ++ .../OpenSearchAutoConfiguration.java | 52 ++ .../opensearch/OpenSearchProperties.java | 76 ++ .../boot/store/qcloud/QCloudProperties.java | 58 ++ .../qcloud/QCloudStoreAutoConfiguration.java | 32 + .../main/resources/META-INF/spring.factories | 10 + ...ot.autoconfigure.AutoConfiguration.imports | 8 + .../easy-agents-store-aliyun/pom.xml | 35 + .../store/aliyun/AliyunVectorStore.java | 221 +++++ .../store/aliyun/AliyunVectorStoreConfig.java | 67 ++ .../easy-agents-store-chroma/pom.xml | 47 + .../store/chroma/ChromaExpressionAdaptor.java | 101 +++ .../store/chroma/ChromaVectorStore.java | 794 ++++++++++++++++ .../store/chroma/ChromaVectorStoreConfig.java | 203 +++++ .../store/chroma/ChromaVectorStoreTest.java | 383 ++++++++ .../easy-agents-store-elasticsearch/pom.xml | 49 + .../ElasticSearchVectorStore.java | 282 ++++++ .../ElasticSearchVectorStoreConfig.java | 82 ++ .../ElasticSearchVectorStoreTest.java | 99 ++ .../easy-agents-store-opensearch/pom.xml | 56 ++ .../opensearch/OpenSearchVectorStore.java | 264 ++++++ .../OpenSearchVectorStoreConfig.java | 85 ++ .../opensearch/OpenSearchVectorStoreTest.java | 97 ++ .../easy-agents-store-pgvector/pom.xml | 41 + .../store/pgvector/PgvectorUtil.java | 63 ++ .../store/pgvector/PgvectorVectorStore.java | 231 +++++ .../pgvector/PgvectorVectorStoreConfig.java | 127 +++ .../store/pgvector/PgvectorDbTest.java | 134 +++ .../easy-agents-store-qcloud/pom.xml | 28 + .../store/qcloud/QCloudVectorStore.java | 177 ++++ .../store/qcloud/QCloudVectorStoreConfig.java | 75 ++ .../easy-agents-store-qdrant/pom.xml | 61 ++ .../store/qdrant/QdrantVectorStore.java | 186 ++++ .../store/qdrant/QdrantVectorStoreConfig.java | 73 ++ .../store/qdrant/QdrantVectorStoreTest.java | 65 ++ .../easy-agents-store-redis/pom.xml | 36 + .../store/redis/RedisVectorStore.java | 256 ++++++ .../store/redis/RedisVectorStoreConfig.java | 58 ++ .../easy-agents-store-vectorex/pom.xml | 36 + .../store/vectorex/VectoRexStore.java | 158 ++++ .../store/vectorex/VectoRexStoreConfig.java | 76 ++ .../easy-agents-store-vectorexdb/pom.xml | 41 + .../store/vectorex/VectoRexStore.java | 156 ++++ .../store/vectorex/VectoRexStoreConfig.java | 56 ++ .../easyagents/store/vectorex/test/Test.java | 63 ++ easy-agents-store/pom.xml | 35 + easy-agents-support/pom.xml | 35 + .../flow/support/provider/EasyAgentsLlm.java | 69 ++ .../easy-agents-tool-javascript/pom.xml | 21 + .../easy-agents-tool-python/pom.xml | 21 + .../easy-agents-tool-shell/pom.xml | 21 + easy-agents-tool/pom.xml | 27 + pom.xml | 545 +++++++++++ testresource/a.doc | Bin 0 -> 141824 bytes testresource/a.pdf | Bin 0 -> 180897 bytes testresource/ark_demo_img_1.png | Bin 0 -> 90493 bytes 496 files changed, 45020 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 README.md create mode 100644 changes.md create mode 100644 easy-agents-bom/pom.xml create mode 100644 easy-agents-chat/easy-agents-chat-deepseek/pom.xml create mode 100644 easy-agents-chat/easy-agents-chat-deepseek/src/main/java/com/easyagents/llm/deepseek/DeepseekChatModel.java create mode 100644 easy-agents-chat/easy-agents-chat-deepseek/src/main/java/com/easyagents/llm/deepseek/DeepseekConfig.java create mode 100644 easy-agents-chat/easy-agents-chat-deepseek/src/test/java/com/easyagents/llm/deepseek/DeepseekTest.java create mode 100644 easy-agents-chat/easy-agents-chat-ollama/pom.xml create mode 100644 easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaChatConfig.java create mode 100644 easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaChatModel.java create mode 100644 easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaRequestSpecBuilder.java create mode 100644 easy-agents-chat/easy-agents-chat-ollama/src/test/java/com/easyagents/llm/ollama/OllamaChatModelTest.java create mode 100644 easy-agents-chat/easy-agents-chat-ollama/src/test/java/com/easyagents/llm/ollama/WeatherFunctions.java create mode 100644 easy-agents-chat/easy-agents-chat-openai/pom.xml create mode 100644 easy-agents-chat/easy-agents-chat-openai/src/main/java/com/easyagents/llm/openai/OpenAIChatConfig.java create mode 100644 easy-agents-chat/easy-agents-chat-openai/src/main/java/com/easyagents/llm/openai/OpenAIChatModel.java create mode 100644 easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/ChatModelTestUtils.java create mode 100644 easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/GiteeAiImageTest.java create mode 100644 easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/OpenAIChatModelTest.java create mode 100644 easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/WeatherFunctions.java create mode 100644 easy-agents-chat/easy-agents-chat-qwen/pom.xml create mode 100644 easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatConfig.java create mode 100644 easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatModel.java create mode 100644 easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatOptions.java create mode 100644 easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenRequestSpecBuilder.java create mode 100644 easy-agents-chat/easy-agents-chat-qwen/src/test/java/com/easyagents/llm/qwen/test/QwenTest.java create mode 100644 easy-agents-chat/easy-agents-chat-qwen/src/test/java/com/easyagents/llm/qwen/test/WeatherFunctions.java create mode 100644 easy-agents-chat/pom.xml create mode 100644 easy-agents-core/pom.xml create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/Consts.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/IAgent.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgent.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentListener.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentState.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentTool.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActMessageBuilder.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActStep.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActStepParser.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/react/Util.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgent.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgentFactory.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgentRegistry.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/BigDecimalConverter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/BigIntegerConverter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/BooleanConverter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/ByteArrayConverter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/ByteConverter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/ConvertException.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/ConvertService.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/DoubleConverter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/FloatConverter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/IConverter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/IntegerConverter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/LongConverter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/convert/ShortConverter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/document/Document.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/document/DocumentSplitter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/document/id/DocumentIdGenerator.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/document/id/DocumentIdGeneratorFactory.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/document/id/MD5IdGenerator.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/document/id/RandomIdGenerator.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/document/splitter/AIDocumentSplitter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/document/splitter/MarkdownHeaderSplitter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/document/splitter/RegexDocumentSplitter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/document/splitter/SimpleDocumentSplitter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/document/splitter/SimpleTokenizeSplitter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/File2TextService.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/File2TextUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/ExtractorRegistry.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/FileExtractor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/DocExtractor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/DocxExtractor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/HtmlExtractor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PdfTextExtractor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PlainTextExtractor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PptxExtractor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/source/ByteArrayDocumentSource.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/source/ByteStreamDocumentSource.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/source/DocumentSource.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/source/FileDocumentSource.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/source/HttpDocumentSource.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/source/TemporaryFileStreamDocumentSource.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/file2text/util/IOUtils.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/memory/ChatMemory.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/memory/DefaultChatMemory.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/memory/Memory.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/memory/package-info.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/message/AbstractTextMessage.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/message/AiMessage.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/message/Message.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/message/SystemMessage.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/message/ToolCall.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/message/ToolMessage.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/message/UserMessage.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/message/package-info.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/BaseChatModel.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatConfig.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatContext.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatContextHolder.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatInterceptor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatModel.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatObservabilityInterceptor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatOptions.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/GlobalChatInterceptors.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/MessageResponse.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/OpenAICompatibleChatModel.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/StreamChain.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/StreamResponseListener.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/SyncChain.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/ChatMessageLogger.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/DefaultChatMessageLogger.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/IChatMessageLogger.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/package-info.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/response/AbstractBaseMessageResponse.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/response/AiMessageResponse.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/BaseTool.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/FunctionTool.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/GlobalToolInterceptors.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/MethodParameter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/MethodTool.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/Parameter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/Tool.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolChain.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolContext.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolContextHolder.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolExecutor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolInterceptor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolObservabilityInterceptor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolScanner.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/annotation/ToolDef.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/annotation/ToolParam.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/BaseStreamClientListener.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatClient.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatMessageSerializer.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatRequestSpec.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatRequestSpecBuilder.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/HttpClient.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/OkHttpClientUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatClient.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatMessageSerializer.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatRequestSpecBuilder.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamClient.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamClientListener.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamContext.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/DnjsonClient.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/SseClient.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/Util.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/WebSocketClient.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/config/BaseModelConfig.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/embedding/BaseEmbeddingModel.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/embedding/EmbeddingModel.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/embedding/EmbeddingOptions.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/exception/ModelException.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/image/BaseImageModel.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/image/BaseImageRequest.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/image/EditImageRequest.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/image/GenerateImageRequest.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/image/Image.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/image/ImageModel.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/image/ImageResponse.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/image/VaryImageRequest.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/rerank/BaseRerankModel.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankConfig.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankException.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankModel.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankOptions.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/observability/Observability.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/parser/AiMessageParser.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/parser/JSONArrayParser.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/parser/JSONObjectParser.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/parser/Parser.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/parser/impl/DefaultAiMessageParser.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/prompt/MemoryPrompt.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/prompt/Prompt.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/prompt/PromptTemplate.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/prompt/SimplePrompt.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/prompt/package-info.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/DocumentStore.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/DocumentStoreConfig.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/SearchWrapper.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/StoreOptions.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/StoreResult.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/VectorData.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/VectorStore.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/condition/Condition.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/condition/ConditionType.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/condition/Connector.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/condition/ExpressionAdaptor.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/condition/Group.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/condition/Key.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/condition/Not.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/condition/Operand.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/condition/Value.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/store/exception/StoreException.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/ArrayUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/ClassUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/CollectionUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/Copyable.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/HashUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/IOUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/ImageUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/JSONUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/LocalTokenCounter.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/MapUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/Maps.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/MessageUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/Metadata.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/NamedThreadFactory.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/NamedThreadPools.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/RetryException.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/Retryer.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/StringUtil.java create mode 100644 easy-agents-core/src/main/java/com/easyagents/core/util/package-info.java create mode 100644 easy-agents-core/src/test/java/com/easyagents/core/message/ToolCallTest.java create mode 100644 easy-agents-core/src/test/java/com/easyagents/core/test/PromptTemplateTest.java create mode 100644 easy-agents-core/src/test/java/com/easyagents/core/test/SearchWrapperTest.java create mode 100644 easy-agents-core/src/test/java/com/easyagents/core/test/splitter/MarkdownHeaderSplitterTest.java create mode 100644 easy-agents-core/src/test/java/com/easyagents/core/test/splitter/SimpleDocumentSplitterTest.java create mode 100644 easy-agents-core/src/test/java/com/easyagents/core/test/splitter/SimpleTokenizeSplitterTest.java create mode 100644 easy-agents-core/src/test/java/com/easyagents/core/test/tool/ToolTest.java create mode 100644 easy-agents-core/src/test/java/com/easyagents/core/test/util/HttpClientTest.java create mode 100644 easy-agents-core/src/test/java/com/easyagents/core/test/util/MapsTest.java create mode 100644 easy-agents-embedding/easy-agents-embedding-ollama/pom.xml create mode 100644 easy-agents-embedding/easy-agents-embedding-ollama/src/main/java/com/easyagents/embedding/ollama/OllamaEmbeddingConfig.java create mode 100644 easy-agents-embedding/easy-agents-embedding-ollama/src/main/java/com/easyagents/embedding/ollama/OllamaEmbeddingModel.java create mode 100644 easy-agents-embedding/easy-agents-embedding-openai/pom.xml create mode 100644 easy-agents-embedding/easy-agents-embedding-openai/src/main/java/com/easyagents/embedding/openai/OpenAIEmbeddingConfig.java create mode 100644 easy-agents-embedding/easy-agents-embedding-openai/src/main/java/com/easyagents/embedding/openai/OpenAIEmbeddingModel.java create mode 100644 easy-agents-embedding/easy-agents-embedding-qwen/pom.xml create mode 100644 easy-agents-embedding/easy-agents-embedding-qwen/src/main/java/com/easyagents/embedding/qwen/QwenEmbeddingConfig.java create mode 100644 easy-agents-embedding/easy-agents-embedding-qwen/src/main/java/com/easyagents/embedding/qwen/QwenEmbeddingModel.java create mode 100644 easy-agents-embedding/pom.xml create mode 100644 easy-agents-flow/pom.xml create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainConsts.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainDefinition.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainException.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainState.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainStatus.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainSuspendException.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainUpdateTimeoutException.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/DataType.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Edge.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EdgeCondition.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Event.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EventManager.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ExceptionSummary.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/JsCodeCondition.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Node.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeCondition.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeState.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeStatus.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidResult.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidator.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Parameter.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/RefType.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/BaseEvent.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainEndEvent.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainResumeEvent.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainStartEvent.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainStatusChangeEvent.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/EdgeConditionCheckFailedEvent.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/EdgeTriggerEvent.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeEndEvent.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeStartEvent.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainErrorListener.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainEventListener.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainOutputListener.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/NodeErrorListener.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/package-info.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainDefinitionRepository.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLock.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLockTimeoutException.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateField.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateModifier.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateRepository.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryChainStateRepository.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryNodeStateRepository.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LocalChainLock.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateField.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateModifier.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateRepository.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainRuntime.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/InMemoryTriggerStore.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/Trigger.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerContext.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerScheduler.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerStore.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerType.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngine.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngineManager.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngineProvider.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/GraalvmToFastJSONUtils.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/JavascriptRuntimeEngine.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorage.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorageManager.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorageProvider.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/Knowledge.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/KnowledgeManager.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/KnowledgeProvider.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/LlmManager.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/LlmProvider.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/node/BaseNode.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/node/CodeNode.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/node/ConfirmNode.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/node/EndNode.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/node/HttpNode.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/node/KnowledgeNode.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LoopNode.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/node/SearchEngineNode.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/node/StartNode.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/node/TemplateNode.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/BaseNodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/ChainParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/DefaultNodeParsers.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/NodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/CodeNodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/ConfirmNodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/EndNodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/HttpNodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/KnowledgeNodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/LlmNodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/LoopNodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/SearchEngineNodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/StartNodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/TemplateNodeParser.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/BaseSearchEngine.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngine.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngineManager.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngineProvider.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/impl/BochaaiSearchEngineImpl.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/CollectionUtil.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IOUtil.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IterableUtil.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/JsConditionUtil.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/MapUtil.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/Maps.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/NamedThreadFactory.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/NamedThreadPools.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OKHttpClientWrapper.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OkHttpClientUtil.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/StringUtil.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/TextTemplate.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/JsInteropUtils.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/ProxyList.java create mode 100644 easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/ProxyMap.java create mode 100644 easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainAsyncStringTest.java create mode 100644 easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainEvnTest.java create mode 100644 easy-agents-flow/src/test/java/com/easyagents/flow/core/test/TinyflowTest.java create mode 100644 easy-agents-image/easy-agents-image-gitee/pom.xml create mode 100644 easy-agents-image/easy-agents-image-gitee/src/main/java/com/easyagents/image/gitee/GiteeImageModel.java create mode 100644 easy-agents-image/easy-agents-image-gitee/src/main/java/com/easyagents/image/gitee/GiteeImageModelConfig.java create mode 100644 easy-agents-image/easy-agents-image-gitee/src/test/java/com/easyagents/image/test/GiteeImageModelTest.java create mode 100644 easy-agents-image/easy-agents-image-openai/pom.xml create mode 100644 easy-agents-image/easy-agents-image-openai/src/main/java/com/easyagents/image/openai/OpenAIImageModel.java create mode 100644 easy-agents-image/easy-agents-image-openai/src/main/java/com/easyagents/image/openai/OpenAIImageModelConfig.java create mode 100644 easy-agents-image/easy-agents-image-openai/src/test/java/com/easyagents/image/test/OpenAIImageModelTest.java create mode 100644 easy-agents-image/easy-agents-image-qianfan/pom.xml create mode 100644 easy-agents-image/easy-agents-image-qianfan/src/main/java/com/easyagents/image/qianfan/QianfanImageModel.java create mode 100644 easy-agents-image/easy-agents-image-qianfan/src/main/java/com/easyagents/image/qianfan/QianfanImageModelConfig.java create mode 100644 easy-agents-image/easy-agents-image-qianfan/src/test/java/com/easyagents/image/test/QianfanImageModelTest.java create mode 100644 easy-agents-image/easy-agents-image-qwen/pom.xml create mode 100644 easy-agents-image/easy-agents-image-qwen/src/main/java/com/easyagents/image/qwen/QwenImageModel.java create mode 100644 easy-agents-image/easy-agents-image-qwen/src/main/java/com/easyagents/image/qwen/QwenImageModelConfig.java create mode 100644 easy-agents-image/easy-agents-image-qwen/src/test/java/com/easyagents/image/test/QwenImageModelTest.java create mode 100644 easy-agents-image/easy-agents-image-siliconflow/pom.xml create mode 100644 easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconImageModel.java create mode 100644 easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconflowImageModelConfig.java create mode 100644 easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconflowImageModels.java create mode 100644 easy-agents-image/easy-agents-image-siliconflow/src/test/java/com/easyagents/image/siliconflow/test/SiliconflowImageModelTest.java create mode 100644 easy-agents-image/easy-agents-image-stability/pom.xml create mode 100644 easy-agents-image/easy-agents-image-stability/src/main/java/com/easyagents/image/stability/StabilityImageModel.java create mode 100644 easy-agents-image/easy-agents-image-stability/src/main/java/com/easyagents/image/stability/StabilityImageModelConfig.java create mode 100644 easy-agents-image/easy-agents-image-stability/src/test/java/com/easyagents/image/test/StabilityImageModelTest.java create mode 100644 easy-agents-image/easy-agents-image-tencent/pom.xml create mode 100644 easy-agents-image/easy-agents-image-tencent/src/main/java/com/easyagents/image/tencent/TencentImageModel.java create mode 100644 easy-agents-image/easy-agents-image-tencent/src/main/java/com/easyagents/image/tencent/TencentImageModelConfig.java create mode 100644 easy-agents-image/easy-agents-image-tencent/src/test/java/com/easyagents/image/test/TencentImageModelTest.java create mode 100644 easy-agents-image/easy-agents-image-volcengine/pom.xml create mode 100644 easy-agents-image/easy-agents-image-volcengine/src/main/java/com/easyagents/image/volcengine/VolcengineImageModel.java create mode 100644 easy-agents-image/easy-agents-image-volcengine/src/main/java/com/easyagents/image/volcengine/VolcengineImageModelConfig.java create mode 100644 easy-agents-image/easy-agents-image-volcengine/src/test/java/VolcengineImageTest.java create mode 100644 easy-agents-image/easy-agents-image-volcengine/src/test/java/VolcengineTest.java create mode 100644 easy-agents-image/pom.xml create mode 100644 easy-agents-mcp/pom.xml create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/CloseableTransport.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/HttpSseTransportFactory.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/HttpStreamTransportFactory.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCallException.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpClientDescriptor.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpClientManager.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpConfig.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpTool.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpTransportFactory.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/StdioTransportFactory.java create mode 100644 easy-agents-mcp/src/main/resources/mcp-servers.json create mode 100644 easy-agents-mcp/src/test/java/com/easyagents/mcp/client/McpClientManagerTest.java create mode 100644 easy-agents-rerank/easy-agents-rerank-default/pom.xml create mode 100644 easy-agents-rerank/easy-agents-rerank-default/src/main/java/com/easyagents/rerank/DefaultRerankModel.java create mode 100644 easy-agents-rerank/easy-agents-rerank-default/src/main/java/com/easyagents/rerank/DefaultRerankModelConfig.java create mode 100644 easy-agents-rerank/easy-agents-rerank-default/src/test/java/com/easyagents/rereank/gitee/DefaultRerankModelTest.java create mode 100644 easy-agents-rerank/easy-agents-rerank-gitee/pom.xml create mode 100644 easy-agents-rerank/easy-agents-rerank-gitee/src/main/java/com/easyagents/rerank/gitee/GiteeRerankModel.java create mode 100644 easy-agents-rerank/easy-agents-rerank-gitee/src/main/java/com/easyagents/rerank/gitee/GiteeRerankModelConfig.java create mode 100644 easy-agents-rerank/easy-agents-rerank-gitee/src/test/java/com/easyagents/rereank/gitee/GiteeRerankModelTest.java create mode 100644 easy-agents-rerank/pom.xml create mode 100644 easy-agents-samples/easy-agents-helloworld/.gitignore create mode 100644 easy-agents-samples/easy-agents-helloworld/pom.xml create mode 100644 easy-agents-samples/easy-agents-helloworld/src/main/java/com/easyagents/Main.java create mode 100644 easy-agents-samples/readme.md create mode 100644 easy-agents-search-engine/easy-agents-search-engine-es/pom.xml create mode 100644 easy-agents-search-engine/easy-agents-search-engine-es/src/main/java/com/easyagents/engine/es/ESConfig.java create mode 100644 easy-agents-search-engine/easy-agents-search-engine-es/src/main/java/com/easyagents/engine/es/ElasticSearcher.java create mode 100644 easy-agents-search-engine/easy-agents-search-engine-es/src/test/java/com/easyagents/search/engines/es/ElasticSearcherTest.java create mode 100644 easy-agents-search-engine/easy-agents-search-engine-lucene/pom.xml create mode 100644 easy-agents-search-engine/easy-agents-search-engine-lucene/src/main/java/com/easyagents/search/engine/lucene/LuceneConfig.java create mode 100644 easy-agents-search-engine/easy-agents-search-engine-lucene/src/main/java/com/easyagents/search/engine/lucene/LuceneSearcher.java create mode 100644 easy-agents-search-engine/easy-agents-search-engine-lucene/src/test/java/com/easyagents/engines/test/TestLuceneCRUD.java create mode 100644 easy-agents-search-engine/easy-agents-search-engine-service/pom.xml create mode 100644 easy-agents-search-engine/easy-agents-search-engine-service/src/main/java/com/easyagents/search/engine/service/DocumentSearcher.java create mode 100644 easy-agents-search-engine/pom.xml create mode 100644 easy-agents-spring-boot-starter/pom.xml create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/deepseek/DeepSeekAutoConfiguration.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/deepseek/DeepSeekProperties.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/ollama/OllamaAutoConfiguration.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/ollama/OllamaProperties.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/openai/OpenAIAutoConfiguration.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/openai/OpenAIProperties.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/qwen/QwenAutoConfiguration.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/qwen/QwenProperties.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/aliyun/AliyunAutoConfiguration.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/aliyun/AliyunProperties.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/chroma/ChromaAutoConfiguration.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/chroma/ChromaProperties.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/elasticsearch/ElasticSearchAutoConfiguration.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/elasticsearch/ElasticSearchProperties.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/opensearch/OpenSearchAutoConfiguration.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/opensearch/OpenSearchProperties.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/qcloud/QCloudProperties.java create mode 100644 easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/qcloud/QCloudStoreAutoConfiguration.java create mode 100644 easy-agents-spring-boot-starter/src/main/resources/META-INF/spring.factories create mode 100644 easy-agents-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 easy-agents-store/easy-agents-store-aliyun/pom.xml create mode 100644 easy-agents-store/easy-agents-store-aliyun/src/main/java/com/easyagents/store/aliyun/AliyunVectorStore.java create mode 100644 easy-agents-store/easy-agents-store-aliyun/src/main/java/com/easyagents/store/aliyun/AliyunVectorStoreConfig.java create mode 100644 easy-agents-store/easy-agents-store-chroma/pom.xml create mode 100644 easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaExpressionAdaptor.java create mode 100644 easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaVectorStore.java create mode 100644 easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaVectorStoreConfig.java create mode 100644 easy-agents-store/easy-agents-store-chroma/src/test/java/com/easyagents/store/chroma/ChromaVectorStoreTest.java create mode 100644 easy-agents-store/easy-agents-store-elasticsearch/pom.xml create mode 100644 easy-agents-store/easy-agents-store-elasticsearch/src/main/java/com/easyagents/store/elasticsearch/ElasticSearchVectorStore.java create mode 100644 easy-agents-store/easy-agents-store-elasticsearch/src/main/java/com/easyagents/store/elasticsearch/ElasticSearchVectorStoreConfig.java create mode 100644 easy-agents-store/easy-agents-store-elasticsearch/src/test/java/com/easyagents/store/opensearch/ElasticSearchVectorStoreTest.java create mode 100644 easy-agents-store/easy-agents-store-opensearch/pom.xml create mode 100644 easy-agents-store/easy-agents-store-opensearch/src/main/java/com/easyagents/store/opensearch/OpenSearchVectorStore.java create mode 100644 easy-agents-store/easy-agents-store-opensearch/src/main/java/com/easyagents/store/opensearch/OpenSearchVectorStoreConfig.java create mode 100644 easy-agents-store/easy-agents-store-opensearch/src/test/java/com/easyagents/store/opensearch/OpenSearchVectorStoreTest.java create mode 100644 easy-agents-store/easy-agents-store-pgvector/pom.xml create mode 100644 easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorUtil.java create mode 100644 easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorVectorStore.java create mode 100644 easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorVectorStoreConfig.java create mode 100644 easy-agents-store/easy-agents-store-pgvector/src/test/java/com/easyagents/store/pgvector/PgvectorDbTest.java create mode 100644 easy-agents-store/easy-agents-store-qcloud/pom.xml create mode 100644 easy-agents-store/easy-agents-store-qcloud/src/main/java/com/easyagents/store/qcloud/QCloudVectorStore.java create mode 100644 easy-agents-store/easy-agents-store-qcloud/src/main/java/com/easyagents/store/qcloud/QCloudVectorStoreConfig.java create mode 100644 easy-agents-store/easy-agents-store-qdrant/pom.xml create mode 100644 easy-agents-store/easy-agents-store-qdrant/src/main/java/com/easyagents/store/qdrant/QdrantVectorStore.java create mode 100644 easy-agents-store/easy-agents-store-qdrant/src/main/java/com/easyagents/store/qdrant/QdrantVectorStoreConfig.java create mode 100644 easy-agents-store/easy-agents-store-qdrant/src/test/java/com/easyagents/store/qdrant/QdrantVectorStoreTest.java create mode 100644 easy-agents-store/easy-agents-store-redis/pom.xml create mode 100644 easy-agents-store/easy-agents-store-redis/src/main/java/com/easyagents/store/redis/RedisVectorStore.java create mode 100644 easy-agents-store/easy-agents-store-redis/src/main/java/com/easyagents/store/redis/RedisVectorStoreConfig.java create mode 100644 easy-agents-store/easy-agents-store-vectorex/pom.xml create mode 100644 easy-agents-store/easy-agents-store-vectorex/src/main/java/com/easyagents/store/vectorex/VectoRexStore.java create mode 100644 easy-agents-store/easy-agents-store-vectorex/src/main/java/com/easyagents/store/vectorex/VectoRexStoreConfig.java create mode 100644 easy-agents-store/easy-agents-store-vectorexdb/pom.xml create mode 100644 easy-agents-store/easy-agents-store-vectorexdb/src/main/java/com/easyagents/store/vectorex/VectoRexStore.java create mode 100644 easy-agents-store/easy-agents-store-vectorexdb/src/main/java/com/easyagents/store/vectorex/VectoRexStoreConfig.java create mode 100644 easy-agents-store/easy-agents-store-vectorexdb/src/test/java/com/easyagents/store/vectorex/test/Test.java create mode 100644 easy-agents-store/pom.xml create mode 100644 easy-agents-support/pom.xml create mode 100644 easy-agents-support/src/main/java/com/easyagents/flow/support/provider/EasyAgentsLlm.java create mode 100644 easy-agents-tool/easy-agents-tool-javascript/pom.xml create mode 100644 easy-agents-tool/easy-agents-tool-python/pom.xml create mode 100644 easy-agents-tool/easy-agents-tool-shell/pom.xml create mode 100644 easy-agents-tool/pom.xml create mode 100644 pom.xml create mode 100644 testresource/a.doc create mode 100644 testresource/a.pdf create mode 100644 testresource/ark_demo_img_1.png diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5eff13a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +# 匹配全部文件 +[*] +# 结尾换行符,可选"lf"、"cr"、"crlf" +end_of_line = lf +# 在文件结尾插入新行 +insert_final_newline = true +# 删除一行中的前后空格 +trim_trailing_whitespace = true +# 设置字符集 +charset = utf-8 +# 缩进风格,可选"space"、"tab" +indent_style = space +# 缩进的空格数 +indent_size = 4 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aac688e --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +**/target/ +**/node_modules/ +.idea +*.iml +.project +.settings +starter/**/jboot.properties +*.jar +*.log +.logs +logs +*.iws +*.classpath +.DS_Store +*.patch +~$* +*/*.jar +docker_volumes/ +package-lock.json +yarn.lock +/docs/.vitepress/cache/ +/docs/.vitepress/dist/ +/docs/pnpm-lock.yaml +application-dev.yml +.flattened-pom.xml diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4a2d8f --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Easy-Agents + +Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖从模型调用到工具执行、MCP 集成、检索与工作流编排的完整链路。 + +## 主要能力 + +- 多模型统一接入(Chat / Embedding / Rerank / Image) +- Prompt 与消息上下文管理 +- Tool 定义、执行与拦截器机制 +- MCP 客户端能力(调用、拦截、缓存与管理) +- 文档读取与切分、向量存储与检索 +- 工作流执行引擎(Flow)与 Easy-Agents 适配支持 + +## 模块说明 + +- `easy-agents-bom`:依赖版本管理(BOM)。 +- `easy-agents-core`:核心抽象与基础能力。 +- `easy-agents-chat`:对话模型接入实现集合。 +- `easy-agents-embedding`:向量化模型实现集合。 +- `easy-agents-rerank`:重排模型实现集合。 +- `easy-agents-image`:图像模型能力。 +- `easy-agents-store`:向量存储实现。 +- `easy-agents-search-engine`:检索引擎实现。 +- `easy-agents-tool`:工具调用能力。 +- `easy-agents-mcp`:MCP 集成。 +- `easy-agents-flow`:流程编排核心引擎。 +- `easy-agents-support`:Flow 与 Easy-Agents 适配模块。 +- `easy-agents-spring-boot-starter`:Spring Boot 自动配置支持。 +- `easy-agents-samples`:示例工程。 + +## 环境要求 + +- JDK 8+ +- Maven 3.8+ + +## 构建与安装 + +在项目根目录执行: + +```bash +mvn -DskipTests -Dmaven.javadoc.skip=true -Dgpg.skip=true clean install +``` + +构建完成后,相关构件会安装到本地 Maven 仓库,可供 `easyflow` 等项目直接依赖。 + +## 快速示例 + +```java +public static void main(String[] args) { + OpenAIChatModel chatModel = OpenAIChatConfig.builder() + .provider("GiteeAI") + .endpoint("https://ai.gitee.com") + .requestPath("/v1/chat/completions") + .apiKey("your-api-key") + .model("Qwen3-32B") + .buildModel(); + + String output = chatModel.chat("如何才能更幽默?"); + System.out.println(output); +} +``` + +## 在业务项目中引入(示例) + +```xml + + + + com.easyagents + easy-agents-bom + 0.0.1 + + + + + + + com.easyagents + easy-agents-flow + + + com.easyagents + easy-agents-support + + +``` + diff --git a/changes.md b/changes.md new file mode 100644 index 0000000..6b83009 --- /dev/null +++ b/changes.md @@ -0,0 +1,856 @@ +# Easy-Agents ChangeLog + +## v2.0.0-rc.6 20260127 +- fix(core): update message retrieval and context handling in streaming components +- feat(http): add proxy authentication support to OkHttpClientUtil +- feat(github): add issue templates for bug reports and documentation feedback + + +## v2.0.0-rc.5 20260127 +- feat(core): add JSON error message detection and enhance embedding model error handling +- feat(test): add ChatOptions support and enhance stream testing utilities +- refactor(chat): rename extra field to extraBody in ChatOptions +- fix(stream): handle failure flag properly in stream client listener +- refactor(core): remove unused string utility and simplify thinking protocol logic +- refactor(chat): rename variable for clarity in OpenAI request builder +- refactor(chat): rename extra field to extraBody in ChatOptions +- refactor(chat): remove default thinking protocol handling and add dynamic protocol detection +- feat(chat): add thinking protocol support for chat configurations + + +## v2.0.0-rc.4 20260123 +- fix: Optimizing Alibaba Cloud vector database vector failure prompt +- feat(embedding): add dimensions and user support to embedding options +- feat(document): add MarkdownHeaderSplitter for hierarchical document splitting +- feat(chat): add reasoning content support for tool messages in chat configuration + + +## v2.0.0-rc.3 20260112 +- feat(readme): add MCP capabilities to the list of features +- fix(openai): remove unsupported top_k parameter from openai request +- fix(openai): handle JSON parsing errors in OpenAI chat client +- refactor(ollama): update configuration defaults and parameter naming +- chore(ollama): clean up request spec builder code +- refactor(qwen): remove response format option from QwenChatOptions +- feat(chat): add response format support to chat options +- chore(build): remove mvn17.sh script +- feat(mcp): add methods to access client descriptor and health checker + + +## v2.0.0-rc.2 20260108 +- fix(deps): update POI dependencies and remove slf4j-simple +- fix(http): add null check for request headers in HttpClient + + +## v2.0.0-rc.1 20260106 +- fix(OpenAIChatMessageSerializer): Added null check for parameters array to prevent NPE + + +## v2.0.0-beta.9 20260105 +- refactor(stream): remove content check in stream client listener +- fix(mcp): handle duplicate client registration properly prevent resource leaks when client is re-registered +- refactor(message): rename shouldNewCall method to isNewCall for clarity +- refactor(message): optimize tool call comparison logic in AiMessage +- test(openai): update OpenAI chat model test with tool call support + + +## v2.0.0-beta.8 20260104 +- fix(observability): correct OpenTelemetry propagator detection logic +- fix(observability): improve OpenTelemetry SDK registration check +- feat(observability): add OpenTelemetry SDK reuse check + + +## v2.0.0-beta.7 20260104 +- refactor(docs): update MCP client method name in documentation +- docs(chat): update MCP documentation with JDK version requirements +- chore(build): set maven compiler properties to Java 8 +- docs(chat): add comprehensive MCP client integration guide +- refactor(client): rename getClient to getMcpClient for clarity +- refactor(mcp): move client-related classes to dedicated package +- test(mcp): add comprehensive unit tests for McpClientManager +- feat(config): add MCP server configuration file and Added mcp-servers.json with everything server configuration for test +- refactor(mcp): replace McpRootConfig with McpConfig for server specifications +- feat(mcp): add exception handling for MCP tool calls +- fix(mcp): handle null and single text content in tool invocation +- feat(mcp): add MCP tool retrieval functionality +- feat(mcp): add McpTool implementation for MCP tool integration +- feat(tool): add defaultValue property to Parameter class +- chore(license): Updated copyright year from 2025 to 2026 +- (mcp):feat add Model Context Protocol client management module + + + +## v2.0.0-beta.6 20251230 +- refactor(core): Replace clearFullMessage with finalizeFullMessage to properly handle content +- fix(stream): clear full message content on stream completion +- test(openai): update OpenAI chat model test configuration and weather function +- refactor(OpenAIChatMessageSerializer): update tool message handling and serialization +- feat(chat): add tool message support with compatibility for models that don't support native format +- refactor(chat): update ChatConfig tool support methods and remove unused field +- test(utils): add ChatModelTestUtils for stream testing +- refactor(stream): simplify message handling and stop logic in BaseStreamClientListener +- refactor(message): update AiMessage delta completion logic + + + +## v2.0.0-beta.5 20251219 +- test(openai): remove unnecessary thread sleep in chat model tests +- feat(chat): add includeUsage option for streaming responses +- feat(openai): add stream chat test for bailian model +- feat(core): add stopped flag and improve stream listener logic +- fix(stream): handle final delta correctly in stream client listener and Wrapped final message notification in try-finally block +- feat(openai): add support for extra options in chat request +- docs(models): update embedding model translation + + + +## v2.0.0-beta.4 20251210 +- fix(core):优化AI消息增量更新的空值判断 +- fix(core):修复工具调用合并逻辑 +- chore(deps): 移除嵌入模型相关依赖配置 +- fix(message):优化 ToolCall 参数解析逻辑 +- feat(core):优化聊天模型和客户端实现 +- fix: embedding module packaging +- feat(bom): 添加嵌入模型依赖项 + + +## v2.0.0-beta.2 20251202 +- 修复:修复 Maven 依赖异常,更新版本号,感谢 @jieven + +## v2.0.0-beta.1 20251126 +- 移除 Chain 模块,合并到 Tinyflow +- 移除 documentParser 模块 +- 移除 DocumentLoader 模块 +- 新增 file2text 模块 +- 新增 observability 模块 +- 新增 RAG 执行 AI 对文档进行拆分 +- 修改 LLM 为 ChatModel +- 修改 LLMClient 为 StreamClient +- 修改 LLMClientListener 为 StreamClientListener +- 修改 LLMConfig 为 ChatConfig +- 修改 Function 为 Tool +- 重命名 PromptFormat 为 MessageFormat +- 重命名 TextPromptTemplate 为 PromptTemplate +- 重命名 TextPrompt 为 SimplePrompt +- 重命名 HistoriesPrompt 为 MemoryPrompt +- 重命名 HumanMessage 等合并为 UserMessage + + +## v1.4.2 20251117 +- 新增:新增本地 Token 计算的相关内容,方便在某些情况下无法获取外部 token 时使用 +- 新增: 新增 file2text 模块 +- 新增: Chain 支持主动去 暂停执行 或 恢复执行 +- 新增:feat: 新增可观测的相关能力 +- 优化:重构移动 ReAct 的包到 agents 目录下 +- 优化:优化 Chain.addEdge 自动为没有 id 的 edge 添加 id +- 优化:Chain.addNode 自动为 node 添加 inwardEdges 和 outwardEdges +- 优化:优化 ChainHolder 的属性类型定义,方便准确序列化 +- 优化:ChainEdge 移除无用的定义 + + + +## v1.4.1 20251023 +- 新增:prompt 格式化新增 `{{ aa ?? bb ?? 'default'}}` 取值语法 +- 优化:优化 chain.get 方法获取数据的性能 +- 优化:移除 gitee 旧的 api 支持(官方已下架) + + +## v1.4.0 20251020 +- 修复: computeCost 可能等于 null 而出现 NPE 的问题 + + +## v1.3.9 20251019 +- 新增: 添加 Chain 关于算力消耗配置的功能 +- 新增: Chain 添加 loopNodeExecutionLimit 配置,保护由于用户配置不当可能造成死循环执行的问题 +- 新增: 优化监听器,当多个监听器发生时,有监听器发生错误不影响其他监听器执行 +- 新增: Node 支持配置积分消耗的计算逻辑和表达式 +- 新增: Chain 的取值支持三目运算 +- 修复:避免截断后添加 systemMessage 时导致 memory 中的 systemMessage 越来越多 + + +## v1.3.8 20251010 +- 新增:图片消息添加多张图片的支持 +- 新增:大模型节点添加图片识别的能力 +- 新增:大模型节点支持多种 json 格式输出 +- 优化:优化 okHttpclient 的请求机制,更轻量化 +- 优化:优化 OkHttpClientUtil 的配置支持环境变量 +- 优化:ImageUtil 支持自动添加 base64 url 前缀 + + +## v1.3.7 20250929 +- 新增:支持 Chroma 向量数据库(1.1.10),目前只支持单租户,单数据库(有默认且可配置),感谢 @joe.wen +- 新增:TextPromptTemplate.formatToString 添加 escapeJson 参数配置的支持 + + +## v1.3.6 20250924 +- 修复:ConfirmNode 必须勾选必填才能正确进行数据确认的问题 + + +## v1.3.5 20250917 +- 新增: 新增 ChainNodeValidator 用于给 Node 自定义其验证器 +- 新增: 新增 ChainNode.validate 方法,用于校验其数据是否完善 +- 新增: 工作流执行新增节点算力消耗的定义 +- 新增: getParameterValues 通过 LinkedHashMap 返回属性,支持参数顺序 +- 优化:优化 HumanMessage 类的函数处理逻辑 +- 优化: 重构 Chain 的验证逻辑 +- 修复: 修复 Parameter 为非必填,但是也必须输入参数的问题 +- 修复: 在 js 执行节点可能出现 java.lang.NoClassDefFoundError: com/ibm/icu/number/Notation 错误的问题 +- 修复:正则表达式 `Action Input: (\{.?})` 在匹配时会漏掉最后一个 `"}"` 的问题,感谢 @狂野流星 + + +## v1.3.4 20250817 +- 新增:Parameter 新增 formPlaceholder 属性 + + +## v1.3.3 20250806 +- 优化:优化合并 ConfirmParameter 到 Parameter,以支持更多的场景 + + +## v1.3.2 20250731 +- 优化:优化 AiMessageResponse.getFunctionCallers 方法 +- 修复:多轮 tool call 时,获取最后一条 HumanMessage 错误的问题 +- 修复:Chain 的 Parameter 类型为 Array 时,内容固定值无法解析的问题 + + +## v1.3.1 20250722 +- 新增:ChatOptions 新增 extra 配置,用于自定义大模型的参数内容 +- 修复:节点异步执行的情况下,可能出现 check 不正确的问题 +- 修复:jsExecNode 无法转换结果为 JsonObject 的问题 +- 优化:优化 getNodeContext 方法,只需要传入 id 值 +- 优化:移除 Moonshot ,使用 openai 替代 + + +## v1.2.9 20250718 +- 优化:优化 parent 和 children 的过度设计,使之逻辑更加简洁 +- 修复:修复 Node 包含子的 chain 时,会导致 json 解析错误的问题 + + +## v1.2.8 20250715 +- 新增: ReActAgent 添加 ChatOptions 的设置能力 +- 新增: ConfirmParameter 添加更多的配置参数支持 +- 优化: 优化 getParameterValues 的错误信息 +- 优化: 移除 chain 非必要的 error 日志 +- 优化: 优化 Chain.getParameterValues + + +## v1.2.7 20250713 +- 新增:代码执行节点添加 “_context” 对象 +- 新增:新增 Chain.toJSON() 和 Chain.fromJSON() 方法 +- 新增:新增 ConfirmNode 用于支持用户手动确认的场景 +- 优化:优化 Chain.getParameterValues 方法 +- 优化:移动 Chain 监听器的相关类到 listener 包里去 + + +## v1.2.6 20250709 +- 新增: Chain 新增 getNodeExecuteResult 方法 +- 优化:优化重构 EdgeCondition 和 NodeCondition 方法 + + +## v1.2.5 20250707 +- 新增:新增 Audio 多模态的支持 +- 新增:新增 ReActMessageBuilder,允许用户构建自定义的消息 +- 优化:feat: 修改 TextAndImageMessage 和 TextAndAudioMessage 为 HumanImageMessage 和 HumanAudioMessage +- 修复:DeepseekLlm 无法自动注入,配置 factories 文件 + + +## v1.2.4 20250701 +- 新增:在节点执行出错时,添加必要的错误日志输出 +- 新增:大模型 Parameter 添加子 Parameter 的配置支持 +- 新增:ReActAgent 添加 continueOnActionJsonParseError 和 continueOnActionInvokeError 配置 +- 修复:修复 EmbeddingModel.dimensions() 错误信息不友好的问题 +- 修复:tool call第二次请求模型时缺少了tools信息 close #ICG584 + + +## v1.2.3 20250626 +- 新增:节点添加循环执行的配置能力 +- 新增:新增 starter 中 deepseek 的配置支持,openai 可以指定 chatpath 属性 +- 新增:OpenAILlm 支持自定义 HttpClient +- 优化:优化 bom 模块依赖冲突 close #ICG2TD +- 优化:优化 ReActAgent 代码,新增更多的监听支持 +- 优化:优化 OkHttpClientUtil 的默认参数 +- 优化:重命名 JavascriptStringCondition 为 JsCodeCondition +- 修复:修复 Gitee 生成图片错误的问题 +- 文档:更新 chain 的相关文档 +- 文档:更新优化 LLM 示例代码 +- 文档:优化 prompt 示例代码 +- 文档:节点循环示例代码 + + +## v1.2.2 20250618 +- 新增:新增 es 和 lucene 搜索引擎 + + +## v1.2.0 20250614 +- 新增:新增 "default" rerank 模型,用于对接多个不同的 rerank 服务 +- 新增:新增 ReAct Agent +- 优化:挂起-恢复执行逻辑优化,每次执行节点移除挂起节点列表中该节点 + + +## v1.1.8 20250605 +- 新增:阿里云增加余弦相似度得分回显 +- 修复: 修正 Milvus 下 COSINE 相似度计算方法 + + + +## v1.1.7 20250604 +- 修复:使用 qwen-plus 调用 function_call 没有正确拼接大模型返回的参数问题 +- 修复: 修复 DeepseekLlmUtil 类型转换错误 +- 修复: HistoriesPrompt 的 toMessages 可能多次添加 systemMessage 的问题 + + + +## v1.1.5 20250603 +- 修复:修复 CodeNode 的 js 无法通过 "." 调用 map 数据的问题 + + + +## v1.1.4 20250530 +- 新增: 为 ChainStartEvent 和 ChainResumeEvent 添加获取初始化参数的功能 +- 优化: 优化 JsExecNode 在每次执行脚本时新建一个独立 Context +- 优化: 优化 Event 的 toString +- 修复: node 的状态在执行时未自动变化的问题 + + + +## v1.1.3 20250527 +- 修复:修复阿里云百炼 text-embedding-v3 向量化使用 milvus 使用默认向量模型导致两次维度不一致问题 +- 修复:qwen3 非流式返回设置 enable_thinking 为 false + + + +## v1.1.2 20250524 +- 新增: StreamResponseListener 添加 onMatchedFunction 方法 +- 新增: 添加 openai 兼容 api 的其他第三方 api 测试 +- 优化: 添加 FunctionPrompt 的 toString 方法 +- 优化: 优化 ImagePrompt 的方法 +- 优化: 优化 ToolPrompt 支持多个方法调用 +- 优化: 优化 Stream 模型下的 Function Call +- 优化: 优化 SseClient 的 tryToStop 方法 +- 优化: 优化 FunctionCall 以及添加 toString 方法 +- 优化: 优化 OpenAILlm.java + + + +## v1.1.1 20250522 +- 新增:新增 NodeErrorListener 用于监听 node 的错误情况 +- 优化:重构 ChainErrorListener 的参数顺序 +- 优化:优化 getParameterValues 的默认值获取 + + + +## v1.1.0 20250516 +- 优化:增强 LLM 的 markdown 包裹优化 +- 优化:重命名 StringUtil.obtainFirstHasText 方法名称为 getFirstWithText +- 修复:修复大模型节点,返回 json 内容时不正确的问题 +- 修复:修复 EndNode 在输出固定值时出现 NPE 的问题 + + + +## v1.0.9 20250513 +- 新增: Chain 添加 reset 方法,使之调用后可以执行多次 +- 优化:不允许设置默认 EmbeddingOptions 配置的 encodingFormat +- 优化:修改模型思考过程的设置,让 content 和 reasoningContent 输出内容一致,感谢 @Alex + + + +## v1.0.8 20250511 +- 优化:优化 elasticSearch 用户自定义集合名称就用用户自定义集合,没有传就用默认集合名称 +- 优化:从命名 TextPromptTemplate.create 方法名称为 TextPromptTemplate.of,更加符合 “缓存” 的特征 +- 修复:修复 openSearch 存储报错问题 +- 文档:添加提示词相关文档 +- 文档:添加 “模板缓存” 的相关文档 +- 测试:添加 milvus 向量存储用法示例测试类,感谢 @lyg + + + +## v1.0.7 20250508 +- 新增: 添加 Milvus 的相识度返回 +- 新增: Chain.getParameterValues 添加对固定数据格式填充的能力 +- 优化: Parameter 添加 dataType 默认数据 +- 优化: TextPromptTemplate.create 添加缓存以提高性能 + + + +## v1.0.6 20250507 +- 新增: 增加 qdrant 向量数据库支持 +- 优化: 重构 TextPromptTemplate,使其支持更多的语法 +- 优化: 优化 pom 管理 + + + +## v1.0.5 20250430 +- 新增: 允许通过 ChatOptions 在运行时动态替换模型名称 +- 新增:增加是否开启思考模式参数,适用于 Qwen3 模型 +- 新增:Document 增加文档标题 +- 新增:增强知识库查询条件 +- 新增:优化 Chain 的 get 逻辑,支持获取对象的属性内容 +- 测试:添加通过 OpenAI 的 API 调用 Gitee 进行图片识别 +- 测试:添加 chain 的数据获取测试 + + + +## v1.0.4 20250427 +- 新增: 为 VectorData 添加 score 属性,统一文档 score 字段 +- 优化:重构 Chain 的异步执行逻辑 + + + +## v1.0.3 20250425 +- 新增: deepseek-r1 推理过程增量输出改为完整输出和内容的输出保持一致,感谢 @liutf +- 新增: 增加 QwenChatOptions,让通义千问支持更多的参数,感谢 @liutf +- 新增:新增 ChainHolder,用于序列化 ChainNode,以支持分布式执行 +- 优化:优化 Chain,在暂停时抛出异常 + + + +## v1.0.2 20250412 +- feat: add JavascriptStringCondition +- refactor: move "description" property to ChainNode +- test: add ChainConditionStringTest + +--- +- 新增:添加 JavascriptStringCondition 条件 +- 重构:移动 "description" 属性到 ChainNode +- 测试:添加 ChainConditionStringTest 测试 + + + +## v1.0.1 20250411 +- fix: LlmNode can not return the correct result if outType is text +- fix: TextPromptTemplate can not parse `{{}}` + +--- +- 修复:修复 LlmNode 当配置 outType 时,不能返回正确结果的问题 +- 修复:TextPromptTemplate 无法可以解析 `{{}}` 的问题 + + + +## v1.0.0 20250407 +- fix: fixed NodeContext.isUpstreamFullyExecuted() method +- feat: add "concurrencyLimitSleepMillis" config for SparkLlm +- feat: openai add chatPath config and embed path config +- feat: HistoriesPrompt add temporaryMessages config + +--- +- 修复:NodeContext.isUpstreamFullyExecuted() 方法判断错误的问题 +- 新增: SparkLlm 添加 concurrencyLimitSleepMillis 配置 +- 新增: openai 添加 chatPath 配置和 embed path 配置 +- 新增: HistoriesPrompt 添加 temporaryMessages 配置 + + + +## v1.0.0-rc.9 20250331 +- feat: Added support for vector database Pgvector, thanks @daxian1218 +- feat: Chain added "SUSPEND" state and ChainSuspendListener listener +- feat: Chain's RefType added "fixed" type. +- feat: Chain's Parameter added "defaultValue" +- feat: Chain added ChainResumeEvent event +- feat: ChainNode added "awaitAsyncResult" property configuration +- refactor: Return the complete response and answer information of coze chat to obtain complete information such as conversation_id, thanks @knowpigxia +- refactor: Optimize the implementation details of RedisVectorStore +- refactor: Chain removed OnErrorEvent and added ChainErrorListener instead +- refactor: Rename BaseNode's "getParameters" method to "getParameterValues" +- refactor: Rename Chain's event and remove the On prefix + +--- +- 新增:新增向量数据库 Pgvector 的支持,感谢 @daxian1218 +- 新增:Chain 新增 "SUSPEND" 状态以及 ChainSuspendListener 监听 +- 新增:Chain 的 RefType 新增 "fixed" 类型。 +- 新增:Chain 的 Parameter 新增 "defaultValue" +- 新增:Chain 新增 ChainResumeEvent 事件 +- 新增:ChainNode 新增 "awaitAsyncResult" 属性配置 +- 优化:返回 coze chat 完整的 response、answer 信息,以便获取 conversation_id 等完整信息,感谢 @knowpigxia +- 优化:优化 RedisVectorStore 的实现细节 +- 优化:Chain 移除 OnErrorEvent 并新增 ChainErrorListener 代替 +- 优化:重命名 BaseNode 的 "getParameters" 方法为 "getParameterValues" +- 优化:重命名 Chain 的事件,移除 On 前缀 + + + +## v1.0.0-rc.8 20250318 +- feat: Added LLM support for siliconflow, thanks @daxian1218 +- feat: Chain's dynamic code node supports running Javascript scripts, thanks @hhongda +- feat: Removed deepseek's invalid dependency on openai module, thanks @daxian1218 +- feat: Optimized EmbeddingModel and added direct embedding of String + +--- +- 新增:新增 LLM 对 siliconflow(硅基流动)的支持,感谢 @daxian1218 +- 新增:Chain 的动态代码节点支持运行 Javascript 脚本,感谢 @hhongda +- 优化:移除 deepseek 无效的依赖 openai 模块,感谢 @daxian1218 +- 优化:优化 EmbeddingModel,添加直接对 String 的 embed + + + +## v1.0.0-rc.7 20250312 +- feat: Added the tool of adding reasoning content to the return message, supporting deepseek's reasoning return, thanks @rirch +- feat: Added support for vectorexdb embedded version, no need to deploy database separately, thanks @javpower +- feat: Added support for accessing Tencent's large model language, Wensheng graph model and vectorization interface, thanks @sunchanghuilinqing +- feat: Support for docking Doubao doubao-1-5-vision-pro-32k multimodal model and Wensheng graph, thanks @wang110wyy +- feat: Added Wensheng graph model of Alibaba Bailian platform, thanks @sunchanghuilinqing +- feat: Added VLLM-based large model access, thanks @sunchanghuilinqing +- feat: Added LogUtil for log output +- feat: Optimized the relevant code logic of DnjsonClient +- fix: The problem of too long uid of Spark large model, thanks @wu-zhihao +- fix: ChatStream of Ollama Llm An error occurred when actively closing the stream +- fix: Fixed an issue where the endpoint configuration of OllamaProperties was incorrect by default + +--- +- 新增:添加在在返回消息中增加推理内容的功能,支持 deepseek 的推理返回,感谢 @rirch +- 新增:添加 vectorexdb 内嵌版本支持,无需额外部署数据库,感谢 @javpower +- 新增:添加接入腾讯大模型语言、文生图模型与向量化接口的支持,感谢 @sunchanghuilinqing +- 新增:对接豆包 doubao-1-5-vision-pro-32k 多模态模型以及文生图的支持,感谢 @wang110wyy +- 新增:新增阿里百炼平台的文生图模型,感谢 @sunchanghuilinqing +- 新增:新增基于 VLLM 部署大模型接入,感谢 @sunchanghuilinqing +- 新增:新增 LogUtil 用于输出日志 +- 优化:优化 DnjsonClient 的相关代码逻辑 +- 修复:星火大模型的 uid 太长的问题,感谢 @wu-zhihao +- 修复:Ollama Llm 的 chatStream 主动关闭流时发生错误的问题 +- 修复:修复默认情况下 OllamaProperties 的 endpoint 配置错误的问题 + + + +## v1.0.0-rc.6 20250220 +- feat: Springboot's automatic configuration class for Ollama +- feat: Added ToolPrompt tool to facilitate the use with Function Call +- refactor: Change openAi to openAI +- refactor: Optimize LlmNode and TextPromptTemplate +- refactor: Upgrade related dependencies to the latest version +- refactor: Optimize the empty user prompt words defined during the LlmNode runtime +- refactor: Move the package name of tools to the directory chatModel (destructive update!!!) +- refactor: Refactor InputParameter and OutputKey to merge into Parameter (destructive update!!!) +- fix: Use the openai interface to connect to the local ollama to build a large model, and multiple tool definitions are called abnormally +- fix: Fix the problem that easy-agents-bom cannot pull group code + +--- +- 新增:Springboot 对 Ollama 的自动配置类 +- 新增:新增 ToolPrompt 功能,方便配合 Function Call 的使用 +- 优化:修改 openAi 为 openAI +- 优化:优化 LlmNode 和 TextPromptTemplate +- 优化:升级相关依赖到最新版本 +- 优化:优化 LlmNode 运行期定义空的用户提示词 +- 优化:移动 tools 的包名到目录 chatModel(破坏性更新 !!!) +- 优化:重构 InputParameter 和 OutputKey 合并到 Parameter(破坏性更新 !!!) +- 修复:使用 openai 接口对接本地 ollama 搭建大模型,多个函数定义调用异常 +- 修复:修复 easy-agents-bom 无法拉群代码的问题 + + + +## v1.0.0-rc.5 20250210 +- feat: Added support for VectoRex vector database +- feat: Added support for DeepSeek large models +- feat: ImagePrompt adds support for local files, Stream and Base64 configurations +- refactor: easy-agents-bom facilitates one-click import of all modules + +--- +- 新增:添加 VectoRex 向量数据库的支持 +- 新增:增加 DeepSeek 大模型的支持 +- 新增:ImagePrompt 添加本地文件、Stream 和 Base64 配置的支持 +- 优化:easy-agents-bom 方便一键导入所有模块 + + + +## v1.0.0-rc.4 20241230 +- refactor: Use pom to build and only manage versions +- refactor: Optimize the relevant code of RedisVectorStore +- refactor: BaseNode.getChainParameters() method +- refactor: Optimize Chain.executeForResult method + +--- +- 优化: 采用 pom方式构建并只做版本统一管理 +- 优化: 优化 RedisVectorStore 的相关代码 +- 优化: BaseNode.getChainParameters() 方法 +- 优化: 优化 Chain.executeForResult 方法 + + + +## v1.0.0-rc.3 20241126 +- refactor: optimize Chain.executeForResult() method +- refactor: optimize Chain events +- fix: fixed Spark payload build error +- fix: fixed qwen model unable to embed + +--- +- 优化: 优化 Chain.executeForResult() 方法 +- 优化: 优化 Chain 的相关 event 事件 +- 修复: 修复星火大模型 payload 构建错误 +- 修复: 修复 qwen 大模型无法使用 Embedding 的问题 + + + +## v1.0.0-rc.2 20241118 +- feat: Gitee AI adds support for Function Calling +- feat: HumanMessage adds support for toolChoice configuration +- refactor: Optimize editing node BaseNode and Maps tool classes + +--- +- 新增: Gitee AI 添加对 Function Calling 的支持 +- 新增: HumanMessage 添加 toolChoice 配置的支持 +- 优化: 优化编辑节点 BaseNode 和 Maps 工具类 + + + +## v1.0.0-rc.1 20241106 +- refactor: add BaseFunction.java +- fix: spark LLM can not support v4.0 +- fix: fix code node can not get the parameters + +--- +- 优化:新增 BaseFunction 类 +- 修复:修复星火大模型不支持 v4.0 的问题 +- 修复:修复代码节点无法获取参数的问题 + + + +## v1.0.0-rc.0 20241104 +- refactor: refactor chatModel apis +- refactor: refactor chain and nodes +- refactor: optimize easy-agents-solon-plugin @noear_admin + +--- +- 优化:重构 chatModel api +- 优化:重构 chain 链路 及其相关节点 +- 优化:优化 easy-agents-solon-plugin @noear_admin + + + +## v1.0.0-beta.13 20241026 +- feat: add plugin for solon framework +- refactor: optimize VectorStore delete methods +- refactor: optimize RedisVectorStore for sort by desc +- refactor: optimize SparkLLM embedding + +--- +- 新增:添加 solon 添加新的插件支持 +- 优化: 重构 VectorStore 的 delete 方法 +- 优化: 优化 RedisVectorStore 的搜索排序 +- 优化: 星火大模型新增秒级并发超过授权路数限制进行重试 + + +## v1.0.0-beta.12 20241025 +- refactor:add DocumentStoreConfig +- refactor:optimize HistoriesPrompt.java +- refactor: update pom.xml in easy-agents-bom +- refactor: upgrade jedis version to "5.2.0" +- refactor: optimize RedisVectorStore +- fix: NoClassDefFoundError in jdk17: javax/xml/bind/DatatypeConverter 感谢 @songyinyin #I9AELG + +--- +- 优化:添加 DocumentStoreConfig,向量数据库的配置都实现 DocumentStoreConfig +- 优化:重构优化 HistoriesPrompt,使其支持更多的属性配置 +- 优化:更新 easy-agents-bom 的 pom.xml +- 优化:升级 jedis 版本为 "5.2.0" +- 优化:重构 RedisVectorStore 的错误信息,使之错误信息更加友好 +- 修复:修复 jdk17 下出现 NoSuchMethodError 问题,感谢 @songyinyin #I9AELG + + + +## v1.0.0-beta.11 20240918 +- feat: GenerateImageRequest add negativePrompt property +- feat: Maps Util add putOrDefault method +- feat: add siliconFlow image models +- feat: ChatOptions add "seed" property +- feat: Maps can put a child map by key +- feat: Ollama add options config +- feat: Ollama tool calling support +- feat: add StringUtil.isJsonObject method +- refactor: BaseImageRequest add extend options property +- refactor: make ImagePrompt to extends HumanMessage +- refactor: ImageResponse add error flag and errorMessage properties +- refactor: rename Image.writeBytesToFile to writeToFile +- refactor: rename "giteesd3" to "gitee" +- refactor: optimize VectorData.toString + + +--- +- 新增:GenerateImageRequest 添加反向提示词相关属性 +- 新增:Maps 工具类添加 putOrDefault 方法 +- 新增:添加 siliconFlow 的图片模型的支持 +- 新增: ChatOptions 添加 "seed" 属性 +- 新增:Maps 可以 put 一个子 map 的功能 +- 新增:新增 Ollama 的函数调用(Function Calling)的支持 +- 新增:添加 StringUtil.isJsonObject 方法 +- 优化:重构 BaseImageRequest 类,添加 options 属性 +- 优化:重构 ImagePrompt 使之继承于 HumanMessage +- 优化:重构 ImageResponse 类,添加 error 和 errorMessage 属性 +- 优化:修改 Image.writeBytesToFile 方法为 writeToFile +- 优化:重命名 "giteesd3" 为 "gitee" +- 优化:重构 VectorData.toString 方法 + + + + +## v1.0.0-beta.10 20240909 +- feat: Added support for RedisStore vector storage, thanks to @giteeClass +- feat: Added support for large model dialogues for Coze Bot, thanks to @yulongsheng +- feat: Automatic configuration of Springboot for ElasticSearch Store, thanks to @songyinyin +- feat: Added support for Embedding of Tongyi Qianwen, thanks to @sssllg +- feat: Added support for all text generation models of Gitee AI's serverless +- feat: Added support for all image generation models of Gitee AI's serverless +- docs: Corrected sample code errors in the documentation + +--- +- 新增:添加 RedisStore 的向量存储支持,感谢 @giteeClass +- 新增:新增 Coze Bot 的大模型对话支持,感谢 @yulongsheng +- 新增: ElasticSearch Store 对 Springboot 的自动配置功能,感谢@songyinyin +- 新增:新增通义千问的 Embedding 支持,感谢 @sssllg +- 新增:新增对 Gitee AI 的 serverless 所有文本生成模型的支持 +- 新增:新增对 Gitee AI 的 serverless 所有图片生成模型的支持 +- 文档:修正文档的示例代码错误 + + + +## v1.0.0-beta.9 20240813 +- feat: add custom request header in openaiLLM https://github.com/easy-agents/easy-agents/issues/5 +- feat: add https.proxyHost config for the http client, close https://github.com/easy-agents/easy-agents/issues/1 +- feat: add SpringBoot3 auto config support @songyinyin +- feat: add openSearch store support @songyinyin +- fix: fix config error in QwenAutoConfiguration @songyinyin +- fix: NPE in OpenAILLmUtil.promptToEmbeddingsPayload +- fix: fix FunctionMessageResponse error in BaseLlmClientListener, @imayou +- refactor: update bom module +- refactor: optimize SparkLlm.java + +--- +- 新增: 添加自定义 openaiLLM 请求 api 的支持 https://github.com/easy-agents/easy-agents/issues/5 +- 新增: 添加 https.proxyHost 配置的支持 https://github.com/easy-agents/easy-agents/issues/1 +- 新增: 添加对 SpringBoot3 自动配置的支持 @songyinyin +- 新增: 添加使用 openSearch 用于向量数据存储的支持 @songyinyin +- 修复: 修复 QwenAutoConfiguration 配置错误的问题 @songyinyin +- 修复: 修复 OpenAILLmUtil.promptToEmbeddingsPayload 空指针异常的问题 +- 修复: 修复 FunctionMessageResponse 在某些情况下出错的问题, @imayou +- 优化: 更新重构 bom 模块 +- 优化: 优化 SparkLlm.java 的相关代码 + + + +## v1.0.0-beta.8 20240714 +- feat: add "async" flag for the ChainNode +- feat: add Ollama LLM +- feat: add DnjsonClient for OllamaLlm +- refactor: refactor ChainCondition.java +- refactor: add throw LlmException if LLMs has error +- refactor: refactor DocumentParser +- refactor: refactor chain module +- refactor: rename GroovyExecNode.java and QLExpressExecNode.java +- refactor: add children property in Parameter +- refactor: remove unused code AsyncHttpClient.java +- refactor: use LlmException to replace LLMClientException + fix: Milvus type mismatch for filed 'id' +- test: add LoopChain test +- test: add ollama test use openai instance +- docs: add Japanese README + +--- +- 新增:为 ChainNode 添加 "async" 属性标识的设置 +- 新增:添加基于 Ollama 大语言模型的对接,非 openai 适配模式 +- 新增:新增 DnjsonClient 用于和 Ollama 的 stream 模型对接 +- 优化:重构 ChainCondition +- 优化:chat 时当大语言模型发生错误时抛出异常,之前返回 null +- 优化:重构 DocumentParser +- 优化:Parameter 支持子参数的配置能力 +- 修复:Milvus 向量数据库当传入 number 类型是出错的问题 +- 测试:添加对 LoopChain 的测试 +- 测试:添加文使用 openai 兼容 api 对 Ollama 对接的测试 + + + + +## v1.0.0-beta.7 20240705 +- feat: add image models support +- feat: add SimpleTokenizeSplitter +- feat: add OmniParseDocumentParser +- feat: add openai,stability AI and gitee-sd3 AI support +- feat: add moonshot support +- feat: add chain dsl support +- refactor: optimize chatModel clients +- refactor: optimize SparkLLM +- refactor: optimize slf4j dependencies +- refactor: optimize Agent define +- refactor: optimize chain +- test: add .pdf and .doc parse test +- test: add SimpleDocumentSplitterTest.java + +--- +- 新增:新增图片模型的支持 +- 新增:新增 SimpleTokenizeSplitter 分割器 +- 新增:新增 OmniParseDocumentParser 文档解析器 +- 新增:新增 openai、stability ai 以及 gitee ai 对图片生成的支持 +- 新增:新增月之暗面的支持 +- 优化:优化 chatModel 客户端的细节 +- 优化:优化星火大模型的细节 +- 优化:优化 slf4j 依赖的细节 +- 优化:优化 Agent 和 Chain 的定义细节 +- 测试:添加 .pdf 和 .doc 的解析测试 +- 测试:添加文档分割器的测试 +- 测试:添加 token 文档分割器的测试 + + + +## v1.0.0-beta.5 20240617 +- feat: add ImagePrompt to send image to LLM +- feat: chatOptions add topP/topK and stop config +- refactor: rename TextMessage.java to AbstractTextMessage.java +- refactor: refactor chatModel methods +- refactor: refactor FunctionMessageResponse.java +- refactor: optimize HttpClient.java And SseClient.java +- fix: fix tool calling error in QwenLLM +- test: add chat with image test + + +--- +- 新增:新增 ImagePrompt 用于发送图片对话的场景 +- 新增:对话模型下的 ChatOptions 添加 topK 和 topP 配置的支持 +- 优化:重命名 TextMessage 为 AbstractTextMessage +- 优化:重构 LLM 的方法定义,使之更加简单易用 +- 优化:优化 HttpClient.java 和 SseClient.java 的相关代码 +- 修复:通义千问 QwenLLM 在 tool calling 下无法正常调用的问题 +- 测试:添加发送图片相关的测试内容 + + + + + +## v1.0.0-beta.4 20240531 +- feat: OpenAILlm support embedding model config +- feat: add get dimensions method in EmbeddingModel +- feat: SparkLlm support embedding +- feat: optimize MilvusVectorStore +- feat: MilvusVectorStore add username and password config +- refactor: optimize HttpClient.java +- refactor: optimize AliyunVectorStore +- refactor: update StoreOptions to extends Metadata +- refactor: optimize StoreResult and Metadata +- fix: fix AIMessage tokens parse + + +--- +- 新增:OpenAILlm 添加自定义 embedding 模型的支持 +- 新增:EmbeddingModel 添加获取向量维度的支持 +- 新增:SparkLlm 星火大模型添加对 embedding 的支持 +- 新增:添加 Milvus 向量数据库的支持 +- 优化:优化 HttpClient 的代码 +- 优化:优化 AliyunVectorStore 的代码 +- 优化:优化 StoreOptions 和 Metadata 的代码 +- 修复:AIMessage 的 tokens 消耗解析不正确的问题 + + + + +## v1.0.0-beta.3 20240516 +- feat:add "description" to agent for automatic arrangement by LLM +- feat: StoreResult can return ids if document store success +- feat: StoreOptions support set multi partitionNames +- feat: add DocumentIdGenerator for Document and Store +- feat: add ChainException for Chain.executeForResult +- refactor: rename "SimplePrompt" to "TextPrompt" + +--- +- 新增:为 Agent 添加 description 属性,方便用于 AI 自动编排的场景 +- 新增:Agent 添加对 outputDefs 的定义支持 +- 新增:添加 DocumentIdGenerator 用于在对文档存储时自动生成 id 的功能 +- 新增:StoreOptions 添加多个 partitionName 配置的支持 +- 新增:当 Document 保存成功时,自动返回保存的 id +- 优化:Chain.executeForResult 会抛出异常 ChainException +- 修复:ChatGLM 的 Chat JSON 解析错误的问题 +- 测试:优化 SparkLlmTest 的测试代码 +- 文档:完善基础文档 diff --git a/easy-agents-bom/pom.xml b/easy-agents-bom/pom.xml new file mode 100644 index 0000000..e68005e --- /dev/null +++ b/easy-agents-bom/pom.xml @@ -0,0 +1,232 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-bom + easy-agents-bom + + + 8 + 8 + UTF-8 + + + + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + org.jetbrains.kotlin + kotlin-stdlib-common + + + + com.easyagents + easy-agents-core + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.jetbrains.kotlin + kotlin-stdlib-common + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + org.slf4j + slf4j-simple + + + + + + + + com.easyagents + easy-agents-image-gitee + + + com.easyagents + easy-agents-image-openai + + + com.easyagents + easy-agents-image-qianfan + + + com.easyagents + easy-agents-image-qwen + + + org.slf4j + slf4j-simple + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + + + com.easyagents + easy-agents-image-siliconflow + + + com.easyagents + easy-agents-image-stability + + + com.easyagents + easy-agents-image-tencent + + + com.easyagents + easy-agents-image-volcengine + + + + + + + com.easyagents + easy-agents-chat-deepseek + + + com.easyagents + easy-agents-chat-ollama + + + com.easyagents + easy-agents-chat-openai + + + com.easyagents + easy-agents-chat-qwen + + + + + + + com.easyagents + easy-agents-embedding-openai + + + com.easyagents + easy-agents-embedding-ollama + + + com.easyagents + easy-agents-embedding-qwen + + + + + + + com.easyagents + easy-agents-rerank-default + + + com.easyagents + easy-agents-rerank-gitee + + + + + + com.easyagents + easy-agents-store-aliyun + + + com.easyagents + easy-agents-store-chroma + + + com.easyagents + easy-agents-store-elasticsearch + + + com.easyagents + easy-agents-store-opensearch + + + com.easyagents + easy-agents-store-pgvector + + + com.easyagents + easy-agents-store-qcloud + + + com.easyagents + easy-agents-store-qdrant + + + com.easyagents + easy-agents-store-redis + + + com.easyagents + easy-agents-store-vectorex + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.slf4j + slf4j-simple + + + + + com.easyagents + easy-agents-store-vectorexdb + + + org.slf4j + slf4j-simple + + + org.jetbrains.kotlin + kotlin-stdlib + + + + + + + + com.easyagents + easy-agents-search-engine-service + + + com.easyagents + easy-agents-search-engine-es + + + com.easyagents + easy-agents-search-engine-lucene + + + + + diff --git a/easy-agents-chat/easy-agents-chat-deepseek/pom.xml b/easy-agents-chat/easy-agents-chat-deepseek/pom.xml new file mode 100644 index 0000000..155de3b --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-deepseek/pom.xml @@ -0,0 +1,30 @@ + + 4.0.0 + + com.easyagents + easy-agents-chat + ${revision} + + + jar + easy-agents-chat-deepseek + easy-agents-chat-deepseek + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + + + junit + junit + test + + + diff --git a/easy-agents-chat/easy-agents-chat-deepseek/src/main/java/com/easyagents/llm/deepseek/DeepseekChatModel.java b/easy-agents-chat/easy-agents-chat-deepseek/src/main/java/com/easyagents/llm/deepseek/DeepseekChatModel.java new file mode 100644 index 0000000..da2c092 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-deepseek/src/main/java/com/easyagents/llm/deepseek/DeepseekChatModel.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.llm.deepseek; + +import com.easyagents.core.model.chat.OpenAICompatibleChatModel; +import com.easyagents.core.model.chat.ChatInterceptor; +import com.easyagents.core.model.chat.GlobalChatInterceptors; + +import java.util.List; + +/** + * @author huangjf + * @version : v1.0 + */ +public class DeepseekChatModel extends OpenAICompatibleChatModel { + + + /** + * 构造一个聊天模型实例,不使用实例级拦截器。 + * + * @param config 聊天模型配置 + */ + public DeepseekChatModel(DeepseekConfig config) { + super(config); + } + + /** + * 构造一个聊天模型实例,并指定实例级拦截器。 + *

+ * 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并, + * 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。 + * + * @param config 聊天模型配置 + * @param userInterceptors 实例级拦截器列表 + */ + public DeepseekChatModel(DeepseekConfig config, List userInterceptors) { + super(config, userInterceptors); + } +} diff --git a/easy-agents-chat/easy-agents-chat-deepseek/src/main/java/com/easyagents/llm/deepseek/DeepseekConfig.java b/easy-agents-chat/easy-agents-chat-deepseek/src/main/java/com/easyagents/llm/deepseek/DeepseekConfig.java new file mode 100644 index 0000000..6a84766 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-deepseek/src/main/java/com/easyagents/llm/deepseek/DeepseekConfig.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.llm.deepseek; + +import com.easyagents.core.model.chat.ChatConfig; + +/** + * @author huangjf + * @version : v1.0 + */ +public class DeepseekConfig extends ChatConfig { + + private static final String DEFAULT_MODEL = "deepseek-chat"; + private static final String DEFAULT_ENDPOINT = "https://api.deepseek.com"; + private static final String DEFAULT_REQUEST_PATH = "/chat/completions"; + + public DeepseekConfig() { + setEndpoint(DEFAULT_ENDPOINT); + setRequestPath(DEFAULT_REQUEST_PATH); + setModel(DEFAULT_MODEL); + } + +} diff --git a/easy-agents-chat/easy-agents-chat-deepseek/src/test/java/com/easyagents/llm/deepseek/DeepseekTest.java b/easy-agents-chat/easy-agents-chat-deepseek/src/test/java/com/easyagents/llm/deepseek/DeepseekTest.java new file mode 100644 index 0000000..67cfe6f --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-deepseek/src/test/java/com/easyagents/llm/deepseek/DeepseekTest.java @@ -0,0 +1,92 @@ +package com.easyagents.llm.deepseek; + +import com.easyagents.core.message.UserMessage; +import com.easyagents.core.model.client.StreamContext; +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.model.chat.StreamResponseListener; +import com.easyagents.core.model.chat.tool.annotation.ToolDef; +import com.easyagents.core.model.chat.tool.annotation.ToolParam; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.message.SystemMessage; +import com.easyagents.core.prompt.MemoryPrompt; +import com.easyagents.core.prompt.SimplePrompt; +import com.easyagents.core.util.StringUtil; + +import java.util.Scanner; + +public class DeepseekTest { + + + @ToolDef(name = "get_the_weather_info", description = "get the weather info") + public static String getWeatherInfo(@ToolParam(name = "city", description = "城市名称") String name) { + //在这里,我们应该通过第三方接口调用 api 信息 + return name + "的天气是阴转多云。 "; + } + + @ToolDef(name = "get_holiday_balance", description = "获取假期余额") + public static String getHolidayBalance() { + //在这里,我们应该通过第三方接口调用 api 信息 + String username = "michael"; + return username + "你的年假还剩余3天,有效期至26年1月。调休假剩余1天,长期有效。 "; + } + + public static ChatModel getLLM() { + DeepseekConfig deepseekConfig = new DeepseekConfig(); + deepseekConfig.setEndpoint("https://api.siliconflow.cn/v1"); + deepseekConfig.setApiKey("*********************"); + deepseekConfig.setModel("Pro/deepseek-ai/DeepSeek-V3"); + deepseekConfig.setLogEnabled(true); + return new DeepseekChatModel(deepseekConfig); + } + + public static void chatHr() { + ChatModel chatModel = getLLM(); + MemoryPrompt prompt = new MemoryPrompt(); + // 加入system + prompt.addMessage(new SystemMessage("你是一个人事助手小智,专注于为用户提供高效、精准的信息查询和问题解答服务。")); + System.out.println("我是小智,你的人事小助手!请尽情吩咐小智!"); + Scanner scanner = new Scanner(System.in); + String userInput = scanner.nextLine(); + while (userInput != null) { + // 第二步:创建 HumanMessage,并添加方法调用 + UserMessage userMessage = new UserMessage(userInput); + userMessage.addToolsFromClass(DeepseekTest.class); + // 第三步:将 HumanMessage 添加到 HistoriesPrompt 中 + prompt.addMessage(userMessage); + // 第四步:调用 chatStream 方法,进行对话 + chatModel.chatStream(prompt, new StreamResponseListener() { + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + if (StringUtil.hasText(response.getMessage().getContent())) { + System.out.print(response.getMessage().getContent()); + } + if (response.getMessage().isFinalDelta()) { + System.out.println(response); + System.out.println("------"); + } + } + + @Override + public void onStop(StreamContext context) { + System.out.println("stop!!!------"); + } + }); + userInput = scanner.nextLine(); + } + + } + + + public static void functionCall() { + ChatModel chatModel = getLLM(); + SimplePrompt prompt = new SimplePrompt("今天北京的天气怎么样"); + prompt.addToolsFromClass(DeepseekTest.class); + AiMessageResponse response = chatModel.chat(prompt); + System.out.println(response.executeToolCallsAndGetResults()); + } + + public static void main(String[] args) { +// functionCall(); + chatHr(); + } +} diff --git a/easy-agents-chat/easy-agents-chat-ollama/pom.xml b/easy-agents-chat/easy-agents-chat-ollama/pom.xml new file mode 100644 index 0000000..88b9d7f --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-ollama/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.easyagents + easy-agents-chat + ${revision} + + + easy-agents-chat-ollama + easy-agents-chat-ollama + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + compile + + + junit + junit + test + + + + diff --git a/easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaChatConfig.java b/easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaChatConfig.java new file mode 100644 index 0000000..5416f25 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaChatConfig.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.llm.ollama; + +import com.easyagents.core.model.chat.ChatConfig; + +public class OllamaChatConfig extends ChatConfig { + + private static final String DEFAULT_PROVIDER = "ollama"; + private static final String DEFAULT_ENDPOINT = "https://localhost:11434"; + private static final String DEFAULT_REQUEST_PATH = "/v1/chat/completions"; + + public OllamaChatConfig() { + setProvider(DEFAULT_PROVIDER); + setEndpoint(DEFAULT_ENDPOINT); + setRequestPath(DEFAULT_REQUEST_PATH); + } + +} diff --git a/easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaChatModel.java b/easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaChatModel.java new file mode 100644 index 0000000..c2fc7ac --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaChatModel.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.llm.ollama; + +import com.easyagents.core.model.chat.OpenAICompatibleChatModel; +import com.easyagents.core.model.chat.ChatInterceptor; +import com.easyagents.core.model.chat.GlobalChatInterceptors; +import com.easyagents.core.model.client.ChatRequestSpecBuilder; + + +import java.util.List; + +public class OllamaChatModel extends OpenAICompatibleChatModel { + + /** + * 构造一个聊天模型实例,不使用实例级拦截器。 + * + * @param config 聊天模型配置 + */ + public OllamaChatModel(OllamaChatConfig config) { + super(config); + } + + /** + * 构造一个聊天模型实例,并指定实例级拦截器。 + *

+ * 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并, + * 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。 + * + * @param config 聊天模型配置 + * @param userInterceptors 实例级拦截器列表 + */ + public OllamaChatModel(OllamaChatConfig config, List userInterceptors) { + super(config, userInterceptors); + } + + + @Override + public ChatRequestSpecBuilder getChatRequestSpecBuilder() { + return new OllamaRequestSpecBuilder(); + } + +} diff --git a/easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaRequestSpecBuilder.java b/easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaRequestSpecBuilder.java new file mode 100644 index 0000000..8ce0f50 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-ollama/src/main/java/com/easyagents/llm/ollama/OllamaRequestSpecBuilder.java @@ -0,0 +1,21 @@ +package com.easyagents.llm.ollama; + +import com.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.ChatOptions; +import com.easyagents.core.model.client.OpenAIChatRequestSpecBuilder; +import com.easyagents.core.prompt.Prompt; +import com.easyagents.core.util.Maps; + +public class OllamaRequestSpecBuilder extends OpenAIChatRequestSpecBuilder { + protected Maps buildBaseParamsOfRequestBody(Prompt prompt, ChatOptions options, ChatConfig config) { + Maps params = super.buildBaseParamsOfRequestBody(prompt, options, config); + params.setIf(!options.isStreaming(), "stream", false); + + // 支持思考 + if (config.isSupportThinking()) { + params.setIf(options.getThinkingEnabled() != null, "thinking", options.getThinkingEnabled()); + } + + return params; + } +} diff --git a/easy-agents-chat/easy-agents-chat-ollama/src/test/java/com/easyagents/llm/ollama/OllamaChatModelTest.java b/easy-agents-chat/easy-agents-chat-ollama/src/test/java/com/easyagents/llm/ollama/OllamaChatModelTest.java new file mode 100644 index 0000000..b655e1f --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-ollama/src/test/java/com/easyagents/llm/ollama/OllamaChatModelTest.java @@ -0,0 +1,94 @@ +package com.easyagents.llm.ollama; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.model.exception.ModelException; +import com.easyagents.core.prompt.SimplePrompt; +import org.junit.Test; + +public class OllamaChatModelTest { + + @Test(expected = ModelException.class) + public void testChat() { + OllamaChatConfig config = new OllamaChatConfig(); + config.setEndpoint("http://localhost:11434"); + config.setModel("llama3"); + config.setLogEnabled(true); + + ChatModel chatModel = new OllamaChatModel(config); + String chat = chatModel.chat("Why is the sky blue?"); + System.out.println(">>>" + chat); + } + + + @Test + public void testChatStream() throws InterruptedException { + OllamaChatConfig config = new OllamaChatConfig(); + config.setEndpoint("http://localhost:11434"); + config.setModel("llama3"); + config.setLogEnabled(true); + + ChatModel chatModel = new OllamaChatModel(config); + chatModel.chatStream("Why is the sky blue?", (context, response) -> System.out.println(response.getMessage().getContent())); + + Thread.sleep(2000); + } + + + @Test + public void testFunctionCall1() throws InterruptedException { + OllamaChatConfig config = new OllamaChatConfig(); + config.setEndpoint("http://localhost:11434"); + config.setModel("llama3.1"); + config.setLogEnabled(true); + + ChatModel chatModel = new OllamaChatModel(config); + + SimplePrompt prompt = new SimplePrompt("What's the weather like in Beijing?"); + prompt.addToolsFromClass(WeatherFunctions.class); + AiMessageResponse response = chatModel.chat(prompt); + + System.out.println(response.executeToolCallsAndGetResults()); + } + + + @Test + public void testFunctionCall2() throws InterruptedException { + OllamaChatConfig config = new OllamaChatConfig(); + config.setEndpoint("http://localhost:11434"); + config.setModel("llama3.1"); + config.setLogEnabled(true); + + ChatModel chatModel = new OllamaChatModel(config); + + SimplePrompt prompt = new SimplePrompt("What's the weather like in Beijing?"); + prompt.addToolsFromClass(WeatherFunctions.class); + AiMessageResponse response = chatModel.chat(prompt); + + if (response.hasToolCalls()) { + prompt.setToolMessages(response.executeToolCallsAndGetToolMessages()); + AiMessageResponse response1 = chatModel.chat(prompt); + System.out.println(response1.getMessage().getContent()); + } + } + + + @Test + public void testVisionModel() { + OllamaChatConfig config = new OllamaChatConfig(); + config.setEndpoint("http://localhost:11434"); + config.setModel("llava"); + config.setLogEnabled(true); + + ChatModel chatModel = new OllamaChatModel(config); + + SimplePrompt imagePrompt = new SimplePrompt("What's in the picture?"); + imagePrompt.addImageUrl("https://agentsflex.com/assets/images/logo.png"); + + AiMessageResponse response = chatModel.chat(imagePrompt); + AiMessage message = response == null ? null : response.getMessage(); + System.out.println(message); + } + +} diff --git a/easy-agents-chat/easy-agents-chat-ollama/src/test/java/com/easyagents/llm/ollama/WeatherFunctions.java b/easy-agents-chat/easy-agents-chat-ollama/src/test/java/com/easyagents/llm/ollama/WeatherFunctions.java new file mode 100644 index 0000000..2dbb2f2 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-ollama/src/test/java/com/easyagents/llm/ollama/WeatherFunctions.java @@ -0,0 +1,22 @@ +package com.easyagents.llm.ollama; + +import com.easyagents.core.model.chat.tool.annotation.ToolDef; +import com.easyagents.core.model.chat.tool.annotation.ToolParam; + +public class WeatherFunctions { + + @ToolDef(name = "get_the_weather_info", description = "get the weather info") + public static String getWeatherInfo( + @ToolParam(name = "city", description = "the city name") String name + ) { + return "Snowy days"; + } + + + @ToolDef(name = "get_the_temperature", description = "get the temperature") + public static String getTemperature( + @ToolParam(name = "city", description = "the city name") String name + ) { + return "The temperature in " + name + " is 15°C"; + } +} diff --git a/easy-agents-chat/easy-agents-chat-openai/pom.xml b/easy-agents-chat/easy-agents-chat-openai/pom.xml new file mode 100644 index 0000000..cc198fd --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-openai/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + com.easyagents + easy-agents-chat + ${revision} + + + easy-agents-chat-openai + easy-agents-chat-openai + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + + + junit + junit + test + + + + diff --git a/easy-agents-chat/easy-agents-chat-openai/src/main/java/com/easyagents/llm/openai/OpenAIChatConfig.java b/easy-agents-chat/easy-agents-chat-openai/src/main/java/com/easyagents/llm/openai/OpenAIChatConfig.java new file mode 100644 index 0000000..ab30275 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-openai/src/main/java/com/easyagents/llm/openai/OpenAIChatConfig.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.llm.openai; + +import com.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.ChatInterceptor; +import com.easyagents.core.util.StringUtil; + +import java.util.List; +import java.util.Map; + +/** + * OpenAI 聊天模型的配置类,支持通过 Builder 模式创建配置或直接构建 {@link OpenAIChatModel}。 + *

+ * 默认值: + *

+ *

+ * 该配置类专为 OpenAI 兼容 API 设计,适用于 OpenAI 官方、Azure OpenAI 或其他兼容服务。 + */ +public class OpenAIChatConfig extends ChatConfig { + + private static final String DEFAULT_PROVIDER = "openai"; + private static final String DEFAULT_MODEL = "gpt-3.5-turbo"; + private static final String DEFAULT_ENDPOINT = "https://api.openai.com"; + private static final String DEFAULT_REQUEST_PATH = "/v1/chat/completions"; + + public OpenAIChatConfig() { + setProvider(DEFAULT_PROVIDER); + setEndpoint(DEFAULT_ENDPOINT); + setRequestPath(DEFAULT_REQUEST_PATH); + setModel(DEFAULT_MODEL); + } + + /** + * 创建一个 {@link OpenAIChatModel} 实例,使用当前配置。 + * + * @return 新的 {@link OpenAIChatModel} 实例 + */ + public final OpenAIChatModel toChatModel() { + return new OpenAIChatModel(this); + } + + /** + * 创建一个 {@link OpenAIChatModel} 实例,使用当前配置和指定的实例级拦截器。 + * + * @param interceptors 实例级拦截器列表,可为 {@code null} 或空列表 + * @return 新的 {@link OpenAIChatModel} 实例 + */ + public final OpenAIChatModel toChatModel(List interceptors) { + return new OpenAIChatModel(this, interceptors); + } + + + /** + * 构建器类,用于流畅地创建 {@link OpenAIChatConfig} 或直接构建 {@link OpenAIChatModel}。 + */ + public static class Builder { + private final OpenAIChatConfig config = new OpenAIChatConfig(); + + // --- BaseModelConfig fields --- + + public Builder apiKey(String apiKey) { + config.setApiKey(apiKey); + return this; + } + + public Builder provider(String provider) { + config.setProvider(provider); + return this; + } + + public Builder endpoint(String endpoint) { + config.setEndpoint(endpoint); + return this; + } + + public Builder requestPath(String requestPath) { + config.setRequestPath(requestPath); + return this; + } + + public Builder model(String model) { + config.setModel(model); + return this; + } + + /** + * 添加单个自定义属性(会进行深拷贝,不会持有外部引用)。 + */ + public Builder customProperty(String key, Object value) { + config.putCustomProperty(key, value); + return this; + } + + /** + * 设置自定义属性映射(会进行深拷贝,不会持有外部 map 引用)。 + */ + public Builder customProperties(Map customProperties) { + config.setCustomProperties(customProperties); + return this; + } + + // --- ChatConfig fields --- + + public Builder supportImage(Boolean supportImage) { + config.setSupportImage(supportImage); + return this; + } + + public Builder supportImageBase64Only(Boolean supportImageBase64Only) { + config.setSupportImageBase64Only(supportImageBase64Only); + return this; + } + + public Builder supportAudio(Boolean supportAudio) { + config.setSupportAudio(supportAudio); + return this; + } + + public Builder supportVideo(Boolean supportVideo) { + config.setSupportVideo(supportVideo); + return this; + } + + public Builder supportTool(Boolean supportTool) { + config.setSupportTool(supportTool); + return this; + } + + public Builder supportThinking(Boolean supportThinking) { + config.setSupportThinking(supportThinking); + return this; + } + + public Builder thinkingEnabled(boolean thinkingEnabled) { + config.setThinkingEnabled(thinkingEnabled); + return this; + } + + public Builder observabilityEnabled(boolean observabilityEnabled) { + config.setObservabilityEnabled(observabilityEnabled); + return this; + } + + public Builder logEnabled(boolean logEnabled) { + config.setLogEnabled(logEnabled); + return this; + } + + + /** + * 构建 {@link OpenAIChatConfig} 配置对象。 + *

+ * 该方法会校验必要字段(如 {@code apiKey}),若缺失将抛出异常。 + * + * @return 构建完成的配置对象 + * @throws IllegalStateException 如果 {@code apiKey} 未设置或为空 + */ + public OpenAIChatConfig build() { + if (StringUtil.noText(config.getApiKey())) { + throw new IllegalStateException("apiKey must be set for OpenAIChatConfig"); + } + return config; + } + + /** + * 直接构建 {@link OpenAIChatModel} 实例,使用默认(全局)拦截器。 + * + * @return 新的聊天模型实例 + * @throws IllegalStateException 如果 {@code apiKey} 未设置或为空 + */ + public OpenAIChatModel buildModel() { + return new OpenAIChatModel(build()); + } + + /** + * 直接构建 {@link OpenAIChatModel} 实例,并指定实例级拦截器。 + * + * @param interceptors 实例级拦截器列表,可为 {@code null} 或空 + * @return 新的聊天模型实例 + * @throws IllegalStateException 如果 {@code apiKey} 未设置或为空 + */ + public OpenAIChatModel buildModel(List interceptors) { + return new OpenAIChatModel(build(), interceptors); + } + } + + /** + * 获取一个新的构建器实例,用于链式配置。 + * + * @return {@link Builder} 实例 + */ + public static Builder builder() { + return new Builder(); + } +} diff --git a/easy-agents-chat/easy-agents-chat-openai/src/main/java/com/easyagents/llm/openai/OpenAIChatModel.java b/easy-agents-chat/easy-agents-chat-openai/src/main/java/com/easyagents/llm/openai/OpenAIChatModel.java new file mode 100644 index 0000000..924996d --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-openai/src/main/java/com/easyagents/llm/openai/OpenAIChatModel.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.llm.openai; + +import com.easyagents.core.model.chat.BaseChatModel; +import com.easyagents.core.model.chat.OpenAICompatibleChatModel; +import com.easyagents.core.model.chat.ChatInterceptor; +import com.easyagents.core.model.chat.GlobalChatInterceptors; + +import java.util.List; + +/** + * OpenAI 聊天模型实现。 + *

+ * 该类封装了 OpenAI API 的具体调用细节,包括: + *

+ *

+ * 所有横切逻辑(监控、日志、拦截)由 {@link BaseChatModel} 的责任链处理, + * 本类只关注 OpenAI 协议特有的实现细节。 + */ +public class OpenAIChatModel extends OpenAICompatibleChatModel { + + + /** + * 构造一个聊天模型实例,不使用实例级拦截器。 + * + * @param config 聊天模型配置 + */ + public OpenAIChatModel(OpenAIChatConfig config) { + super(config); + } + + /** + * 构造一个聊天模型实例,并指定实例级拦截器。 + *

+ * 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并, + * 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。 + * + * @param config 聊天模型配置 + * @param userInterceptors 实例级拦截器列表 + */ + public OpenAIChatModel(OpenAIChatConfig config, List userInterceptors) { + super(config, userInterceptors); + } +} diff --git a/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/ChatModelTestUtils.java b/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/ChatModelTestUtils.java new file mode 100644 index 0000000..5a4c2bb --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/ChatModelTestUtils.java @@ -0,0 +1,86 @@ +package com.easyagents.llm.openai; + +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.model.chat.ChatOptions; +import com.easyagents.core.model.chat.StreamResponseListener; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.model.client.StreamContext; +import com.easyagents.core.prompt.Prompt; +import com.easyagents.core.prompt.SimplePrompt; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public class ChatModelTestUtils { + + public static void waitForStream( + ChatModel model, + String prompt, + StreamResponseListener listener) { + waitForStream(model, new SimplePrompt(prompt), listener, Integer.MAX_VALUE, null); + } + + public static void waitForStream( + ChatModel model, + String prompt, + StreamResponseListener listener, + ChatOptions options) { + waitForStream(model, new SimplePrompt(prompt), listener, Integer.MAX_VALUE, options); + } + + public static void waitForStream( + ChatModel model, + Prompt prompt, + StreamResponseListener listener) { + waitForStream(model, prompt, listener, Integer.MAX_VALUE, null); + } + + public static void waitForStream( + ChatModel model, + Prompt prompt, + StreamResponseListener listener, + ChatOptions options) { + waitForStream(model, prompt, listener, Integer.MAX_VALUE, options); + } + + public static void waitForStream( + ChatModel model, + Prompt prompt, + StreamResponseListener listener, + long timeoutSeconds, ChatOptions options) { + + CountDownLatch latch = new CountDownLatch(1); + + StreamResponseListener wrapped = new StreamResponseListener() { + @Override + public void onStart(StreamContext context) { + listener.onStart(context); + } + + @Override + public void onMessage(StreamContext ctx, AiMessageResponse resp) { + listener.onMessage(ctx, resp); + } + + @Override + public void onStop(StreamContext ctx) { + listener.onStop(ctx); + latch.countDown(); + } + + @Override + public void onFailure(StreamContext context, Throwable throwable) { + listener.onFailure(context, throwable); + } + }; + + model.chatStream(prompt, wrapped, options); + try { + if (!latch.await(timeoutSeconds, TimeUnit.SECONDS)) { + throw new RuntimeException("Stream did not complete within " + timeoutSeconds + "s"); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/GiteeAiImageTest.java b/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/GiteeAiImageTest.java new file mode 100644 index 0000000..cea810d --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/GiteeAiImageTest.java @@ -0,0 +1,53 @@ +package com.easyagents.llm.openai; + +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.prompt.SimplePrompt; +import org.jetbrains.annotations.NotNull; +import org.junit.Test; + +public class GiteeAiImageTest { + + + @NotNull + private static OpenAIChatConfig getOpenAIChatConfig() { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setApiKey("PXW1GXE******L7D12"); +// config.setModel("InternVL3-78B"); + config.setModel("Qwen3-32B"); + config.setEndpoint("https://ai.gitee.com"); + config.setLogEnabled(true); + return config; + } + + @Test + public void testImage() { + OpenAIChatConfig config = getOpenAIChatConfig(); + ChatModel chatModel = new OpenAIChatModel(config); + + SimplePrompt prompt = new SimplePrompt("请识别并输入 markdown,请用中文输出"); + prompt.addImageUrl("http://www.codeformat.cn/static/images/logo.png"); + + AiMessageResponse response = chatModel.chat(prompt); + if (!response.isError()) { + System.out.println(response.getMessage().getContent()); + } + } + + @Test + public void testChat() { + OpenAIChatConfig config = getOpenAIChatConfig(); + config.setSupportImage(false); + ChatModel chatModel = new OpenAIChatModel(config); + + SimplePrompt prompt = new SimplePrompt("你叫什么名字"); + prompt.addImageUrl("http://www.codeformat.cn/static/images/logo.png"); + + AiMessageResponse response = chatModel.chat(prompt); + if (!response.isError()) { + System.out.println(response.getMessage().getContent()); + } + + } + +} diff --git a/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/OpenAIChatModelTest.java b/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/OpenAIChatModelTest.java new file mode 100644 index 0000000..07667f7 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/OpenAIChatModelTest.java @@ -0,0 +1,500 @@ +package com.easyagents.llm.openai; + +import com.easyagents.core.agent.react.ReActAgent; +import com.easyagents.core.agent.react.ReActAgentListener; +import com.easyagents.core.agent.react.ReActAgentState; +import com.easyagents.core.agent.react.ReActStep; +import com.easyagents.core.memory.ChatMemory; +import com.easyagents.core.message.ToolCall; +import com.easyagents.core.message.ToolMessage; +import com.easyagents.core.message.UserMessage; +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.model.chat.ChatOptions; +import com.easyagents.core.model.chat.StreamResponseListener; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.model.chat.tool.Tool; +import com.easyagents.core.model.chat.tool.ToolScanner; +import com.easyagents.core.model.client.StreamContext; +import com.easyagents.core.model.exception.ModelException; +import com.easyagents.core.prompt.SimplePrompt; +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class OpenAIChatModelTest { + + @Test(expected = ModelException.class) + public void testChat() { + + String output = OpenAIChatConfig.builder() + .endpoint("https://ai.gitee.com") + .provider("GiteeAI") + .model("Qwen3-32B") + .apiKey("PXW1****D12") + .buildModel() + .chat("你叫什么名字"); + + System.out.println(output); + } + + @Test() + public void testChatStream() { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setApiKey("PXW1GXE***"); + config.setEndpoint("https://ai.gitee.com"); + config.setModel("Qwen3-32B"); + config.setLogEnabled(true); + + ChatOptions options = ChatOptions.builder().thinkingEnabled(false).build(); + + ChatModel chatModel = new OpenAIChatModel(config); + + ChatModelTestUtils.waitForStream(chatModel, "你叫什么名字", new StreamResponseListener() { + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + System.out.println(response.getMessage().getContent()); + } + + @Override + public void onFailure(StreamContext context, Throwable throwable) { + System.out.println("onFailure>>>>" + throwable); + } + + @Override + public void onStop(StreamContext context) { + System.out.println("stop!!!!"); + } + }, options); + + } + + + @Test() + public void testChatStreamBailian() { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setApiKey("sk-32ab******57502"); + config.setEndpoint("https://dashscope.aliyuncs.com"); + config.setRequestPath("/compatible-mode/v1/chat/completions"); + config.setModel("qwen3-max"); + ChatModel chatModel = new OpenAIChatModel(config); + + SimplePrompt prompt = new SimplePrompt("北京的天气如何?"); + prompt.addToolsFromClass(WeatherFunctions.class); + + ChatModelTestUtils.waitForStream(chatModel, prompt, new StreamResponseListener() { + @Override + public void onFailure(StreamContext context, Throwable throwable) { + System.out.println("onFailure>>>>" + throwable); + } + + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + if (response.getMessage().getContent() == null) { + System.out.println(response.getMessage()); + } + + if (response.getMessage().isFinalDelta()) { + List toolCalls = response.getMessage().getToolCalls(); + System.out.println(toolCalls); + } + + System.out.println("onMessage >>>>>" + response.getMessage().getContent()); + } + + @Override + public void onStop(StreamContext context) { + System.out.println("stop!!!!"); + } + }); + + } + + @Test + public void testChatOllama() { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setEndpoint("http://localhost:11434"); + config.setModel("llama3"); + config.setLogEnabled(true); + + ChatModel chatModel = new OpenAIChatModel(config); + chatModel.chatStream("who are you", new StreamResponseListener() { + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + System.out.println(response.getMessage().getContent()); + } + + @Override + public void onStop(StreamContext context) { + System.out.println("stop!!!!"); + } + }); + +// try { +// Thread.sleep(2000); +// } catch (InterruptedException e) { +// throw new RuntimeException(e); +// } + } + + + @Test() + public void testChatWithImage() { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setApiKey("sk-5gqOcl*****"); + config.setModel("gpt-4-turbo"); + + + ChatModel chatModel = new OpenAIChatModel(config); + SimplePrompt prompt = new SimplePrompt("What's in this image?"); + prompt.addImageUrl("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"); + + + AiMessageResponse response = chatModel.chat(prompt); + System.out.println(response); + } + + + @Test() + public void testFunctionCalling1() throws InterruptedException { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setApiKey("sk-rts5NF6n*******"); + + OpenAIChatModel llm = new OpenAIChatModel(config); + + SimplePrompt prompt = new SimplePrompt("今天北京的天气怎么样"); + prompt.addToolsFromClass(WeatherFunctions.class); + AiMessageResponse response = llm.chat(prompt); + + System.out.println(response.executeToolCallsAndGetResults()); + // 阴转多云 + } + + @Test() + public void testFunctionCalling2() throws InterruptedException { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setApiKey("sk-rts5NF6n*******"); + + OpenAIChatModel llm = new OpenAIChatModel(config); + + SimplePrompt prompt = new SimplePrompt("今天北京的天气怎么样"); + prompt.addToolsFromClass(WeatherFunctions.class); + AiMessageResponse response = llm.chat(prompt); + + if (response.hasToolCalls()) { + prompt.setToolMessages(response.executeToolCallsAndGetToolMessages()); + AiMessageResponse response1 = llm.chat(prompt); + System.out.println(response1.getMessage().getContent()); + } else { + System.out.println(response); + } + } + + @Test() + public void testFunctionCalling3() throws InterruptedException { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setLogEnabled(true); + config.setEndpoint("https://ark.cn-beijing.volces.com"); + config.setRequestPath("/api/v3/chat/completions"); + config.setModel("deepseek-v3-250324"); + config.setApiKey("2d57a"); + + OpenAIChatModel llm = new OpenAIChatModel(config); + + SimplePrompt prompt = new SimplePrompt("今天北京的天气怎么样"); + prompt.addToolsFromClass(WeatherFunctions.class); + AiMessageResponse response = llm.chat(prompt); + + if (response.hasToolCalls()) { + prompt.setToolMessages(response.executeToolCallsAndGetToolMessages()); + AiMessageResponse response1 = llm.chat(prompt); + System.out.println(response1.getMessage().getContent()); + } else { + System.out.println(response); + } + } + + @Test() + public void testFunctionCalling4() throws InterruptedException { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setLogEnabled(true); + config.setEndpoint("https://ark.cn-beijing.volces.com"); + config.setRequestPath("/api/v3/chat/completions"); + config.setModel("deepseek-v3-250324"); + config.setApiKey("2d57aa75"); + + OpenAIChatModel llm = new OpenAIChatModel(config); + + SimplePrompt prompt = new SimplePrompt("今天北京的天气怎么样"); + prompt.addToolsFromClass(WeatherFunctions.class); + llm.chatStream(prompt, new StreamResponseListener() { + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + System.out.println(" onMessage >>>>>" + response.hasToolCalls()); + } + }); + + TimeUnit.SECONDS.sleep(5); + } + + @Test() + public void testFunctionCalling44() throws InterruptedException { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setLogEnabled(true); + config.setEndpoint("https://ark.cn-beijing.volces.com"); + config.setRequestPath("/api/v3/chat/completions"); + config.setModel("deepseek-v3-250324"); + config.setApiKey("2d5"); + + OpenAIChatModel llm = new OpenAIChatModel(config); + + SimplePrompt prompt = new SimplePrompt("今天北京的天气怎么样"); + prompt.addToolsFromClass(WeatherFunctions.class); + llm.chatStream(prompt, new StreamResponseListener() { + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + System.out.println(" onMessage >>>>>" + response.hasToolCalls()); + } + }); + + TimeUnit.SECONDS.sleep(5); + } + + @Test() + public void testFunctionCalling444() throws InterruptedException { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setLogEnabled(true); + config.setEndpoint("https://ai.gitee.com"); +// config.setRequestPath("/api/v3/chat/completions"); +// config.setModel("Qwen3-32B"); + config.setModel("DeepSeek-V3.2"); + config.setApiKey("PXW1G***L7D12"); +// config.setLogEnabled(false); + + OpenAIChatModel llm = new OpenAIChatModel(config); + + + SimplePrompt prompt = new SimplePrompt("北京和上海的天气怎么样"); + prompt.addToolsFromClass(WeatherFunctions.class); + llm.chatStream(prompt, new StreamResponseListener() { + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + +// System.out.println("onMessage11 >>>>>" + response); + if (response.getMessage().isFinalDelta() && response.hasToolCalls()) { + System.out.println(":::::::: start...."); + List toolMessages = response.executeToolCallsAndGetToolMessages(); + prompt.setAiMessage(response.getMessage()); + prompt.setToolMessages(toolMessages); + llm.chatStream(prompt, new StreamResponseListener() { + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + String msg = response.getMessage().getContent() != null ? response.getMessage().getContent() : response.getMessage().getReasoningContent(); + System.out.println(":::22" + msg); + } + + @Override + public void onStop(StreamContext context) { + System.out.println("onStop >>>>>"); + } + }); + } else { + String msg = response.getMessage().getContent() != null ? response.getMessage().getContent() : response.getMessage().getReasoningContent(); + System.out.println(">>>" + msg); + } + } + }); + + TimeUnit.SECONDS.sleep(25); + + } + + + @Test() + public void testFunctionCalling5() throws InterruptedException { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setLogEnabled(true); + config.setEndpoint("https://ai.gitee.com"); + config.setModel("Qwen3-32B"); + config.setApiKey("PXW1G*********D12"); + + OpenAIChatModel llm = new OpenAIChatModel(config); + + SimplePrompt prompt = new SimplePrompt("北京和上海的天气怎么样"); + prompt.addToolsFromClass(WeatherFunctions.class); + llm.chatStream(prompt, new StreamResponseListener() { + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { +// System.out.println("onMessage >>>>>" + response); + } + }); + + TimeUnit.SECONDS.sleep(25); + } + + + @Test() + public void testFunctionCalling55() throws InterruptedException { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setLogEnabled(true); + config.setEndpoint("https://ai.gitee.com"); + config.setModel("Qwen3-32B"); +// config.setModel("DeepSeek-V3"); +// config.setSupportToolMessage(false); + config.setApiKey("PXW1"); + + + OpenAIChatModel llm = new OpenAIChatModel(config); + + SimplePrompt prompt = new SimplePrompt("/no_think 北京和上海的天气怎么样"); + prompt.addToolsFromClass(WeatherFunctions.class); + + + llm.chatStream(prompt, new StreamResponseListener() { + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + if (response.getMessage().isFinalDelta() && response.hasToolCalls()) { + System.out.println(":::::::: start...."); + prompt.setAiMessage(response.getMessage()); + prompt.setToolMessages(response.executeToolCallsAndGetToolMessages()); + llm.chatStream(prompt, new StreamResponseListener() { + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + String msg = response.getMessage().getContent() != null ? response.getMessage().getContent() : response.getMessage().getReasoningContent(); + System.out.println(":::" + msg); + } + }); + } else { + String msg = response.getMessage().getContent() != null ? response.getMessage().getContent() : response.getMessage().getReasoningContent(); + System.out.println(">>>" + msg); + } + } + }); + + TimeUnit.SECONDS.sleep(25); + } + + @Test() + public void testFunctionCalling6() throws InterruptedException { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setLogEnabled(true); + config.setEndpoint("https://ai.gitee.com"); + config.setModel("Qwen3-32B"); + config.setApiKey("PXW1"); + + OpenAIChatModel llm = new OpenAIChatModel(config); + + SimplePrompt prompt = new SimplePrompt("/no_think 北京和上海的天气怎么样"); + prompt.addToolsFromClass(WeatherFunctions.class); + AiMessageResponse response = llm.chat(prompt); + + prompt.setToolMessages(response.executeToolCallsAndGetToolMessages()); + + System.out.println(llm.chat(prompt)); + + } + + + @Test() + public void testReAct1() throws InterruptedException { + OpenAIChatConfig config = new OpenAIChatConfig(); +// config.setDebug(true); + config.setEndpoint("https://ai.gitee.com"); + config.setModel("Qwen3-32B"); + config.setApiKey("****"); + + OpenAIChatModel llm = new OpenAIChatModel(config); + + List tools = ToolScanner.scan(WeatherFunctions.class); +// ReActAgent reActAgent = new ReActAgent(llm, functions, "北京和上海的天气怎么样?"); + ReActAgent reActAgent = new ReActAgent(llm, tools, "介绍一下北京"); + reActAgent.addListener(new ReActAgentListener() { + + @Override + public void onActionStart(ReActStep step) { + System.out.println(">>>>>>" + step.getThought()); + System.out.println("正在调用工具 >>>>> " + step.getAction() + ":" + step.getActionInput()); + } + + @Override + public void onActionEnd(ReActStep step, Object result) { + System.out.println("工具调用结束 >>>>> " + step.getAction() + ":" + step.getActionInput() + ">>>>结果:" + result); + } + + @Override + public void onFinalAnswer(String finalAnswer) { + System.out.println("onFinalAnswer >>>>>" + finalAnswer); + } + + @Override + public void onNonActionResponse(AiMessageResponse response) { + System.out.println("onNonActionResponse >>>>>" + response.getMessage().getContent()); + } + }); + + reActAgent.execute(); + } + + + @Test() + public void testReAct2() throws InterruptedException { + OpenAIChatConfig config = new OpenAIChatConfig(); +// config.setDebug(true); + config.setEndpoint("https://ai.gitee.com"); + config.setModel("Qwen2-72B-Instruct"); + config.setApiKey("*****"); + + OpenAIChatModel llm = new OpenAIChatModel(config); + + List tools = ToolScanner.scan(WeatherFunctions.class); + ReActAgent reActAgent = new ReActAgent(llm, tools, "今天的天气怎么样?"); +// reActAgent.setStreamable(true); + reActAgent.addListener(new ReActAgentListener() { + + @Override + public void onChatResponseStream(StreamContext context, AiMessageResponse response) { +// System.out.print(response.getMessage().getContent()); + } + + @Override + public void onRequestUserInput(String question) { + System.out.println("onRequestUserInput>>>" + question); + + ReActAgentState state = reActAgent.getState(); + state.addMessage(new UserMessage("我在北京市")); + ReActAgent newAgent = new ReActAgent(llm, tools, state); + newAgent.addListener(this); + newAgent.execute(); + } + + @Override + public void onActionStart(ReActStep step) { + System.out.println(">>>>>>" + step.getThought()); + System.out.println("正在调用工具 >>>>> " + step.getAction() + ":" + step.getActionInput()); + } + + @Override + public void onActionEnd(ReActStep step, Object result) { + System.out.println("工具调用结束 >>>>> " + step.getAction() + ":" + step.getActionInput() + ">>>>结果:" + result); + } + + @Override + public void onFinalAnswer(String finalAnswer) { + System.out.println("onFinalAnswer >>>>>" + finalAnswer); + ChatMemory memory = reActAgent.getMemoryPrompt().getMemory(); + System.out.println(memory); + } + + @Override + public void onNonActionResponseStream(StreamContext context) { + System.out.println("onNonActionResponseStream >>>>>" + context); + } + }); + + reActAgent.execute(); + + TimeUnit.SECONDS.sleep(20); + } + + +} diff --git a/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/WeatherFunctions.java b/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/WeatherFunctions.java new file mode 100644 index 0000000..6f1e757 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-openai/src/test/java/com/easyagents/llm/openai/WeatherFunctions.java @@ -0,0 +1,23 @@ +package com.easyagents.llm.openai; + +import com.easyagents.core.model.chat.tool.annotation.ToolDef; +import com.easyagents.core.model.chat.tool.annotation.ToolParam; + +import java.util.concurrent.ThreadLocalRandom; + +public class WeatherFunctions { + + private static final String[] weathers = { + "晴", "多云", "阴", "小雨", "中雨", "大雨", "暴雨", "雷阵雨", + "小雪", "中雪", "大雪", "暴雪", "雨夹雪", "雾", "霾", "沙尘暴", + "冰雹", "阵雨", "冻雨", "晴间多云", "局部多云", "强对流" + }; + + @ToolDef(name = "get_the_weather_info", description = "get the weather info") + public static String getWeatherInfo(@ToolParam(name = "city", description = "the city name") String name) { + String weather = weathers[ThreadLocalRandom.current().nextInt(weathers.length)]; + System.out.println(">>>>>>>>>>>>>>!!!!!!" + name + ":" + weather); + return weather; + } + +} diff --git a/easy-agents-chat/easy-agents-chat-qwen/pom.xml b/easy-agents-chat/easy-agents-chat-qwen/pom.xml new file mode 100644 index 0000000..d7e5ccd --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-qwen/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + com.easyagents + easy-agents-chat + ${revision} + + + easy-agents-chat-qwen + easy-agents-chat-qwen + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + compile + + + junit + junit + test + + + + diff --git a/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatConfig.java b/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatConfig.java new file mode 100644 index 0000000..a438668 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatConfig.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.llm.qwen; + +import com.easyagents.core.model.chat.ChatConfig; + +public class QwenChatConfig extends ChatConfig { + + private static final String DEFAULT_MODEL = "qwen-turbo"; + private static final String DEFAULT_ENDPOINT = "https://dashscope.aliyuncs.com"; + private static final String DEFAULT_REQUEST_PATH = "/compatible-mode/v1/chat/completions"; + + public QwenChatConfig() { + setEndpoint(DEFAULT_ENDPOINT); + setRequestPath(DEFAULT_REQUEST_PATH); + setModel(DEFAULT_MODEL); + } + +} diff --git a/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatModel.java b/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatModel.java new file mode 100644 index 0000000..e903678 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatModel.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.llm.qwen; + +import com.easyagents.core.model.chat.OpenAICompatibleChatModel; +import com.easyagents.core.model.chat.ChatInterceptor; +import com.easyagents.core.model.chat.GlobalChatInterceptors; +import com.easyagents.core.model.client.ChatRequestSpecBuilder; + +import java.util.List; + +public class QwenChatModel extends OpenAICompatibleChatModel { + + + /** + * 构造一个聊天模型实例,不使用实例级拦截器。 + * + * @param config 聊天模型配置 + */ + public QwenChatModel(QwenChatConfig config) { + super(config); + } + + /** + * 构造一个聊天模型实例,并指定实例级拦截器。 + *

+ * 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并, + * 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。 + * + * @param config 聊天模型配置 + * @param userInterceptors 实例级拦截器列表 + */ + public QwenChatModel(QwenChatConfig config, List userInterceptors) { + super(config, userInterceptors); + } + + + @Override + public ChatRequestSpecBuilder getChatRequestSpecBuilder() { + return new QwenRequestSpecBuilder(); + } +} diff --git a/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatOptions.java b/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatOptions.java new file mode 100644 index 0000000..050699e --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenChatOptions.java @@ -0,0 +1,303 @@ +package com.easyagents.llm.qwen; + +import com.easyagents.core.model.chat.ChatOptions; +import com.alibaba.fastjson2.annotation.JSONField; + +import java.util.List; + +/** + * 通义千问API参考 + * + * @author liutf + */ +public class QwenChatOptions extends ChatOptions { + /** + * 输出数据的模态,仅支持 Qwen-Omni 模型指定。(可选) + * 默认值为["text"] + * 可选值: + * ["text"]:输出文本。 + */ + private List modalities; + + /** + * 控制模型生成文本时的内容重复度。(可选) + * 取值范围:[-2.0, 2.0]。正数会减少重复度,负数会增加重复度。 + *

+     * 适用场景:
+     *      较高的presence_penalty适用于要求多样性、趣味性或创造性的场景,如创意写作或头脑风暴。
+     *      较低的presence_penalty适用于要求一致性或专业术语的场景,如技术文档或其他正式文档。
+     * 
+ * 不建议修改QVQ模型的默认presence_penalty值。 + */ + private Float presencePenalty; + + /** + * 生成响应的个数,取值范围是1-4。 + * 对于需要生成多个响应的场景(如创意写作、广告文案等),可以设置较大的 n 值。 + *
+     *     当前仅支持 qwen-plus 模型,且在传入 tools 参数时固定为1。
+     *     设置较大的 n 值不会增加输入 Token 消耗,会增加输出 Token 的消耗。
+     * 
+ */ + private Integer n; + + /** + * 是否开启并行工具调用。 + * 参数为true时开启,为false时不开启。 + * 并行工具调用请参见:https://help.aliyun.com/zh/model-studio/qwen-function-calling#cb6b5c484bt4x + */ + private Boolean parallelToolCalls; + + /** + * 当您使用翻译模型时需要配置的翻译参数。 + */ + private TranslationOptions translationOptions; + + /** + * 用于控制模型在生成文本时是否使用互联网搜索结果进行参考。 + * 取值如下: + * True:启用互联网搜索,模型会将搜索结果作为文本生成过程中的参考信息,但模型会基于其内部逻辑判断是否使用互联网搜索结果。 + * False(默认):关闭互联网搜索。 + * 启用互联网搜索功能可能会增加 Token 的消耗。 + * 当前支持 qwen-max、qwen-plus、qwen-turbo + */ + private Boolean enableSearch; + + + /** + * 思考模式预算,适用于 Qwen3 模型。 + * 思考过程的最大长度,只在 enable_thinking 为 true 时生效。适用于 Qwen3 的商业版与开源版模型。 + * 详情请参见限制思考长度:https://help.aliyun.com/zh/model-studio/deep-thinking#e7c0002fe4meu + */ + private Integer thinkingBudget; + + /** + * 联网搜索的策略。 + * 仅当enable_search为true时生效。 + */ + private SearchOptions searchOptions; + + public static class TranslationOptions { + /** + * (必选) + * 源语言的英文全称 + * 您可以将source_lang设置为"auto",模型会自动判断输入文本属于哪种语言。 + * 支持的语言: https://help.aliyun.com/zh/model-studio/user-guide/machine-translation#038d2865bbydc + */ + @JSONField(name = "source_lang") + private String sourceLang; + + /** + * (必选) + * 目标语言的英文全称, + * 支持的语言: https://help.aliyun.com/zh/model-studio/user-guide/machine-translation#038d2865bbydc + */ + @JSONField(name = "target_lang") + private String targetLang; + + /** + * 在使用术语干预翻译功能时需要设置的术语数组。 + * https://help.aliyun.com/zh/model-studio/user-guide/machine-translation#2bf54a5ab5voe + */ + @JSONField(name = "terms") + private List terms; + + /** + * 在使用翻译记忆功能时需要设置的翻译记忆数组。 + * https://help.aliyun.com/zh/model-studio/user-guide/machine-translation#17e15234e7gfp + */ + @JSONField(name = "tm_list") + private List tmList; + + /** + * (可选) + * 在使用领域提示功能时需要设置的领域提示语句。 + * 领域提示语句暂时只支持英文。 + * https://help.aliyun.com/zh/model-studio/user-guide/machine-translation#4af23a31db7lf + */ + @JSONField(name = "domains") + private String domains; + + public String getSourceLang() { + return sourceLang; + } + + public TranslationOptions setSourceLang(String sourceLang) { + this.sourceLang = sourceLang; + return this; + } + + public String getTargetLang() { + return targetLang; + } + + public TranslationOptions setTargetLang(String targetLang) { + this.targetLang = targetLang; + return this; + } + + public List getTerms() { + return terms; + } + + public TranslationOptions setTerms(List terms) { + this.terms = terms; + return this; + } + + public List getTmList() { + return tmList; + } + + public TranslationOptions setTmList(List tmList) { + this.tmList = tmList; + return this; + } + + public String getDomains() { + return domains; + } + + public TranslationOptions setDomains(String domains) { + this.domains = domains; + return this; + } + } + + public static class TranslationOptionsExt { + /** + * 源语言的术语/源语言的语句 + */ + private String source; + /** + * 目标语言的术语/目标语言的语句 + */ + private String target; + + public String getSource() { + return source; + } + + public TranslationOptionsExt setSource(String source) { + this.source = source; + return this; + } + + public String getTarget() { + return target; + } + + public TranslationOptionsExt setTarget(String target) { + this.target = target; + return this; + } + } + + + public static class SearchOptions { + /** + * 是否强制开启搜索。(可选)默认值为false + * 参数值:true=强制开启;false=不强制开启。 + */ + @JSONField(name = "forced_search") + private Boolean forcedSearch; + + /** + * 搜索互联网信息的数量,(可选)默认值为"standard" + * 参数值: + * standard:在请求时搜索5条互联网信息; + * pro:在请求时搜索10条互联网信息。 + */ + @JSONField(name = "search_strategy") + private String searchStrategy; + + public Boolean getForcedSearch() { + return forcedSearch; + } + + public SearchOptions setForcedSearch(Boolean forcedSearch) { + this.forcedSearch = forcedSearch; + return this; + } + + public String getSearchStrategy() { + return searchStrategy; + } + + public SearchOptions setSearchStrategy(String searchStrategy) { + this.searchStrategy = searchStrategy; + return this; + } + } + + public List getModalities() { + return modalities; + } + + public QwenChatOptions setModalities(List modalities) { + this.modalities = modalities; + return this; + } + + public Float getPresencePenalty() { + return presencePenalty; + } + + public QwenChatOptions setPresencePenalty(Float presencePenalty) { + this.presencePenalty = presencePenalty; + return this; + } + + public Integer getN() { + return n; + } + + public QwenChatOptions setN(Integer n) { + this.n = n; + return this; + } + + public Boolean getParallelToolCalls() { + return parallelToolCalls; + } + + public QwenChatOptions setParallelToolCalls(Boolean parallelToolCalls) { + this.parallelToolCalls = parallelToolCalls; + return this; + } + + public TranslationOptions getTranslationOptions() { + return translationOptions; + } + + public QwenChatOptions setTranslationOptions(TranslationOptions translationOptions) { + this.translationOptions = translationOptions; + return this; + } + + public Boolean getEnableSearch() { + return enableSearch; + } + + public QwenChatOptions setEnableSearch(Boolean enableSearch) { + this.enableSearch = enableSearch; + return this; + } + + public Integer getThinkingBudget() { + return thinkingBudget; + } + + public void setThinkingBudget(Integer thinkingBudget) { + this.thinkingBudget = thinkingBudget; + } + + public SearchOptions getSearchOptions() { + return searchOptions; + } + + public QwenChatOptions setSearchOptions(SearchOptions searchOptions) { + this.searchOptions = searchOptions; + return this; + } +} diff --git a/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenRequestSpecBuilder.java b/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenRequestSpecBuilder.java new file mode 100644 index 0000000..52580b4 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-qwen/src/main/java/com/easyagents/llm/qwen/QwenRequestSpecBuilder.java @@ -0,0 +1,30 @@ +package com.easyagents.llm.qwen; + +import com.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.ChatOptions; +import com.easyagents.core.model.client.OpenAIChatRequestSpecBuilder; +import com.easyagents.core.prompt.Prompt; +import com.easyagents.core.util.CollectionUtil; +import com.easyagents.core.util.Maps; + +public class QwenRequestSpecBuilder extends OpenAIChatRequestSpecBuilder { + + @Override + protected Maps buildBaseParamsOfRequestBody(Prompt prompt, ChatOptions options, ChatConfig config) { + Maps params = super.buildBaseParamsOfRequestBody(prompt, options, config); + if (options instanceof QwenChatOptions) { + QwenChatOptions op = (QwenChatOptions) options; + params.setIf(CollectionUtil.hasItems(op.getModalities()), "modalities", op.getModalities()); + params.setIf(op.getPresencePenalty() != null, "presence_penalty", op.getPresencePenalty()); + params.setIf(op.getResponseFormat() != null, "response_format", op.getResponseFormat()); + params.setIf(op.getN() != null, "n", op.getN()); + params.setIf(op.getParallelToolCalls() != null, "parallel_tool_calls", op.getParallelToolCalls()); + params.setIf(op.getTranslationOptions() != null, "translation_options", op.getTranslationOptions()); + params.setIf(op.getEnableSearch() != null, "enable_search", op.getEnableSearch()); + params.setIf(op.getEnableSearch() != null && op.getEnableSearch() && op.getSearchOptions() != null, "search_options", op.getSearchOptions()); + params.setIf(op.getThinkingEnabled() != null, "enable_thinking", op.getThinkingEnabled()); + params.setIf(op.getThinkingEnabled() != null && op.getThinkingEnabled() && op.getThinkingBudget() != null, "thinking_budget", op.getThinkingBudget()); + } + return params; + } +} diff --git a/easy-agents-chat/easy-agents-chat-qwen/src/test/java/com/easyagents/llm/qwen/test/QwenTest.java b/easy-agents-chat/easy-agents-chat-qwen/src/test/java/com/easyagents/llm/qwen/test/QwenTest.java new file mode 100644 index 0000000..2d8f08f --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-qwen/src/test/java/com/easyagents/llm/qwen/test/QwenTest.java @@ -0,0 +1,113 @@ +package com.easyagents.llm.qwen.test; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.model.chat.ChatOptions; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.model.exception.ModelException; +import com.easyagents.core.prompt.SimplePrompt; + +import com.easyagents.llm.qwen.QwenChatConfig; +import com.easyagents.llm.qwen.QwenChatModel; +import com.easyagents.llm.qwen.QwenChatOptions; +import com.easyagents.llm.qwen.QwenChatOptions.SearchOptions; +import org.junit.Test; + +public class QwenTest { + + public static void main(String[] args) throws InterruptedException { + QwenChatConfig config = new QwenChatConfig(); + + //https://bailian.console.aliyun.com/?apiKey=1#/api-key + config.setApiKey("sk-28a6be3236****"); + config.setModel("qwen-plus"); + + ChatModel chatModel = new QwenChatModel(config); + chatModel.chatStream("请写一个小兔子战胜大灰狼的故事", (context, response) -> { + AiMessage message = response.getMessage(); + System.out.println(">>>> " + message.getContent()); + }); + + Thread.sleep(10000); + } + + @Test(expected = ModelException.class) + public void testForcedSearch() throws InterruptedException { + QwenChatConfig config = new QwenChatConfig(); + config.setApiKey("sk-28a6be3236****"); + config.setModel("qwen-max"); + + ChatModel chatModel = new QwenChatModel(config); + QwenChatOptions options = new QwenChatOptions(); + options.setEnableSearch(true); + options.setSearchOptions(new SearchOptions().setForcedSearch(true)); + + String responseStr = chatModel.chat("今天是几号?", options); + + System.out.println(responseStr); + } + + @Test + public void testFunctionCalling() throws InterruptedException { + QwenChatConfig config = new QwenChatConfig(); + config.setApiKey("sk-28a6be3236****"); + config.setModel("qwen-turbo"); + + ChatModel chatModel = new QwenChatModel(config); + + SimplePrompt prompt = new SimplePrompt("今天北京的天气怎么样"); + prompt.addToolsFromClass(WeatherFunctions.class); + AiMessageResponse response = chatModel.chat(prompt); + + System.out.println(response.executeToolCallsAndGetResults()); + // "Today it will be dull and overcast in 北京" + } + + /** + * 动态替换模型 + */ + @Test + public void testDynamicModel() throws InterruptedException { + // 默认模型 + QwenChatConfig config = new QwenChatConfig(); + config.setApiKey("sk-28a6be3236****"); + config.setModel("qwen-turbo"); + + // 运行时动态替换模型 + ChatOptions options = new QwenChatOptions(); + options.setModel("deepseek-r1"); + + ChatModel chatModel = new QwenChatModel(config); + chatModel.chatStream("请写一个小兔子战胜大灰狼的故事", (context, response) -> { + AiMessage message = response.getMessage(); + System.err.println(message.getReasoningContent()); + System.out.println(message.getFullContent()); + System.out.println(); + }, options); + Thread.sleep(10000); + } + + /** + * 测试千问3 开启思考模式的开关 + */ + @Test + public void testQwen3Thinking() throws InterruptedException { + QwenChatConfig config = new QwenChatConfig(); + config.setApiKey("sk-28a6be3236****"); + config.setModel("qwen3-235b-a22b"); + + ChatModel chatModel = new QwenChatModel(config); + QwenChatOptions options = new QwenChatOptions(); + options.setThinkingEnabled(false); + //options.setThinkingBudget(1024); + + chatModel.chatStream("你是谁", (context, response) -> { + AiMessage message = response.getMessage(); + System.err.println(message.getReasoningContent()); + System.out.println(message.getFullContent()); + System.out.println(); + }, options); + Thread.sleep(10000); + } + +} diff --git a/easy-agents-chat/easy-agents-chat-qwen/src/test/java/com/easyagents/llm/qwen/test/WeatherFunctions.java b/easy-agents-chat/easy-agents-chat-qwen/src/test/java/com/easyagents/llm/qwen/test/WeatherFunctions.java new file mode 100644 index 0000000..0fb3c56 --- /dev/null +++ b/easy-agents-chat/easy-agents-chat-qwen/src/test/java/com/easyagents/llm/qwen/test/WeatherFunctions.java @@ -0,0 +1,14 @@ +package com.easyagents.llm.qwen.test; + +import com.easyagents.core.model.chat.tool.annotation.ToolDef; +import com.easyagents.core.model.chat.tool.annotation.ToolParam; + +public class WeatherFunctions { + + @ToolDef(name = "get_the_weather_info", description = "get the weather info") + public static String getWeatherInfo( + @ToolParam(name = "city", description = "the city name") String name + ) { + return "Today it will be dull and overcast in " + name; + } +} diff --git a/easy-agents-chat/pom.xml b/easy-agents-chat/pom.xml new file mode 100644 index 0000000..d2de989 --- /dev/null +++ b/easy-agents-chat/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-chat + easy-agents-chat + + pom + + easy-agents-chat-openai + easy-agents-chat-qwen + easy-agents-chat-ollama + easy-agents-chat-deepseek + + + + 8 + 8 + UTF-8 + + + diff --git a/easy-agents-core/pom.xml b/easy-agents-core/pom.xml new file mode 100644 index 0000000..3256372 --- /dev/null +++ b/easy-agents-core/pom.xml @@ -0,0 +1,120 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-core + easy-agents-core + + + 5.5.1 + 8 + 8 + UTF-8 + + + + + + com.squareup.okhttp3 + okhttp + + + + com.squareup.okhttp3 + okhttp-sse + + + + com.knuddels + jtokkit + 1.1.0 + + + + com.alibaba.fastjson2 + fastjson2 + + + + org.slf4j + slf4j-api + + + + + org.apache.poi + poi + ${poi.version} + + + + org.apache.poi + poi-ooxml + ${poi.version} + + + + org.apache.poi + poi-scratchpad + ${poi.version} + + + + org.apache.pdfbox + pdfbox + 2.0.30 + + + + org.jsoup + jsoup + 1.18.1 + + + + com.google.code.gson + gson + 2.11.0 + compile + + + + + + io.opentelemetry + opentelemetry-api + + + + + io.opentelemetry + opentelemetry-sdk + + + + + io.opentelemetry + opentelemetry-exporter-logging + + + + io.opentelemetry + opentelemetry-exporter-otlp + + + + junit + junit + test + + + + + diff --git a/easy-agents-core/src/main/java/com/easyagents/core/Consts.java b/easy-agents-core/src/main/java/com/easyagents/core/Consts.java new file mode 100644 index 0000000..e8431f4 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/Consts.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core; + +public class Consts { + + public static final String VERSION = "2.0.0-rc.6"; +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/IAgent.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/IAgent.java new file mode 100644 index 0000000..0f08c74 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/IAgent.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.agent; + +public interface IAgent { + + void execute(); + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgent.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgent.java new file mode 100644 index 0000000..2e89959 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgent.java @@ -0,0 +1,564 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.agent.react; + +import com.easyagents.core.agent.IAgent; +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.Message; +import com.easyagents.core.message.ToolCall; +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.model.chat.ChatOptions; +import com.easyagents.core.model.chat.StreamResponseListener; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.model.chat.tool.Tool; +import com.easyagents.core.model.chat.tool.ToolExecutor; +import com.easyagents.core.model.chat.tool.ToolInterceptor; +import com.easyagents.core.model.client.StreamContext; +import com.easyagents.core.prompt.MemoryPrompt; +import com.easyagents.core.util.StringUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +/** + * ReActAgent 是一个通用的 ReAct 模式 Agent,支持 Reasoning + Action 的交互方式。 + */ +public class ReActAgent implements IAgent { + + public static final String PARENT_AGENT_KEY = "__parent_react_agent"; + + private static final Logger log = LoggerFactory.getLogger(ReActAgent.class); + + private static final String DEFAULT_PROMPT_TEMPLATE = + "你是一个 ReAct Agent,结合 Reasoning(推理)和 Action(行动)来解决问题。\n" + + "但在处理用户问题时,请首先判断:\n" + + "1. 如果问题可以通过你的常识或已有知识直接回答 → 请忽略 ReAct 框架,直接输出自然语言回答。\n" + + "2. 如果问题需要调用特定工具才能解决(如查询、计算、获取外部信息等)→ 请严格按照 ReAct 格式响应。\n" + + "\n" + + "如果你选择使用 ReAct 模式,请遵循以下格式:\n" + + "Thought: 描述你对当前问题的理解,包括已知信息和缺失信息,说明你下一步将采取什么行动及其原因。\n" + + "Action: 从下方列出的工具中选择一个合适的工具,仅输出工具名称,不得虚构。\n" + + "Action Input: 使用标准 JSON 格式提供该工具所需的参数,确保字段名与工具描述一致。\n" + + "\n" + + "在 ReAct 模式下,如果你已获得足够信息可以直接回答用户,请输出:\n" + + "Final Answer: [你的回答]\n" + + "\n" + + "如果你发现用户的问题缺少关键信息(例如时间、地点、具体目标、主体信息等),且无法通过工具获取,\n" + + "请主动向用户提问,格式如下:\n" + + "Request: [你希望用户澄清的问题]" + + "\n" + + "注意事项:\n" + + "1. 每次只能选择一个工具并执行一个动作。\n" + + "2. 在未收到工具执行结果前,不要自行假设其输出。\n" + + "3. 不得编造工具或参数,所有工具均列于下方。\n" + + "4. 输出顺序必须为:Thought → Action → Action Input。\n" + + "\n" + + "### 可用工具列表:\n" + + "{tools}\n" + + "\n" + + "### 用户问题如下:\n" + + "{user_input}"; + + private static final int DEFAULT_MAX_ITERATIONS = 20; + + private final ChatModel chatModel; + private final List tools; + private final ReActAgentState state; + + private ReActStepParser reActStepParser = ReActStepParser.DEFAULT; // 默认解析器 + private final MemoryPrompt memoryPrompt; + private ChatOptions chatOptions; + private ReActMessageBuilder messageBuilder = new ReActMessageBuilder(); + + // 监听器集合 + private final List listeners = new ArrayList<>(); + + // 拦截器集合 + private final List toolInterceptors = new ArrayList<>(); + + + public ReActAgent(ChatModel chatModel, List tools, String userQuery) { + this.chatModel = chatModel; + this.tools = tools; + this.state = new ReActAgentState(); + this.state.userQuery = userQuery; + this.state.promptTemplate = DEFAULT_PROMPT_TEMPLATE; + this.state.maxIterations = DEFAULT_MAX_ITERATIONS; + this.memoryPrompt = new MemoryPrompt(); + } + + public ReActAgent(ChatModel chatModel, List tools, String userQuery, MemoryPrompt memoryPrompt) { + this.chatModel = chatModel; + this.tools = tools; + this.state = new ReActAgentState(); + this.state.userQuery = userQuery; + this.state.promptTemplate = DEFAULT_PROMPT_TEMPLATE; + this.state.maxIterations = DEFAULT_MAX_ITERATIONS; + this.memoryPrompt = memoryPrompt; + } + + public ReActAgent(ChatModel chatModel, List tools, ReActAgentState state) { + this.chatModel = chatModel; + this.tools = tools; + this.state = state; + this.memoryPrompt = new MemoryPrompt(); + if (state.messageHistory != null) { + this.memoryPrompt.addMessages(state.messageHistory); + } + } + + /** + * 注册监听器 + */ + public void addListener(ReActAgentListener listener) { + listeners.add(listener); + } + + /** + * 移除监听器 + */ + public void removeListener(ReActAgentListener listener) { + listeners.remove(listener); + } + + public void addToolInterceptor(ToolInterceptor interceptor) { + toolInterceptors.add(interceptor); + } + + public List getToolInterceptors() { + return toolInterceptors; + } + + public ChatModel getChatModel() { + return chatModel; + } + + public List getTools() { + return tools; + } + + public ReActStepParser getReActStepParser() { + return reActStepParser; + } + + public void setReActStepParser(ReActStepParser reActStepParser) { + this.reActStepParser = reActStepParser; + } + + public List getListeners() { + return listeners; + } + + public boolean isStreamable() { + return this.state.streamable; + } + + public void setStreamable(boolean streamable) { + this.state.streamable = streamable; + } + + + public MemoryPrompt getMemoryPrompt() { + return memoryPrompt; + } + + public ReActMessageBuilder getMessageBuilder() { + return messageBuilder; + } + + public void setMessageBuilder(ReActMessageBuilder messageBuilder) { + this.messageBuilder = messageBuilder; + } + + public ChatOptions getChatOptions() { + return chatOptions; + } + + public void setChatOptions(ChatOptions chatOptions) { + this.chatOptions = chatOptions; + } + + public ReActAgentState getState() { + state.messageHistory = memoryPrompt.getMessages(); + return state; + } + + /** + * 运行 ReAct Agent 流程 + */ + @Override + public void execute() { + try { + List messageHistory = state.getMessageHistory(); + if (messageHistory == null || messageHistory.isEmpty()) { + String toolsDescription = Util.buildToolsDescription(tools); + String prompt = state.promptTemplate + .replace("{tools}", toolsDescription) + .replace("{user_input}", state.userQuery); + + Message message = messageBuilder.buildStartMessage(prompt, tools, state.userQuery); + memoryPrompt.addMessage(message); + } + if (this.isStreamable()) { + startNextReActStepStream(); + } else { + startNextReactStepNormal(); + } + } catch (Exception e) { + log.error("运行 ReAct Agent 出错:" + e); + notifyOnError(e); + } + } + + private void startNextReactStepNormal() { + while (state.iterationCount < state.maxIterations) { + + state.iterationCount++; + + AiMessageResponse response = chatModel.chat(memoryPrompt, chatOptions); + notifyOnChatResponse(response); + + String content = response.getMessage().getContent(); + AiMessage message = new AiMessage(content); + + // 请求用户输入 + if (isRequestUserInput(content)) { + String question = extractRequestQuestion(content); + message.addMetadata("type", "reActRequest"); + memoryPrompt.addMessage(message); + notifyOnRequestUserInput(question); // 新增监听器回调 + break; // 暂停执行,等待用户回复 + } + // ReAct 动作 + else if (isReActAction(content)) { + message.addMetadata("type", "reActAction"); + memoryPrompt.addMessage(message); + if (!processReActSteps(content)) { + break; + } + } + + // 最终答案 + else if (isFinalAnswer(content)) { + String flag = reActStepParser.getFinalAnswerFlag(); + String answer = content.substring(content.indexOf(flag) + flag.length()); + message.addMetadata("type", "reActFinalAnswer"); + memoryPrompt.addMessage(message); + notifyOnFinalAnswer(answer); + break; + } + // 不是 Action + else { + memoryPrompt.addMessage(message); + notifyOnNonActionResponse(response); + break; + } + } + + // 显式通知达到最大迭代 + if (state.iterationCount >= state.maxIterations) { + notifyOnMaxIterationsReached(); + } + } + + + private void startNextReActStepStream() { + if (state.iterationCount >= state.maxIterations) { + notifyOnMaxIterationsReached(); + return; + } + + state.iterationCount++; + + chatModel.chatStream(memoryPrompt, new StreamResponseListener() { + + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + notifyOnChatResponseStream(context, response); + } + + @Override + public void onStop(StreamContext context) { + AiMessage lastAiMessage = context.getFullMessage(); + if (lastAiMessage == null) { + notifyOnError(new RuntimeException("没有收到任何回复")); + return; + } + + String content = lastAiMessage.getFullContent(); + if (StringUtil.noText(content)) { + notifyOnError(new RuntimeException("没有收到任何回复")); + return; + } + + AiMessage message = new AiMessage(content); + + // 请求用户输入 + if (isRequestUserInput(content)) { + String question = extractRequestQuestion(content); + message.addMetadata("type", "reActRequest"); + memoryPrompt.addMessage(message); + notifyOnRequestUserInput(question); // 新增监听器回调 + } + + // ReAct 动作 + else if (isReActAction(content)) { + message.addMetadata("type", "reActAction"); + memoryPrompt.addMessage(message); + if (processReActSteps(content)) { + // 递归继续执行下一个 ReAct 步骤 + startNextReActStepStream(); + } + } + + // 最终答案 + else if (isFinalAnswer(content)) { + message.addMetadata("type", "reActFinalAnswer"); + memoryPrompt.addMessage(message); + String flag = reActStepParser.getFinalAnswerFlag(); + String answer = content.substring(content.indexOf(flag) + flag.length()); + notifyOnFinalAnswer(answer); + } else { + memoryPrompt.addMessage(message); + // 不是 Action + notifyOnNonActionResponseStream(context); + } + } + + @Override + public void onFailure(StreamContext context, Throwable throwable) { + notifyOnError((Exception) throwable); + } + }, chatOptions); + } + + + private boolean isFinalAnswer(String content) { + return reActStepParser.isFinalAnswer(content); + } + + private boolean isReActAction(String content) { + return reActStepParser.isReActAction(content); + } + + private boolean isRequestUserInput(String content) { + return reActStepParser.isRequest(content); + } + + private String extractRequestQuestion(String content) { + return reActStepParser.extractRequestQuestion(content); + } + + + private boolean processReActSteps(String content) { + List reActSteps = reActStepParser.parse(content); + if (reActSteps.isEmpty()) { + notifyOnStepParseError(content); + return false; + } + + for (ReActStep step : reActSteps) { + boolean stepExecuted = false; + for (Tool tool : tools) { + if (tool.getName().equals(step.getAction())) { + try { + notifyOnActionStart(step); + + Object result = null; + try { + ToolCall toolCall = new ToolCall(); + toolCall.setId("react_call_" + state.iterationCount + "_" + System.currentTimeMillis()); + toolCall.setName(step.getAction()); + toolCall.setArguments(step.getActionInput()); + + ToolExecutor executor = new ToolExecutor(tool, toolCall, toolInterceptors); + + // 方便 “子Agent” 或者 tool, 获取当前的 ReActAgent + executor.addInterceptor((context, chain) -> { + context.setAttribute(PARENT_AGENT_KEY, ReActAgent.this); + return chain.proceed(context); + }); + + result = executor.execute(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + notifyOnActionEnd(step, result); + } + + Message message = messageBuilder.buildObservationMessage(step, result); + memoryPrompt.addMessage(message); + stepExecuted = true; + } catch (Exception e) { + log.error(e.toString(), e); + notifyOnActionInvokeError(e); + + if (!state.continueOnActionInvokeError) { + return false; + } + + Message message = messageBuilder.buildActionErrorMessage(step, e); + memoryPrompt.addMessage(message); + return true; + } + break; + } + } + + if (!stepExecuted) { + notifyOnActionNotMatched(step, tools); + return false; + } + } + + + return true; + } + + + // ========== 通知监听器的方法 ========== + private void notifyOnChatResponse(AiMessageResponse response) { + for (ReActAgentListener listener : listeners) { + try { + listener.onChatResponse(response); + } catch (Exception e) { + log.error(e.toString(), e); + } + } + } + + private void notifyOnNonActionResponse(AiMessageResponse response) { + for (ReActAgentListener listener : listeners) { + try { + listener.onNonActionResponse(response); + } catch (Exception e) { + log.error(e.toString(), e); + } + } + } + + private void notifyOnNonActionResponseStream(StreamContext context) { + for (ReActAgentListener listener : listeners) { + try { + listener.onNonActionResponseStream(context); + } catch (Exception e) { + log.error(e.toString(), e); + } + } + } + + private void notifyOnChatResponseStream(StreamContext context, AiMessageResponse response) { + for (ReActAgentListener listener : listeners) { + try { + listener.onChatResponseStream(context, response); + } catch (Exception e) { + log.error(e.toString(), e); + } + } + } + + private void notifyOnFinalAnswer(String finalAnswer) { + for (ReActAgentListener listener : listeners) { + try { + listener.onFinalAnswer(finalAnswer); + } catch (Exception e) { + log.error(e.toString(), e); + } + } + } + + private void notifyOnRequestUserInput(String question) { + for (ReActAgentListener listener : listeners) { + try { + listener.onRequestUserInput(question); + } catch (Exception e) { + log.error(e.toString(), e); + } + } + } + + private void notifyOnActionStart(ReActStep reActStep) { + for (ReActAgentListener listener : listeners) { + try { + listener.onActionStart(reActStep); + } catch (Exception e) { + log.error(e.toString(), e); + } + } + } + + private void notifyOnActionEnd(ReActStep reActStep, Object result) { + for (ReActAgentListener listener : listeners) { + try { + listener.onActionEnd(reActStep, result); + } catch (Exception e) { + log.error(e.toString(), e); + } + } + } + + private void notifyOnMaxIterationsReached() { + for (ReActAgentListener listener : listeners) { + try { + listener.onMaxIterationsReached(); + } catch (Exception e) { + log.error(e.toString(), e); + } + } + } + + private void notifyOnStepParseError(String content) { + for (ReActAgentListener listener : listeners) { + try { + listener.onStepParseError(content); + } catch (Exception e) { + log.error(e.toString(), e); + } + } + } + + private void notifyOnActionNotMatched(ReActStep step, List tools) { + for (ReActAgentListener listener : listeners) { + try { + listener.onActionNotMatched(step, tools); + } catch (Exception e) { + log.error(e.toString(), e); + } + } + } + + private void notifyOnActionInvokeError(Exception e) { + for (ReActAgentListener listener : listeners) { + try { + listener.onActionInvokeError(e); + } catch (Exception e1) { + log.error(e.toString(), e1); + } + } + } + + private void notifyOnError(Exception e) { + for (ReActAgentListener listener : listeners) { + try { + listener.onError(e); + } catch (Exception e1) { + log.error(e.toString(), e1); + } + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentListener.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentListener.java new file mode 100644 index 0000000..0fb8446 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentListener.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.agent.react; + +import com.easyagents.core.model.client.StreamContext; +import com.easyagents.core.model.chat.tool.Tool; +import com.easyagents.core.model.chat.response.AiMessageResponse; + +import java.util.List; + +/** + * ReActAgent 的监听器接口,用于监听执行过程中的关键事件。 + */ +public interface ReActAgentListener { + + /** + * 当 LLM 生成响应时触发 + * + * @param response 原始响应内容 + */ + default void onChatResponse(AiMessageResponse response) { + } + + /** + * 当 LLM 生成响应时触发 + * + * @param context 上下文信息 + * @param response 原始响应内容 + */ + default void onChatResponseStream(StreamContext context, AiMessageResponse response) { + } + + + /** + * 当未命中工具时触发 + * + * @param response 原始响应内容 + */ + default void onNonActionResponse(AiMessageResponse response) { + } + + /** + * 当未命中工具时触发 + */ + default void onNonActionResponseStream(StreamContext context) { + } + + + /** + * 当检测到最终答案时触发 + * + * @param finalAnswer 最终答案内容 + */ + default void onFinalAnswer(String finalAnswer) { + } + + + /** + * 当需要用户输入时触发 + */ + default void onRequestUserInput(String question) { + + } + + /** + * 当调用工具前触发 + * + * @param step 当前步骤 + */ + default void onActionStart(ReActStep step) { + } + + /** + * 当调用工具完成后触发 + * + * @param step 工具名称 + * @param result 工具返回结果 + */ + default void onActionEnd(ReActStep step, Object result) { + } + + /** + * 当达到最大迭代次数仍未获得答案时触发 + */ + default void onMaxIterationsReached() { + } + + + /** + * 当解析步骤时发生错误时触发 + * + * @param content 错误内容 + */ + default void onStepParseError(String content) { + + } + + /** + * 当未匹配到任何工具时触发 + * + * @param step 当前步骤 + * @param tools 可用的工具列表 + */ + default void onActionNotMatched(ReActStep step, List tools) { + + } + + /** + * 当工具执行错误时触发 + * + * @param e 错误对象 + */ + default void onActionInvokeError(Exception e) { + + } + + /** + * 当工具返回的 JSON 格式错误时触发 + * + * @param step 当前步骤 + * @param error 错误对象 + */ + default void onActionJsonParserError(ReActStep step, Exception error) { + + } + + /** + * 当发生异常时触发 + * + * @param error 异常对象 + */ + default void onError(Exception error) { + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentState.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentState.java new file mode 100644 index 0000000..ee5e381 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentState.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.agent.react; + +import com.easyagents.core.message.Message; +import com.easyagents.core.message.UserMessage; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONReader; +import com.alibaba.fastjson2.JSONWriter; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +public class ReActAgentState implements Serializable { + + private static final long serialVersionUID = 1L; + + String userQuery; + List messageHistory; + int iterationCount = 0; + int maxIterations; + boolean streamable; + String promptTemplate; + boolean continueOnActionInvokeError; + + public String getUserQuery() { + return userQuery; + } + + public void setUserQuery(String userQuery) { + this.userQuery = userQuery; + } + + public List getMessageHistory() { + return messageHistory; + } + + public void setMessageHistory(List messageHistory) { + this.messageHistory = messageHistory; + } + + public void addMessage(UserMessage message) { + if (messageHistory == null) { + messageHistory = new ArrayList<>(); + } + messageHistory.add(message); + } + + public int getIterationCount() { + return iterationCount; + } + + public void setIterationCount(int iterationCount) { + this.iterationCount = iterationCount; + } + + public int getMaxIterations() { + return maxIterations; + } + + public void setMaxIterations(int maxIterations) { + this.maxIterations = maxIterations; + } + + public boolean isStreamable() { + return streamable; + } + + public void setStreamable(boolean streamable) { + this.streamable = streamable; + } + + public String getPromptTemplate() { + return promptTemplate; + } + + public void setPromptTemplate(String promptTemplate) { + this.promptTemplate = promptTemplate; + } + + public boolean isContinueOnActionInvokeError() { + return continueOnActionInvokeError; + } + + public void setContinueOnActionInvokeError(boolean continueOnActionInvokeError) { + this.continueOnActionInvokeError = continueOnActionInvokeError; + } + + public String toJSON() { + return JSON.toJSONString(this, JSONWriter.Feature.WriteClassName); + } + + public static ReActAgentState fromJSON(String json) { + return JSON.parseObject(json, ReActAgentState.class, JSONReader.Feature.SupportClassForName); + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentTool.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentTool.java new file mode 100644 index 0000000..f5ed1c0 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActAgentTool.java @@ -0,0 +1,123 @@ +///* +// * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). +// *

+// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// *

+// * http://www.apache.org/licenses/LICENSE-2.0 +// *

+// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// */ +//package com.easyagents.core.agent.react; +// +//import com.easyagents.core.model.chat.tool.Parameter; +//import com.easyagents.core.model.chat.tool.Tool; +//import com.easyagents.core.model.chat.tool.ToolContextHolder; +// +//import java.util.Map; +//import java.util.concurrent.CountDownLatch; +//import java.util.concurrent.TimeUnit; +// +//public class ReActAgentTool implements Tool { +// +// public static final String PARENT_AGENT_KEY = "__parent_react_agent"; +// +// private final ReActAgent subAgent; +// private String name; +// private String description; +// +// public ReActAgentTool(ReActAgent subAgent) { +// this.subAgent = subAgent; +// } +// +// +// @Override +// public String getName() { +// return name; +// } +// +// @Override +// public String getDescription() { +// return description; +// } +// +// @Override +// public Parameter[] getParameters() { +// return new Parameter[0]; +// } +// +// @Override +// public Object invoke(Map argsMap) { +// ReActAgent parentAgent = ToolContextHolder.currentContext().getAttribute(PARENT_AGENT_KEY); +// +// +// if (parentAgent != null) { +// // @todo 获取父 agent 的监听器 和 历史消息,传入给 sub Agent +// } +// +// SyncReActListener listener = new SyncReActListener(); +// subAgent.addListener(listener); +// +// subAgent.execute(); +// +// try { +// return listener.getFinalAnswer(1000, TimeUnit.MILLISECONDS); +// } catch (InterruptedException e) { +// Thread.currentThread().interrupt(); +// throw new RuntimeException("ReActAgent execution was interrupted", e); +// } +// } +// +// +// // 同步监听器(内部类或独立类) +// public static class SyncReActListener implements ReActAgentListener { +// private final CountDownLatch latch = new CountDownLatch(1); +// private String finalAnswer; +// private Exception error; +// +// @Override +// public void onFinalAnswer(String answer) { +// this.finalAnswer = answer; +// latch.countDown(); +// } +// +// @Override +// public void onError(Exception e) { +// this.error = e; +// latch.countDown(); +// } +// +// @Override +// public void onMaxIterationsReached() { +// this.error = new RuntimeException("ReActAgent reached max iterations without final answer"); +// latch.countDown(); +// } +// +// // 其他回调可留空 +// @Override +// public void onActionStart(ReActStep step) { +// } +// +// @Override +// public void onActionEnd(ReActStep step, Object result) { +// } +// +// public String getFinalAnswer(long timeout, TimeUnit unit) throws InterruptedException { +// if (!latch.await(timeout, unit)) { +// throw new RuntimeException("ReActAgent execution timed out"); +// } +// if (error != null) { +// throw new RuntimeException("ReActAgent execution failed", error); +// } +// if (finalAnswer == null) { +// throw new RuntimeException("ReActAgent did not produce a final answer"); +// } +// return finalAnswer; +// } +// } +//} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActMessageBuilder.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActMessageBuilder.java new file mode 100644 index 0000000..1f0fa10 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActMessageBuilder.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.agent.react; + +import com.easyagents.core.message.UserMessage; +import com.easyagents.core.model.chat.tool.Tool; +import com.easyagents.core.message.Message; + +import java.util.List; + +public class ReActMessageBuilder { + + + /** + * 构建 ReAct 开始消息 + * + * @param prompt 提示词 + * @param tools 函数列表 + * @param userQuery 用户问题 + * @return 返回 HumanMessage + */ + public Message buildStartMessage(String prompt, List tools, String userQuery) { + UserMessage message = new UserMessage(prompt); + message.addMetadata("tools", tools); + message.addMetadata("user_input", userQuery); + message.addMetadata("type", "reActWrapper"); + return message; + } + + + /** + * 构建 JSON 解析错误消息,用于 json 发送错误时,让 AI 自动修正 + * + * @param e 错误信息 + * @param step 发送错误的步骤 + * @return 返回 HumanMessage + */ + public Message buildJsonParserErrorMessage(Exception e, ReActStep step) { + String errorMsg = "JSON 解析失败: " + e.getMessage() + ", 原始内容: " + step.getActionInput(); + String observation = "Action:" + step.getAction() + "\n" + + "Action Input:" + step.getActionInput() + "\n" + + "Error:" + errorMsg + "\n" + + "请检查你的 Action Input 格式是否正确,并纠正 JSON 内容重新生成响应。\n"; + UserMessage userMessage = new UserMessage(observation + "请继续推理下一步。"); + userMessage.addMetadata("type", "reActObservation"); + return userMessage; + } + + /** + * 构建 Observation 消息,让 AI 自动思考 + * + * @param step 步骤 + * @param result 步骤结果 + * @return 步骤结果消息 + */ + public Message buildObservationMessage(ReActStep step, Object result) { + String observation = buildObservationString(step, result); + UserMessage userMessage = new UserMessage(observation + "\n请继续推理下一步。"); + userMessage.addMetadata("type", "reActObservation"); + return userMessage; + } + + + /** + * 构建 Observation 字符串 + * + * @param step 步骤 + * @param result 步骤结果 + * @return 步骤结果字符串 + */ + public static String buildObservationString(ReActStep step, Object result) { + return "Action:" + step.getAction() + "\n" + + "Action Input:" + step.getActionInput() + "\n" + + "Action Result:" + result + "\n"; + } + + /** + * 构建 Action 错误消息,用于 Action 错误时,让 AI 自动修正 + * + * @param step 步骤 + * @param e 错误信息 + * @return 错误消息 + */ + public Message buildActionErrorMessage(ReActStep step, Exception e) { + // 将错误信息反馈给 AI,让其修正 + String observation = buildObservationString(step, "Error: " + e.getMessage()) + "\n" + + "请根据错误信息调整参数并重新尝试。\n"; + UserMessage userMessage = new UserMessage(observation + "请继续推理下一步。"); + userMessage.addMetadata("type", "reActObservation"); + return userMessage; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActStep.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActStep.java new file mode 100644 index 0000000..6dc5542 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActStep.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.agent.react; + +public class ReActStep { + + private String thought; + private String action; + private String actionInput; + + public ReActStep() { + } + + public ReActStep(String thought, String action, String actionInput) { + this.thought = thought; + this.action = action; + this.actionInput = actionInput; + } + + public String getThought() { + return thought; + } + + public void setThought(String thought) { + this.thought = thought; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getActionInput() { + return actionInput; + } + + public void setActionInput(String actionInput) { + this.actionInput = actionInput; + } + + + @Override + public String toString() { + return "ReActStep{" + + "thought='" + thought + '\'' + + ", action='" + action + '\'' + + ", actionInput='" + actionInput + '\'' + + '}'; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActStepParser.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActStepParser.java new file mode 100644 index 0000000..9976526 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/ReActStepParser.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.agent.react; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public interface ReActStepParser { + + ReActStepParser DEFAULT = content -> { + if (content == null || content.trim().isEmpty()) { + return Collections.emptyList(); + } + + List steps = new ArrayList<>(); + String[] lines = content.split("\n"); + + String currentThought = null; + String currentAction = null; + String currentRequest = null; + StringBuilder currentActionInput = new StringBuilder(); + boolean inActionInput = false; + + for (String line : lines) { + String trimmedLine = line.trim(); + + // 如果遇到新的 Thought,且已有完整步骤,先保存 + if (trimmedLine.startsWith("Thought:")) { + if (currentThought != null && currentAction != null) { + // 保存上一个完整的 step(在遇到新 Thought 或 Final Answer 时触发) + steps.add(new ReActStep(currentThought, currentAction, currentActionInput.toString().trim())); + // 重置状态 + currentActionInput.setLength(0); + inActionInput = false; + } + currentThought = trimmedLine.substring("Thought:".length()).trim(); + currentAction = null; + } + // 如果遇到 Action + else if (trimmedLine.startsWith("Action:")) { + if (currentThought == null) { + // 如果 Action 出现在 Thought 之前,视为格式错误,可选择忽略或报错 + continue; + } + currentAction = trimmedLine.substring("Action:".length()).trim(); + } + // 如果遇到 Action Input + else if (trimmedLine.startsWith("Action Input:")) { + if (currentAction == null) { + // Action Input 出现在 Action 之前,跳过 + continue; + } + String inputPart = trimmedLine.substring("Action Input:".length()).trim(); + currentActionInput.append(inputPart); + inActionInput = true; + } + // 如果正在读取 Action Input 的后续行(多行 JSON) + else if (inActionInput) { + // 判断是否是下一个结构的开始:Thought / Action / Final Answer + if (trimmedLine.startsWith("Thought:") || + trimmedLine.startsWith("Action:") || + trimmedLine.startsWith("Final Answer:")) { + // 实际上这一行属于下一段,应退回处理 + // 但我们是在 for 循环里,无法“退回”,所以先保存当前 step + steps.add(new ReActStep(currentThought, currentAction, currentActionInput.toString().trim())); + currentActionInput.setLength(0); + inActionInput = false; + currentThought = null; + currentAction = null; + // 重新处理当前行(递归或标记),但为简化,我们直接继续下一轮 + // 因为下一轮会处理 Thought/Action + continue; + } else { + // 是 Action Input 的续行,追加(保留原始换行或加空格) + if (currentActionInput.length() > 0) { + currentActionInput.append("\n"); + } + currentActionInput.append(line); // 保留原始缩进(可选) + } + } + // 如果遇到 Final Answer,结束当前步骤(如果有) + else if (trimmedLine.startsWith("Final Answer:")) { + if (currentThought != null && currentAction != null) { + steps.add(new ReActStep(currentThought, currentAction, currentActionInput.toString().trim())); + } + // Final Answer 本身不作为 ReActStep,通常单独处理 + break; // 或 continue,视需求而定 + } + // 空行或无关行:如果是 Action Input 多行内容,已在上面处理;否则忽略 + } + + // 循环结束后,检查是否还有未保存的步骤 + if (currentThought != null && currentAction != null) { + steps.add(new ReActStep(currentThought, currentAction, currentActionInput.toString().trim())); + } + + return steps; + }; + + + List parse(String content); + + + default boolean isFinalAnswer(String content) { + return content.contains(getFinalAnswerFlag()); + } + + default boolean isReActAction(String content) { + return content.contains("Action:") && content.contains("Action Input:"); + } + + default boolean isRequest(String content) { + return content.contains("Request:"); + } + + default String extractRequestQuestion(String content) { + return content.trim().substring("Request:".length()).trim(); + } + + default String getFinalAnswerFlag() { + return "Final Answer:"; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/react/Util.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/Util.java new file mode 100644 index 0000000..f16d843 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/react/Util.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.agent.react; + +import com.easyagents.core.model.chat.tool.Parameter; +import com.easyagents.core.model.chat.tool.Tool; + +import java.util.List; + +/** + * ReAct Agent 工具函数辅助类 + */ +public class Util { + + /** + * 生成带缩进的空格字符串 + */ + public static String indent(int depth) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < depth; i++) { + sb.append(" "); // 2 spaces per level + } + return sb.toString(); + } + + /** + * 基于工具列表生成结构化、LLM 友好的工具描述文本 + */ + public static String buildToolsDescription(List tools) { + StringBuilder sb = new StringBuilder(); + for (Tool tool : tools) { + sb.append("### 工具名称: ").append(tool.getName()).append("\n"); + sb.append("**描述**: ").append(tool.getDescription()).append("\n"); + sb.append("**调用参数格式 (JSON 对象)**:\n"); + sb.append("```json\n"); + + sb.append("{\n"); + Parameter[] rootParams = tool.getParameters(); + for (int i = 0; i < rootParams.length; i++) { + appendParameter(sb, rootParams[i], 1); + if (i < rootParams.length - 1) { + sb.append(","); + } + sb.append("\n"); + } + sb.append("}\n"); + sb.append("```\n\n"); + } + return sb.toString(); + } + + /** + * 递归追加参数描述(支持 object 和 array) + */ + private static void appendParameter(StringBuilder sb, Parameter param, int depth) { + String currentIndent = indent(depth); + String typeLabel = getTypeLabel(param); + + // 构建注释信息 + StringBuilder comment = new StringBuilder(); + if (param.isRequired()) { + comment.append(" (必填)"); + } else { + comment.append(" (可选)"); + } + + if (param.getDescription() != null && !param.getDescription().trim().isEmpty()) { + comment.append(" - ").append(param.getDescription().trim()); + } + + if (param.getEnums() != null && param.getEnums().length > 0) { + comment.append(" [可选值: ").append(String.join(", ", param.getEnums())).append("]"); + } + + String paramName = param.getName() != null ? param.getName() : "item"; + + // 判断是否为数组类型 + boolean isArray = isArrayType(param.getType()); + + if (isArray && param.getChildren() != null && !param.getChildren().isEmpty()) { + // 数组元素为对象:描述其结构 + sb.append(currentIndent).append("\"").append(paramName).append("\": ["); + sb.append(comment).append("\n"); + + String innerIndent = indent(depth + 1); + sb.append(innerIndent).append("{\n"); + + List elementFields = param.getChildren(); + for (int i = 0; i < elementFields.size(); i++) { + appendParameter(sb, elementFields.get(i), depth + 2); + if (i < elementFields.size() - 1) { + sb.append(","); + } + sb.append("\n"); + } + sb.append(innerIndent).append("}\n"); + sb.append(currentIndent).append("]"); + + } else if (isArray) { + // 简单类型数组 + sb.append(currentIndent).append("\"").append(paramName).append("\": [ \"<") + .append(typeLabel).append(">") + .append(comment) + .append("\" ]"); + + } else if (param.getChildren() != null && !param.getChildren().isEmpty()) { + // 嵌套对象 + sb.append(currentIndent).append("\"").append(paramName).append("\": {"); + sb.append(comment).append("\n"); + + List children = param.getChildren(); + for (int i = 0; i < children.size(); i++) { + appendParameter(sb, children.get(i), depth + 1); + if (i < children.size() - 1) { + sb.append(","); + } + sb.append("\n"); + } + sb.append(currentIndent).append("}"); + + } else { + // 叶子字段(简单类型) + sb.append(currentIndent).append("\"").append(paramName).append("\": \"<") + .append(typeLabel).append(">") + .append(comment) + .append("\""); + } + } + + /** + * 判断类型是否为数组 + */ + private static boolean isArrayType(String type) { + if (type == null) return false; + String lower = type.toLowerCase(); + return "array".equals(lower) || "list".equals(lower); + } + + /** + * 获取标准化的类型标签 + */ + private static String getTypeLabel(Parameter param) { + String type = param.getType(); + if (type == null) return "string"; + + // 若有子字段,则视为 object + if (param.getChildren() != null && !param.getChildren().isEmpty()) { + return "object"; + } + + String lower = type.toLowerCase(); + if ("string".equals(lower) || "str".equals(lower)) { + return "string"; + } else if ("integer".equals(lower) || "int".equals(lower)) { + return "integer"; + } else if ("number".equals(lower) || "float".equals(lower) || "double".equals(lower)) { + return "number"; + } else if ("boolean".equals(lower) || "bool".equals(lower)) { + return "boolean"; + } else if ("array".equals(lower) || "list".equals(lower)) { + return "array"; + } else { + return type; // 保留自定义类型名,如 date, uri 等 + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgent.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgent.java new file mode 100644 index 0000000..4f52036 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgent.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.agent.route; + +import com.easyagents.core.agent.IAgent; +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.Message; +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.model.chat.ChatOptions; +import com.easyagents.core.prompt.MemoryPrompt; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * RouteAgent:负责路由用户输入到最合适的 IAgent。 + * - 不实现 IAgent 接口 + * - route() 方法返回 IAgent 实例,或 null(表示无匹配) + * - 不处理 Direct Answer,一律返回 null + * - 支持关键字快速匹配 + LLM 智能路由 + */ +public class RoutingAgent { + + private static final Logger log = LoggerFactory.getLogger(RoutingAgent.class); + + private static final String DEFAULT_ROUTING_PROMPT_TEMPLATE = + "你是一个智能路由助手,请严格按以下规则响应:\n" + + "\n" + + "可用处理模块(Agent)及其能力描述:\n" + + "{agent_descriptions}\n" + + "\n" + + "规则:\n" + + "1. 如果用户问题属于某个模块的能力范围,请输出:Route: [模块名]\n" + + "2. 如果问题可以直接回答(如问候、常识、简单对话),请输出:Direct: [你的自然语言回答]\n" + + "3. 如果问题涉及多个模块,选择最核心的一个。\n" + + "4. 不要解释、不要输出其他内容,只输出上述两种格式之一。\n" + + "\n" + + "当前对话上下文(最近几轮):\n" + + "{conversation_context}\n" + + "\n" + + "用户最新问题:\n" + + "{user_input}"; + + private final ChatModel chatModel; + private final RoutingAgentRegistry routingAgentRegistry; + private final String userQuery; + private final MemoryPrompt memoryPrompt; + + private String routingPromptTemplate = DEFAULT_ROUTING_PROMPT_TEMPLATE; + private ChatOptions chatOptions; + private boolean enableKeywordRouting = true; + private boolean enableLlmRouting = true; + + public RoutingAgent(ChatModel chatModel, RoutingAgentRegistry routingAgentRegistry, + String userQuery, MemoryPrompt memoryPrompt) { + this.chatModel = chatModel; + this.routingAgentRegistry = routingAgentRegistry; + this.userQuery = userQuery; + this.memoryPrompt = memoryPrompt; + } + + /** + * 路由用户输入,返回匹配的 IAgent 实例。 + * 仅当明确路由到某个 Agent 时才返回 IAgent,否则返回 null。 + * + * @return IAgent 实例(仅 Route:xxx 场景),或 null(包括 Direct:、无匹配、异常等) + */ + public IAgent route() { + try { + // 1. 关键字快速匹配 + if (enableKeywordRouting) { + String agentName = routingAgentRegistry.findAgentByKeyword(userQuery); + if (agentName != null) { + log.debug("关键字匹配命中 Agent: {}", agentName); + return createAgent(agentName); + } + } + + // 2. LLM 智能路由 + if (enableLlmRouting) { + String contextSummary = buildContextSummary(memoryPrompt); + String agentDescriptions = routingAgentRegistry.getAgentDescriptions(); + String prompt = routingPromptTemplate + .replace("{agent_descriptions}", agentDescriptions) + .replace("{conversation_context}", contextSummary) + .replace("{user_input}", userQuery); + + String decision = chatModel.chat(prompt, chatOptions); + + if (decision != null && decision.startsWith("Route:")) { + String agentName = decision.substring("Route:".length()).trim(); + return createAgent(agentName); + } + } + + // 3. 无有效 Route,返回 null + log.debug("RouteAgent 未匹配到可路由的 Agent,返回 null。Query: {}", userQuery); + return null; + + } catch (Exception e) { + log.error("RouteAgent 路由异常,返回 null", e); + return null; + } + } + + private IAgent createAgent(String agentName) { + RoutingAgentFactory factory = routingAgentRegistry.getAgentFactory(agentName); + if (factory == null) { + log.warn("Agent 不存在: {}, 返回 null", agentName); + return null; + } + return factory.create(chatModel, userQuery, memoryPrompt); + } + + private String buildContextSummary(MemoryPrompt history) { + List messages = history.getMessages(); + if (messages == null || messages.isEmpty()) { + return "(无历史对话)"; + } + + int start = Math.max(0, messages.size() - 4); + StringBuilder sb = new StringBuilder(); + for (int i = start; i < messages.size(); i++) { + Message msg = messages.get(i); + String role = msg instanceof AiMessage ? "AI" : "User"; + String content = msg.getTextContent() != null ? msg.getTextContent() : ""; + sb.append(role).append(": ").append(content.trim()).append("\n"); + } + return sb.toString().trim(); + } + + + public void setEnableKeywordRouting(boolean enable) { + this.enableKeywordRouting = enable; + } + + public void setEnableLlmRouting(boolean enable) { + this.enableLlmRouting = enable; + } + + public void setRoutingPromptTemplate(String routingPromptTemplate) { + if (routingPromptTemplate != null && !routingPromptTemplate.trim().isEmpty()) { + this.routingPromptTemplate = routingPromptTemplate; + } + } + + public void setChatOptions(ChatOptions chatOptions) { + if (chatOptions != null) { + this.chatOptions = chatOptions; + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgentFactory.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgentFactory.java new file mode 100644 index 0000000..92fbe6e --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgentFactory.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.agent.route; + +import com.easyagents.core.agent.IAgent; +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.prompt.MemoryPrompt; + +/** + * ReActAgent 工厂接口,支持不同 Agent 的定制化创建。 + */ +@FunctionalInterface +public interface RoutingAgentFactory { + IAgent create(ChatModel chatModel, String userQuery, MemoryPrompt memoryPrompt); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgentRegistry.java b/easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgentRegistry.java new file mode 100644 index 0000000..e469b65 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/agent/route/RoutingAgentRegistry.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.agent.route; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Agent 注册中心,用于管理所有可用的 ReActAgent 工厂。 + */ +public class RoutingAgentRegistry { + + private final Map agentFactories = new HashMap<>(); + private final Map agentDescriptions = new HashMap<>(); + private final Map keywordToAgent = new HashMap<>(); + + /** + * 注册 Agent,并可选绑定关键字(用于快速匹配) + */ + public void register(String name, String description, RoutingAgentFactory factory) { + register(name, description, null, factory); + } + + public void register(String name, String description, List keywords, RoutingAgentFactory factory) { + agentFactories.put(name, factory); + agentDescriptions.put(name, description); + + if (keywords != null && !keywords.isEmpty()) { + for (String kw : keywords) { + if (kw != null && !kw.trim().isEmpty()) { + keywordToAgent.put(kw.trim().toLowerCase(), name); + } + } + } + } + + // 按关键字查找 Agent + public String findAgentByKeyword(String userQuery) { + if (userQuery == null) return null; + String lowerQuery = userQuery.toLowerCase(); + for (Map.Entry entry : keywordToAgent.entrySet()) { + if (lowerQuery.contains(entry.getKey())) { + return entry.getValue(); + } + } + return null; + } + + + public RoutingAgentFactory getAgentFactory(String name) { + return agentFactories.get(name); + } + + public String getAgentDescriptions() { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : agentDescriptions.entrySet()) { + sb.append("- ").append(entry.getKey()).append(": ").append(entry.getValue()).append("\n"); + } + return sb.toString().trim(); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/BigDecimalConverter.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/BigDecimalConverter.java new file mode 100644 index 0000000..1c1c7c5 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/BigDecimalConverter.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public class BigDecimalConverter implements IConverter { + @Override + public java.math.BigDecimal convert(String text) { + return new java.math.BigDecimal(text); + } +} + diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/BigIntegerConverter.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/BigIntegerConverter.java new file mode 100644 index 0000000..ea190e1 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/BigIntegerConverter.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public class BigIntegerConverter implements IConverter { + @Override + public java.math.BigInteger convert(String text) { + return new java.math.BigInteger(text); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/BooleanConverter.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/BooleanConverter.java new file mode 100644 index 0000000..41ed90c --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/BooleanConverter.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public class BooleanConverter implements IConverter { + @Override + public Boolean convert(String text) { + String value = text.toLowerCase(); + if ("true".equals(value) || "1".equals(value)) { + return Boolean.TRUE; + } else if ("false".equals(value) || "0".equals(value)) { + return Boolean.FALSE; + } else { + throw new RuntimeException("Can not parse to boolean type of value: " + text); + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/ByteArrayConverter.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/ByteArrayConverter.java new file mode 100644 index 0000000..a08d69d --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/ByteArrayConverter.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public class ByteArrayConverter implements IConverter { + @Override + public byte[] convert(String text) { + return text.getBytes(); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/ByteConverter.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/ByteConverter.java new file mode 100644 index 0000000..d4217ff --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/ByteConverter.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public class ByteConverter implements IConverter { + @Override + public Byte convert(String text) { + return Byte.parseByte(text); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/ConvertException.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/ConvertException.java new file mode 100644 index 0000000..efdbeae --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/ConvertException.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public class ConvertException extends RuntimeException{ +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/ConvertService.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/ConvertService.java new file mode 100644 index 0000000..ccf5ba1 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/ConvertService.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +import com.easyagents.core.util.ArrayUtil; +import com.easyagents.core.util.StringUtil; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.Map; + +public class ConvertService { + + private static final Map, IConverter> CONVERTER_MAP = new HashMap<>(); + + static { + register(new BooleanConverter(), Boolean.class, boolean.class); + register(new IntegerConverter(), Integer.class, int.class); + register(new LongConverter(), Long.class, long.class); + register(new DoubleConverter(), Double.class, double.class); + register(new FloatConverter(), Float.class, float.class); + register(new ShortConverter(), Short.class, short.class); + + register(new BigDecimalConverter(), BigDecimal.class); + register(new BigIntegerConverter(), BigInteger.class); + register(new ByteConverter(), byte.class); + register(new ByteArrayConverter(), byte[].class); + + + } + + private static void register(IConverter converter, Class... classes) { + for (Class clazz : classes) { + CONVERTER_MAP.put(clazz, converter); + } + } + + public static Object convert(Object value, Class toType) { + if (value == null || (value.getClass() == String.class && StringUtil.noText((String) value) + && toType != String.class)) { + return null; + } + + if (value.getClass().isAssignableFrom(toType)) { + return value; + } + + if (toType == Serializable.class && ArrayUtil.contains(value.getClass().getInterfaces(), Serializable.class)) { + return value; + } + + String valueString = value.toString().trim(); + if (valueString.isEmpty()) { + return null; + } + + IConverter converter = CONVERTER_MAP.get(toType); + if (converter != null) { + return converter.convert(valueString); + } + + return null; + } + + public static Object getPrimitiveDefaultValue(Class paraClass) { + if (paraClass == int.class || paraClass == long.class || paraClass == float.class || paraClass == double.class) { + return 0; + } else if (paraClass == boolean.class) { + return Boolean.FALSE; + } else if (paraClass == short.class) { + return (short) 0; + } else if (paraClass == byte.class) { + return (byte) 0; + } else if (paraClass == char.class) { + return '\u0000'; + } else { + //不存在这种类型 + return null; + } + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/DoubleConverter.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/DoubleConverter.java new file mode 100644 index 0000000..3670eb3 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/DoubleConverter.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public class DoubleConverter implements IConverter { + @Override + public Double convert(String text) { + return Double.parseDouble(text); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/FloatConverter.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/FloatConverter.java new file mode 100644 index 0000000..1499e44 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/FloatConverter.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public class FloatConverter implements IConverter { + @Override + public Float convert(String text) { + return Float.parseFloat(text); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/IConverter.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/IConverter.java new file mode 100644 index 0000000..960853f --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/IConverter.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public interface IConverter { + + /** + * convert the given text to type . + * + * @param text the text to convert. + * @return the convert value or null. + */ + T convert(String text) throws ConvertException; +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/IntegerConverter.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/IntegerConverter.java new file mode 100644 index 0000000..84c3d91 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/IntegerConverter.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public class IntegerConverter implements IConverter{ + @Override + public Integer convert(String text) throws ConvertException { + return Integer.parseInt(text); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/LongConverter.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/LongConverter.java new file mode 100644 index 0000000..3fd000c --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/LongConverter.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public class LongConverter implements IConverter { + @Override + public Long convert(String text) { + return Long.parseLong(text); + } +} + diff --git a/easy-agents-core/src/main/java/com/easyagents/core/convert/ShortConverter.java b/easy-agents-core/src/main/java/com/easyagents/core/convert/ShortConverter.java new file mode 100644 index 0000000..b0f9454 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/convert/ShortConverter.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.convert; + +public class ShortConverter implements IConverter{ + @Override + public Short convert(String text) throws ConvertException { + return Short.parseShort(text); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/document/Document.java b/easy-agents-core/src/main/java/com/easyagents/core/document/Document.java new file mode 100644 index 0000000..0b36b59 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/document/Document.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.document; + +import com.easyagents.core.store.VectorData; + +public class Document extends VectorData { + + /** + * Document ID + */ + private Object id; + + /** + * Document title + */ + private String title; + + /** + * Document Content + */ + private String content; + + + /** + * 得分,目前只有在 rerank 场景使用 + */ + private Double score; + + + public Document() { + } + + public Document(String content) { + this.content = content; + } + + public Object getId() { + return id; + } + + public void setId(Object id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + @Override + public Double getScore() { + return score; + } + + @Override + public void setScore(Double score) { + this.score = score; + } + + public static Document of(String content){ + Document document = new Document(); + document.setContent(content); + return document; + } + + @Override + public String toString() { + return "Document{" + + "id=" + id + + ", title='" + title + '\'' + + ", content='" + content + '\'' + + ", score=" + score + + '}'; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/document/DocumentSplitter.java b/easy-agents-core/src/main/java/com/easyagents/core/document/DocumentSplitter.java new file mode 100644 index 0000000..366ab1c --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/document/DocumentSplitter.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.document; + +import com.easyagents.core.document.id.DocumentIdGenerator; + +import java.util.Collections; +import java.util.List; + +import static java.util.stream.Collectors.toList; + +public interface DocumentSplitter { + + List split(Document text, DocumentIdGenerator idGenerator); + + default List split(Document text) { + return split(text, null); + } + + default List splitAll(List documents, DocumentIdGenerator idGenerator) { + if (documents == null || documents.isEmpty()) { + return Collections.emptyList(); + } + return documents.stream() + .flatMap(document -> split(document, idGenerator).stream()) + .collect(toList()); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/document/id/DocumentIdGenerator.java b/easy-agents-core/src/main/java/com/easyagents/core/document/id/DocumentIdGenerator.java new file mode 100644 index 0000000..1bfb972 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/document/id/DocumentIdGenerator.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.document.id; + +import com.easyagents.core.document.Document; + +public interface DocumentIdGenerator { + + /** + * Generate a unique ID for the Document + * + * @param document Document + * @return the unique ID + */ + Object generateId(Document document); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/document/id/DocumentIdGeneratorFactory.java b/easy-agents-core/src/main/java/com/easyagents/core/document/id/DocumentIdGeneratorFactory.java new file mode 100644 index 0000000..cbd9b84 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/document/id/DocumentIdGeneratorFactory.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.document.id; + +public abstract class DocumentIdGeneratorFactory { + + private static DocumentIdGeneratorFactory factory = new DocumentIdGeneratorFactory() { + final MD5IdGenerator randomIdGenerator = new MD5IdGenerator(); + + @Override + public DocumentIdGenerator createGenerator() { + return randomIdGenerator; + } + }; + + public static DocumentIdGeneratorFactory getFactory() { + return factory; + } + + public static void setFactory(DocumentIdGeneratorFactory factory) { + if (factory == null) { + throw new NullPointerException("factory can not be null"); + } + DocumentIdGeneratorFactory.factory = factory; + } + + public static DocumentIdGenerator getDocumentIdGenerator() { + return factory.createGenerator(); + } + + + abstract DocumentIdGenerator createGenerator(); + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/document/id/MD5IdGenerator.java b/easy-agents-core/src/main/java/com/easyagents/core/document/id/MD5IdGenerator.java new file mode 100644 index 0000000..fd8de10 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/document/id/MD5IdGenerator.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.document.id; + +import com.easyagents.core.document.Document; +import com.easyagents.core.util.HashUtil; + +public class MD5IdGenerator implements DocumentIdGenerator { + /** + * Generate a unique ID for the Document + * + * @param document Document + * @return the unique ID + */ + @Override + public Object generateId(Document document) { + return document.getContent() != null ? HashUtil.md5(document.getContent()) : null; + } + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/document/id/RandomIdGenerator.java b/easy-agents-core/src/main/java/com/easyagents/core/document/id/RandomIdGenerator.java new file mode 100644 index 0000000..b37dae4 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/document/id/RandomIdGenerator.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.document.id; + +import com.easyagents.core.document.Document; + +import java.util.UUID; + +public class RandomIdGenerator implements DocumentIdGenerator { + + /** + * Generate a unique ID for the Document + * + * @param document Document + * @return the unique ID + */ + @Override + public Object generateId(Document document) { + return UUID.randomUUID().toString(); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/AIDocumentSplitter.java b/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/AIDocumentSplitter.java new file mode 100644 index 0000000..fcd4a14 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/AIDocumentSplitter.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.document.splitter; + +import com.easyagents.core.document.Document; +import com.easyagents.core.document.DocumentSplitter; +import com.easyagents.core.document.id.DocumentIdGenerator; +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.model.chat.ChatOptions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * AIDocumentSplitter:基于大模型(AI/LLM)的语义文档拆分器。 + * 使用 "---" 作为段落分隔符,避免 JSON 解析风险。 + * 支持注入 fallback 拆分器以提高鲁棒性。 + */ +public class AIDocumentSplitter implements DocumentSplitter { + + private static final Logger log = LoggerFactory.getLogger(AIDocumentSplitter.class); + + private static final String DEFAULT_SPLIT_PROMPT_TEMPLATE = + "你是一个专业的文档处理助手,请将以下长文档按语义拆分为多个逻辑连贯的段落块。\n" + + "要求:\n" + + "1. 每个块应保持主题/语义完整性,避免在句子中间切断。\n" + + "2. 每个块长度建议在 200-500 字之间(可根据内容灵活调整)。\n" + + "3. **不要添加任何解释、编号、前缀或后缀**。\n" + + "4. **仅用三连短横线 \"---\" 作为块之间的分隔符**,格式如下:\n" + + "\n" + + "块1内容\n" + + "---\n" + + "块2内容\n" + + "---\n" + + "块3内容\n" + + "\n" + + "注意:开头不要有 ---,结尾也不要有多余的 ---。\n" + + "\n" + + "文档内容如下:\n" + + "{document}"; + + private static final String CHUNK_SEPARATOR = "---"; + + private final ChatModel chatModel; + private String splitPromptTemplate = DEFAULT_SPLIT_PROMPT_TEMPLATE; + private ChatOptions chatOptions = new ChatOptions.Builder().temperature(0.2f).build(); + private int maxChunks = 20; + private int maxTotalLength = 10000; + + // 可配置的 fallback 拆分器 + private DocumentSplitter fallbackSplitter; + + public AIDocumentSplitter(ChatModel chatModel) { + this.chatModel = chatModel; + } + + @Override + public List split(Document document, DocumentIdGenerator idGenerator) { + if (document == null || document.getContent() == null || document.getContent().trim().isEmpty()) { + return Collections.emptyList(); + } + + String content = document.getContent().trim(); + if (content.length() > maxTotalLength) { + log.warn("文档过长({} 字符),已截断至 {} 字符", content.length(), maxTotalLength); + content = content.substring(0, maxTotalLength); + } + + List chunks; + try { + String prompt = splitPromptTemplate.replace("{document}", content); + String llmOutput = chatModel.chat(prompt, chatOptions); + + chunks = parseChunksBySeparator(llmOutput, CHUNK_SEPARATOR); + } catch (Exception e) { + log.error("AI 拆分失败,使用 fallback 拆分器", e); + if (fallbackSplitter == null) { + log.error("没有可用的 fallback 拆分器,请检查配置"); + return Collections.emptyList(); + } + List fallbackDocs = fallbackSplitter.split(document, idGenerator); + if (fallbackDocs.size() > maxChunks) { + return new ArrayList<>(fallbackDocs.subList(0, maxChunks)); + } + return fallbackDocs; + } + + List validChunks = chunks.stream() + .map(String::trim) + .filter(s -> !s.isEmpty()) + .limit(maxChunks) + .collect(Collectors.toList()); + + List result = new ArrayList<>(); + for (String chunk : validChunks) { + Document doc = new Document(); + doc.setContent(chunk); + doc.setTitle(document.getTitle()); + if (idGenerator != null) { + doc.setId(idGenerator.generateId(doc)); + } + result.add(doc); + } + + return result; + } + + private List parseChunksBySeparator(String text, String separator) { + if (text == null || text.trim().isEmpty()) { + return Collections.emptyList(); + } + + String[] parts = text.split(separator, -1); + List chunks = new ArrayList<>(); + for (String part : parts) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + chunks.add(trimmed); + } + } + + if (chunks.size() == 1 && text.contains(separator)) { + return tryAlternativeSplit(text, separator); + } + + return chunks; + } + + private List tryAlternativeSplit(String text, String separator) { + String normalized = text.replaceAll("\\s*---\\s*", "---"); + return parseChunksBySeparator(normalized, separator); + } + + // ===== Getters & Setters ===== + + public void setFallbackSplitter(DocumentSplitter fallbackSplitter) { + this.fallbackSplitter = fallbackSplitter; + } + + public void setSplitPromptTemplate(String splitPromptTemplate) { + if (splitPromptTemplate != null && !splitPromptTemplate.trim().isEmpty()) { + this.splitPromptTemplate = splitPromptTemplate; + } + } + + public void setChatOptions(ChatOptions chatOptions) { + if (chatOptions != null) { + this.chatOptions = chatOptions; + } + } + + public void setMaxChunks(int maxChunks) { + this.maxChunks = Math.max(1, maxChunks); + } + + public void setMaxTotalLength(int maxTotalLength) { + this.maxTotalLength = Math.max(100, maxTotalLength); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/MarkdownHeaderSplitter.java b/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/MarkdownHeaderSplitter.java new file mode 100644 index 0000000..1a34b5c --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/MarkdownHeaderSplitter.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.document.splitter; + +import com.easyagents.core.document.Document; +import com.easyagents.core.document.DocumentSplitter; +import com.easyagents.core.document.id.DocumentIdGenerator; +import com.easyagents.core.util.StringUtil; + +import java.util.*; + +public class MarkdownHeaderSplitter implements DocumentSplitter { + + /** + * 最大标题级别(inclusive),用于触发拆分。 + * 例如:splitLevel = 2 表示在 # 和 ## 处拆分,### 及以下不作为新块起点。 + */ + private int splitLevel; + + /** + * 是否在每个 chunk 中保留父级标题路径(如 "Introduction > Background") + */ + private boolean includeParentHeaders = true; + + public MarkdownHeaderSplitter() { + } + + public MarkdownHeaderSplitter(int splitLevel) { + if (splitLevel < 1 || splitLevel > 6) { + throw new IllegalArgumentException("splitLevel must be between 1 and 6, got: " + splitLevel); + } + this.splitLevel = splitLevel; + } + + public MarkdownHeaderSplitter(int splitLevel, boolean includeParentHeaders) { + this(splitLevel); + this.includeParentHeaders = includeParentHeaders; + } + + public int getSplitLevel() { + return splitLevel; + } + + public void setSplitLevel(int splitLevel) { + if (splitLevel < 1 || splitLevel > 6) { + throw new IllegalArgumentException("splitLevel must be between 1 and 6"); + } + this.splitLevel = splitLevel; + } + + public boolean isIncludeParentHeaders() { + return includeParentHeaders; + } + + public void setIncludeParentHeaders(boolean includeParentHeaders) { + this.includeParentHeaders = includeParentHeaders; + } + + @Override + public List split(Document document, DocumentIdGenerator idGenerator) { + if (document == null || StringUtil.noText(document.getContent())) { + return Collections.emptyList(); + } + + String content = document.getContent(); + String[] lines = content.split("\n"); + + List chunks = new ArrayList<>(); + Deque headerStack = new ArrayDeque<>(); + + StringBuilder currentContent = new StringBuilder(); + int currentStartLine = 0; + + boolean inCodeBlock = false; + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (line == null) { + currentContent.append("\n"); + continue; + } + + // 检测围栏代码块的开始或结束(支持 ``` 或 ~~~) + String trimmedLine = stripLeading(line); + if (trimmedLine.startsWith("```") || trimmedLine.startsWith("~~~")) { + inCodeBlock = !inCodeBlock; + currentContent.append(line).append("\n"); + continue; + } + + if (!inCodeBlock) { + HeaderInfo header = parseHeader(line); + if (header != null && header.level <= splitLevel) { + // 触发新 chunk + if (currentContent.length() > 0 || !chunks.isEmpty()) { + flushChunk(chunks, currentContent.toString(), headerStack, currentStartLine, i - 1, document); + currentContent.setLength(0); + } + currentStartLine = i; + + // 弹出栈中层级大于等于当前的标题 + while (!headerStack.isEmpty() && headerStack.peek().level >= header.level) { + headerStack.pop(); + } + headerStack.push(header); + + // 将标题行加入当前内容(保留结构) + currentContent.append(line).append("\n"); + continue; + } + } + + // 普通文本行或代码块内行 + currentContent.append(line).append("\n"); + } + + // Flush remaining content + if (currentContent.length() > 0) { + flushChunk(chunks, currentContent.toString(), headerStack, currentStartLine, lines.length - 1, document); + } + + // 构建结果 Document 列表 + List result = new ArrayList<>(); + for (DocumentChunk chunk : chunks) { + Document doc = new Document(); + doc.setContent(chunk.content.trim()); + doc.addMetadata(document.getMetadataMap()); + + if (includeParentHeaders && !chunk.headerPath.isEmpty()) { + doc.addMetadata("header_path", String.join(" > ", chunk.headerPath)); + } + doc.addMetadata("start_line", String.valueOf(chunk.startLine)); + doc.addMetadata("end_line", String.valueOf(chunk.endLine)); + + if (idGenerator != null) { + doc.setId(idGenerator.generateId(doc)); + } + result.add(doc); + } + + return result; + } + + private void flushChunk(List chunks, String content, + Deque headerStack, int startLine, int endLine, Document sourceDoc) { + if (StringUtil.noText(content.trim())) { + return; + } + + // 从根到当前构建标题路径 + List headerPath = new ArrayList<>(); + List stackCopy = new ArrayList<>(headerStack); + Collections.reverse(stackCopy); + for (HeaderInfo h : stackCopy) { + headerPath.add(h.text); + } + + chunks.add(new DocumentChunk(content, headerPath, startLine, endLine)); + } + + /** + * 解析一行是否为合法的 ATX 标题(# Title) + * + * @param line 输入行 + * @return HeaderInfo 或 null + */ + private HeaderInfo parseHeader(String line) { + if (line == null || line.isEmpty()) { + return null; + } + line = stripLeading(line); + if (!line.startsWith("#")) { + return null; + } + + int level = 0; + int i = 0; + while (i < line.length() && line.charAt(i) == '#') { + level++; + i++; + } + + if (level > 6) { + return null; // 非法标题 + } + + // 必须后跟空格或行结束(符合 CommonMark 规范) + if (i < line.length() && line.charAt(i) != ' ') { + return null; + } + + String text = line.substring(i).trim(); + return new HeaderInfo(level, text); + } + + private static String stripLeading(String s) { + if (s == null || s.isEmpty()) { + return s; + } + int i = 0; + while (i < s.length() && Character.isWhitespace(s.charAt(i))) { + i++; + } + return i == 0 ? s : s.substring(i); + } + + // -- 内部辅助类 -- + + private static class HeaderInfo { + final int level; + final String text; + + HeaderInfo(int level, String text) { + this.level = level; + this.text = text; + } + } + + private static class DocumentChunk { + final String content; + final List headerPath; + final int startLine; + final int endLine; + + DocumentChunk(String content, List headerPath, int startLine, int endLine) { + this.content = content; + this.headerPath = headerPath; + this.startLine = startLine; + this.endLine = endLine; + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/RegexDocumentSplitter.java b/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/RegexDocumentSplitter.java new file mode 100644 index 0000000..c6fd94d --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/RegexDocumentSplitter.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.document.splitter; + +import com.easyagents.core.document.DocumentSplitter; +import com.easyagents.core.document.Document; +import com.easyagents.core.document.id.DocumentIdGenerator; +import com.easyagents.core.util.StringUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class RegexDocumentSplitter implements DocumentSplitter { + + private final String regex; + + public RegexDocumentSplitter(String regex) { + this.regex = regex; + } + + @Override + public List split(Document document, DocumentIdGenerator idGenerator) { + if (document == null || StringUtil.noText(document.getContent())) { + return Collections.emptyList(); + } + String[] textArray = document.getContent().split(regex); + List chunks = new ArrayList<>(textArray.length); + for (String textString : textArray) { + if (StringUtil.noText(textString)) { + continue; + } + Document newDocument = new Document(); + newDocument.addMetadata(document.getMetadataMap()); + newDocument.setContent(textString); + + //we should invoke setId after setContent + newDocument.setId(idGenerator == null ? null : idGenerator.generateId(newDocument)); + chunks.add(newDocument); + } + return chunks; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/SimpleDocumentSplitter.java b/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/SimpleDocumentSplitter.java new file mode 100644 index 0000000..65910ac --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/SimpleDocumentSplitter.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.document.splitter; + +import com.easyagents.core.document.Document; +import com.easyagents.core.document.DocumentSplitter; +import com.easyagents.core.document.id.DocumentIdGenerator; +import com.easyagents.core.util.StringUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class SimpleDocumentSplitter implements DocumentSplitter { + private int chunkSize; + private int overlapSize; + + public SimpleDocumentSplitter(int chunkSize) { + this.chunkSize = chunkSize; + if (this.chunkSize <= 0) { + throw new IllegalArgumentException("chunkSize must be greater than 0, chunkSize: " + this.chunkSize); + } + } + + public SimpleDocumentSplitter(int chunkSize, int overlapSize) { + this.chunkSize = chunkSize; + this.overlapSize = overlapSize; + + if (this.chunkSize <= 0) { + throw new IllegalArgumentException("chunkSize must be greater than 0, chunkSize: " + this.chunkSize); + } + if (this.overlapSize >= this.chunkSize) { + throw new IllegalArgumentException("overlapSize must be less than chunkSize, overlapSize: " + this.overlapSize + ", chunkSize: " + this.chunkSize); + } + } + + public int getChunkSize() { + return chunkSize; + } + + public void setChunkSize(int chunkSize) { + this.chunkSize = chunkSize; + } + + public int getOverlapSize() { + return overlapSize; + } + + public void setOverlapSize(int overlapSize) { + this.overlapSize = overlapSize; + } + + @Override + public List split(Document document, DocumentIdGenerator idGenerator) { + if (document == null || StringUtil.noText(document.getContent())) { + return Collections.emptyList(); + } + + String content = document.getContent(); + int index = 0, currentIndex = index; + int maxIndex = content.length(); + + List chunks = new ArrayList<>(); + while (currentIndex < maxIndex) { + int endIndex = Math.min(currentIndex + chunkSize, maxIndex); + String chunk = content.substring(currentIndex, endIndex).trim(); + currentIndex = currentIndex + chunkSize - overlapSize; + + if (chunk.isEmpty()) { + continue; + } + + Document newDocument = new Document(); + newDocument.addMetadata(document.getMetadataMap()); + newDocument.setContent(chunk); + + //we should invoke setId after setContent + newDocument.setId(idGenerator == null ? null : idGenerator.generateId(newDocument)); + chunks.add(newDocument); + } + + return chunks; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/SimpleTokenizeSplitter.java b/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/SimpleTokenizeSplitter.java new file mode 100644 index 0000000..e7cc454 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/document/splitter/SimpleTokenizeSplitter.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.document.splitter; + +import com.easyagents.core.document.Document; +import com.easyagents.core.document.DocumentSplitter; +import com.easyagents.core.document.id.DocumentIdGenerator; +import com.easyagents.core.util.StringUtil; +import com.knuddels.jtokkit.Encodings; +import com.knuddels.jtokkit.api.Encoding; +import com.knuddels.jtokkit.api.EncodingRegistry; +import com.knuddels.jtokkit.api.EncodingType; +import com.knuddels.jtokkit.api.IntArrayList; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class SimpleTokenizeSplitter implements DocumentSplitter { + private EncodingRegistry registry = Encodings.newLazyEncodingRegistry(); + private EncodingType encodingType = EncodingType.CL100K_BASE; + private int chunkSize; + private int overlapSize; + + public SimpleTokenizeSplitter(int chunkSize) { + this.chunkSize = chunkSize; + if (this.chunkSize <= 0) { + throw new IllegalArgumentException("chunkSize must be greater than 0, chunkSize: " + this.chunkSize); + } + } + + public SimpleTokenizeSplitter(int chunkSize, int overlapSize) { + this.chunkSize = chunkSize; + this.overlapSize = overlapSize; + + if (this.chunkSize <= 0) { + throw new IllegalArgumentException("chunkSize must be greater than 0, chunkSize: " + this.chunkSize); + } + if (this.overlapSize >= this.chunkSize) { + throw new IllegalArgumentException("overlapSize must be less than chunkSize, overlapSize: " + this.overlapSize + ", chunkSize: " + this.chunkSize); + } + } + + public int getChunkSize() { + return chunkSize; + } + + public void setChunkSize(int chunkSize) { + this.chunkSize = chunkSize; + } + + public int getOverlapSize() { + return overlapSize; + } + + public void setOverlapSize(int overlapSize) { + this.overlapSize = overlapSize; + } + + public EncodingRegistry getRegistry() { + return registry; + } + + public void setRegistry(EncodingRegistry registry) { + this.registry = registry; + } + + public EncodingType getEncodingType() { + return encodingType; + } + + public void setEncodingType(EncodingType encodingType) { + this.encodingType = encodingType; + } + + @Override + public List split(Document document, DocumentIdGenerator idGenerator) { + if (document == null || StringUtil.noText(document.getContent())) { + return Collections.emptyList(); + } + + String content = document.getContent(); + Encoding encoding = this.registry.getEncoding(this.encodingType); + + List tokens = encoding.encode(content).boxed(); + + + int index = 0, currentIndex = index; + int maxIndex = tokens.size(); + + List chunks = new ArrayList<>(); + while (currentIndex < maxIndex) { + int endIndex = Math.min(currentIndex + chunkSize, maxIndex); + List chunkTokens = tokens.subList(currentIndex, endIndex); + + IntArrayList intArrayList = new IntArrayList(); + for (Integer chunkToken : chunkTokens) { + intArrayList.add(chunkToken); + } + String chunkText = encoding.decode(intArrayList).trim(); + if (chunkText.isEmpty()) { + continue; + } + + //UTF-8 'Unicode replacement character' which in your case is 0xFFFD (65533 in Hex). + //fix 修复中文乱码的问题 + boolean firstIsReplacement = chunkText.charAt(0) == 65533; + boolean lastIsReplacement = chunkText.charAt(chunkText.length() - 1) == 65533; + + if (firstIsReplacement || lastIsReplacement) { + if (firstIsReplacement) currentIndex -= 1; + if (lastIsReplacement) endIndex += 1; + + chunkTokens = tokens.subList(currentIndex, endIndex); + intArrayList = new IntArrayList(); + for (Integer chunkToken : chunkTokens) { + intArrayList.add(chunkToken); + } + + chunkText = encoding.decode(intArrayList).trim(); + } + + currentIndex = currentIndex + chunkSize - overlapSize; + + Document newDocument = new Document(); + newDocument.addMetadata(document.getMetadataMap()); + newDocument.setContent(chunkText); + + //we should invoke setId after setContent + newDocument.setId(idGenerator == null ? null : idGenerator.generateId(newDocument)); + chunks.add(newDocument); + } + + return chunks; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/File2TextService.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/File2TextService.java new file mode 100644 index 0000000..281a1ae --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/File2TextService.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text; + + +import com.easyagents.core.file2text.extractor.FileExtractor; +import com.easyagents.core.file2text.extractor.ExtractorRegistry; +import com.easyagents.core.file2text.source.*; + +import java.io.File; +import java.io.InputStream; +import java.util.List; +import java.util.stream.Collectors; + +public class File2TextService { + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(File2TextService.class); + private final ExtractorRegistry registry; + + public File2TextService() { + this(new ExtractorRegistry()); + } + + public File2TextService(ExtractorRegistry registry) { + this.registry = registry; + } + + public ExtractorRegistry getRegistry() { + return registry; + } + + public String extractTextFromHttpUrl(String httpUrl) { + return extractTextFromSource(new HttpDocumentSource(httpUrl)); + } + + public String extractTextFromHttpUrl(String httpUrl, String fileName) { + return extractTextFromSource(new HttpDocumentSource(httpUrl, fileName)); + } + + public String extractTextFromHttpUrl(String httpUrl, String fileName, String mimeType) { + return extractTextFromSource(new HttpDocumentSource(httpUrl, fileName, mimeType)); + } + + public String extractTextFromFile(File file) { + return extractTextFromSource(new FileDocumentSource(file)); + } + + public String extractTextFromStream(InputStream is, String fileName, String mimeType) { + return extractTextFromSource(new ByteStreamDocumentSource(is, fileName, mimeType)); + } + + public String extractTextFromBytes(byte[] bytes, String fileName, String mimeType) { + return extractTextFromSource(new ByteArrayDocumentSource(bytes, fileName, mimeType)); + } + + + /** + * 从 DocumentSource 提取纯文本 + * 支持多 Extractor 降级重试 + * + * @param source 文档输入源 + * @return 提取的文本(非空去空格),若无法提取则抛出异常 + * @throws IllegalArgumentException 输入源为空 + */ + public String extractTextFromSource(DocumentSource source) { + if (source == null) { + throw new IllegalArgumentException("DocumentSource cannot be null"); + } + + try { + // 获取可用的 Extractor(按优先级排序) + List candidates = registry.findExtractors(source); + if (candidates.isEmpty()) { + log.warn("No extractor supports this document: " + safeFileName(source)); + return null; + } + + // 日志:输出候选 Extractor + log.info("Trying extractors for {}: {}", safeFileName(source), + candidates.stream() + .map(e -> e.getClass().getSimpleName()) + .collect(Collectors.joining(", "))); + + + for (FileExtractor extractor : candidates) { + try { + log.debug("Trying {} on {}", extractor.getClass().getSimpleName(), safeFileName(source)); + + String text = extractor.extractText(source); + if (text != null && !text.trim().isEmpty()) { + log.debug("Success with {}: extracted {} chars", + extractor.getClass().getSimpleName(), text.length()); + return text; + } else { + log.debug("Extractor {} returned null", extractor.getClass().getSimpleName()); + } + } catch (Exception e) { + log.warn("Extractor {} failed on {}: {}", + extractor.getClass().getSimpleName(), + safeFileName(source), + e.toString()); + } + } + + log.warn(String.format("All %d extractors failed for: %s", candidates.size(), safeFileName(source))); + return null; + } finally { + source.cleanup(); + } + + } + + private String safeFileName(DocumentSource source) { + try { + return source.getFileName() != null ? source.getFileName() : "unknown"; + } catch (Exception e) { + return "unknown"; + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/File2TextUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/File2TextUtil.java new file mode 100644 index 0000000..392cbb2 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/File2TextUtil.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text; + + +import com.easyagents.core.file2text.source.ByteArrayDocumentSource; +import com.easyagents.core.file2text.source.ByteStreamDocumentSource; +import com.easyagents.core.file2text.source.FileDocumentSource; +import com.easyagents.core.file2text.source.HttpDocumentSource; + +import java.io.File; +import java.io.InputStream; + +public class File2TextUtil { + private static File2TextService file2TextService = new File2TextService(); + + public static void setFile2TextService(File2TextService file2TextService) { + if (file2TextService == null) { + throw new IllegalArgumentException("File2TextService cannot be null"); + } + File2TextUtil.file2TextService = file2TextService; + } + + public static String readFromHttpUrl(String httpUrl) { + return file2TextService.extractTextFromSource(new HttpDocumentSource(httpUrl)); + } + + public static String readFromHttpUrl(String httpUrl, String fileName) { + return file2TextService.extractTextFromSource(new HttpDocumentSource(httpUrl, fileName)); + } + + public static String readFromHttpUrl(String httpUrl, String fileName, String mimeType) { + return file2TextService.extractTextFromSource(new HttpDocumentSource(httpUrl, fileName, mimeType)); + } + + public static String readFromFile(File file) { + return file2TextService.extractTextFromSource(new FileDocumentSource(file)); + } + + public static String readFromStream(InputStream is, String fileName, String mimeType) { + return file2TextService.extractTextFromSource(new ByteStreamDocumentSource(is, fileName, mimeType)); + } + + public static String readFromBytes(byte[] bytes, String fileName, String mimeType) { + return file2TextService.extractTextFromSource(new ByteArrayDocumentSource(bytes, fileName, mimeType)); + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/ExtractorRegistry.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/ExtractorRegistry.java new file mode 100644 index 0000000..a2a905a --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/ExtractorRegistry.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.extractor; + + +import com.easyagents.core.file2text.extractor.impl.*; +import com.easyagents.core.file2text.source.DocumentSource; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Extractor 注册中心 + */ +public class ExtractorRegistry { + + private final List extractors = new ArrayList<>(); + + public ExtractorRegistry() { + register(new PdfTextExtractor()); + register(new DocxExtractor()); + register(new DocExtractor()); + register(new PptxExtractor()); + register(new HtmlExtractor()); + register(new PlainTextExtractor()); + } + + /** + * 注册一个 Extractor + */ + public synchronized void register(FileExtractor extractor) { + Objects.requireNonNull(extractor, "Extractor cannot be null"); + extractors.add(extractor); + } + + /** + * 批量注册 + */ + public void registerAll(List extractors) { + extractors.forEach(this::register); + } + + + public List findExtractors(DocumentSource source) { + return extractors.stream() + .filter(extractor -> extractor.supports(source)) + .sorted(FileExtractor.ORDER_COMPARATOR) + .collect(Collectors.toList()); + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/FileExtractor.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/FileExtractor.java new file mode 100644 index 0000000..62d50ca --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/FileExtractor.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.extractor; + +import com.easyagents.core.file2text.source.DocumentSource; + +import java.io.IOException; +import java.util.Comparator; + +public interface FileExtractor { + + Comparator ORDER_COMPARATOR = + Comparator.comparingInt(FileExtractor::getOrder); + + /** + * 判断该 Extractor 是否支持处理此文档 + */ + boolean supports(DocumentSource source); + + String extractText(DocumentSource source) throws IOException; + + + default int getOrder() { + return 100; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/DocExtractor.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/DocExtractor.java new file mode 100644 index 0000000..46f8d22 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/DocExtractor.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.extractor.impl; + +import com.easyagents.core.file2text.extractor.FileExtractor; +import com.easyagents.core.file2text.source.DocumentSource; +import org.apache.poi.hwpf.HWPFDocument; +import org.apache.poi.hwpf.extractor.WordExtractor; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * DOC 文档提取器(.doc) + * 支持旧版 Word 97-2003 格式 + */ +public class DocExtractor implements FileExtractor { + + private static final Set SUPPORTED_MIME_TYPES; + private static final Set SUPPORTED_EXTENSIONS; + + static { + Set mimeTypes = new HashSet<>(); + mimeTypes.add("application/msword"); + SUPPORTED_MIME_TYPES = Collections.unmodifiableSet(mimeTypes); + + Set extensions = new HashSet<>(); + extensions.add("doc"); + extensions.add("dot"); + SUPPORTED_EXTENSIONS = Collections.unmodifiableSet(extensions); + } + + @Override + public boolean supports(DocumentSource source) { + String mimeType = source.getMimeType(); + String fileName = source.getFileName(); + + if (mimeType != null && SUPPORTED_MIME_TYPES.contains(mimeType)) { + return true; + } + + if (fileName != null) { + String ext = getExtension(fileName); + if (ext != null && SUPPORTED_EXTENSIONS.contains(ext.toLowerCase())) { + return true; + } + } + + return false; + } + + @Override + public String extractText(DocumentSource source) throws IOException { + try (InputStream is = source.openStream(); + POIFSFileSystem fs = new POIFSFileSystem(is); + HWPFDocument doc = new HWPFDocument(fs)) { + + WordExtractor extractor = new WordExtractor(doc); + String[] paragraphs = extractor.getParagraphText(); + + StringBuilder text = new StringBuilder(); + for (String para : paragraphs) { + // 清理控制字符 + String clean = para.replaceAll("[\\r\\001]+", "").trim(); + if (!clean.isEmpty()) { + text.append(clean).append("\n"); + } + } + + return text.toString().trim(); + } catch (Exception e) { + throw new IOException("Failed to extract .doc file: " + e.getMessage(), e); + } + } + + @Override + public int getOrder() { + return 15; // 低于 .docx + } + + private String getExtension(String fileName) { + if (fileName == null || !fileName.contains(".")) return null; + int lastDot = fileName.lastIndexOf('.'); + return fileName.substring(lastDot + 1); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/DocxExtractor.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/DocxExtractor.java new file mode 100644 index 0000000..3f8dc4d --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/DocxExtractor.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.extractor.impl; + + +import com.easyagents.core.file2text.extractor.FileExtractor; +import com.easyagents.core.file2text.source.DocumentSource; +import org.apache.poi.xwpf.usermodel.*; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * DOCX 文档提取器(.docx, .dotx) + * 支持段落、表格、列表文本提取 + */ +public class DocxExtractor implements FileExtractor { + + private static final Set KNOWN_MIME_TYPES; + private static final String MIME_PREFIX = "application/vnd.openxmlformats-officedocument.wordprocessingml"; + private static final Set SUPPORTED_EXTENSIONS; + + static { + // 精确 MIME(可选) + Set mimeTypes = new HashSet<>(); + mimeTypes.add("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + KNOWN_MIME_TYPES = Collections.unmodifiableSet(mimeTypes); + + // 支持的扩展名 + Set extensions = new HashSet<>(); + extensions.add("docx"); + extensions.add("dotx"); + SUPPORTED_EXTENSIONS = Collections.unmodifiableSet(extensions); + } + + @Override + public boolean supports(DocumentSource source) { + String mimeType = source.getMimeType(); + String fileName = source.getFileName(); + + // 1. MIME 精确匹配 + if (mimeType != null && KNOWN_MIME_TYPES.contains(mimeType)) { + return true; + } + + // 2. MIME 前缀匹配 + if (mimeType != null && mimeType.startsWith(MIME_PREFIX)) { + return true; + } + + // 3. 扩展名匹配 + if (fileName != null) { + String ext = getExtension(fileName); + if (ext != null && SUPPORTED_EXTENSIONS.contains(ext.toLowerCase())) { + return true; + } + } + + return false; + } + + @Override + public String extractText(DocumentSource source) throws IOException { + StringBuilder text = new StringBuilder(); + + try (InputStream is = source.openStream(); + XWPFDocument document = new XWPFDocument(is)) { + + // 提取段落 + for (XWPFParagraph paragraph : document.getParagraphs()) { + String paraText = getParagraphText(paragraph); + if (paraText != null && !paraText.trim().isEmpty()) { + text.append(paraText).append("\n"); + } + } + + // 提取表格 + for (XWPFTable table : document.getTables()) { + text.append("\n[Table Start]\n"); + for (XWPFTableRow row : table.getRows()) { + List cellTexts = row.getTableCells().stream() + .map(this::getCellText) + .map(String::trim) + .collect(Collectors.toList()); + text.append(cellTexts).append("\n"); + } + text.append("[Table End]\n\n"); + } + + } catch (Exception e) { + throw new IOException("Failed to extract DOCX: " + e.getMessage(), e); + } + + return text.toString().trim(); + } + + private String getParagraphText(XWPFParagraph paragraph) { + StringBuilder text = new StringBuilder(); + for (XWPFRun run : paragraph.getRuns()) { + String runText = run.text(); + if (runText != null) { + text.append(runText); + } + } + return text.length() > 0 ? text.toString() : null; + } + + private String getCellText(XWPFTableCell cell) { + String simpleText = cell.getText(); + if (simpleText != null && !simpleText.isEmpty()) { + return simpleText; + } + StringBuilder text = new StringBuilder(); + for (XWPFParagraph p : cell.getParagraphs()) { + String pt = getParagraphText(p); + if (pt != null) { + text.append(pt).append(" "); + } + } + return text.toString().trim(); + } + + @Override + public int getOrder() { + return 10; + } + + private String getExtension(String fileName) { + if (fileName == null || !fileName.contains(".")) return null; + int lastDot = fileName.lastIndexOf('.'); + return fileName.substring(lastDot + 1); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/HtmlExtractor.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/HtmlExtractor.java new file mode 100644 index 0000000..51ebcbf --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/HtmlExtractor.java @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.extractor.impl; + +import com.easyagents.core.file2text.extractor.FileExtractor; +import com.easyagents.core.file2text.source.DocumentSource; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.nodes.Node; +import org.jsoup.select.Elements; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +/** + * 增强版 HTML 文档提取器 + * 支持可配置的噪音过滤规则(含中文网站常见广告) + */ +public class HtmlExtractor implements FileExtractor { + + private static final Set SUPPORTED_MIME_TYPES; + private static final Set SUPPORTED_EXTENSIONS; + + static { + Set mimeTypes = new HashSet<>(); + mimeTypes.add("text/html"); + mimeTypes.add("application/xhtml+xml"); + SUPPORTED_MIME_TYPES = Collections.unmodifiableSet(mimeTypes); + + Set extensions = new HashSet<>(); + extensions.add("html"); + extensions.add("htm"); + extensions.add("xhtml"); + extensions.add("mhtml"); + SUPPORTED_EXTENSIONS = Collections.unmodifiableSet(extensions); + } + + // 噪音过滤规则 + private static final Set DEFAULT_SELECTORS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "script", "style", "noscript", + "nav", "header", "footer", "aside", + "iframe", "embed", "object", "video", "audio", + ".ads", ".advertisement", ".ad-", "ad:", + ".sidebar", ".sider", ".widget", ".module", + ".breadcrumb", ".pager", ".pagination", + ".share", ".social", ".like", ".subscribe", + ".cookie", ".consent", ".banner", ".popup", + "[data-ad]", "[data-testid*='ad']", "[data-type='advertisement']" + ))); + + private static final Set CLASS_KEYWORDS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "ad", "adv", "advertisement", "banner", "sponsor", + "sidebar", "sider", "widget", "module", "recommend", + "related", "similar", "youlike", "hot", "tuijian", + "share", "social", "like", "follow", "subscribe", + "cookie", "consent", "popup", "modal", "dialog", + "footer", "nav", "breadcrumb", "pager", "pagination" + ))); + + private static final Pattern ID_CLASS_PATTERN = Pattern.compile("(?i)\\b(" + + String.join("|", + "ad", "adv", "advertisement", "banner", "sponsor", + "sidebar", "sider", "widget", "module", "tuijian", + "share", "social", "like", "follow", "subscribe", + "cookie", "consent", "popup", "modal", "dialog", + "footer", "nav", "breadcrumb", "pager", "pagination" + ) + ")\\b" + ); + + // 可动态添加的自定义规则 + private static final Set CUSTOM_SELECTORS = ConcurrentHashMap.newKeySet(); + private static final Set CUSTOM_CLASS_KEYWORDS = ConcurrentHashMap.newKeySet(); + + /** + * 添加自定义噪音选择器(CSS 选择器) + */ + public static void addCustomSelector(String selector) { + CUSTOM_SELECTORS.add(selector); + } + + /** + * 添加自定义 class/id 关键词 + */ + public static void addCustomKeyword(String keyword) { + CUSTOM_CLASS_KEYWORDS.add(keyword.toLowerCase()); + } + + @Override + public boolean supports(DocumentSource source) { + String mimeType = source.getMimeType(); + String fileName = source.getFileName(); + + if (mimeType != null && SUPPORTED_MIME_TYPES.contains(mimeType.toLowerCase())) { + return true; + } + + if (fileName != null) { + String ext = getExtension(fileName); + if (ext != null && SUPPORTED_EXTENSIONS.contains(ext.toLowerCase())) { + return true; + } + } + + return false; + } + + @Override + public String extractText(DocumentSource source) throws IOException { + try (InputStream is = source.openStream()) { + String html = readToString(is, StandardCharsets.UTF_8); + if (html.trim().isEmpty()) { + return ""; + } + + Document doc = Jsoup.parse(html); + doc.outputSettings().prettyPrint(false); + + StringBuilder text = new StringBuilder(); + + extractTitle(doc, text); + extractBodyContent(doc, text); + + return text.toString().trim(); + } catch (Exception e) { + throw new IOException("Failed to parse HTML: " + e.getMessage(), e); + } + } + + private void extractTitle(Document doc, StringBuilder text) { + Elements titleEl = doc.select("title"); + if (!titleEl.isEmpty()) { + String title = titleEl.first().text().trim(); + if (!title.isEmpty()) { + text.append(title).append("\n\n"); + } + } + } + + private void extractBodyContent(Document doc, StringBuilder text) { + Element body = doc.body(); + if (body == null) return; + + // 1. 移除已知噪音元素(CSS 选择器) + removeElementsBySelectors(body); + + // 2. 移除 class/id 包含关键词的元素 + removeElementsWithKeywords(body); + + // 3. 遍历剩余节点 + for (Node node : body.childNodes()) { + appendNodeText(node, text, 0); + } + } + + /** + * 使用 CSS 选择器移除噪音 + */ + private void removeElementsBySelectors(Element body) { + List allSelectors = new ArrayList<>(DEFAULT_SELECTORS); + allSelectors.addAll(CUSTOM_SELECTORS); + + for (String selector : allSelectors) { + try { + body.select(selector).remove(); + } catch (Exception e) { + // 忽略无效选择器 + } + } + } + + /** + * 移除 class 或 id 包含关键词的元素 + */ + private void removeElementsWithKeywords(Element body) { + // 合并默认和自定义关键词 + Set keywords = new HashSet<>(CLASS_KEYWORDS); + keywords.addAll(CUSTOM_CLASS_KEYWORDS); + + // 使用 DFS 遍历所有元素 + Deque stack = new ArrayDeque<>(); + stack.push(body); + + while (!stack.isEmpty()) { + Element el = stack.pop(); + + // 检查 class 或 id 是否匹配 + String classes = el.className().toLowerCase(); + String id = el.id().toLowerCase(); + + for (String keyword : keywords) { + if (classes.contains(keyword) || id.contains(keyword)) { + el.remove(); // 移除整个元素 + break; + } + } + + // 匹配正则模式 + if (ID_CLASS_PATTERN.matcher(classes).find() || + ID_CLASS_PATTERN.matcher(id).find()) { + el.remove(); + continue; + } + + // 将子元素加入栈 + for (Element child : el.children()) { + stack.push(child); + } + } + } + + + private String repeat(String string, int times) { + if (times <= 0) return ""; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < times; i++) { + sb.append(string); + } + return sb.toString(); + } + + // 节点文本提取 + private void appendNodeText(Node node, StringBuilder text, int level) { + if (node == null) return; + + if (node instanceof org.jsoup.nodes.TextNode) { + String txt = ((org.jsoup.nodes.TextNode) node).text().trim(); + if (!txt.isEmpty()) { + text.append(txt).append(" "); + } + } else if (node instanceof Element) { + Element el = (Element) node; + String tagName = el.tagName().toLowerCase(); + + if (tagName.matches("h[1-6]")) { + text.append("\n") + .append(repeat("##", Integer.parseInt(tagName.substring(1)))) + .append(el.text().trim()) + .append("\n\n"); + } else if (tagName.equals("p")) { + String paraText = el.text().trim(); + if (!paraText.isEmpty()) { + text.append(paraText).append("\n\n"); + } + } else if (tagName.equals("li")) { + text.append("- ").append(el.text().trim()).append("\n"); + } else if (tagName.equals("table")) { + extractTable(el, text); + text.append("\n"); + } else if (tagName.equals("br")) { + text.append("\n"); + } else if (tagName.equals("a")) { + String href = el.attr("href"); + String textPart = el.text().trim(); + if (!textPart.isEmpty()) { + text.append(textPart); + if (!href.isEmpty() && !href.equals(textPart)) { + text.append(" [").append(href).append("]"); + } + text.append(" "); + } + } else if (isBlockLevel(tagName)) { + text.append("\n"); + for (Node child : el.childNodes()) { + appendNodeText(child, text, level + 1); + } + text.append("\n"); + } else { + for (Node child : el.childNodes()) { + appendNodeText(child, text, level); + } + } + } + } + + private boolean isBlockLevel(String tagName) { + Set blockTags = new HashSet<>(Arrays.asList( + "div", "p", "h1", "h2", "h3", "h4", "h5", "h6", + "ul", "ol", "li", "table", "tr", "td", "th", + "blockquote", "pre", "section", "article", "figure" + )); + return blockTags.contains(tagName); + } + + private void extractTable(Element table, StringBuilder text) { + text.append("[Table Start]\n"); + Elements rows = table.select("tr"); + for (Element row : rows) { + Elements cells = row.select("td, th"); + List cellTexts = new ArrayList<>(); + for (Element cell : cells) { + cellTexts.add(cell.text().trim()); + } + text.append(String.join(" | ", cellTexts)).append("\n"); + } + text.append("[Table End]\n"); + } + + private String readToString(InputStream is, java.nio.charset.Charset charset) throws IOException { + StringBuilder sb = new StringBuilder(); + try (java.io.InputStreamReader reader = new java.io.InputStreamReader(is, charset); + java.io.BufferedReader br = new java.io.BufferedReader(reader)) { + char[] buffer = new char[8192]; + int read; + while ((read = br.read(buffer)) != -1) { + sb.append(buffer, 0, read); + } + } + return sb.toString(); + } + + @Override + public int getOrder() { + return 12; + } + + private String getExtension(String fileName) { + if (fileName == null || !fileName.contains(".")) return null; + int lastDot = fileName.lastIndexOf('.'); + return fileName.substring(lastDot + 1); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PdfTextExtractor.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PdfTextExtractor.java new file mode 100644 index 0000000..8c28c08 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PdfTextExtractor.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.extractor.impl; + +import com.easyagents.core.file2text.extractor.FileExtractor; +import com.easyagents.core.file2text.source.DocumentSource; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * PDF 文本提取器 + * 支持标准 PDF(非扫描件) + */ +public class PdfTextExtractor implements FileExtractor { + + private static final Set SUPPORTED_MIME_TYPES; + private static final Set SUPPORTED_EXTENSIONS; + + static { + Set mimeTypes = new HashSet<>(); + mimeTypes.add("application/pdf"); + SUPPORTED_MIME_TYPES = Collections.unmodifiableSet(mimeTypes); + + Set extensions = new HashSet<>(); + extensions.add("pdf"); + SUPPORTED_EXTENSIONS = Collections.unmodifiableSet(extensions); + } + + @Override + public boolean supports(DocumentSource source) { + String mimeType = source.getMimeType(); + String fileName = source.getFileName(); + + if (mimeType != null && SUPPORTED_MIME_TYPES.contains(mimeType)) { + return true; + } + + if (fileName != null) { + String ext = getExtension(fileName); + return "pdf".equalsIgnoreCase(ext); + } + + return false; + } + + @Override + public String extractText(DocumentSource source) throws IOException { + try (InputStream is = source.openStream(); + PDDocument doc = PDDocument.load(is)) { + PDFTextStripper stripper = new PDFTextStripper(); + return stripper.getText(doc).trim(); + } catch (Exception e) { + throw new IOException("Failed to extract PDF text: " + e.getMessage(), e); + } + } + + @Override + public int getOrder() { + return 10; + } + + private String getExtension(String fileName) { + if (fileName == null || !fileName.contains(".")) return null; + int lastDot = fileName.lastIndexOf('.'); + return fileName.substring(lastDot + 1); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PlainTextExtractor.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PlainTextExtractor.java new file mode 100644 index 0000000..2029f8a --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PlainTextExtractor.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.extractor.impl; + + +import com.easyagents.core.file2text.extractor.FileExtractor; +import com.easyagents.core.file2text.source.DocumentSource; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * 纯文本文件提取器(支持 UTF-8、GBK、GB2312 编码自动检测) + * 支持 .txt, .md, .log, .csv, .json, .xml 等文本格式 + */ +public class PlainTextExtractor implements FileExtractor { + + private static final Set SUPPORTED_MIME_TYPES; + private static final Set SUPPORTED_EXTENSIONS; + + static { + Set mimeTypes = new HashSet<>(); + mimeTypes.add("text/plain"); + mimeTypes.add("text/markdown"); + mimeTypes.add("text/csv"); + mimeTypes.add("application/json"); + mimeTypes.add("application/xml"); + SUPPORTED_MIME_TYPES = Collections.unmodifiableSet(mimeTypes); + + Set extensions = new HashSet<>(); + extensions.add("txt"); + extensions.add("text"); + extensions.add("md"); + extensions.add("markdown"); + extensions.add("log"); + extensions.add("csv"); + extensions.add("json"); + extensions.add("xml"); + extensions.add("yml"); + extensions.add("yaml"); + extensions.add("properties"); + extensions.add("conf"); + SUPPORTED_EXTENSIONS = Collections.unmodifiableSet(extensions); + } + + @Override + public boolean supports(DocumentSource source) { + String mimeType = source.getMimeType(); + String fileName = source.getFileName(); + + if (mimeType != null && (mimeType.startsWith("text/") || SUPPORTED_MIME_TYPES.contains(mimeType))) { + return true; + } + + if (fileName != null) { + String ext = getExtension(fileName); + return ext != null && SUPPORTED_EXTENSIONS.contains(ext.toLowerCase()); + } + + return false; + } + + @Override + public String extractText(DocumentSource source) throws IOException { + try (InputStream is = source.openStream()) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, "utf-8"))) { + StringBuilder text = new StringBuilder(); + char[] buffer = new char[8192]; + int read; + while ((read = reader.read(buffer)) != -1) { + text.append(buffer, 0, read); + } + return text.toString().trim(); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + + @Override + public int getOrder() { + return 5; // 高优先级 + } + + private String getExtension(String fileName) { + if (fileName == null || !fileName.contains(".")) return null; + int lastDot = fileName.lastIndexOf('.'); + return fileName.substring(lastDot + 1).toLowerCase(); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PptxExtractor.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PptxExtractor.java new file mode 100644 index 0000000..bc2f085 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/extractor/impl/PptxExtractor.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.extractor.impl; + +import com.easyagents.core.file2text.extractor.FileExtractor; +import com.easyagents.core.file2text.source.DocumentSource; +import org.apache.poi.xslf.usermodel.*; +import org.apache.xmlbeans.XmlException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.*; + +/** + * PPTX 文档提取器(.pptx) + * 提取幻灯片中的标题、段落、表格文本 + */ +public class PptxExtractor implements FileExtractor { + + private static final Set SUPPORTED_MIME_TYPES; + private static final String MIME_PREFIX = "application/vnd.openxmlformats-officedocument.presentationml"; + private static final Set SUPPORTED_EXTENSIONS; + + static { + // 精确 MIME(可选) + Set mimeTypes = new HashSet<>(); + mimeTypes.add("application/vnd.openxmlformats-officedocument.presentationml.presentation"); + mimeTypes.add("application/vnd.openxmlformats-officedocument.presentationml.slideshow"); + SUPPORTED_MIME_TYPES = Collections.unmodifiableSet(mimeTypes); + + // 支持的扩展名 + Set extensions = new HashSet<>(); + extensions.add("pptx"); + extensions.add("ppsx"); + extensions.add("potx"); + SUPPORTED_EXTENSIONS = Collections.unmodifiableSet(extensions); + } + + @Override + public boolean supports(DocumentSource source) { + String mimeType = source.getMimeType(); + String fileName = source.getFileName(); + + // 1. MIME 精确匹配 + if (mimeType != null && SUPPORTED_MIME_TYPES.contains(mimeType)) { + return true; + } + + // 2. MIME 前缀匹配 + if (mimeType != null && mimeType.startsWith(MIME_PREFIX)) { + return true; + } + + // 3. 扩展名匹配 + if (fileName != null) { + String ext = getExtension(fileName); + if (ext != null && SUPPORTED_EXTENSIONS.contains(ext.toLowerCase())) { + return true; + } + } + + return false; + } + + @Override + public String extractText(DocumentSource source) throws IOException { + StringBuilder text = new StringBuilder(); + + try (InputStream is = source.openStream(); + XMLSlideShow slideShow = new XMLSlideShow(is)) { + + List slides = slideShow.getSlides(); + + for (int i = 0; i < slides.size(); i++) { + XSLFSlide slide = slides.get(i); + text.append("\n--- Slide ").append(i + 1).append(" ---\n"); + + // 提取所有形状中的文本 + for (XSLFShape shape : slide.getShapes()) { + if (shape instanceof XSLFTextShape) { + XSLFTextShape textShape = (XSLFTextShape) shape; + String shapeText = textShape.getText(); + if (shapeText != null && !shapeText.trim().isEmpty()) { + text.append(shapeText).append("\n"); + } + } + } + + // 可选:提取表格 + extractTablesFromSlide(slide, text); + } + + } catch (XmlException e) { + throw new IOException("Invalid PPTX structure: " + e.getMessage(), e); + } catch (Exception e) { + throw new IOException("Failed to extract PPTX: " + e.getMessage(), e); + } + + return text.toString().trim(); + } + + /** + * 提取幻灯片中的表格内容 + */ + private void extractTablesFromSlide(XSLFSlide slide, StringBuilder text) { + for (XSLFShape shape : slide.getShapes()) { + if (shape instanceof XSLFTable) { + XSLFTable table = (XSLFTable) shape; + text.append("\n[Table Start]\n"); + for (XSLFTableRow row : table.getRows()) { + List cellTexts = new ArrayList<>(); + for (XSLFTableCell cell : row.getCells()) { + String cellText = cell.getText(); + cellTexts.add(cellText != null ? cellText.trim() : ""); + } + text.append(String.join(" | ", cellTexts)).append("\n"); + } + text.append("[Table End]\n"); + } + } + } + + @Override + public int getOrder() { + return 10; + } + + private String getExtension(String fileName) { + if (fileName == null || !fileName.contains(".")) return null; + int lastDot = fileName.lastIndexOf('.'); + return fileName.substring(lastDot + 1); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/ByteArrayDocumentSource.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/ByteArrayDocumentSource.java new file mode 100644 index 0000000..0e7bd29 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/ByteArrayDocumentSource.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.source; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +public class ByteArrayDocumentSource implements DocumentSource { + private final byte[] data; + private final String fileName; + private final String mimeType; + + public ByteArrayDocumentSource(byte[] data, String fileName) { + this(data, fileName, null); + } + + public ByteArrayDocumentSource(byte[] data, String fileName, String mimeType) { + this.data = data.clone(); + this.fileName = fileName; + this.mimeType = mimeType; + } + + @Override + public String getFileName() { + return fileName; + } + + @Override + public String getMimeType() { + return mimeType; + } + + @Override + public InputStream openStream() { + return new ByteArrayInputStream(data); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/ByteStreamDocumentSource.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/ByteStreamDocumentSource.java new file mode 100644 index 0000000..db66962 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/ByteStreamDocumentSource.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.source; + + +import com.easyagents.core.file2text.util.IOUtils; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +public class ByteStreamDocumentSource implements DocumentSource { + private final byte[] data; + private final String fileName; + private final String mimeType; + + public ByteStreamDocumentSource(InputStream inputStream, String fileName) { + this(inputStream, fileName, null); + } + + public ByteStreamDocumentSource(InputStream inputStream, String fileName, String mimeType) { + this.data = IOUtils.toByteArray(inputStream, Integer.MAX_VALUE); + this.fileName = fileName; + this.mimeType = mimeType; + } + + @Override + public String getFileName() { + return fileName; + } + + @Override + public String getMimeType() { + return mimeType; + } + + @Override + public InputStream openStream() { + return new ByteArrayInputStream(data); + } + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/DocumentSource.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/DocumentSource.java new file mode 100644 index 0000000..72bd613 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/DocumentSource.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.source; + + +import java.io.InputStream; + +public interface DocumentSource { + String getFileName(); + + String getMimeType(); + + InputStream openStream() throws Exception; + + default void cleanup() { + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/FileDocumentSource.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/FileDocumentSource.java new file mode 100644 index 0000000..64b91bf --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/FileDocumentSource.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.source; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; + +public class FileDocumentSource implements DocumentSource { + private final File file; + private final String mimeType; + + public FileDocumentSource(File file) { + this(file, null); + } + + public FileDocumentSource(File file, String mimeType) { + this.file = file; + this.mimeType = mimeType; + } + + @Override + public String getFileName() { + return file.getName(); + } + + @Override + public String getMimeType() { + return mimeType; + } + + @Override + public InputStream openStream() { + try { + return new FileInputStream(file); + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/HttpDocumentSource.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/HttpDocumentSource.java new file mode 100644 index 0000000..fa08d2e --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/HttpDocumentSource.java @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.source; + + +import com.easyagents.core.file2text.util.IOUtils; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Paths; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 从 HTTP/HTTPS URL 读取文档的输入源,支持缓存(避免重复请求) + * 自动判断使用内存缓存还是临时文件缓存 + */ +public class HttpDocumentSource implements DocumentSource { + private static final int DEFAULT_CONNECT_TIMEOUT = 20_000; + private static final int DEFAULT_READ_TIMEOUT = 60_000; + private static final long MEMORY_THRESHOLD = 10 * 1024 * 1024; // 10MB 以内走内存 + + private final String url; + private final String providedFileName; + private final String mimeType; + private final int connectTimeout; + private final int readTimeout; + private final java.util.function.Consumer connectionCustomizer; + + private volatile byte[] cachedBytes = null; + private volatile File tempFile = null; + private volatile String resolvedFileName = null; + private volatile String resolvedMimeType = null; + private final AtomicBoolean downloaded = new AtomicBoolean(false); + + public HttpDocumentSource(String url) { + this(url, null, null, DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, null); + } + + public HttpDocumentSource(String url, String fileName) { + this(url, fileName, null, DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, null); + } + + public HttpDocumentSource(String url, String fileName, String mimeType) { + this(url, fileName, mimeType, DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, null); + } + + public HttpDocumentSource( + String url, + String fileName, + String mimeType, + int connectTimeout, + int readTimeout, + java.util.function.Consumer connectionCustomizer + ) { + this.url = validateUrl(url); + this.providedFileName = fileName; + this.mimeType = mimeType; + this.connectTimeout = connectTimeout; + this.readTimeout = readTimeout; + this.connectionCustomizer = connectionCustomizer; + } + + private String validateUrl(String url) { + try { + new URL(url).toURI(); + return url; + } catch (Exception e) { + throw new RuntimeException("Invalid URL: " + url); + } + } + + @Override + public String getFileName() { + if (resolvedFileName != null) { + return resolvedFileName; + } + synchronized (this) { + if (resolvedFileName == null) { + resolvedFileName = detectFileName(); + } + } + return resolvedFileName; + } + + private String detectFileName() { + // 1. 用户提供 + if (providedFileName != null && !providedFileName.trim().isEmpty()) { + return sanitizeFileName(providedFileName); + } + + // 2. 从 URL 路径提取 + String fromUrl = extractFileNameFromUrl(); + if (fromUrl != null) return fromUrl; + + // 3. 从 Content-Disposition 提取(需要连接) + try { + HttpURLConnection conn = createConnection(); + conn.setRequestMethod("HEAD"); // 只获取头 + conn.connect(); + String fromHeader = extractFileNameFromHeader(conn); + conn.disconnect(); + if (fromHeader != null) return fromHeader; + } catch (IOException e) { + // 忽略 + } + + return "downloaded-file"; + } + + private String extractFileNameFromUrl() { + try { + URL urlObj = new URL(this.url); + String path = urlObj.getPath(); + if (path != null && path.length() > 1) { + String name = Paths.get(path).getFileName().toString(); + if (name.contains(".")) { + return sanitizeFileName(name); + } + } + } catch (Exception e) { + // 忽略 + } + return null; + } + + private String extractFileNameFromHeader(HttpURLConnection conn) { + try { + String header = conn.getHeaderField("Content-Disposition"); + if (header != null) { + Pattern pattern = Pattern.compile("filename\\s*=\\s*\"?([^\";]+)\"?"); + Matcher matcher = pattern.matcher(header); + if (matcher.find()) { + return sanitizeFileName(matcher.group(1)); + } + } + } catch (Exception e) { + // 忽略 + } + return null; + } + + public static String sanitizeFileName(String filename) { + if (filename == null) return "unknown"; + return filename + .replaceAll("[\\\\/:*?\"<>|]", "_") + .replaceAll("\\.\\.", "_") + .replaceAll("^\\s+|\\s+$", "") + .isEmpty() ? "file" : filename; + } + + @Override + public String getMimeType() { + if (resolvedMimeType != null) { + return resolvedMimeType; + } + synchronized (this) { + if (resolvedMimeType == null) { + try { + HttpURLConnection conn = createConnection(); + conn.setRequestMethod("HEAD"); + conn.connect(); + resolvedMimeType = conn.getContentType(); + conn.disconnect(); + } catch (IOException e) { + resolvedMimeType = mimeType; // fallback + } + if (resolvedMimeType == null) { + resolvedMimeType = mimeType; + } + } + } + return resolvedMimeType; + } + + @Override + public InputStream openStream() throws IOException { + downloadIfNeeded(); + if (cachedBytes != null) { + return new ByteArrayInputStream(cachedBytes); + } else if (tempFile != null) { + return new FileInputStream(tempFile); + } else { + throw new IOException("No content available"); + } + } + + /** + * 下载一次,缓存结果 + */ + private void downloadIfNeeded() throws IOException { + if (downloaded.get()) return; + + synchronized (this) { + if (downloaded.get()) return; + + HttpURLConnection conn = createConnection(); + conn.connect(); + + try { + int code = conn.getResponseCode(); + if (code >= 400) { + throw new IOException("HTTP " + code + " from " + url); + } + + // 判断是否走内存 or 临时文件 + long contentLength = conn.getContentLengthLong(); + boolean useMemory = contentLength > 0 && contentLength <= MEMORY_THRESHOLD; + + if (useMemory) { + // 内存缓存 + this.cachedBytes = IOUtils.toByteArray(conn.getInputStream(), MEMORY_THRESHOLD); + } else { + // 临时文件缓存 + this.tempFile = File.createTempFile("http-", ".cache"); + this.tempFile.deleteOnExit(); + try (FileOutputStream fos = new FileOutputStream(tempFile)) { + IOUtils.copyStream(conn.getInputStream(), fos, Long.MAX_VALUE); + } + } + + // 更新 MIME(如果未指定) + if (this.resolvedMimeType == null) { + this.resolvedMimeType = conn.getContentType(); + } + + } finally { + conn.disconnect(); + } + + downloaded.set(true); + } + } + + private HttpURLConnection createConnection() throws IOException { + URL urlObj = new URL(this.url); + HttpURLConnection conn = (HttpURLConnection) urlObj.openConnection(); + conn.setConnectTimeout(connectTimeout); + conn.setReadTimeout(readTimeout); + conn.setInstanceFollowRedirects(true); + conn.setRequestMethod("GET"); + conn.setRequestProperty("User-Agent", "DocumentParser/1.0"); + if (connectionCustomizer != null) { + connectionCustomizer.accept(conn); + } + return conn; + } + + /** + * 获取缓存大小(用于调试) + */ + public long getCachedSize() { + if (cachedBytes != null) return cachedBytes.length; + if (tempFile != null) return tempFile.length(); + return 0; + } + + /** + * 清理临时文件 + */ + public void cleanup() { + if (tempFile != null && tempFile.exists()) { + tempFile.delete(); + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/TemporaryFileStreamDocumentSource.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/TemporaryFileStreamDocumentSource.java new file mode 100644 index 0000000..ba99edd --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/source/TemporaryFileStreamDocumentSource.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.source; + + +import com.easyagents.core.file2text.util.IOUtils; + +import java.io.*; +import java.nio.file.Files; +import java.util.Objects; +import java.util.logging.Logger; + +/** + * 将输入流保存到临时文件的 DocumentSource + * 适用于大文件,避免内存溢出 + * 线程安全,文件名唯一,支持自动清理 + */ +public class TemporaryFileStreamDocumentSource implements DocumentSource { + private static final Logger log = Logger.getLogger(TemporaryFileStreamDocumentSource.class.getName()); + private static final long DEFAULT_MAX_SIZE = 100 * 1024 * 1024; // 100MB + + private final File tempFile; + private final String fileName; + private final String mimeType; + + /** + * 创建临时文件源(默认最大 100MB) + */ + public TemporaryFileStreamDocumentSource(InputStream inputStream, String fileName, String mimeType) throws IOException { + this(inputStream, fileName, mimeType, DEFAULT_MAX_SIZE); + } + + /** + * 创建临时文件源(可指定最大大小) + * + * @param inputStream 输入流 + * @param fileName 建议文件名(用于日志和扩展名推断) + * @param mimeType MIME 类型(可选) + * @param maxSize 最大允许大小(字节) + * @throws IOException 文件过大或 I/O 错误 + */ + public TemporaryFileStreamDocumentSource( + InputStream inputStream, + String fileName, + String mimeType, + long maxSize) throws IOException { + + Objects.requireNonNull(inputStream, "InputStream cannot be null"); + + this.fileName = sanitizeFileName(fileName); + this.mimeType = mimeType; + + // 推断后缀(用于调试) + String suffix = inferSuffix(this.fileName); + + // 创建唯一临时文件 + this.tempFile = File.createTempFile("doc-", suffix); + this.tempFile.deleteOnExit(); // JVM 退出时清理 + + log.info("Creating temp file for " + this.fileName + ": " + tempFile.getAbsolutePath()); + + // 复制流(带大小限制) + try (FileOutputStream fos = new FileOutputStream(tempFile)) { + IOUtils.copyStream(inputStream, fos, maxSize); + } catch (IOException e) { + // 清理失败的临时文件 + boolean deleted = tempFile.delete(); + log.warning("Failed to write temp file, deleted: " + deleted); + throw e; + } + + log.fine("Temp file created: " + tempFile.length() + " bytes"); + } + + @Override + public String getFileName() { + return fileName; + } + + @Override + public String getMimeType() { + return mimeType; + } + + + @Override + public InputStream openStream() throws IOException { + if (!tempFile.exists()) { + throw new FileNotFoundException("Temp file not found: " + tempFile.getAbsolutePath()); + } + return Files.newInputStream(tempFile.toPath()); + } + + @Override + public void cleanup() { + if (tempFile.exists()) { + boolean deleted = tempFile.delete(); + if (!deleted) { + log.warning("Failed to delete temp file: " + tempFile.getAbsolutePath()); + } else { + log.fine("Cleaned up temp file: " + tempFile.getAbsolutePath()); + } + } + } + + // ======================== + // 工具方法 + // ======================== + + /** + * 推断文件后缀(用于临时文件命名,便于调试) + */ + private String inferSuffix(String fileName) { + if (fileName == null || !fileName.contains(".")) { + return ".tmp"; + } + int lastDot = fileName.lastIndexOf('.'); + String ext = fileName.substring(lastDot); // 包含 . + if (ext.length() > 1 && ext.length() <= 10 && ext.matches("\\.[a-zA-Z0-9]{1,10}")) { + return ext; + } + return ".tmp"; + } + + /** + * 清理文件名中的非法字符 + */ + private String sanitizeFileName(String fileName) { + if (fileName == null) return "unknown"; + return fileName + .replaceAll("[\\\\/:*?\"<>|]", "_") + .replaceAll("\\.\\.", "_") + .replaceAll("^\\s+|\\s+$", "") + .isEmpty() ? "file" : fileName; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/file2text/util/IOUtils.java b/easy-agents-core/src/main/java/com/easyagents/core/file2text/util/IOUtils.java new file mode 100644 index 0000000..455c37d --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/file2text/util/IOUtils.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.file2text.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class IOUtils { + private static final int BUFFER_SIZE = 8192; + + public static byte[] toByteArray(InputStream is, long maxSize) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + copyStream(is, buffer, maxSize); + return buffer.toByteArray(); + } + + public static void copyStream(InputStream is, OutputStream os, long maxSize) { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + long total = 0; + try { + while ((bytesRead = is.read(buffer)) != -1) { + if (total + bytesRead > maxSize) { + throw new RuntimeException("Stream too large: limit is " + maxSize + " bytes"); + } + os.write(buffer, 0, bytesRead); + total += bytesRead; + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/memory/ChatMemory.java b/easy-agents-core/src/main/java/com/easyagents/core/memory/ChatMemory.java new file mode 100644 index 0000000..5cdd715 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/memory/ChatMemory.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.memory; + +import com.easyagents.core.message.Message; + +import java.util.Collection; +import java.util.List; + +public interface ChatMemory extends Memory { + + List getMessages(int count); + + void addMessage(Message message); + + default void addMessages(Collection messages){ + for (Message message : messages) { + addMessage(message); + } + } + + void clear(); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/memory/DefaultChatMemory.java b/easy-agents-core/src/main/java/com/easyagents/core/memory/DefaultChatMemory.java new file mode 100644 index 0000000..613422e --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/memory/DefaultChatMemory.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.memory; + +import com.easyagents.core.message.Message; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class DefaultChatMemory implements ChatMemory { + private final Object id; + private final List messages = new ArrayList<>(); + + public DefaultChatMemory() { + this.id = UUID.randomUUID().toString(); + } + + public DefaultChatMemory(Object id) { + this.id = id; + } + + @Override + public Object id() { + return id; + } + + @Override + public List getMessages(int count) { + if (count <= 0) { + throw new IllegalArgumentException("count must be greater than 0"); + } + if (count >= messages.size()) { + // 返回副本,避免修改原始消息 + return new ArrayList<>(messages); + } else { + return messages.subList(messages.size() - count, messages.size()); + } + } + + @Override + public void addMessage(Message message) { + messages.add(message); + } + + @Override + public void clear() { + messages.clear(); + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/memory/Memory.java b/easy-agents-core/src/main/java/com/easyagents/core/memory/Memory.java new file mode 100644 index 0000000..063574e --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/memory/Memory.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.memory; + +import java.io.Serializable; + +public interface Memory extends Serializable { + + Object id(); + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/memory/package-info.java b/easy-agents-core/src/main/java/com/easyagents/core/memory/package-info.java new file mode 100644 index 0000000..783e8e1 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/memory/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * 存储 + */ +package com.easyagents.core.memory; diff --git a/easy-agents-core/src/main/java/com/easyagents/core/message/AbstractTextMessage.java b/easy-agents-core/src/main/java/com/easyagents/core/message/AbstractTextMessage.java new file mode 100644 index 0000000..4ac3d41 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/message/AbstractTextMessage.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.message; + +import com.easyagents.core.util.Copyable; + +public abstract class AbstractTextMessage> + extends Message implements Copyable { + + protected String content; + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + @Override + public String getTextContent() { + return content; + } + + /** + * 创建并返回当前对象的副本。 + * + * @return 一个新的、内容相同但内存独立的对象 + */ + @Override + public abstract T copy(); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/message/AiMessage.java b/easy-agents-core/src/main/java/com/easyagents/core/message/AiMessage.java new file mode 100644 index 0000000..7daa0b7 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/message/AiMessage.java @@ -0,0 +1,351 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.message; + +import com.easyagents.core.util.StringUtil; + +import java.util.*; + +public class AiMessage extends AbstractTextMessage { + + private Integer index; + private Integer promptTokens; + private Integer completionTokens; + private Integer totalTokens; + private Integer localPromptTokens; + private Integer localCompletionTokens; + private Integer localTotalTokens; + private String reasoningContent; + private List toolCalls; + + private String fullContent; + private String fullReasoningContent; + + /** + * LLM 响应结束的原因(如 "stop", "length", "tool_calls" 等), + * 符合 OpenAI 等主流 API 的 finish_reason 语义。 + */ + private String finishReason; + + // 同 reasoningContent,只是某些框架会返回这个字段,而不是 finishReason + private String stopReason; + + private Boolean finished; + + public AiMessage() { + super(); + } + + public AiMessage(String content) { + this.fullContent = content; + } + + public void merge(AiMessage delta) { + if (delta.content != null) { + if (this.content == null) this.content = ""; + this.content += delta.content; + this.fullContent = this.content; + } + + if (delta.reasoningContent != null) { + if (this.reasoningContent == null) this.reasoningContent = ""; + this.reasoningContent += delta.reasoningContent; + this.fullReasoningContent = this.reasoningContent; + } + + if (delta.toolCalls != null && !delta.toolCalls.isEmpty()) { + if (this.toolCalls == null) this.toolCalls = new ArrayList<>(); + mergeToolCalls(delta.toolCalls); + } + if (delta.index != null) this.index = delta.index; + if (delta.promptTokens != null) this.promptTokens = delta.promptTokens; + if (delta.completionTokens != null) this.completionTokens = delta.completionTokens; + if (delta.totalTokens != null) this.totalTokens = delta.totalTokens; + if (delta.localPromptTokens != null) this.localPromptTokens = delta.localPromptTokens; + if (delta.localCompletionTokens != null) this.localCompletionTokens = delta.localCompletionTokens; + if (delta.localTotalTokens != null) this.localTotalTokens = delta.localTotalTokens; + if (delta.finishReason != null) this.finishReason = delta.finishReason; + if (delta.stopReason != null) this.stopReason = delta.stopReason; + } + + private void mergeToolCalls(List deltaCalls) { + if (deltaCalls == null || deltaCalls.isEmpty()) return; + + if (this.toolCalls == null || this.toolCalls.isEmpty()) { + this.toolCalls = new ArrayList<>(deltaCalls); + return; + } + + ToolCall lastCall = this.toolCalls.get(this.toolCalls.size() - 1); + + // 正常情况下 delta 部分只有 1 条 + ToolCall deltaCall = deltaCalls.get(0); + + // 新增 + if (isNewCall(deltaCall, lastCall)) { + this.toolCalls.add(deltaCall); + } + // 合并 + else { + mergeSingleCall(lastCall, deltaCall); + } + } + + private boolean isNewCall(ToolCall deltaCall, ToolCall lastCall) { + if (StringUtil.noText(deltaCall.getId()) && StringUtil.noText(deltaCall.getName())) { + return false; + } + + if (StringUtil.hasText(deltaCall.getId())) { + return !deltaCall.getId().equals(lastCall.getId()); + } + + if (StringUtil.hasText(deltaCall.getName())) { + return !deltaCall.getName().equals(lastCall.getName()); + } + + return false; + } + + private void mergeSingleCall(ToolCall existing, ToolCall delta) { + if (delta.getArguments() != null) { + if (existing.getArguments() == null) { + existing.setArguments(""); + } + existing.setArguments(existing.getArguments() + delta.getArguments()); + } + if (StringUtil.hasText(delta.getId())) { + existing.setId(delta.getId()); + } + if (StringUtil.hasText(delta.getName())) { + existing.setName(delta.getName()); + } + } + + // ===== Getters & Setters (保持原有不变) ===== + public Integer getIndex() { + return index; + } + + public void setIndex(Integer index) { + this.index = index; + } + + public Integer getPromptTokens() { + return promptTokens; + } + + public void setPromptTokens(Integer promptTokens) { + this.promptTokens = promptTokens; + } + + public Integer getCompletionTokens() { + return completionTokens; + } + + public void setCompletionTokens(Integer completionTokens) { + this.completionTokens = completionTokens; + } + + public Integer getTotalTokens() { + return totalTokens; + } + + public void setTotalTokens(Integer totalTokens) { + this.totalTokens = totalTokens; + } + + public Integer getLocalPromptTokens() { + return localPromptTokens; + } + + public void setLocalPromptTokens(Integer localPromptTokens) { + this.localPromptTokens = localPromptTokens; + } + + public Integer getLocalCompletionTokens() { + return localCompletionTokens; + } + + public void setLocalCompletionTokens(Integer localCompletionTokens) { + this.localCompletionTokens = localCompletionTokens; + } + + public Integer getLocalTotalTokens() { + return localTotalTokens; + } + + public void setLocalTotalTokens(Integer localTotalTokens) { + this.localTotalTokens = localTotalTokens; + } + + public String getFullContent() { + return fullContent; + } + + public void setFullContent(String fullContent) { + this.fullContent = fullContent; + } + + public String getReasoningContent() { + return reasoningContent; + } + + public void setReasoningContent(String reasoningContent) { + this.reasoningContent = reasoningContent; + } + + public String getFinishReason() { + return finishReason; + } + + public void setFinishReason(String finishReason) { + this.finishReason = finishReason; + } + + public String getStopReason() { + return stopReason; + } + + public void setStopReason(String stopReason) { + this.stopReason = stopReason; + } + + @Override + public String getTextContent() { + return fullContent; + } + + /** + * 创建并返回当前对象的副本。 + * + * @return 一个新的、内容相同但内存独立的对象 + */ + @Override + public AiMessage copy() { + AiMessage copy = new AiMessage(); + // 基本字段 + copy.content = this.content; + copy.fullContent = this.fullContent; + copy.reasoningContent = this.reasoningContent; + copy.fullReasoningContent = this.fullReasoningContent; + copy.finishReason = this.finishReason; + copy.stopReason = this.stopReason; + copy.finished = this.finished; + + // Token 字段 + copy.index = this.index; + copy.promptTokens = this.promptTokens; + copy.completionTokens = this.completionTokens; + copy.totalTokens = this.totalTokens; + copy.localPromptTokens = this.localPromptTokens; + copy.localCompletionTokens = this.localCompletionTokens; + copy.localTotalTokens = this.localTotalTokens; + + // ToolCalls: 深拷贝 List 和每个 ToolCall + if (this.toolCalls != null) { + copy.toolCalls = new ArrayList<>(); + for (ToolCall tc : this.toolCalls) { + if (tc != null) { + copy.toolCalls.add(tc.copy()); + } else { + copy.toolCalls.add(null); + } + } + } + + // Metadata + if (this.metadataMap != null) { + copy.metadataMap = new HashMap<>(this.metadataMap); + } + + return copy; + } + + public List getToolCalls() { + return toolCalls; + } + + public void setToolCalls(List toolCalls) { + this.toolCalls = toolCalls; + } + + public String getFullReasoningContent() { + return fullReasoningContent; + } + + public void setFullReasoningContent(String fullReasoningContent) { + this.fullReasoningContent = fullReasoningContent; + } + + public int getEffectiveTotalTokens() { + if (this.totalTokens != null) return this.totalTokens; + if (this.promptTokens != null && this.completionTokens != null) { + return this.promptTokens + this.completionTokens; + } + if (this.localTotalTokens != null) return this.localTotalTokens; + if (this.localPromptTokens != null && this.localCompletionTokens != null) { + return this.localPromptTokens + this.localCompletionTokens; + } + return 0; + } + + public Boolean getFinished() { + return finished; + } + + public void setFinished(Boolean finished) { + this.finished = finished; + } + + + /** + * 判断当前对象是否为最终的 delta 对象。 + * + * @return true 表示当前对象为最终的 delta 对象,否则为 false + */ + public boolean isFinalDelta() { + return (finished != null && finished); + } + + public boolean hasFinishOrStopReason() { + return StringUtil.hasText(this.finishReason) + || StringUtil.hasText(this.stopReason); + } + + + @Override + public String toString() { + return "AiMessage{" + + "index=" + index + + ", promptTokens=" + promptTokens + + ", completionTokens=" + completionTokens + + ", totalTokens=" + totalTokens + + ", localPromptTokens=" + localPromptTokens + + ", localCompletionTokens=" + localCompletionTokens + + ", localTotalTokens=" + localTotalTokens + + ", reasoningContent='" + reasoningContent + '\'' + + ", toolCalls=" + toolCalls + + ", fullContent='" + fullContent + '\'' + + ", fullReasoningContent='" + fullReasoningContent + '\'' + + ", finishReason='" + finishReason + '\'' + + ", stopReason='" + stopReason + '\'' + + ", finished=" + finished + + ", content='" + content + '\'' + + ", metadataMap=" + metadataMap + + '}'; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/message/Message.java b/easy-agents-core/src/main/java/com/easyagents/core/message/Message.java new file mode 100644 index 0000000..6da975c --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/message/Message.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.message; + +import com.easyagents.core.util.Metadata; + +/** + * 表示一个通用的消息(Message),通常用于与大语言模型(如 OpenAI)进行交互。 + * 消息内容可以是纯文本,也可以是多模态内容(例如:文本 + 图像等)。 + * + *

该类继承自 {@link Metadata},允许附加任意元数据(如来源、时间戳、追踪ID等)。 + * + * @see #getTextContent() + */ +public abstract class Message extends Metadata { + + /** + * 提取消息中的纯文本部分。 + * + *

无论原始内容是纯文本还是多模态结构(如文本+图像),本方法应返回其中所有文本内容的合理合并结果。 + * 例如,在 OpenAI 多模态消息中,应遍历所有 {@code content} 元素,提取类型为 {@code text} 的部分并拼接。 + * + *

返回的字符串应不包含非文本元素(如图像、音频等),且应保持原始文本的语义顺序(如适用)。 + * 若消息中无文本内容,则返回空字符串({@code ""}),而非 {@code null}。 + * + *

该方法主要用于日志记录、监控、文本分析等仅需文本语义的场景。 + * + * @return 消息中提取出的纯文本内容。 + */ + public abstract String getTextContent(); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/message/SystemMessage.java b/easy-agents-core/src/main/java/com/easyagents/core/message/SystemMessage.java new file mode 100644 index 0000000..e38ee20 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/message/SystemMessage.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.message; + +import java.util.HashMap; + +public class SystemMessage extends AbstractTextMessage { + + public SystemMessage() { + } + + public SystemMessage(String content) { + this.content = content; + } + + public static SystemMessage of(String content) { + return new SystemMessage(content); + } + + @Override + public String toString() { + return "SystemMessage{" + + "content='" + content + '\'' + + ", metadataMap=" + metadataMap + + '}'; + } + + /** + * 创建并返回当前对象的副本。 + * + * @return 一个新的、内容相同但内存独立的对象 + */ + @Override + public SystemMessage copy() { + SystemMessage copy = new SystemMessage(); + copy.content = this.content; + if (this.metadataMap != null) { + copy.metadataMap = new HashMap<>(this.metadataMap); + } + return copy; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/message/ToolCall.java b/easy-agents-core/src/main/java/com/easyagents/core/message/ToolCall.java new file mode 100644 index 0000000..0c89ddf --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/message/ToolCall.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.message; + +import com.easyagents.core.util.Copyable; +import com.easyagents.core.util.Maps; +import com.alibaba.fastjson2.JSON; + +import java.io.Serializable; +import java.util.Map; + +public class ToolCall implements Serializable, Copyable { + + private String id; + private String name; + private String arguments; + + public ToolCall() { + } + + public ToolCall(String id, String name, String arguments) { + this.id = id; + this.name = name; + this.arguments = arguments; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getArguments() { + return arguments; + } + + public void setArguments(String arguments) { + this.arguments = arguments; + } + + public Map getArgsMap() { + if (arguments == null || arguments.isEmpty()) { + return null; + } + + String jsonStr = arguments.trim(); + + try { + return JSON.parseObject(jsonStr); + } catch (Exception e) { + if (jsonStr.contains("{") && jsonStr.contains("}")) { + String json = jsonStr.substring(jsonStr.indexOf("{"), jsonStr.lastIndexOf("}") + 1); + return JSON.parseObject(json); + } + + if (!jsonStr.startsWith("{")) jsonStr = "{" + jsonStr; + if (!jsonStr.endsWith("}")) jsonStr = jsonStr + "}"; + + return JSON.parseObject(jsonStr); + } + } + + @Override + public String toString() { + return "ToolCall{" + + "id='" + id + '\'' + + ", name='" + name + '\'' + + ", arguments='" + arguments + '\'' + + '}'; + } + + public String toJsonString() { + return Maps.of("id", id) + .set("name", name) + .set("arguments", arguments) + .toJSON(); + } + + /** + * 创建并返回当前对象的副本。 + * + * @return 一个新的、内容相同但内存独立的对象 + */ + @Override + public ToolCall copy() { + ToolCall copy = new ToolCall(); + copy.id = this.id; + copy.name = this.name; + copy.arguments = this.arguments; + return copy; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/message/ToolMessage.java b/easy-agents-core/src/main/java/com/easyagents/core/message/ToolMessage.java new file mode 100644 index 0000000..4f9e5e9 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/message/ToolMessage.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.message; + +import java.util.HashMap; + +public class ToolMessage extends AbstractTextMessage { + + private String toolCallId; + + public String getToolCallId() { + return toolCallId; + } + + public void setToolCallId(String toolCallId) { + this.toolCallId = toolCallId; + } + + + @Override + public String toString() { + return "ToolMessage{" + + "toolCallId='" + toolCallId + '\'' + + ", content='" + content + '\'' + + ", metadataMap=" + metadataMap + + '}'; + } + + /** + * 创建并返回当前对象的副本。 + * + * @return 一个新的、内容相同但内存独立的对象 + */ + @Override + public ToolMessage copy() { + ToolMessage copy = new ToolMessage(); + copy.content = this.content; + copy.toolCallId = this.toolCallId; + if (this.metadataMap != null) { + copy.metadataMap = new HashMap<>(this.metadataMap); + } + return copy; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/message/UserMessage.java b/easy-agents-core/src/main/java/com/easyagents/core/message/UserMessage.java new file mode 100644 index 0000000..adc1a1e --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/message/UserMessage.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.message; + +import com.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.tool.Tool; +import com.easyagents.core.model.chat.tool.ToolScanner; +import com.easyagents.core.util.ImageUtil; + +import java.io.File; +import java.util.*; + +public class UserMessage extends AbstractTextMessage { + + private List audioUrls; + private List videoUrls; + private List imageUrls; + private List tools; + private String toolChoice; + + public UserMessage() { + } + + public UserMessage(String content) { + setContent(content); + } + + public void addTool(Tool tool) { + if (this.tools == null) + this.tools = new java.util.ArrayList<>(); + this.tools.add(tool); + } + + public void addTools(Collection functions) { + if (this.tools == null) { + this.tools = new java.util.ArrayList<>(); + } + if (functions != null) { + this.tools.addAll(functions); + } + } + + public void addToolsFromClass(Class funcClass, String... methodNames) { + if (this.tools == null) + this.tools = new java.util.ArrayList<>(); + this.tools.addAll(ToolScanner.scan(funcClass, methodNames)); + } + + public void addToolsFromObject(Object funcObject, String... methodNames) { + if (this.tools == null) + this.tools = new java.util.ArrayList<>(); + this.tools.addAll(ToolScanner.scan(funcObject, methodNames)); + } + + public List getTools() { + return tools; + } + + public Map getToolsMap() { + if (tools == null) { + return Collections.emptyMap(); + } + Map map = new HashMap<>(tools.size()); + for (Tool tool : tools) { + map.put(tool.getName(), tool); + } + return map; + } + + public void setTools(List tools) { + if (tools == null) { + this.tools = null; + } else { + this.tools = new ArrayList<>(tools); + } + } + + public String getToolChoice() { + return toolChoice; + } + + public void setToolChoice(String toolChoice) { + this.toolChoice = toolChoice; + } + + + /// /// Audio + public List getAudioUrls() { + return audioUrls; + } + + public void setAudioUrls(List audioUrls) { + this.audioUrls = audioUrls; + } + + public void addAudioUrl(String audioUrl) { + if (audioUrls == null) { + audioUrls = new ArrayList<>(1); + } + audioUrls.add(audioUrl); + } + + + /// /// Video + public List getVideoUrls() { + return videoUrls; + } + + public void setVideoUrls(List videoUrls) { + this.videoUrls = videoUrls; + } + + public void addVideoUrl(String videoUrl) { + if (videoUrls == null) { + videoUrls = new ArrayList<>(1); + } + videoUrls.add(videoUrl); + } + + + /// /// Images + public List getImageUrls() { + return imageUrls; + } + + public List getImageUrlsForChat(ChatConfig config) { + if (this.imageUrls == null) { + return null; + } + List result = new ArrayList<>(this.imageUrls.size()); + for (String url : imageUrls) { + if (config != null && config.isSupportImageBase64Only() + && url.toLowerCase().startsWith("http")) { + url = ImageUtil.imageUrlToDataUri(url); + } + result.add(url); + } + return result; + } + + public void setImageUrls(List imageUrls) { + this.imageUrls = imageUrls; + } + + public void addImageUrl(String imageUrl) { + if (this.imageUrls == null) { + this.imageUrls = new ArrayList<>(1); + } + this.imageUrls.add(imageUrl); + } + + public void addImageFile(File imageFile) { + addImageUrl(ImageUtil.imageFileToDataUri(imageFile)); + } + + public void addImageBytes(byte[] imageBytes, String mimeType) { + addImageUrl(ImageUtil.imageBytesToDataUri(imageBytes, mimeType)); + } + + + @Override + public String toString() { + return "UserMessage{" + + "audioUrls=" + audioUrls + + ", videoUrls=" + videoUrls + + ", imageUrls=" + imageUrls + + ", functions=" + tools + + ", toolChoice='" + toolChoice + '\'' + + ", content='" + content + '\'' + + ", metadataMap=" + metadataMap + + '}'; + } + + /** + * 创建并返回当前对象的副本。 + * + * @return 一个新的、内容相同但内存独立的对象 + */ + @Override + public UserMessage copy() { + UserMessage copy = new UserMessage(); + copy.content = this.content; + copy.toolChoice = this.toolChoice; + + // 深拷贝集合 + if (this.audioUrls != null) copy.audioUrls = new ArrayList<>(this.audioUrls); + if (this.videoUrls != null) copy.videoUrls = new ArrayList<>(this.videoUrls); + if (this.imageUrls != null) copy.imageUrls = new ArrayList<>(this.imageUrls); + if (this.tools != null) copy.tools = new ArrayList<>(this.tools); + + if (this.metadataMap != null) { + copy.metadataMap = new HashMap<>(this.metadataMap); + } + + return copy; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/message/package-info.java b/easy-agents-core/src/main/java/com/easyagents/core/message/package-info.java new file mode 100644 index 0000000..68cc758 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/message/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * 消息 + */ +package com.easyagents.core.message; diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/BaseChatModel.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/BaseChatModel.java new file mode 100644 index 0000000..b4fbf8d --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/BaseChatModel.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.model.chat.log.ChatMessageLogger; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.model.client.ChatClient; +import com.easyagents.core.model.client.ChatRequestSpec; +import com.easyagents.core.model.client.ChatRequestSpecBuilder; +import com.easyagents.core.prompt.Prompt; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 支持责任链、统一上下文和协议客户端的聊天模型基类。 + *

+ * 该类为所有具体的 LLM 实现(如 OpenAI、Qwen、Ollama)提供统一入口,并集成: + *

    + *
  • 责任链模式:通过 {@link ChatInterceptor} 实现请求拦截、监控、日志等横切逻辑
  • + *
  • 线程上下文管理:通过 {@link ChatContextHolder} 在整个调用链中传递上下文信息
  • + *
  • 协议执行抽象:通过 {@link ChatClient} 解耦协议细节,支持 HTTP/gRPC/WebSocket 等
  • + *
  • 可观测性:自动集成 OpenTelemetry(通过 {@link ChatObservabilityInterceptor})
  • + *
+ * + *

架构流程

+ *
    + *
  1. 调用 {@link #chat(Prompt, ChatOptions)} 或 {@link #chatStream(Prompt, StreamResponseListener, ChatOptions)}
  2. + *
  3. 构建请求上下文(URL/Headers/Body)并初始化 {@link ChatContext}
  4. + *
  5. 构建责任链:可观测性拦截器 → 全局拦截器 → 用户拦截器
  6. + *
  7. 责任链执行:每个拦截器可修改 {@link ChatContext},最后由 {@link ChatClient} 执行实际调用
  8. + *
  9. 结果返回给调用方
  10. + *
+ * + * @param 具体的配置类型,必须是 {@link ChatConfig} 的子类 + */ +public abstract class BaseChatModel implements ChatModel { + + /** + * 聊天模型配置,包含 API Key、Endpoint、Model 等信息 + */ + protected final T config; + protected ChatClient chatClient; + protected ChatRequestSpecBuilder chatRequestSpecBuilder; + + /** + * 拦截器链,按执行顺序存储(可观测性 → 全局 → 用户) + */ + private final List interceptors; + + /** + * 构造一个聊天模型实例,不使用实例级拦截器。 + * + * @param config 聊天模型配置 + */ + public BaseChatModel(T config) { + this(config, Collections.emptyList()); + } + + /** + * 构造一个聊天模型实例,并指定实例级拦截器。 + *

+ * 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并, + * 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。 + * + * @param config 聊天模型配置 + * @param userInterceptors 实例级拦截器列表 + */ + public BaseChatModel(T config, List userInterceptors) { + this.config = config; + this.interceptors = buildInterceptorChain(userInterceptors); + } + + /** + * 构建完整的拦截器链。 + *

+ * 执行顺序: + * 1. 可观测性拦截器(最外层,最早执行) + * 2. 全局拦截器(通过 GlobalChatInterceptors 注册) + * 3. 用户拦截器(实例级) + * + * @param userInterceptors 用户提供的拦截器列表 + * @return 按执行顺序排列的拦截器链 + */ + private List buildInterceptorChain(List userInterceptors) { + List chain = new ArrayList<>(); + + // 1. 可观测性拦截器(最外层) + // 仅在配置启用时添加,负责 OpenTelemetry 追踪和指标上报 + if (config.isObservabilityEnabled()) { + chain.add(new ChatObservabilityInterceptor()); + } + + // 2. 全局拦截器(通过 GlobalChatInterceptors 注册) + // 适用于所有聊天模型实例的通用逻辑(如全局日志、认证) + chain.addAll(GlobalChatInterceptors.getInterceptors()); + + // 3. 用户拦截器(实例级) + // 适用于当前实例的特定逻辑 + if (userInterceptors != null) { + chain.addAll(userInterceptors); + } + + return chain; + } + + /** + * 执行同步聊天请求。 + *

+ * 流程: + * 1. 构建请求上下文(URL/Headers/Body) + * 2. 初始化线程上下文 {@link ChatContext} + * 3. 构建并执行责任链 + * 4. 返回 LLM 响应 + * + * @param prompt 用户输入的提示 + * @param options 聊天选项(如流式开关、超时等) + * @return LLM 响应结果 + */ + @Override + public AiMessageResponse chat(Prompt prompt, ChatOptions options) { + if (options == null) { + options = new ChatOptions(); + } + // 强制关闭流式 + options.setStreaming(false); + + + ChatRequestSpec request = getChatRequestSpecBuilder().buildRequest(prompt, options, config); + + // 初始化聊天上下文(自动清理) + try (ChatContextHolder.ChatContextScope scope = + ChatContextHolder.beginChat(prompt, options, request, config)) { + // 构建同步责任链并执行 + SyncChain chain = buildSyncChain(0); + return chain.proceed(this, scope.context); + } + } + + /** + * 执行流式聊天请求。 + *

+ * 流程与同步请求类似,但返回结果通过回调方式分片返回。 + * + * @param prompt 用户输入的提示 + * @param listener 流式响应监听器 + * @param options 聊天选项 + */ + @Override + public void chatStream(Prompt prompt, StreamResponseListener listener, ChatOptions options) { + if (options == null) { + options = new ChatOptions(); + } + options.setStreaming(true); + + ChatRequestSpec request = getChatRequestSpecBuilder().buildRequest(prompt, options, config); + + try (ChatContextHolder.ChatContextScope scope = + ChatContextHolder.beginChat(prompt, options, request, config)) { + + StreamChain chain = buildStreamChain(0); + chain.proceed(this, scope.context, listener); + } + } + + + /** + * 构建同步责任链。 + *

+ * 递归构建拦截器链,链尾节点负责创建并调用 {@link ChatClient}。 + * + * @param index 当前拦截器索引 + * @return 同步责任链 + */ + private SyncChain buildSyncChain(int index) { + // 链尾:执行实际 LLM 调用 + if (index >= interceptors.size()) { + return (model, context) -> { + AiMessageResponse aiMessageResponse = null; + try { + ChatMessageLogger.logRequest(model.getConfig(), context.getRequestSpec().getBody()); + aiMessageResponse = getChatClient().chat(); + return aiMessageResponse; + } finally { + ChatMessageLogger.logResponse(model.getConfig(), aiMessageResponse == null ? "" : aiMessageResponse.getRawText()); + } + }; + } + + // 递归构建下一个节点 + ChatInterceptor current = interceptors.get(index); + SyncChain next = buildSyncChain(index + 1); + + // 当前节点:执行拦截器逻辑 + return (model, context) -> current.intercept(model, context, next); + } + + /** + * 构建流式责任链。 + *

+ * 与同步链类似,但支持流式监听器。 + * + * @param index 当前拦截器索引 + * @return 流式责任链 + */ + private StreamChain buildStreamChain(int index) { + if (index >= interceptors.size()) { + return (model, context, listener) -> { + getChatClient().chatStream(listener); + }; + } + + ChatInterceptor current = interceptors.get(index); + StreamChain next = buildStreamChain(index + 1); + return (model, context, listener) -> current.interceptStream(model, context, listener, next); + } + + + public T getConfig() { + return config; + } + + public ChatClient getChatClient() { + return chatClient; + } + + public void setChatClient(ChatClient chatClient) { + this.chatClient = chatClient; + } + + public ChatRequestSpecBuilder getChatRequestSpecBuilder() { + return chatRequestSpecBuilder; + } + + public void setChatRequestSpecBuilder(ChatRequestSpecBuilder chatRequestSpecBuilder) { + this.chatRequestSpecBuilder = chatRequestSpecBuilder; + } + + public List getInterceptors() { + return interceptors; + } + + /** + * 动态添加拦截器。 + *

+ * 新拦截器会被添加到链的末尾(在用户拦截器区域)。 + * + * @param interceptor 要添加的拦截器 + */ + public void addInterceptor(ChatInterceptor interceptor) { + interceptors.add(interceptor); + } + + public void addInterceptor(int index, ChatInterceptor interceptor) { + interceptors.add(index, interceptor); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatConfig.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatConfig.java new file mode 100644 index 0000000..55b8445 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatConfig.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.model.config.BaseModelConfig; + +public class ChatConfig extends BaseModelConfig { + + protected Boolean supportImage; + protected Boolean supportImageBase64Only; // 某些模型仅支持 base64 格式图片,比如 Ollama 部署的模型,或者某些本地化模型 + protected Boolean supportAudio; + protected Boolean supportVideo; + protected Boolean supportTool; + protected Boolean supportToolMessage; + protected Boolean supportThinking; + + // 在调用工具的时候,是否需要推理结果作为 reasoning_content 传给大模型, 比如 Deepseek + // 参考文档: https://api-docs.deepseek.com/zh-cn/guides/thinking_mode#%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8 + protected Boolean needReasoningContentForToolMessage; + + protected boolean observabilityEnabled = true; // 默认开启 + protected boolean thinkingEnabled = false; // 默认关闭 + protected String thinkingProtocol = "none"; // "deepseek" "qwen" "ollama" "none" + protected boolean logEnabled = true; + + + protected boolean retryEnabled = true; // 默认开启错误重试 + protected int retryCount = 3; + protected int retryInitialDelayMs = 1000; + + + public boolean isLogEnabled() { + return logEnabled; + } + + public void setLogEnabled(boolean logEnabled) { + this.logEnabled = logEnabled; + } + + + public Boolean getSupportImage() { + return supportImage; + } + + public void setSupportImage(Boolean supportImage) { + this.supportImage = supportImage; + } + + public boolean isSupportImage() { + return supportImage == null || supportImage; + } + + public Boolean getSupportImageBase64Only() { + return supportImageBase64Only; + } + + public void setSupportImageBase64Only(Boolean supportImageBase64Only) { + this.supportImageBase64Only = supportImageBase64Only; + } + + public boolean isSupportImageBase64Only() { + return supportImageBase64Only != null && supportImageBase64Only; + } + + public Boolean getSupportAudio() { + return supportAudio; + } + + public void setSupportAudio(Boolean supportAudio) { + this.supportAudio = supportAudio; + } + + public boolean isSupportAudio() { + return supportAudio == null || supportAudio; + } + + public Boolean getSupportVideo() { + return supportVideo; + } + + public void setSupportVideo(Boolean supportVideo) { + this.supportVideo = supportVideo; + } + + public boolean isSupportVideo() { + return supportVideo == null || supportVideo; + } + + public void setSupportTool(Boolean supportTool) { + this.supportTool = supportTool; + } + + public Boolean getSupportTool() { + return supportTool; + } + + public boolean isSupportTool() { + return supportTool == null || supportTool; + } + + public Boolean getSupportToolMessage() { + return supportToolMessage; + } + + public void setSupportToolMessage(Boolean supportToolMessage) { + this.supportToolMessage = supportToolMessage; + } + + public boolean isSupportToolMessage() { + return supportToolMessage == null || supportToolMessage; + } + + public Boolean getSupportThinking() { + return supportThinking; + } + + public void setSupportThinking(Boolean supportThinking) { + this.supportThinking = supportThinking; + } + + public boolean isSupportThinking() { + return supportThinking == null || supportThinking; + } + + public Boolean getNeedReasoningContentForToolMessage() { + return needReasoningContentForToolMessage; + } + + public void setNeedReasoningContentForToolMessage(Boolean needReasoningContentForToolMessage) { + this.needReasoningContentForToolMessage = needReasoningContentForToolMessage; + } + + /** + * 是否需要推理结果作为 reasoning_content 传给大模型 + * 比如 Deepseek 在工具调佣的时候,需要推理结果作为 reasoning_content 传给大模型 + * + * @return 默认值为 false + */ + public boolean isNeedReasoningContentForToolMessage() { + return needReasoningContentForToolMessage != null && needReasoningContentForToolMessage; + } + + public boolean isThinkingEnabled() { + return thinkingEnabled; + } + + public void setThinkingEnabled(boolean thinkingEnabled) { + this.thinkingEnabled = thinkingEnabled; + } + + public String getThinkingProtocol() { + return thinkingProtocol; + } + + public void setThinkingProtocol(String thinkingProtocol) { + this.thinkingProtocol = thinkingProtocol; + } + + public boolean isObservabilityEnabled() { + return observabilityEnabled; + } + + public void setObservabilityEnabled(boolean observabilityEnabled) { + this.observabilityEnabled = observabilityEnabled; + } + + public boolean isRetryEnabled() { + return retryEnabled; + } + + public void setRetryEnabled(boolean retryEnabled) { + this.retryEnabled = retryEnabled; + } + + public int getRetryCount() { + return retryCount; + } + + public void setRetryCount(int retryCount) { + this.retryCount = retryCount; + } + + public int getRetryInitialDelayMs() { + return retryInitialDelayMs; + } + + public void setRetryInitialDelayMs(int retryInitialDelayMs) { + this.retryInitialDelayMs = retryInitialDelayMs; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatContext.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatContext.java new file mode 100644 index 0000000..99ac2a2 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatContext.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.model.client.ChatRequestSpec; +import com.easyagents.core.prompt.Prompt; + +import java.util.Map; + +public class ChatContext { + + Prompt prompt; + ChatConfig config; + ChatOptions options; + ChatRequestSpec requestSpec; + Map attributes; + + public Prompt getPrompt() { + return prompt; + } + + public void setPrompt(Prompt prompt) { + this.prompt = prompt; + } + + public ChatConfig getConfig() { + return config; + } + + public void setConfig(ChatConfig config) { + this.config = config; + } + + public ChatOptions getOptions() { + return options; + } + + public void setOptions(ChatOptions options) { + this.options = options; + } + + + public ChatRequestSpec getRequestSpec() { + return requestSpec; + } + + public void setRequestSpec(ChatRequestSpec requestSpec) { + this.requestSpec = requestSpec; + } + + public Map getAttributes() { + return attributes; + } + + public void addAttribute(String key, Object value) { + if (attributes == null) { + attributes = new java.util.HashMap<>(); + } + attributes.put(key, value); + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatContextHolder.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatContextHolder.java new file mode 100644 index 0000000..adcd32f --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatContextHolder.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.model.client.ChatRequestSpec; +import com.easyagents.core.prompt.Prompt; + +/** + * 聊天上下文管理器,用于在当前线程中保存聊天相关的上下文信息。 + *

+ * 供日志、监控、拦截器等模块使用。 + *

+ * 支持同步和流式调用,通过 {@link ChatContextScope} 实现自动清理。 + */ +public final class ChatContextHolder { + + private static final ThreadLocal CONTEXT_HOLDER = new ThreadLocal<>(); + + private ChatContextHolder() { + // 工具类,禁止实例化 + } + + + /** + * 开始一次聊天上下文,并设置传输层请求信息。 + * 适用于远程 LLM 模型(如 HTTP/gRPC/WebSocket)。 + * + * @param config 聊天配置 + * @param options 聊天选项 + * @param prompt 用户提示 + * @param request 请求信息构建起 + * @return 可用于 try-with-resources 的作用域对象 + */ + public static ChatContextScope beginChat( + Prompt prompt, + ChatOptions options, + ChatRequestSpec request, + ChatConfig config) { + + ChatContext ctx = new ChatContext(); + ctx.prompt = prompt; + ctx.options = options; + ctx.requestSpec = request; + ctx.config = config; + + CONTEXT_HOLDER.set(ctx); + + return new ChatContextScope(ctx); + } + + /** + * 获取当前线程的聊天上下文(可能为 null)。 + * + * @return 聊天上下文,若未设置则返回 null + */ + public static ChatContext currentContext() { + return CONTEXT_HOLDER.get(); + } + + /** + * 手动清除当前线程的上下文。 + *

+ * 通常由 {@link ChatContextScope} 自动调用,无需手动调用。 + */ + public static void clear() { + CONTEXT_HOLDER.remove(); + } + + + /** + * 用于 try-with-resources 的作用域对象,确保上下文自动清理。 + */ + public static class ChatContextScope implements AutoCloseable { + + ChatContext context; + + public ChatContextScope(ChatContext context) { + this.context = context; + } + + @Override + public void close() { + clear(); + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatInterceptor.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatInterceptor.java new file mode 100644 index 0000000..4e68b81 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatInterceptor.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.model.chat.response.AiMessageResponse; + +/** + * 聊天模型请求拦截器。 + *

+ * 通过责任链模式,在 LLM 调用前后插入自定义逻辑。 + * 支持同步({@link #intercept})和流式({@link #interceptStream})两种模式。 + */ +public interface ChatInterceptor { + + /** + * 拦截同步聊天请求。 + */ + AiMessageResponse intercept(BaseChatModel chatModel, ChatContext context, SyncChain chain); + + /** + * 拦截流式聊天请求。 + */ + void interceptStream(BaseChatModel chatModel, ChatContext context, StreamResponseListener listener, StreamChain chain); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatModel.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatModel.java new file mode 100644 index 0000000..c2a0c2d --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatModel.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.model.exception.ModelException; +import com.easyagents.core.model.chat.response.AbstractBaseMessageResponse; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.prompt.Prompt; +import com.easyagents.core.prompt.SimplePrompt; + +public interface ChatModel { + + default String chat(String prompt) { + return chat(prompt, new ChatOptions()); + } + + default String chat(String prompt, ChatOptions options) { + AbstractBaseMessageResponse response = chat(new SimplePrompt(prompt), options); + if (response != null && response.isError()) { + throw new ModelException(response.getErrorMessage()); + } + return response != null && response.getMessage() != null ? response.getMessage().getContent() : null; + } + + default AiMessageResponse chat(Prompt prompt) { + return chat(prompt, new ChatOptions()); + } + + AiMessageResponse chat(Prompt prompt, ChatOptions options); + + default void chatStream(String prompt, StreamResponseListener listener) { + this.chatStream(new SimplePrompt(prompt), listener, new ChatOptions()); + } + + default void chatStream(String prompt, StreamResponseListener listener, ChatOptions options) { + this.chatStream(new SimplePrompt(prompt), listener, options); + } + + //chatStream + default void chatStream(Prompt prompt, StreamResponseListener listener) { + this.chatStream(prompt, listener, new ChatOptions()); + } + + void chatStream(Prompt prompt, StreamResponseListener listener, ChatOptions options); + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatObservabilityInterceptor.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatObservabilityInterceptor.java new file mode 100644 index 0000000..d7274fa --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatObservabilityInterceptor.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.model.client.StreamContext; +import com.easyagents.core.observability.Observability; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class ChatObservabilityInterceptor implements ChatInterceptor { + + private static final Tracer TRACER = Observability.getTracer(); + private static final Meter METER = Observability.getMeter(); + + private static final LongCounter LLM_REQUEST_COUNT = METER.counterBuilder("llm.request.count") + .setDescription("Total number of LLM requests") + .build(); + + private static final DoubleHistogram LLM_LATENCY_HISTOGRAM = METER.histogramBuilder("llm.request.latency") + .setDescription("LLM request latency in seconds") + .setUnit("s") + .build(); + + private static final LongCounter LLM_ERROR_COUNT = METER.counterBuilder("llm.request.error.count") + .setDescription("Total number of LLM request errors") + .build(); + + private static final int MAX_RESPONSE_LENGTH_FOR_SPAN = 500; + + @Override + public AiMessageResponse intercept(BaseChatModel chatModel, ChatContext context, SyncChain chain) { + + ChatConfig config = chatModel.getConfig(); + + if (config == null || !config.isObservabilityEnabled() || !Observability.isEnabled()) { + return chain.proceed(chatModel, context); + } + + String provider = config.getProvider(); + String model = config.getModel(); + String operation = "chat"; + + Span span = TRACER.spanBuilder(provider + "." + operation) + .setAttribute("llm.provider", provider) + .setAttribute("llm.model", model) + .setAttribute("llm.operation", operation) + .startSpan(); + + + long startTimeNanos = System.nanoTime(); + + try (Scope ignored = span.makeCurrent()) { + + AiMessageResponse response = chain.proceed(chatModel, context); + boolean success = (response != null) && !response.isError(); + + if (success) { + enrichSpan(span, response.getMessage()); + } + + recordMetrics(provider, model, operation, success, startTimeNanos); + return response; + + } catch (Exception e) { + recordError(span, e, provider, model, operation, startTimeNanos); + throw e; + } finally { + span.end(); + } + } + + @Override + public void interceptStream( + BaseChatModel chatModel, + ChatContext context, + StreamResponseListener originalListener, + StreamChain chain) { + + ChatConfig config = chatModel.getConfig(); + + if (config == null || !config.isObservabilityEnabled()) { + chain.proceed(chatModel, context, originalListener); + return; + } + + String provider = config.getProvider(); + String model = config.getModel(); + String operation = "chatStream"; + + Span span = TRACER.spanBuilder(provider + "." + operation) + .setAttribute("llm.provider", provider) + .setAttribute("llm.model", model) + .setAttribute("llm.operation", operation) + .startSpan(); + + Scope scope = span.makeCurrent(); + long startTimeNanos = System.nanoTime(); + + AtomicBoolean recorded = new AtomicBoolean(false); + + StreamResponseListener wrappedListener = new StreamResponseListener() { + @Override + public void onStart(StreamContext context) { + originalListener.onStart(context); + } + + @Override + public void onMessage(StreamContext context, AiMessageResponse response) { + originalListener.onMessage(context, response); + } + + + @Override + public void onFailure(StreamContext context, Throwable throwable) { + safeRecord(false, throwable); + originalListener.onFailure(context, throwable); + } + + @Override + public void onStop(StreamContext context) { + boolean success = !context.isError(); + if (success) { + enrichSpan(span, context.getFullMessage()); + } + safeRecord(success, null); + originalListener.onStop(context); + } + + private void safeRecord(boolean success, Throwable throwable) { + if (recorded.compareAndSet(false, true)) { + if (throwable != null) { + span.setStatus(StatusCode.ERROR, throwable.getMessage()); + span.recordException(throwable); + } + span.end(); + scope.close(); + recordMetrics(provider, model, operation, success, startTimeNanos); + } + } + }; + + try { + chain.proceed(chatModel, context, wrappedListener); + } catch (Exception e) { + if (recorded.compareAndSet(false, true)) { + recordError(span, e, provider, model, operation, startTimeNanos); + } + scope.close(); + throw e; + } + } + + private void enrichSpan(Span span, AiMessage msg) { + if (msg != null) { + span.setAttribute("llm.total_tokens", msg.getEffectiveTotalTokens()); + String content = msg.getContent(); + if (content != null) { + span.setAttribute("llm.response", + content.substring(0, Math.min(content.length(), MAX_RESPONSE_LENGTH_FOR_SPAN))); + } + } + } + + private void recordMetrics(String provider, String model, String operation, boolean success, long startTimeNanos) { + double latencySeconds = (System.nanoTime() - startTimeNanos) / 1_000_000_000.0; + Attributes attrs = Attributes.of( + AttributeKey.stringKey("llm.provider"), provider, + AttributeKey.stringKey("llm.model"), model, + AttributeKey.stringKey("llm.operation"), operation, + AttributeKey.stringKey("llm.success"), String.valueOf(success) + ); + LLM_REQUEST_COUNT.add(1, attrs); + LLM_LATENCY_HISTOGRAM.record(latencySeconds, attrs); + if (!success) { + LLM_ERROR_COUNT.add(1, attrs); + } + } + + private void recordError(Span span, Exception e, String provider, String model, String operation, long startTimeNanos) { + span.setStatus(StatusCode.ERROR, e.getMessage()); + span.recordException(e); + span.end(); + // Scope 会在 finally 或 safeRecord 中关闭 + recordMetrics(provider, model, operation, false, startTimeNanos); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatOptions.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatOptions.java new file mode 100644 index 0000000..3116314 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatOptions.java @@ -0,0 +1,453 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.util.Maps; +import com.easyagents.core.util.StringUtil; + +import java.util.List; +import java.util.Map; + +/** + * 聊天选项配置类,用于控制大语言模型(LLM)的生成行为。 + * 支持 Builder 模式,便于链式调用。 + * 注意:不同模型厂商对参数的支持和默认值可能不同。 + */ +public class ChatOptions { + + + /** + * 指定使用的大模型名称。 + * 例如:"gpt-4", "qwen-max", "claude-3-sonnet" 等。 + * 如果未设置,将使用客户端默认模型。 + */ + private String model; + + /** + * 随机种子(Seed),用于控制生成结果的可重复性。 + * 当 seed 相同时,相同输入将产生相同输出(前提是其他参数也一致)。 + * 注意:并非所有模型都支持 seed 参数。 + */ + private String seed; + + /** + * 温度(Temperature)控制输出的随机性。 + *

    + *
  • 值越低(如 0.1~0.3):输出更确定、稳定、可重复,适合事实性任务(如 RAG、结构化输出)
  • + *
  • 值越高(如 0.7~1.0):输出更多样、有创意,但可能不稳定或偏离事实
  • + *
+ * 推荐值: + *
    + *
  • 文档处理、路由、工具调用:0.1 ~ 0.3
  • + *
  • 问答、摘要:0.2 ~ 0.5
  • + *
  • 创意写作:0.7 ~ 1.0
  • + *
+ * 默认值:0.5f + */ + private Float temperature = 0.5f; + + /** + * Top-p(也称 nucleus sampling)控制生成时考虑的概率质量。 + * 模型从累积概率不超过 p 的最小词集中采样。 + * - 值为 1.0 表示考虑所有词(等同于无 top-p 限制) + * - 值为 0.9 表示只考虑累积概率达 90% 的词 + * 注意:temperature 和 top_p 不应同时调整,通常只用其一。 + */ + private Float topP; + + /** + * Top-k 控制生成时考虑的最高概率词的数量。 + * 模型仅从 top-k 个最可能的词中采样。 + * - 值为 50 表示只考虑概率最高的 50 个词 + * - 值越小,输出越确定;值越大,输出越多样 + * 注意:与 top_p 类似,通常不与 temperature 同时使用。 + */ + private Integer topK; + + /** + * 生成内容的最大 token 数量(不包括输入 prompt)。 + * 用于限制响应长度,防止生成过长内容。 + * 注意:不同模型有不同上限,超过将被截断或报错。 + */ + private Integer maxTokens; + + /** + * 停止序列(Stop Sequences),当生成内容包含这些字符串时立即停止。 + * 例如:设置为 ["\n", "。"] 可在句末或换行时停止。 + * 适用于需要精确控制输出长度的场景。 + */ + private List stop; + + /** + * 是否启用“思考模式”(Thinking Mode)。 + * 适用于支持该特性的模型(如 Qwen3),开启后模型会显式输出推理过程。 + * 默认为 null(由模型决定)。 + */ + private Boolean thinkingEnabled; + + /** + * 是否返回 Usage 信息, 仅在 stream 模式下有效。 + * 适用于支持该特性的模型(如 Qwen3),开启后模型会返回 Usage 信息。 + * 默认为 true。 + */ + private Boolean includeUsage; + + /** + * 额外的模型参数,用于传递模型特有或未明确暴露的配置。 + * 例如:{"response_format": "json", "presence_penalty": 0.5} + * 使用 addExtraBody() 方法可方便地添加单个参数。 + */ + private Map extraBody; + + + protected Boolean retryEnabled; // 默认开启错误重试 + protected Integer retryCount; + protected Integer retryInitialDelayMs; + + + private Map responseFormat; + + /** + * 是否为流式请求。 + * 这个不允许用户设置,由 Framework 自动设置(用户设置也可能被修改)。 + * 用户调用 chat 或者 chatStream 方法时,会自动设置这个字段。 + */ + private boolean streaming; + + // ===== 构造函数 ===== + public ChatOptions() { + } + + private ChatOptions(Builder builder) { + this.model = builder.model; + this.seed = builder.seed; + this.temperature = builder.temperature; + this.topP = builder.topP; + this.topK = builder.topK; + this.maxTokens = builder.maxTokens; + this.stop = builder.stop; + this.thinkingEnabled = builder.thinkingEnabled; + this.includeUsage = builder.includeUsage; + this.extraBody = builder.extraBody; + this.retryEnabled = builder.retryEnabled; + this.retryCount = builder.retryCount; + this.retryInitialDelayMs = builder.retryInitialDelayMs; + this.responseFormat = builder.responseFormat; + } + + // ===== Getter / Setter ===== + + public String getModel() { + return model; + } + + public String getModelOrDefault(String defaultModel) { + return StringUtil.hasText(model) ? model : defaultModel; + } + + public void setModel(String model) { + this.model = model; + } + + public String getSeed() { + return seed; + } + + public void setSeed(String seed) { + this.seed = seed; + } + + public Float getTemperature() { + return temperature; + } + + public void setTemperature(Float temperature) { + if (temperature != null && temperature < 0) { + throw new IllegalArgumentException("temperature must be greater than 0"); + } + this.temperature = temperature; + } + + public Float getTopP() { + return topP; + } + + public void setTopP(Float topP) { + if (topP != null && (topP < 0 || topP > 1)) { + throw new IllegalArgumentException("topP must be between 0 and 1"); + } + this.topP = topP; + } + + public Integer getTopK() { + return topK; + } + + public void setTopK(Integer topK) { + if (topK != null && topK < 0) { + throw new IllegalArgumentException("topK must be greater than 0"); + } + this.topK = topK; + } + + public Integer getMaxTokens() { + return maxTokens; + } + + public void setMaxTokens(Integer maxTokens) { + if (maxTokens != null && maxTokens < 0) { + throw new IllegalArgumentException("maxTokens must be greater than 0"); + } + this.maxTokens = maxTokens; + } + + public List getStop() { + return stop; + } + + public void setStop(List stop) { + this.stop = stop; + } + + public Boolean getThinkingEnabled() { + return thinkingEnabled; + } + + public Boolean getThinkingEnabledOrDefault(Boolean defaultValue) { + return thinkingEnabled != null ? thinkingEnabled : defaultValue; + } + + public void setThinkingEnabled(Boolean thinkingEnabled) { + this.thinkingEnabled = thinkingEnabled; + } + + public Boolean getIncludeUsage() { + return includeUsage; + } + + public Boolean getIncludeUsageOrDefault(Boolean defaultValue) { + return includeUsage != null ? includeUsage : defaultValue; + } + + public void setIncludeUsage(Boolean includeUsage) { + this.includeUsage = includeUsage; + } + + + public Map getExtraBody() { + return extraBody; + } + + public void setExtraBody(Map extraBody) { + this.extraBody = extraBody; + } + + /** + * 添加一个额外参数到 extra 映射中。 + * + * @param key 参数名 + * @param value 参数值 + */ + public void addExtraBody(String key, Object value) { + if (extraBody == null) { + extraBody = Maps.of(key, value); + } else { + extraBody.put(key, value); + } + } + + public Boolean getRetryEnabled() { + return retryEnabled; + } + + public boolean getRetryEnabledOrDefault(boolean defaultValue) { + return retryEnabled != null ? retryEnabled : defaultValue; + } + + public void setRetryEnabled(Boolean retryEnabled) { + this.retryEnabled = retryEnabled; + } + + public Integer getRetryCount() { + return retryCount; + } + + public int getRetryCountOrDefault(int defaultValue) { + return retryCount != null ? retryCount : defaultValue; + } + + + public void setRetryCount(Integer retryCount) { + this.retryCount = retryCount; + } + + public Integer getRetryInitialDelayMs() { + return retryInitialDelayMs; + } + + public int getRetryInitialDelayMsOrDefault(int defaultValue) { + return retryInitialDelayMs != null ? retryInitialDelayMs : defaultValue; + } + + public void setRetryInitialDelayMs(Integer retryInitialDelayMs) { + this.retryInitialDelayMs = retryInitialDelayMs; + } + + public Map getResponseFormat() { + return responseFormat; + } + + public void setResponseFormat(Map responseFormat) { + this.responseFormat = responseFormat; + } + + public boolean isStreaming() { + return streaming; + } + + public void setStreaming(boolean streaming) { + this.streaming = streaming; + } + + + /** + * 创建 ChatOptions 的 Builder 实例。 + * + * @return 新的 Builder 对象 + */ + public static Builder builder() { + return new Builder(); + } + + /** + * ChatOptions 的构建器类,支持链式调用。 + */ + public static final class Builder { + + private String model; + private String seed; + private Float temperature = 0.5f; + private Float topP; + private Integer topK; + private Integer maxTokens; + private List stop; + private Boolean thinkingEnabled; + private Boolean includeUsage; + private Map extraBody; + private Boolean retryEnabled; + private int retryCount = 3; + private int retryInitialDelayMs = 1000; + public Map responseFormat; + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder seed(String seed) { + this.seed = seed; + return this; + } + + public Builder temperature(Float temperature) { + this.temperature = temperature; + return this; + } + + public Builder topP(Float topP) { + this.topP = topP; + return this; + } + + public Builder topK(Integer topK) { + this.topK = topK; + return this; + } + + public Builder maxTokens(Integer maxTokens) { + this.maxTokens = maxTokens; + return this; + } + + public Builder stop(List stop) { + this.stop = stop; + return this; + } + + public Builder thinkingEnabled(Boolean thinkingEnabled) { + this.thinkingEnabled = thinkingEnabled; + return this; + } + + public Builder includeUsage(Boolean includeUsage) { + this.includeUsage = includeUsage; + return this; + } + + public Builder extraBody(Map extra) { + this.extraBody = extra; + return this; + } + + public Builder addExtraBody(String key, Object value) { + if (this.extraBody == null) { + this.extraBody = Maps.of(key, value); + } else { + this.extraBody.put(key, value); + } + return this; + } + + public Builder retryEnabled(Boolean retryEnabled) { + this.retryEnabled = retryEnabled; + return this; + } + + public Builder retryCount(int retryCount) { + this.retryCount = retryCount; + return this; + } + + public Builder retryInitialDelayMs(int retryInitialDelayMs) { + this.retryInitialDelayMs = retryInitialDelayMs; + return this; + } + + public Builder responseFormat(Map responseFormat) { + this.responseFormat = responseFormat; + return this; + } + + public Builder responseFormatToJsonObject() { + this.responseFormat = Maps.of("type", "json_object"); + return this; + } + + public Builder responseFormatToJsonSchema(Map json_schema) { + this.responseFormat = Maps.of("type", "json_schema").set("json_schema", json_schema); + return this; + } + + /** + * 构建并返回 ChatOptions 实例。 + * + * @return 配置完成的 ChatOptions 对象 + */ + public ChatOptions build() { + return new ChatOptions(this); + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/GlobalChatInterceptors.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/GlobalChatInterceptors.java new file mode 100644 index 0000000..e9b5e52 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/GlobalChatInterceptors.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.easyagents.core.model.chat; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 全局聊天拦截器管理器。 + *

+ * 该类提供静态方法,用于注册和管理应用于所有 {@link BaseChatModel} 实例的全局拦截器。 + * 全局拦截器会在实例级拦截器之前执行,适用于统一的日志、安全、监控等横切关注点。 + *

+ * 使用建议: + *

    + *
  • 在应用启动阶段(如 Spring 的 {@code @PostConstruct} 或 main 方法)注册全局拦截器
  • + *
  • 避免在运行时动态修改,以确保线程安全和行为一致性
  • + *
+ */ +public final class GlobalChatInterceptors { + + /** + * 全局拦截器列表,使用 synchronized 保证线程安全 + */ + private static final List GLOBAL_INTERCEPTORS = new ArrayList<>(); + + /** + * 私有构造函数,防止实例化 + */ + private GlobalChatInterceptors() { + // 工具类,禁止实例化 + } + + /** + * 注册一个全局拦截器。 + *

+ * 该拦截器将应用于所有后续创建的 {@link BaseChatModel} 实例。 + * + * @param interceptor 要注册的拦截器,不能为 null + * @throws IllegalArgumentException 如果 interceptor 为 null + */ + public static synchronized void addInterceptor(ChatInterceptor interceptor) { + if (interceptor == null) { + throw new IllegalArgumentException("ChatInterceptor must not be null"); + } + GLOBAL_INTERCEPTORS.add(interceptor); + } + + /** + * 批量注册多个全局拦截器。 + *

+ * 拦截器将按列表顺序添加,并在执行时按相同顺序调用。 + * + * @param interceptors 拦截器列表,不能为 null;列表中元素不能为 null + * @throws IllegalArgumentException 如果 interceptors 为 null 或包含 null 元素 + */ + public static synchronized void addInterceptors(List interceptors) { + if (interceptors == null) { + throw new IllegalArgumentException("Interceptor list must not be null"); + } + for (ChatInterceptor interceptor : interceptors) { + if (interceptor == null) { + throw new IllegalArgumentException("Interceptor list must not contain null elements"); + } + } + GLOBAL_INTERCEPTORS.addAll(interceptors); + } + + /** + * 获取当前注册的全局拦截器列表的不可变视图。 + *

+ * 该方法供 {@link BaseChatModel} 内部使用,返回值不应被外部修改。 + * + * @return 不可变的全局拦截器列表 + */ + public static List getInterceptors() { + return Collections.unmodifiableList(GLOBAL_INTERCEPTORS); + } + + /** + * 清空所有全局拦截器。 + *

+ * 仅用于测试环境,生产环境应避免调用。 + */ + public static synchronized void clear() { + GLOBAL_INTERCEPTORS.clear(); + } + + /** + * 获取当前全局拦截器的数量。 + *

+ * 用于诊断或监控。 + * + * @return 拦截器数量 + */ + public static synchronized int size() { + return GLOBAL_INTERCEPTORS.size(); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/MessageResponse.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/MessageResponse.java new file mode 100644 index 0000000..ca1e87f --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/MessageResponse.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.message.AiMessage; + +public interface MessageResponse { + M getMessage(); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/OpenAICompatibleChatModel.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/OpenAICompatibleChatModel.java new file mode 100644 index 0000000..dbc4fe6 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/OpenAICompatibleChatModel.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.model.client.ChatClient; +import com.easyagents.core.model.client.ChatRequestSpecBuilder; +import com.easyagents.core.model.client.OpenAIChatClient; +import com.easyagents.core.model.client.OpenAIChatRequestSpecBuilder; + +import java.util.List; + +public class OpenAICompatibleChatModel extends BaseChatModel { + /** + * 构造一个聊天模型实例,不使用实例级拦截器。 + * + * @param config 聊天模型配置 + */ + public OpenAICompatibleChatModel(T config) { + super(config); + } + + /** + * 构造一个聊天模型实例,并指定实例级拦截器。 + *

+ * 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并, + * 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。 + * + * @param config 聊天模型配置 + * @param userInterceptors 实例级拦截器列表 + */ + public OpenAICompatibleChatModel(T config, List userInterceptors) { + super(config, userInterceptors); + } + + @Override + public ChatClient getChatClient() { + if (this.chatClient == null) { + this.chatClient = buildChatClient(); + } + return this.chatClient; + } + + @Override + public ChatRequestSpecBuilder getChatRequestSpecBuilder() { + if (this.chatRequestSpecBuilder == null) { + this.chatRequestSpecBuilder = buildChatRequestSpecBuilder(); + } + return this.chatRequestSpecBuilder; + } + + protected ChatRequestSpecBuilder buildChatRequestSpecBuilder() { + return new OpenAIChatRequestSpecBuilder(); + } + + protected ChatClient buildChatClient() { + return new OpenAIChatClient(this); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/StreamChain.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/StreamChain.java new file mode 100644 index 0000000..6fdea7e --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/StreamChain.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +@FunctionalInterface +public interface StreamChain { + void proceed(BaseChatModel model, ChatContext context, StreamResponseListener listener); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/StreamResponseListener.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/StreamResponseListener.java new file mode 100644 index 0000000..64e9942 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/StreamResponseListener.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.model.client.StreamContext; +import org.slf4j.Logger; + +public interface StreamResponseListener { + + Logger logger = org.slf4j.LoggerFactory.getLogger(StreamResponseListener.class); + + default void onStart(StreamContext context) { + } + + void onMessage(StreamContext context, AiMessageResponse response); + + default void onStop(StreamContext context) { + } + + default void onFailure(StreamContext context, Throwable throwable) { + if (throwable != null) { + logger.error(throwable.toString(), throwable); + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/SyncChain.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/SyncChain.java new file mode 100644 index 0000000..7837a52 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/SyncChain.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +import com.easyagents.core.model.chat.response.AiMessageResponse; + +@FunctionalInterface +public interface SyncChain { + AiMessageResponse proceed(BaseChatModel model, ChatContext context); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/ChatMessageLogger.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/ChatMessageLogger.java new file mode 100644 index 0000000..2035c28 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/ChatMessageLogger.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.log; + +import com.easyagents.core.model.chat.ChatConfig; + +public final class ChatMessageLogger { + + private static IChatMessageLogger logger = new DefaultChatMessageLogger(); + + private ChatMessageLogger() {} + + public static void setLogger(IChatMessageLogger logger) { + if (logger == null){ + throw new IllegalArgumentException("logger can not be null."); + } + ChatMessageLogger.logger = logger; + } + + public static void logRequest(ChatConfig config, String message) { + logger.logRequest(config, message); + } + + public static void logResponse(ChatConfig config, String message) { + logger.logResponse(config, message); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/DefaultChatMessageLogger.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/DefaultChatMessageLogger.java new file mode 100644 index 0000000..809a75e --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/DefaultChatMessageLogger.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.log; + +import com.easyagents.core.model.chat.ChatConfig; + +import java.util.function.Consumer; + +public class DefaultChatMessageLogger implements IChatMessageLogger { + + private final Consumer logConsumer; + + public DefaultChatMessageLogger() { + this(System.out::println); + } + + public DefaultChatMessageLogger(Consumer logConsumer) { + this.logConsumer = logConsumer != null ? logConsumer : System.out::println; + } + + @Override + public void logRequest(ChatConfig config, String message) { + if (shouldLog(config)) { + String provider = getProviderName(config); + String model = getModelName(config); + logConsumer.accept(String.format("[%s/%s] >>>> request: %s", provider, model, message)); + } + } + + @Override + public void logResponse(ChatConfig config, String message) { + if (shouldLog(config)) { + String provider = getProviderName(config); + String model = getModelName(config); + logConsumer.accept(String.format("[%s/%s] <<<< response: %s", provider, model, message)); + } + } + + private boolean shouldLog(ChatConfig config) { + return config != null && config.isLogEnabled(); + } + + private String getProviderName(ChatConfig config) { + String provider = config.getProvider(); + return provider != null ? provider : "unknow"; + } + + private String getModelName(ChatConfig config) { + String model = config.getModel(); + return model != null ? model : "unknow"; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/IChatMessageLogger.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/IChatMessageLogger.java new file mode 100644 index 0000000..4318c10 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/log/IChatMessageLogger.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.log; + +import com.easyagents.core.model.chat.ChatConfig; + +public interface IChatMessageLogger { + void logRequest(ChatConfig config, String message); + + void logResponse(ChatConfig config, String message); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/package-info.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/package-info.java new file mode 100644 index 0000000..93aadc9 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * LLM 大语言模型 + */ +package com.easyagents.core.model.chat; diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/response/AbstractBaseMessageResponse.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/response/AbstractBaseMessageResponse.java new file mode 100644 index 0000000..3efa0f8 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/response/AbstractBaseMessageResponse.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.response; + +import com.easyagents.core.model.chat.MessageResponse; +import com.easyagents.core.message.AiMessage; + +public abstract class AbstractBaseMessageResponse implements MessageResponse { + + protected boolean error = false; + protected String errorMessage; + protected String errorType; + protected String errorCode; + + public boolean isError() { + return error; + } + + public void setError(boolean error) { + this.error = error; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getErrorType() { + return errorType; + } + + public void setErrorType(String errorType) { + this.errorType = errorType; + } + + public String getErrorCode() { + return errorCode; + } + + public void setErrorCode(String errorCode) { + this.errorCode = errorCode; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/response/AiMessageResponse.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/response/AiMessageResponse.java new file mode 100644 index 0000000..039877e --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/response/AiMessageResponse.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.response; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.ToolCall; +import com.easyagents.core.message.ToolMessage; +import com.easyagents.core.message.UserMessage; +import com.easyagents.core.model.chat.ChatContext; +import com.easyagents.core.model.chat.tool.Tool; +import com.easyagents.core.model.chat.tool.ToolInterceptor; +import com.easyagents.core.model.chat.tool.ToolExecutor; +import com.easyagents.core.util.CollectionUtil; +import com.easyagents.core.util.MessageUtil; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; + +import java.util.*; + +public class AiMessageResponse extends AbstractBaseMessageResponse { + + private final ChatContext context; + private final String rawText; + private final AiMessage message; + + public AiMessageResponse(ChatContext context, String rawText, AiMessage message) { + this.context = context; + this.rawText = rawText; + this.message = message; + } + + + public ChatContext getContext() { + return context; + } + + public String getRawText() { + return rawText; + } + + @Override + public AiMessage getMessage() { + return message; + } + + public boolean hasToolCalls() { + if (this.message == null) { + return false; + } + List toolCalls = message.getToolCalls(); + return toolCalls != null && !toolCalls.isEmpty(); + } + + + public List getToolExecutors(ToolInterceptor... interceptors) { + if (this.message == null) { + return Collections.emptyList(); + } + + List calls = message.getToolCalls(); + if (calls == null || calls.isEmpty()) { + return Collections.emptyList(); + } + + UserMessage userMessage = MessageUtil.findLastUserMessage(getContext().getPrompt().getMessages()); + Map funcMap = userMessage.getToolsMap(); + + if (funcMap == null || funcMap.isEmpty()) { + return Collections.emptyList(); + } + + List toolExecutors = new ArrayList<>(calls.size()); + for (ToolCall call : calls) { + Tool tool = funcMap.get(call.getName()); + if (tool != null) { + ToolExecutor executor = new ToolExecutor(tool, call); + if (interceptors != null && interceptors.length > 0) { + executor.addInterceptors(Arrays.asList(interceptors)); + } + toolExecutors.add(executor); + } + } + return toolExecutors; + } + + + public List executeToolCallsAndGetResults(ToolInterceptor... interceptors) { + List toolExecutors = getToolExecutors(interceptors); + + for (ToolExecutor toolExecutor : toolExecutors) { + toolExecutor.addInterceptors(Arrays.asList(interceptors)); + } + + List results = new ArrayList<>(); + for (ToolExecutor toolExecutor : toolExecutors) { + results.add(toolExecutor.execute()); + } + return results; + } + + + public List executeToolCallsAndGetToolMessages(ToolInterceptor... interceptors) { + List toolExecutors = getToolExecutors(interceptors); + + if (CollectionUtil.noItems(toolExecutors)) { + return Collections.emptyList(); + } + + List toolMessages = new ArrayList<>(toolExecutors.size()); + for (ToolExecutor toolExecutor : toolExecutors) { + ToolMessage toolMessage = new ToolMessage(); + String callId = toolExecutor.getToolCall().getId(); + if (StringUtil.hasText(callId)) { + toolMessage.setToolCallId(callId); + } else { + toolMessage.setToolCallId(toolExecutor.getToolCall().getName()); + } + Object result = toolExecutor.execute(); + if (result instanceof CharSequence || result instanceof Number) { + toolMessage.setContent(result.toString()); + } else { + toolMessage.setContent(JSON.toJSONString(result)); + } + toolMessages.add(toolMessage); + } + return toolMessages; + } + + + public static AiMessageResponse error(ChatContext context, String rawText, String errorMessage) { + AiMessageResponse errorResp = new AiMessageResponse(context, rawText, null); + errorResp.setError(true); + errorResp.setErrorMessage(errorMessage); + return errorResp; + } + + + @Override + public String toString() { + return "AiMessageResponse{" + + "context=" + context + + ", rawText='" + rawText + '\'' + + ", message=" + message + + ", error=" + error + + ", errorMessage='" + errorMessage + '\'' + + ", errorType='" + errorType + '\'' + + ", errorCode='" + errorCode + '\'' + + '}'; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/BaseTool.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/BaseTool.java new file mode 100644 index 0000000..841bd22 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/BaseTool.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +import java.io.Serializable; + +public abstract class BaseTool implements Tool, Serializable { + + protected String name; + protected String description; + protected Parameter[] parameters; + + @Override + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public Parameter[] getParameters() { + return parameters; + } + + public void setParameters(Parameter[] parameters) { + this.parameters = parameters; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/FunctionTool.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/FunctionTool.java new file mode 100644 index 0000000..8bce6ae --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/FunctionTool.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +import java.util.Map; +import java.util.function.Function; + +public class FunctionTool extends BaseTool { + + private Function, Object> invoker; + + public FunctionTool() { + } + + @Override + public Object invoke(Map argsMap) { + if (invoker == null) { + throw new IllegalStateException("Tool invoker function is not set."); + } + return invoker.apply(argsMap); + } + + // 允许外部设置 invoker(Builder 会用) + public void setInvoker(Function, Object> invoker) { + this.invoker = invoker; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/GlobalToolInterceptors.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/GlobalToolInterceptors.java new file mode 100644 index 0000000..ba98c6b --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/GlobalToolInterceptors.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 全局函数调用拦截器注册中心。 + *

+ * 所有通过 {@link #addInterceptor(ToolInterceptor)} 注册的拦截器, + * 将自动应用于所有 {@link ToolExecutor} 实例。 + */ +public final class GlobalToolInterceptors { + + private static final List GLOBAL_INTERCEPTORS = new ArrayList<>(); + + private GlobalToolInterceptors() { + } + + public static void addInterceptor(ToolInterceptor interceptor) { + if (interceptor == null) { + throw new IllegalArgumentException("Interceptor must not be null"); + } + GLOBAL_INTERCEPTORS.add(interceptor); + } + + public static List getInterceptors() { + return Collections.unmodifiableList(GLOBAL_INTERCEPTORS); + } + + public static void clear() { + GLOBAL_INTERCEPTORS.clear(); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/MethodParameter.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/MethodParameter.java new file mode 100644 index 0000000..f5efcaf --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/MethodParameter.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +public class MethodParameter extends Parameter { + + protected Class typeClass; + + public Class getTypeClass() { + return typeClass; + } + + public void setTypeClass(Class typeClass) { + this.typeClass = typeClass; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/MethodTool.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/MethodTool.java new file mode 100644 index 0000000..7ad7c0b --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/MethodTool.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +import com.easyagents.core.convert.ConvertService; +import com.easyagents.core.model.chat.tool.annotation.ToolDef; +import com.easyagents.core.model.chat.tool.annotation.ToolParam; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class MethodTool extends BaseTool { + + private Class clazz; + private Object object; + private Method method; + + public Class getClazz() { + return clazz; + } + + public void setClazz(Class clazz) { + this.clazz = clazz; + } + + public Object getObject() { + return object; + } + + public void setObject(Object object) { + this.object = object; + } + + public Method getMethod() { + return method; + } + + public void setMethod(Method method) { + this.method = method; + + ToolDef toolDef = method.getAnnotation(ToolDef.class); + this.name = toolDef.name(); + this.description = toolDef.description(); + + List parameterList = new ArrayList<>(); + java.lang.reflect.Parameter[] methodParameters = method.getParameters(); + for (java.lang.reflect.Parameter methodParameter : methodParameters) { + MethodParameter parameter = getParameter(methodParameter); + parameterList.add(parameter); + } + this.parameters = parameterList.toArray(new MethodParameter[]{}); + } + + @NotNull + private static MethodParameter getParameter(java.lang.reflect.Parameter methodParameter) { + ToolParam toolParam = methodParameter.getAnnotation(ToolParam.class); + MethodParameter parameter = new MethodParameter(); + parameter.setName(toolParam.name()); + parameter.setDescription(toolParam.description()); + parameter.setType(methodParameter.getType().getSimpleName().toLowerCase()); + parameter.setTypeClass(methodParameter.getType()); + parameter.setRequired(toolParam.required()); + parameter.setEnums(toolParam.enums()); + return parameter; + } + + public Object invoke(Map argsMap) { + try { + Object[] args = new Object[this.parameters.length]; + for (int i = 0; i < this.parameters.length; i++) { + MethodParameter parameter = (MethodParameter) this.parameters[i]; + Object value = argsMap.get(parameter.getName()); + args[i] = ConvertService.convert(value, parameter.getTypeClass()); + } + return method.invoke(object, args); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/Parameter.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/Parameter.java new file mode 100644 index 0000000..ad53fae --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/Parameter.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +public class Parameter implements Serializable { + + protected String name; + protected String type; + protected String description; + protected String[] enums; + protected boolean required = false; + protected Object defaultValue; + protected List children; + + // --- getters and setters (keep your existing ones) --- + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String[] getEnums() { + return enums != null ? enums.clone() : null; // defensive copy + } + + public void setEnums(String[] enums) { + this.enums = enums != null ? enums.clone() : null; + } + + public boolean isRequired() { + return required; + } + + public void setRequired(boolean required) { + this.required = required; + } + + public Object getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(Object defaultValue) { + this.defaultValue = defaultValue; + } + + public List getChildren() { + return children != null ? new ArrayList<>(children) : null; // defensive copy + } + + public void setChildren(List children) { + this.children = children != null ? new ArrayList<>(children) : null; + } + + public void addChild(Parameter parameter) { + if (children == null) { + children = new ArrayList<>(); + } + children.add(parameter); + } + + // --- Static builder factory method --- + + public static Builder builder() { + return new Builder(); + } + + // --- Builder inner class --- + public static class Builder { + private String name; + private String type; + private String description; + private String[] enums; + private boolean required = false; + private Object defaultValue; + private List children; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder type(String type) { + this.type = type; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder enums(String... enums) { + this.enums = enums != null ? enums.clone() : null; + return this; + } + + public Builder required(boolean required) { + this.required = required; + return this; + } + + public Builder defaultValue(Object defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public Builder addChild(Parameter child) { + if (this.children == null) { + this.children = new ArrayList<>(); + } + this.children.add(child); + return this; + } + + public Builder children(List children) { + this.children = children != null ? new ArrayList<>(children) : null; + return this; + } + + public Parameter build() { + Parameter param = new Parameter(); + param.setName(name); + param.setType(type); + param.setDescription(description); + param.setEnums(enums); // uses defensive copy internally + param.setRequired(required); + param.setDefaultValue(defaultValue); + param.setChildren(children); // uses defensive copy internally + return param; + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/Tool.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/Tool.java new file mode 100644 index 0000000..d642269 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/Tool.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public interface Tool { + + String getName(); + + String getDescription(); + + Parameter[] getParameters(); + + Object invoke(Map argsMap); + + static Tool.Builder builder() { + return new Tool.Builder(); + } + + class Builder { + private String name; + private String description; + private final List parameters = new ArrayList<>(); + private Function, Object> invoker; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder addParameter(Parameter parameter) { + this.parameters.add(parameter); + return this; + } + + public Builder function(Function, Object> function) { + this.invoker = function; + return this; + } + + public Tool build() { + FunctionTool tool = new FunctionTool(); + tool.setName(name); + tool.setDescription(description); + tool.setParameters(parameters.toArray(new Parameter[0])); + tool.setInvoker(invoker); + return tool; + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolChain.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolChain.java new file mode 100644 index 0000000..0aeedf7 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolChain.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +public interface ToolChain { + Object proceed(ToolContext context) throws Exception; +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolContext.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolContext.java new file mode 100644 index 0000000..e3ddf61 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolContext.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +import com.easyagents.core.message.ToolCall; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +/** + * 函数调用上下文,贯穿整个拦截链。 + */ +public class ToolContext implements Serializable { + private final Tool tool; + private final ToolCall toolCall; + private final Map attributes = new HashMap<>(); + + + public ToolContext(Tool tool, ToolCall toolCall) { + this.tool = tool; + this.toolCall = toolCall; + } + + public Tool getTool() { + return tool; + } + + public ToolCall getToolCall() { + return toolCall; + } + + public Map getArgsMap() { + return toolCall.getArgsMap(); + } + + + public void setAttribute(String key, Object value) { + attributes.put(key, value); + } + + @SuppressWarnings("unchecked") + public T getAttribute(String key) { + return (T) attributes.get(key); + } + + public Map getAttributes() { + return attributes; + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolContextHolder.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolContextHolder.java new file mode 100644 index 0000000..d0906f4 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolContextHolder.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +import com.easyagents.core.message.ToolCall; + +public final class ToolContextHolder { + + private static final ThreadLocal CONTEXT_HOLDER = new ThreadLocal<>(); + + private ToolContextHolder() { + // 工具类,禁止实例化 + } + + + public static ToolContextScope beginExecute(Tool tool, ToolCall toolCall) { + ToolContext ctx = new ToolContext(tool, toolCall); + CONTEXT_HOLDER.set(ctx); + return new ToolContextScope(ctx); + } + + public static ToolContext currentContext() { + return CONTEXT_HOLDER.get(); + } + + public static void clear() { + CONTEXT_HOLDER.remove(); + } + + + /** + * 用于 try-with-resources 的作用域对象,确保上下文自动清理。 + */ + public static class ToolContextScope implements AutoCloseable { + + ToolContext context; + + public ToolContextScope(ToolContext context) { + this.context = context; + } + + @Override + public void close() { + clear(); + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolExecutor.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolExecutor.java new file mode 100644 index 0000000..20f4417 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolExecutor.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +import com.easyagents.core.message.ToolCall; + +import java.util.*; + +/** + * 函数调用执行器,支持责任链拦截。 + *

+ * 执行顺序:全局拦截器 → 用户拦截器 → 实际函数调用。 + */ +public class ToolExecutor { + + private final Tool tool; + private final ToolCall toolCall; + private List interceptors; + + public ToolExecutor(Tool tool, ToolCall toolCall) { + this(tool, toolCall, null); + } + + public ToolExecutor(Tool tool, ToolCall toolCall, + List userInterceptors) { + this.tool = tool; + this.toolCall = toolCall; + this.interceptors = buildInterceptorChain(userInterceptors); + } + + private List buildInterceptorChain( + List userInterceptors) { + + // 1. 全局拦截器 + List chain = new ArrayList<>(GlobalToolInterceptors.getInterceptors()); + + // 2. 用户拦截器 + if (userInterceptors != null) { + chain.addAll(userInterceptors); + } + + return chain; + } + + /** + * 动态添加拦截器(添加到链尾) + */ + public void addInterceptor(ToolInterceptor interceptor) { + if (interceptors == null) { + interceptors = new ArrayList<>(); + } + this.interceptors.add(interceptor); + } + + public void addInterceptors(List interceptors) { + if (interceptors == null) { + interceptors = new ArrayList<>(); + } + this.interceptors.addAll(interceptors); + } + + /** + * 执行函数调用,触发拦截链。 + * + * @return 函数返回值 + * @throws RuntimeException 包装原始异常 + */ + public Object execute() { + try (ToolContextHolder.ToolContextScope scope = ToolContextHolder.beginExecute(tool, toolCall)) { + ToolChain chain = buildChain(0); + return chain.proceed(scope.context); + } catch (Exception e) { + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } else { + throw new RuntimeException("Error invoking function: " + tool.getName(), e); + } + } + } + + private ToolChain buildChain(int index) { + if (index >= interceptors.size()) { + return ctx -> ctx.getTool().invoke(ctx.getArgsMap()); + } + + ToolInterceptor current = interceptors.get(index); + ToolChain next = buildChain(index + 1); + return ctx -> current.intercept(ctx, next); + } + + + public Tool getTool() { + return tool; + } + + public ToolCall getToolCall() { + return toolCall; + } + + public List getInterceptors() { + return interceptors; + } + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolInterceptor.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolInterceptor.java new file mode 100644 index 0000000..7ff777d --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolInterceptor.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +/** + * 函数调用拦截器,用于在函数执行前后插入横切逻辑(如日志、监控、权限等)。 + *

+ * 通过责任链模式组合多个拦截器,最终由链尾执行实际函数调用。 + */ +public interface ToolInterceptor { + + /** + * 拦截函数调用。 + * + * @param context 函数调用上下文 + * @param chain 责任链的下一个节点 + * @return 函数调用结果 + * @throws Exception 通常由实际函数或拦截器抛出 + */ + Object intercept(ToolContext context, ToolChain chain) throws Exception; +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolObservabilityInterceptor.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolObservabilityInterceptor.java new file mode 100644 index 0000000..53b7108 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolObservabilityInterceptor.java @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + + +import com.easyagents.core.observability.Observability; +import com.alibaba.fastjson2.JSON; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; + +import java.io.File; +import java.io.InputStream; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 增强版工具可观测性拦截器,支持: + * - 全局/工具级开关 + * - JSON 结构化参数与结果 + * - 自动脱敏敏感字段 + * - 错误类型分类 + * - 类型安全结果处理 + */ +public class ToolObservabilityInterceptor implements ToolInterceptor { + + private static final Tracer TRACER = Observability.getTracer(); + private static final Meter METER = Observability.getMeter(); + + private static final LongCounter TOOL_CALL_COUNT = METER.counterBuilder("tool.call.count") + .setDescription("Total number of tool calls") + .build(); + + private static final DoubleHistogram TOOL_LATENCY_HISTOGRAM = METER.histogramBuilder("tool.call.latency") + .setDescription("Tool call latency in seconds") + .setUnit("s") + .build(); + + private static final LongCounter TOOL_ERROR_COUNT = METER.counterBuilder("tool.call.error.count") + .setDescription("Total number of tool call errors") + .build(); + + // 长度限制(OpenTelemetry 推荐单个 attribute ≤ 12KB,此处保守) + private static final int MAX_JSON_LENGTH_FOR_SPAN = 4000; + + // 敏感字段正则(匹配 key,不区分大小写) + private static final Pattern SENSITIVE_KEY_PATTERN = Pattern.compile( + ".*(password|token|secret|key|auth|credential|cert|session).*", + Pattern.CASE_INSENSITIVE + ); + + + @Override + public Object intercept(ToolContext context, ToolChain chain) throws Exception { + Tool tool = context.getTool(); + String toolName = tool.getName(); + + // 动态开关:全局关闭 或 工具在黑名单中 + if (!Observability.isEnabled() || Observability.isToolExcluded(toolName)) { + return chain.proceed(context); + } + + Span span = TRACER.spanBuilder("tool." + toolName) + .setAttribute("tool.name", toolName) + .startSpan(); + + long startTimeNanos = System.nanoTime(); + + try (Scope ignored = span.makeCurrent()) { + + // 记录脱敏后的参数(JSON) + Map args = context.getArgsMap(); + if (args != null && !args.isEmpty()) { + String safeArgsJson = safeToJson(args); + span.setAttribute("tool.arguments", safeArgsJson); + } + + // 执行工具 + Object result = chain.proceed(context); + + // 记录结果(成功) + if (result != null) { + String safeResult = safeToString(result); + if (safeResult.length() > MAX_JSON_LENGTH_FOR_SPAN) { + safeResult = safeResult.substring(0, MAX_JSON_LENGTH_FOR_SPAN) + "..."; + } + span.setAttribute("tool.result", safeResult); + } + + recordMetrics(toolName, true, null, startTimeNanos); + return result; + + } catch (Exception e) { + recordError(span, e, toolName, startTimeNanos); + throw e; + } finally { + span.end(); + } + } + + // 安全转为 JSON,自动脱敏 + private String safeToJson(Object obj) { + try { + String json = JSON.toJSONString(obj); + return redactSensitiveFields(json); + } catch (Exception e) { + return "[JSON_SERIALIZATION_ERROR]"; + } + } + + // 简单脱敏:将敏感 key 对应的 value 替换为 "***" + private String redactSensitiveFields(String json) { + // 简单实现:按行处理(适用于格式化 JSON) + // 更严谨可用 JSON parser 遍历,但性能低;此处平衡安全与性能 + String[] lines = json.split("\n"); + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + int colon = line.indexOf(':'); + if (colon > 0) { + String keyPart = line.substring(0, colon); + if (SENSITIVE_KEY_PATTERN.matcher(keyPart).matches()) { + int valueStart = colon + 1; + // 找到 value 开始和结束(简单处理 string/value) + if (valueStart < line.length()) { + char firstChar = line.charAt(valueStart); + if (firstChar == '"' || firstChar == '\'') { + // 字符串值 + int endQuote = line.indexOf(firstChar, valueStart + 1); + if (endQuote > valueStart) { + lines[i] = line.substring(0, valueStart + 1) + "***" + line.substring(endQuote); + } + } else { + // 非字符串值(截至逗号或行尾) + int endValue = line.indexOf(',', valueStart); + if (endValue == -1) endValue = line.length(); + lines[i] = line.substring(0, valueStart) + " \"***\"" + line.substring(endValue); + } + } + } + } + } + return String.join("\n", lines); + } + + // 类型安全的 toString + private String safeToString(Object obj) { + if (obj == null) { + return "null"; + } + if (obj instanceof byte[]) { + return "[binary_data]"; + } + if (obj instanceof InputStream || obj instanceof File) { + return "[stream_or_file]"; + } + if (obj instanceof Map || obj instanceof Iterable) { + return safeToJson(obj); + } + return obj.toString(); + } + + private void recordMetrics(String toolName, boolean success, String errorType, long startTimeNanos) { + double latencySeconds = (System.nanoTime() - startTimeNanos) / 1_000_000_000.0; + AttributesBuilder builder = Attributes.builder() + .put("tool.name", toolName) + .put("tool.success", success); + if (errorType != null) { + builder.put("error.type", errorType); + } + Attributes attrs = builder.build(); + + TOOL_CALL_COUNT.add(1, attrs); + TOOL_LATENCY_HISTOGRAM.record(latencySeconds, attrs); + if (!success) { + TOOL_ERROR_COUNT.add(1, attrs); + } + } + + private void recordError(Span span, Exception e, String toolName, long startTimeNanos) { + span.setStatus(StatusCode.ERROR, e.getMessage()); + span.recordException(e); + + // 错误分类:业务异常(可预期) vs 系统异常(不可预期) + String errorType = e instanceof RuntimeException && !(e instanceof IllegalStateException) ? "business" : "system"; + recordMetrics(toolName, false, errorType, startTimeNanos); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolScanner.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolScanner.java new file mode 100644 index 0000000..e15da98 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/ToolScanner.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool; + +import com.easyagents.core.model.chat.tool.annotation.ToolDef; +import com.easyagents.core.util.ArrayUtil; +import com.easyagents.core.util.ClassUtil; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; + +/** + * 扫描带有 {@link ToolDef} 注解的方法,并将其转换为 {@link Tool} 实例。 + */ +public class ToolScanner { + + /** + * 从指定对象实例中扫描并提取带 {@link ToolDef} 注解的方法,生成工具列表。 + * + * @param object 对象实例(用于非静态方法) + * @param methodNames 可选,指定要扫描的方法名;若为空则扫描所有带注解的方法 + * @return 工具列表 + */ + public static List scan(Object object, String... methodNames) { + return doScan(object.getClass(), object, methodNames); + } + + /** + * 从指定类中扫描并提取带 {@link ToolDef} 注解的静态方法,生成工具列表。 + * + * @param clazz 类(仅扫描静态方法) + * @param methodNames 可选,指定要扫描的方法名;若为空则扫描所有带注解的方法 + * @return 工具列表 + */ + public static List scan(Class clazz, String... methodNames) { + return doScan(clazz, null, methodNames); + } + + private static List doScan(Class clazz, Object object, String... methodNames) { + clazz = ClassUtil.getUsefulClass(clazz); + List methodList = ClassUtil.getAllMethods(clazz, method -> { + if (object == null && !Modifier.isStatic(method.getModifiers())) { + return false; + } + if (method.getAnnotation(ToolDef.class) == null) { + return false; + } + return methodNames.length == 0 || ArrayUtil.contains(methodNames, method.getName()); + }); + + List tools = new ArrayList<>(); + for (Method method : methodList) { + MethodTool tool = new MethodTool(); + tool.setClazz(clazz); + tool.setMethod(method); + if (!Modifier.isStatic(method.getModifiers())) { + tool.setObject(object); + } + tools.add(tool); + } + return tools; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/annotation/ToolDef.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/annotation/ToolDef.java new file mode 100644 index 0000000..40c6fb0 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/annotation/ToolDef.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool.annotation; + +import java.lang.annotation.*; + +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +public @interface ToolDef { + String name() default ""; + String description(); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/annotation/ToolParam.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/annotation/ToolParam.java new file mode 100644 index 0000000..b947a68 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/tool/annotation/ToolParam.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat.tool.annotation; + +import java.lang.annotation.*; + +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER}) +public @interface ToolParam { + String name(); + + String description(); + + String[] enums() default {}; + + boolean required() default false; + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/BaseStreamClientListener.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/BaseStreamClientListener.java new file mode 100644 index 0000000..1edf2d2 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/BaseStreamClientListener.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.model.chat.ChatContext; +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.model.chat.StreamResponseListener; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.parser.AiMessageParser; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class BaseStreamClientListener implements StreamClientListener { + + private final StreamResponseListener streamResponseListener; + private final ChatContext chatContext; + private final AiMessageParser messageParser; + private final StreamContext context; + private final AiMessage fullMessage = new AiMessage(); + private final AtomicBoolean finishedFlag = new AtomicBoolean(false); + private final AtomicBoolean stoppedFlag = new AtomicBoolean(false); + private final AtomicBoolean isFailure = new AtomicBoolean(false); + + public BaseStreamClientListener( + ChatModel chatModel, + ChatContext chatContext, + StreamClient client, + StreamResponseListener streamResponseListener, + AiMessageParser messageParser) { + this.streamResponseListener = streamResponseListener; + this.chatContext = chatContext; + this.messageParser = messageParser; + this.context = new StreamContext(chatModel, chatContext, client); + } + + @Override + public void onStart(StreamClient client) { + streamResponseListener.onStart(context); + } + + @Override + public void onMessage(StreamClient client, String response) { + if (StringUtil.noText(response) || "[DONE]".equalsIgnoreCase(response.trim()) || finishedFlag.get()) { + notifyLastMessageAndStop(response); + return; + } + + try { + JSONObject jsonObject = JSON.parseObject(response); + AiMessage delta = messageParser.parse(jsonObject, chatContext); + + //合并 增量 delta 到 fullMessage + fullMessage.merge(delta); + + // 设置 delta 全内容 + delta.setFullContent(fullMessage.getContent()); + delta.setFullReasoningContent(fullMessage.getReasoningContent()); + + //输出内容 + AiMessageResponse resp = new AiMessageResponse(chatContext, response, delta); + streamResponseListener.onMessage(context, resp); + } catch (Exception err) { + onFailure(this.context.getClient(), err); + onStop(this.context.getClient()); + } + } + + private void notifyLastMessage(String response) { + if (finishedFlag.compareAndSet(false, true)) { + finalizeFullMessage(); + AiMessageResponse resp = new AiMessageResponse(chatContext, response, fullMessage); + streamResponseListener.onMessage(context, resp); + } + } + + private void notifyLastMessageAndStop(String response) { + try { + notifyLastMessage(response); + } finally { + if (stoppedFlag.compareAndSet(false, true)) { + streamResponseListener.onStop(context); + } + } + } + + + @Override + public void onStop(StreamClient client) { + try { + if (!isFailure.get()) { + // onStop 在 sse 的 onClosed 中会被调用,可以用于在 onMessage 出现异常时进行兜底 + notifyLastMessage(null); + } + } finally { + if (stoppedFlag.compareAndSet(false, true)) { + streamResponseListener.onStop(context); + } + } + } + + private void finalizeFullMessage() { + String currentContent = fullMessage.getContent(); + String currentReasoningContent = fullMessage.getReasoningContent(); + + fullMessage.setFullContent(currentContent); + fullMessage.setContent(null); + + fullMessage.setFullReasoningContent(currentReasoningContent); + fullMessage.setReasoningContent(null); + + fullMessage.setFinished(true); + + context.setFullMessage(fullMessage); + } + + + @Override + public void onFailure(StreamClient client, Throwable throwable) { + if (isFailure.compareAndSet(false, true)) { + context.setThrowable(throwable); + streamResponseListener.onFailure(context, throwable); + } + } + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatClient.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatClient.java new file mode 100644 index 0000000..4e0d2bd --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatClient.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import com.easyagents.core.model.chat.BaseChatModel; +import com.easyagents.core.model.chat.StreamResponseListener; +import com.easyagents.core.model.chat.response.AiMessageResponse; + +public abstract class ChatClient { + protected BaseChatModel chatModel; + + public ChatClient(BaseChatModel chatModel) { + this.chatModel = chatModel; + } + + public abstract AiMessageResponse chat(); + + public abstract void chatStream(StreamResponseListener listener); + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatMessageSerializer.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatMessageSerializer.java new file mode 100644 index 0000000..6acdf79 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatMessageSerializer.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import com.easyagents.core.message.Message; +import com.easyagents.core.message.UserMessage; +import com.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.tool.Tool; + +import java.util.List; +import java.util.Map; + +/** + * 将内部消息模型序列化为大模型(如 OpenAI)可识别的请求格式。 + * 虽然名为 ChatMessageSerializer,但同时也支持序列化工具/函数定义, + * 因为这些通常是聊天请求的一部分(如 tools 或 functions 字段)。 + */ +public interface ChatMessageSerializer { + + /** + * 将消息列表序列化为模型所需的聊天消息数组格式。 + * 例如 OpenAI 的 [{"role": "user", "content": "..."}, ...] + * + * @param messages 消息列表,不可为 null + * @return 序列化后的消息数组,若输入为空则返回空列表 + */ + List> serializeMessages(List messages, ChatConfig config); + + /** + * 将函数定义列表序列化为模型所需的工具(tools)或函数(functions)格式。 + * 例如 OpenAI 的 [{"type": "function", "function": {...}}, ...] + * + * @param tools 函数定义列表,可能为 null 或空 + * @return 序列化后的函数定义数组,若输入为空则返回空列表 + */ + List> serializeTools(List tools, ChatConfig config); + + default List> serializeTools(UserMessage userMessage, ChatConfig config) { + return serializeTools(userMessage == null ? null : userMessage.getTools(), config); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatRequestSpec.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatRequestSpec.java new file mode 100644 index 0000000..ebe6f16 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatRequestSpec.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import java.util.Map; + +public class ChatRequestSpec { + private String url; + private Map headers; + private String body; // JSON 字符串 + private int retryCount; + private int retryInitialDelayMs; + + public ChatRequestSpec() { + } + + public ChatRequestSpec(String url, Map headers, String body, int retryCount, int retryInitialDelayMs) { + this.url = url; + this.headers = headers; + this.body = body; + this.retryCount = retryCount; + this.retryInitialDelayMs = retryInitialDelayMs; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public Map getHeaders() { + return headers; + } + + public void setHeaders(Map headers) { + this.headers = headers; + } + + public void addHeader(String key, String value) { + if (this.headers == null) { + this.headers = new java.util.HashMap<>(); + } + this.headers.put(key, value); + } + + public void addHeaders(Map headers) { + if (this.headers == null) { + this.headers = new java.util.HashMap<>(); + } + this.headers.putAll(headers); + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public int getRetryCount() { + return retryCount; + } + + public void setRetryCount(int retryCount) { + this.retryCount = retryCount; + } + + public int getRetryInitialDelayMs() { + return retryInitialDelayMs; + } + + public void setRetryInitialDelayMs(int retryInitialDelayMs) { + this.retryInitialDelayMs = retryInitialDelayMs; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatRequestSpecBuilder.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatRequestSpecBuilder.java new file mode 100644 index 0000000..040c43c --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/ChatRequestSpecBuilder.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import com.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.ChatOptions; +import com.easyagents.core.prompt.Prompt; + +public interface ChatRequestSpecBuilder { + ChatRequestSpec buildRequest(Prompt prompt, ChatOptions options, ChatConfig config); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/HttpClient.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/HttpClient.java new file mode 100644 index 0000000..ed35066 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/HttpClient.java @@ -0,0 +1,328 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import com.easyagents.core.observability.Observability; +import com.easyagents.core.util.IOUtil; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import okhttp3.*; +import okio.BufferedSink; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; + +public class HttpClient { + private static final Logger LOG = LoggerFactory.getLogger(HttpClient.class); + private static final MediaType JSON_TYPE = MediaType.parse("application/json; charset=utf-8"); + + // ===== Observability Components ===== + private static final Tracer TRACER = Observability.getTracer(); + private static final Meter METER = Observability.getMeter(); + + private static final LongCounter HTTP_REQUEST_COUNT = METER.counterBuilder("http.client.request.count") + .setDescription("Total number of HTTP client requests") + .build(); + + private static final DoubleHistogram HTTP_LATENCY_HISTOGRAM = METER.histogramBuilder("http.client.request.duration") + .setDescription("HTTP client request duration in seconds") + .setUnit("s") + .build(); + + private static final LongCounter HTTP_ERROR_COUNT = METER.counterBuilder("http.client.request.error.count") + .setDescription("Total number of HTTP client request errors") + .build(); + + private final OkHttpClient okHttpClient; + + public HttpClient() { + this(OkHttpClientUtil.buildDefaultClient()); + } + + public HttpClient(OkHttpClient okHttpClient) { + this.okHttpClient = okHttpClient; + } + + + public String get(String url) { + return tracedCall(url, "GET", null, null, this::executeString); + } + + public byte[] getBytes(String url) { + return tracedCall(url, "GET", null, null, this::executeBytes); + } + + public String get(String url, Map headers) { + return tracedCall(url, "GET", headers, null, this::executeString); + } + + public String post(String url, Map headers, String payload) { + return tracedCall(url, "POST", headers, payload, this::executeString); + } + + public byte[] postBytes(String url, Map headers, String payload) { + return tracedCall(url, "POST", headers, payload, this::executeBytes); + } + + public String put(String url, Map headers, String payload) { + return tracedCall(url, "PUT", headers, payload, this::executeString); + } + + public String delete(String url, Map headers, String payload) { + return tracedCall(url, "DELETE", headers, payload, this::executeString); + } + + public String multipartString(String url, Map headers, Map payload) { + return tracedCall(url, "POST", headers, payload, (u, m, h, p, s) -> { + //noinspection unchecked + try (Response response = multipart(u, h, (Map) p); + ResponseBody body = response.body()) { + if (body != null) { + return body.string(); + } + } catch (IOException ioe) { + LOG.error("HTTP multipartString failed: " + u, ioe); + s.setStatus(StatusCode.ERROR, ioe.getMessage()); + s.recordException(ioe); + } catch (Exception e) { + LOG.error(e.toString(), e); + throw e; + } + return null; + }); + } + + public byte[] multipartBytes(String url, Map headers, Map payload) { + return tracedCall(url, "POST", headers, payload, (u, m, h, p, s) -> { + //noinspection unchecked + try (Response response = multipart(u, h, (Map) p); + ResponseBody body = response.body()) { + if (body != null) { + return body.bytes(); + } + } catch (IOException ioe) { + LOG.error("HTTP multipartBytes failed: " + u, ioe); + s.setStatus(StatusCode.ERROR, ioe.getMessage()); + s.recordException(ioe); + } catch (Exception e) { + LOG.error(e.toString(), e); + throw e; + } + return null; + }); + } + + // ===== Internal execution methods ===== + + public String executeString(String url, String method, Map headers, Object payload, Span span) { + try (Response response = execute0(url, method, headers, payload); + ResponseBody body = response.body()) { + if (body != null) { + return body.string(); + } + } catch (IOException ioe) { + LOG.error("HTTP executeString failed: " + url, ioe); + span.setStatus(StatusCode.ERROR, ioe.getMessage()); + span.recordException(ioe); + } catch (Exception e) { + LOG.error(e.toString(), e); + throw e; + } + return null; + } + + public byte[] executeBytes(String url, String method, Map headers, Object payload, Span span) { + try (Response response = execute0(url, method, headers, payload); + ResponseBody body = response.body()) { + if (body != null) { + return body.bytes(); + } + } catch (IOException ioe) { + LOG.error("HTTP executeBytes failed: " + url, ioe); + span.setStatus(StatusCode.ERROR, ioe.getMessage()); + span.recordException(ioe); + } catch (Exception e) { + LOG.error(e.toString(), e); + throw e; + } + return null; + } + + private Response execute0(String url, String method, Map headers, Object payload) throws IOException { + Request.Builder builder = new Request.Builder().url(url); + if (headers != null && !headers.isEmpty()) { + headers.forEach((key, value) -> { + if (key != null && value != null) { + builder.addHeader(key, value); + } + }); + } + + Request request; + if ("GET".equalsIgnoreCase(method)) { + request = builder.build(); + } else { + RequestBody body = RequestBody.create(payload == null ? "" : payload.toString(), JSON_TYPE); + request = builder.method(method, body).build(); + } + + Response response = okHttpClient.newCall(request).execute(); + + // Inject status code into current span + injectStatusCodeToCurrentSpan(response); + return response; + } + + public Response multipart(String url, Map headers, Map payload) throws IOException { + Request.Builder builder = new Request.Builder().url(url); + if (headers != null && !headers.isEmpty()) { + headers.forEach(builder::addHeader); + } + + MultipartBody.Builder mbBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM); + payload.forEach((key, value) -> { + if (value instanceof File) { + File file = (File) value; + RequestBody body = RequestBody.create(file, MediaType.parse("application/octet-stream")); + mbBuilder.addFormDataPart(key, file.getName(), body); + } else if (value instanceof InputStream) { + RequestBody body = new InputStreamRequestBody(MediaType.parse("application/octet-stream"), (InputStream) value); + mbBuilder.addFormDataPart(key, key, body); + } else if (value instanceof byte[]) { + mbBuilder.addFormDataPart(key, key, RequestBody.create((byte[]) value)); + } else { + mbBuilder.addFormDataPart(key, String.valueOf(value)); + } + }); + + MultipartBody multipartBody = mbBuilder.build(); + Request request = builder.post(multipartBody).build(); + Response response = okHttpClient.newCall(request).execute(); + + // Inject status code into current span (same as execute0) + injectStatusCodeToCurrentSpan(response); + return response; + } + + // ===== Shared helper for span status code injection ===== + + private void injectStatusCodeToCurrentSpan(Response response) { + Span currentSpan = Span.current(); + if (currentSpan != null && currentSpan != Span.getInvalid()) { + int statusCode = response.code(); + currentSpan.setAttribute("http.status_code", statusCode); + if (statusCode >= 400) { + currentSpan.setStatus(StatusCode.ERROR, "HTTP " + statusCode); + } + } + } + + // ===== Observability wrapper ===== + @FunctionalInterface + private interface HttpClientCall { + T call(String url, String method, Map headers, Object payload, Span span) throws Exception; + } + + private T tracedCall(String url, String method, Map headers, Object payload, HttpClientCall call) { + String host = extractHost(url); + Span span = TRACER.spanBuilder("http.client.request") + .setAttribute("http.method", method) + .setAttribute("http.url", url) + .setAttribute("server.address", host) + .startSpan(); + + long startTime = System.nanoTime(); + boolean success = true; + + try (Scope scope = span.makeCurrent()) { + return call.call(url, method, headers, payload, span); + } catch (Exception e) { + success = false; + span.setStatus(StatusCode.ERROR, e.getMessage()); + span.recordException(e); + throw new RuntimeException("HTTP request failed", e); + } finally { + span.end(); + double latency = (System.nanoTime() - startTime) / 1_000_000_000.0; + Attributes attrs = Attributes.of( + AttributeKey.stringKey("http.method"), method, + AttributeKey.stringKey("server.address"), host, + AttributeKey.stringKey("http.success"), String.valueOf(success) + ); + HTTP_REQUEST_COUNT.add(1, attrs); + HTTP_LATENCY_HISTOGRAM.record(latency, attrs); + if (!success) { + HTTP_ERROR_COUNT.add(1, attrs); + } + } + } + + // ===== Utility ===== + private static String extractHost(String url) { + try { + URI uri = new URI(url); + String host = uri.getHost(); + int port = uri.getPort(); + if (port != -1) { + return host + ":" + port; + } + return host; + } catch (URISyntaxException e) { + return "unknown"; + } + } + + // ===== Inner class ===== + + public static class InputStreamRequestBody extends RequestBody { + private final InputStream inputStream; + private final MediaType contentType; + + public InputStreamRequestBody(MediaType contentType, InputStream inputStream) { + if (inputStream == null) throw new NullPointerException("inputStream == null"); + this.contentType = contentType; + this.inputStream = inputStream; + } + + @Override + public MediaType contentType() { + return contentType; + } + + @Override + public long contentLength() throws IOException { + return inputStream.available() == 0 ? -1 : inputStream.available(); + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + IOUtil.copy(inputStream, sink); + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/OkHttpClientUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/OkHttpClientUtil.java new file mode 100644 index 0000000..8e729af --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/OkHttpClientUtil.java @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import com.easyagents.core.util.StringUtil; +import okhttp3.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.util.concurrent.TimeUnit; + +public class OkHttpClientUtil { + + private static final Logger log = LoggerFactory.getLogger(OkHttpClientUtil.class); + + // 系统属性前缀 + private static final String PREFIX = "okhttp."; + + // 环境变量前缀(大写) + private static final String ENV_PREFIX = "OKHTTP_"; + + private static volatile OkHttpClient defaultClient; + private static volatile OkHttpClient.Builder customBuilder; + + public static void setOkHttpClientBuilder(OkHttpClient.Builder builder) { + if (defaultClient != null) { + throw new IllegalStateException("OkHttpClient has already been initialized. " + + "Please set the builder before first usage."); + } + customBuilder = builder; + } + + public static OkHttpClient buildDefaultClient() { + if (defaultClient == null) { + synchronized (OkHttpClientUtil.class) { + if (defaultClient == null) { + OkHttpClient.Builder builder = customBuilder != null + ? customBuilder + : createDefaultBuilder(); + defaultClient = builder.build(); + log.debug("OkHttpClient initialized with config: connectTimeout={}s, readTimeout={}s, writeTimeout={}s, " + + "connectionPool(maxIdle={}, keepAlive={}min)", + getConnectTimeout(), getReadTimeout(), getWriteTimeout(), + getMaxIdleConnections(), getKeepAliveMinutes()); + } + } + } + return defaultClient; + } + + private static OkHttpClient.Builder createDefaultBuilder() { + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .connectTimeout(getConnectTimeout(), TimeUnit.SECONDS) + .readTimeout(getReadTimeout(), TimeUnit.SECONDS) + .writeTimeout(getWriteTimeout(), TimeUnit.SECONDS) + .connectionPool(new ConnectionPool(getMaxIdleConnections(), getKeepAliveMinutes(), TimeUnit.MINUTES)); + + configureProxy(builder); + return builder; + } + + // ==================== 配置读取方法 ==================== + + private static int getConnectTimeout() { + return getIntConfig("connectTimeout", "CONNECT_TIMEOUT", 60); + } + + private static int getReadTimeout() { + return getIntConfig("readTimeout", "READ_TIMEOUT", 300); + } + + private static int getWriteTimeout() { + return getIntConfig("writeTimeout", "WRITE_TIMEOUT", 60); + } + + private static int getMaxIdleConnections() { + return getIntConfig("connectionPool.maxIdleConnections", "CONNECTION_POOL_MAX_IDLE_CONNECTIONS", 5); + } + + private static long getKeepAliveMinutes() { + return getLongConfig("connectionPool.keepAliveMinutes", "CONNECTION_POOL_KEEP_ALIVE_MINUTES", 10); + } + + private static String getProxyHost() { + String host = getPropertyOrEnv("proxy.host", "PROXY_HOST", null); + if (StringUtil.hasText(host)) return host.trim(); + + // 兼容 Java 标准代理属性(作为 fallback) + host = System.getProperty("https.proxyHost"); + if (StringUtil.hasText(host)) return host.trim(); + + host = System.getProperty("http.proxyHost"); + if (StringUtil.hasText(host)) return host.trim(); + + return null; + } + + private static String getProxyPort() { + String port = getPropertyOrEnv("proxy.port", "PROXY_PORT", null); + if (StringUtil.hasText(port)) return port.trim(); + + // 兼容 Java 标准代理属性 + port = System.getProperty("https.proxyPort"); + if (StringUtil.hasText(port)) return port.trim(); + + port = System.getProperty("http.proxyPort"); + if (StringUtil.hasText(port)) return port.trim(); + + return null; + } + + // 新增:获取代理用户名 + private static String getProxyUsername() { + String username = getPropertyOrEnv("proxy.username", "PROXY_USERNAME", null); + if (StringUtil.hasText(username)) return username.trim(); + + // 兼容 Java 标准代理属性 + username = System.getProperty("https.proxyUser"); + if (StringUtil.hasText(username)) return username.trim(); + + username = System.getProperty("http.proxyUser"); + if (StringUtil.hasText(username)) return username.trim(); + + return null; + } + + // 新增:获取代理密码 + private static String getProxyPassword() { + String password = getPropertyOrEnv("proxy.password", "PROXY_PASSWORD", null); + if (StringUtil.hasText(password)) return password.trim(); + + // 兼容 Java 标准代理属性 + password = System.getProperty("https.proxyPassword"); + if (StringUtil.hasText(password)) return password.trim(); + + password = System.getProperty("http.proxyPassword"); + if (StringUtil.hasText(password)) return password.trim(); + + return null; + } + + // ==================== 工具方法 ==================== + + private static int getIntConfig(String sysPropKey, String envKey, int defaultValue) { + String value = getPropertyOrEnv(sysPropKey, envKey, null); + if (value == null) return defaultValue; + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + log.warn("Invalid integer value for '{}': '{}'. Using default: {}", fullSysPropKey(sysPropKey), value, defaultValue); + return defaultValue; + } + } + + private static long getLongConfig(String sysPropKey, String envKey, long defaultValue) { + String value = getPropertyOrEnv(sysPropKey, envKey, null); + if (value == null) return defaultValue; + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + log.warn("Invalid long value for '{}': '{}'. Using default: {}", fullSysPropKey(sysPropKey), value, defaultValue); + return defaultValue; + } + } + + private static String getPropertyOrEnv(String sysPropKey, String envKey, String defaultValue) { + // 1. 系统属性优先 + String value = System.getProperty(fullSysPropKey(sysPropKey)); + if (value != null) return value; + + // 2. 环境变量 + value = System.getenv(ENV_PREFIX + envKey); + if (value != null) return value; + + return defaultValue; + } + + private static String fullSysPropKey(String key) { + return PREFIX + key; + } + + // ==================== 代理配置 ==================== + + private static void configureProxy(OkHttpClient.Builder builder) { + String proxyHost = getProxyHost(); + String proxyPort = getProxyPort(); + + if (StringUtil.hasText(proxyHost) && StringUtil.hasText(proxyPort)) { + try { + int port = Integer.parseInt(proxyPort); + InetSocketAddress address = new InetSocketAddress(proxyHost, port); + builder.proxy(new Proxy(Proxy.Type.HTTP, address)); + + // 配置代理认证 + String username = getProxyUsername(); + String password = getProxyPassword(); + + if (StringUtil.hasText(username) && StringUtil.hasText(password)) { + configureProxyAuthenticator(builder, username, password); + log.debug("HTTP proxy with authentication configured: {}@{}:{}", + username, proxyHost, port); + } else { + log.debug("HTTP proxy configured (no authentication): {}:{}", proxyHost, port); + } + } catch (NumberFormatException e) { + log.warn("Invalid proxy port '{}'. Proxy will be ignored.", proxyPort, e); + } + } + } + + // 配置代理认证器 + private static void configureProxyAuthenticator(OkHttpClient.Builder builder, String username, String password) { + builder.proxyAuthenticator(new Authenticator() { + @Override + public Request authenticate(Route route, Response response) throws IOException { + // 检查是否是代理认证挑战 + if (response.code() != 407) { + return null; // 不是代理认证,不处理 + } + + // 如果已经尝试过认证,直接放弃,防止死循环 + if (response.request().header("Proxy-Authorization") != null) { + log.error("Proxy authentication failed for user: {}", username); + return null; + } + + // 生成 Basic 认证凭证 (格式: "Basic base64(username:password)") + String credential = Credentials.basic(username, password); + + // 添加 Proxy-Authorization 头并重新发送请求 + return response.request().newBuilder() + .header("Proxy-Authorization", credential) + .build(); + } + }); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatClient.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatClient.java new file mode 100644 index 0000000..6d9c881 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatClient.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.model.chat.BaseChatModel; +import com.easyagents.core.model.chat.ChatContext; +import com.easyagents.core.model.chat.ChatContextHolder; +import com.easyagents.core.model.chat.StreamResponseListener; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.model.client.impl.SseClient; +import com.easyagents.core.parser.AiMessageParser; +import com.easyagents.core.parser.impl.DefaultAiMessageParser; +import com.easyagents.core.util.LocalTokenCounter; +import com.easyagents.core.util.Retryer; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONException; +import com.alibaba.fastjson2.JSONObject; + +/** + * OpenAI 专用聊天客户端。 + *

+ * 封装了 HTTP 同步调用和 SSE 流式调用的具体实现。 + */ +public class OpenAIChatClient extends ChatClient { + + protected HttpClient httpClient; + protected AiMessageParser aiMessageParser; + + public OpenAIChatClient(BaseChatModel chatModel) { + super(chatModel); + } + + public HttpClient getHttpClient() { + if (httpClient == null) { + httpClient = new HttpClient(); + } + return httpClient; + } + + public void setHttpClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + public StreamClient getStreamClient() { + // SseClient 默认实现是每次请求需要新建一个 SseClient 对象,方便进行 stop 调用 + return new SseClient(); + } + + + public AiMessageParser getAiMessageParser() { + if (aiMessageParser == null) { + aiMessageParser = DefaultAiMessageParser.getOpenAIMessageParser(); + } + return aiMessageParser; + } + + public void setAiMessageParser(AiMessageParser aiMessageParser) { + this.aiMessageParser = aiMessageParser; + } + + + @Override + public AiMessageResponse chat() { + HttpClient httpClient = getHttpClient(); + ChatContext context = ChatContextHolder.currentContext(); + ChatRequestSpec requestSpec = context.getRequestSpec(); + + String response = requestSpec.getRetryCount() > 0 ? Retryer.retry(() -> httpClient.post(requestSpec.getUrl(), + requestSpec.getHeaders(), + requestSpec.getBody()), requestSpec.getRetryCount(), requestSpec.getRetryInitialDelayMs()) + : httpClient.post(requestSpec.getUrl(), requestSpec.getHeaders(), requestSpec.getBody()); + + if (StringUtil.noText(response)) { + return AiMessageResponse.error(context, response, "no content for response."); + } + try { + return parseResponse(response, context); + } catch (JSONException e) { + return AiMessageResponse.error(context, response, "invalid json response."); + } + } + + + protected AiMessageResponse parseResponse(String response, ChatContext context) { + JSONObject jsonObject = JSON.parseObject(response); + JSONObject error = jsonObject.getJSONObject("error"); + + AiMessageResponse messageResponse; + if (error != null && !error.isEmpty()) { + String message = error.getString("message"); + messageResponse = AiMessageResponse.error(context, response, message); + messageResponse.setErrorType(error.getString("type")); + messageResponse.setErrorCode(error.getString("code")); + } else { + AiMessage aiMessage = getAiMessageParser().parse(jsonObject, context); + LocalTokenCounter.computeAndSetLocalTokens(context.getPrompt().getMessages(), aiMessage); + messageResponse = new AiMessageResponse(context, response, aiMessage); + } + return messageResponse; + } + + + @Override + public void chatStream(StreamResponseListener listener) { + StreamClient streamClient = getStreamClient(); + ChatContext context = ChatContextHolder.currentContext(); + StreamClientListener clientListener = new BaseStreamClientListener( + chatModel, + context, + streamClient, + listener, + getAiMessageParser() + ); + + ChatRequestSpec requestSpec = context.getRequestSpec(); + if (requestSpec.getRetryCount() > 0) { + Retryer.retry(() -> streamClient.start(requestSpec.getUrl(), requestSpec.getHeaders(), requestSpec.getBody() + , clientListener, chatModel.getConfig()) + , requestSpec.getRetryCount() + , requestSpec.getRetryInitialDelayMs()); + } else { + streamClient.start(requestSpec.getUrl(), requestSpec.getHeaders(), requestSpec.getBody() + , clientListener, chatModel.getConfig()); + } + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatMessageSerializer.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatMessageSerializer.java new file mode 100644 index 0000000..88fe800 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatMessageSerializer.java @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import com.easyagents.core.message.*; +import com.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.tool.Tool; +import com.easyagents.core.model.chat.tool.Parameter; +import com.easyagents.core.util.CollectionUtil; +import com.easyagents.core.util.ImageUtil; +import com.easyagents.core.util.Maps; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; + +import java.util.*; + +public class OpenAIChatMessageSerializer implements ChatMessageSerializer { + + /** + * 将消息列表序列化为模型所需的聊天消息数组格式。 + * 例如 OpenAI 的 [{"role": "user", "content": "..."}, ...] + * + * @param messages 消息列表,不可为 null + * @return 序列化后的消息数组,若输入为空则返回空列表 + */ + @Override + public List> serializeMessages(List messages, ChatConfig config) { + if (messages == null || messages.isEmpty()) { + return null; + } + + return buildMessageList(messages, config); + } + + protected List> buildMessageList(List messages, ChatConfig config) { + List> messageList = new ArrayList<>(messages.size()); + messages.forEach(message -> { + Map objectMap = new HashMap<>(2); + if (message instanceof UserMessage) { + buildUserMessageObject(objectMap, (UserMessage) message, config); + } else if (message instanceof AiMessage) { + buildAIMessageObject(objectMap, (AiMessage) message, config); + } else if (message instanceof SystemMessage) { + buildSystemMessageObject(objectMap, (SystemMessage) message, config); + } else if (message instanceof ToolMessage) { + buildToolMessageObject(objectMap, (ToolMessage) message, config); + } + messageList.add(objectMap); + }); + return messageList; + } + + protected void buildToolMessageObject(Map objectMap, ToolMessage message, ChatConfig config) { + if (config.isSupportToolMessage()) { + objectMap.put("role", "tool"); + objectMap.put("content", message.getTextContent()); + objectMap.put("tool_call_id", message.getToolCallId()); + } + // 部分模型(如 DeepSeek V3)不支持原生 tool message 格式, + // 此处将 tool message 转换为 system message 格式以确保兼容性 + else { + objectMap.put("role", "system"); + Map contentMap = new LinkedHashMap<>(); + contentMap.put("tool_call_id", message.getToolCallId()); + contentMap.put("content", message.getTextContent()); + objectMap.put("content", JSON.toJSONString(contentMap)); + } + } + + protected void buildSystemMessageObject(Map objectMap, SystemMessage message, ChatConfig config) { + objectMap.put("role", "system"); + objectMap.put("content", message.getTextContent()); + } + + protected void buildUserMessageObject(Map objectMap, UserMessage message, ChatConfig config) { + objectMap.put("role", "user"); + objectMap.put("content", buildUserMessageContent(message, config)); + } + + protected void buildAIMessageObject(Map objectMap, AiMessage message, ChatConfig config) { + objectMap.put("role", "assistant"); + objectMap.put("content", message.getTextContent()); + + List calls = message.getToolCalls(); + if (calls != null && !calls.isEmpty()) { + if (config.isSupportToolMessage()) { + objectMap.put("content", ""); // 清空 content,在某模型下,会把思考的部分当做 content 的部分 + buildAIMessageToolCalls(objectMap, calls, false); + + // 兼容性处理,在 ToolMessage 中,需要将 reasoning_content 添加到 payload 中,比如 deepseek 模型 + if (config.isNeedReasoningContentForToolMessage() && StringUtil.hasText(message.getReasoningContent())) { + objectMap.put("reasoning_content", message.getReasoningContent()); + } + } else { + objectMap.put("role", "system"); + buildAIMessageToolCalls(objectMap, calls, true); + } + } + } + + protected void buildAIMessageToolCalls(Map objectMap, List calls, boolean buildToContent) { + List> toolCalls = new ArrayList<>(); + for (ToolCall call : calls) { + Maps toolCall = new Maps(); + toolCall.set("id", call.getId()) + .set("type", "function") + .set("function", Maps.of("name", call.getName()) + .set("arguments", call.getArguments()) + ); + toolCalls.add(toolCall); + } + + if (buildToContent) { + objectMap.put("content", JSON.toJSONString(toolCalls)); + } else { + objectMap.put("tool_calls", toolCalls); + } + } + + + protected Object buildUserMessageContent(UserMessage userMessage, ChatConfig config) { + String content = userMessage.getTextContent(); + List imageUrls = userMessage.getImageUrls(); + List audioUrls = userMessage.getAudioUrls(); + List videoUrls = userMessage.getVideoUrls(); + + if (CollectionUtil.hasItems(imageUrls) || CollectionUtil.hasItems(audioUrls) || CollectionUtil.hasItems(videoUrls)) { + + List> messageContent = new ArrayList<>(); + messageContent.add(Maps.of("type", "text").set("text", content)); + + if (CollectionUtil.hasItems(imageUrls)) { + for (String url : imageUrls) { + if (config.isSupportImageBase64Only() + && url.toLowerCase().startsWith("http")) { + url = ImageUtil.imageUrlToDataUri(url); + } + messageContent.add(Maps.of("type", "image_url").set("image_url", Maps.of("url", url))); + } + } + + if (CollectionUtil.hasItems(audioUrls)) { + for (String url : audioUrls) { + messageContent.add(Maps.of("type", "audio_url").set("audio_url", Maps.of("url", url))); + } + } + + if (CollectionUtil.hasItems(videoUrls)) { + for (String url : videoUrls) { + messageContent.add(Maps.of("type", "video_url").set("video_url", Maps.of("url", url))); + } + } + + return messageContent; + } else { + return content; + } + } + + + /** + * 将函数定义列表序列化为模型所需的工具(tools)或函数(functions)格式。 + * 例如 OpenAI 的 [{"type": "function", "function": {...}}, ...] + * + * @param tools 函数定义列表,可能为 null 或空 + * @return 序列化后的函数定义数组,若输入为空则返回空列表 + */ + @Override + public List> serializeTools(List tools, ChatConfig config) { + if (tools == null || tools.isEmpty()) { + return null; + } + + // 大模型不支持 Function Calling + if (config != null && !config.isSupportTool()) { + return null; + } + + return buildToolList(tools); + } + + + protected List> buildToolList(List tools) { + List> functionList = new ArrayList<>(); + for (Tool tool : tools) { + Map functionRoot = new HashMap<>(); + functionRoot.put("type", "function"); + + Map functionObj = new HashMap<>(); + functionRoot.put("function", functionObj); + + functionObj.put("name", tool.getName()); + functionObj.put("description", tool.getDescription()); + + + Map parametersObj = new HashMap<>(); + functionObj.put("parameters", parametersObj); + parametersObj.put("type", "object"); + + Map propertiesObj = new HashMap<>(); + parametersObj.put("properties", propertiesObj); + + addParameters(tool.getParameters(), propertiesObj, parametersObj); + + functionList.add(functionRoot); + } + + return functionList; + } + + protected void addParameters(Parameter[] parameters, Map propertiesObj, Map parametersObj) { + if (parameters == null || parameters.length == 0) { + return; + } + List requiredProperties = new ArrayList<>(); + for (Parameter parameter : parameters) { + Map parameterObj = new HashMap<>(); + parameterObj.put("type", parameter.getType()); + parameterObj.put("description", parameter.getDescription()); + parameterObj.put("enum", parameter.getEnums()); + if (parameter.isRequired()) { + requiredProperties.add(parameter.getName()); + } + + List children = parameter.getChildren(); + if (children != null && !children.isEmpty()) { + if ("object".equalsIgnoreCase(parameter.getType())) { + Map childrenObj = new HashMap<>(); + parameterObj.put("properties", childrenObj); + addParameters(children.toArray(new Parameter[0]), childrenObj, parameterObj); + } + if ("array".equalsIgnoreCase(parameter.getType())) { + Map itemsObj = new HashMap<>(); + parameterObj.put("items", itemsObj); + handleArrayItems(children, itemsObj); + } + } + + propertiesObj.put(parameter.getName(), parameterObj); + } + + if (!requiredProperties.isEmpty()) { + parametersObj.put("required", requiredProperties); + } + } + + protected void handleArrayItems(List children, Map itemsObj) { + if (children.size() == 1 && children.get(0).getName() == null) { + // 单值数组,数组元素是基础类型 + Parameter firstChild = children.get(0); + itemsObj.put("type", firstChild.getType()); + itemsObj.put("description", firstChild.getDescription()); + itemsObj.put("enum", firstChild.getEnums()); + // 如果基础类型本身也是数组,需要递归处理 + List grandchildren = firstChild.getChildren(); + if (grandchildren != null && !grandchildren.isEmpty()) { + if ("array".equalsIgnoreCase(firstChild.getType())) { + Map nestedItemsObj = new HashMap<>(); + itemsObj.put("items", nestedItemsObj); + handleArrayItems(grandchildren, nestedItemsObj); + } else if ("object".equalsIgnoreCase(firstChild.getType())) { + Map nestedProperties = new HashMap<>(); + itemsObj.put("properties", nestedProperties); + addParameters(grandchildren.toArray(new Parameter[0]), nestedProperties, itemsObj); + } + } + } else { + // 复杂数组,数组元素是对象或其他复杂类型 + Map tempProperties = new HashMap<>(); + addParameters(children.toArray(new Parameter[0]), tempProperties, itemsObj); + + if (!tempProperties.isEmpty()) { + itemsObj.put("type", "object"); + itemsObj.put("properties", tempProperties); + } + } + } +} + diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatRequestSpecBuilder.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatRequestSpecBuilder.java new file mode 100644 index 0000000..6ab5c3c --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatRequestSpecBuilder.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import com.easyagents.core.message.Message; +import com.easyagents.core.message.UserMessage; +import com.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.ChatOptions; +import com.easyagents.core.prompt.Prompt; +import com.easyagents.core.util.Maps; +import com.easyagents.core.util.MessageUtil; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class OpenAIChatRequestSpecBuilder implements ChatRequestSpecBuilder { + + protected ChatMessageSerializer chatMessageSerializer; + + public OpenAIChatRequestSpecBuilder() { + this(new OpenAIChatMessageSerializer()); + } + + public OpenAIChatRequestSpecBuilder(ChatMessageSerializer chatMessageSerializer) { + this.chatMessageSerializer = chatMessageSerializer; + } + + @Override + public ChatRequestSpec buildRequest(Prompt prompt, ChatOptions options, ChatConfig config) { + + String url = buildRequestUrl(prompt, options, config); + Map headers = buildRequestHeaders(prompt, options, config); + String body = buildRequestBody(prompt, options, config); + + boolean retryEnabled = options.getRetryEnabledOrDefault(config.isRetryEnabled()); + int retryCountOrDefault = options.getRetryCountOrDefault(config.getRetryCount()); + int retryInitialDelayMsOrDefault = options.getRetryInitialDelayMsOrDefault(config.getRetryInitialDelayMs()); + + return new ChatRequestSpec(url, headers, body, retryEnabled ? retryCountOrDefault : 0, retryEnabled ? retryInitialDelayMsOrDefault : 0); + } + + protected String buildRequestUrl(Prompt prompt, ChatOptions options, ChatConfig config) { + return config.getFullUrl(); + } + + protected Map buildRequestHeaders(Prompt prompt, ChatOptions options, ChatConfig config) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer " + config.getApiKey()); + return headers; + } + + + protected String buildRequestBody(Prompt prompt, ChatOptions options, ChatConfig config) { + List messages = prompt.getMessages(); + UserMessage userMessage = MessageUtil.findLastUserMessage(messages); + + Maps baseBodyJsonMap = buildBaseParamsOfRequestBody(prompt, options, config); + + Maps bodyJsonMap = baseBodyJsonMap + .set("messages", chatMessageSerializer.serializeMessages(messages, config)) + .setIfNotEmpty("tools", chatMessageSerializer.serializeTools(userMessage, config)) + .setIfContainsKey("tools", "tool_choice", userMessage != null ? userMessage.getToolChoice() : null); + + if (options.isStreaming() && options.getIncludeUsageOrDefault(true)) { + bodyJsonMap.set("stream_options", Maps.of("include_usage", true)); + } + + buildThinkingBody(options, config, bodyJsonMap); + + if (options.getExtraBody() != null) { + bodyJsonMap.putAll(options.getExtraBody()); + } + + return bodyJsonMap.toJSON(); + } + + protected void buildThinkingBody(ChatOptions options, ChatConfig config, Maps bodyJsonMap) { + if (!config.isSupportThinking()) { + return; + } + Boolean thinkingEnabled = options.getThinkingEnabled(); + if (thinkingEnabled == null) { + return; + } + + String thinkingProtocol = config.getThinkingProtocol(); + if (thinkingProtocol == null || "none".equals(thinkingProtocol)) { + return; + } + + switch (thinkingProtocol) { + case "qwen": + bodyJsonMap.set("enable_thinking", thinkingEnabled); + break; + case "deepseek": + bodyJsonMap.set("thinking", Maps.of("type", thinkingEnabled ? "enabled" : "disabled")); + break; + case "ollama": + bodyJsonMap.set("thinking", thinkingEnabled); + default: + // do nothing + } + } + + + protected Maps buildBaseParamsOfRequestBody(Prompt prompt, ChatOptions options, ChatConfig config) { + return Maps.of("model", options.getModelOrDefault(config.getModel())) + .setIf(options.isStreaming(), "stream", true) + .setIfNotNull("top_p", options.getTopP()) +// .setIfNotNull("top_k", options.getTopK()) // openAI 不支持 top_k 标识 + .setIfNotNull("temperature", options.getTemperature()) + .setIfNotNull("max_tokens", options.getMaxTokens()) + .setIfNotEmpty("stop", options.getStop()) + .setIfNotEmpty("response_format", options.getResponseFormat()); + + } + + public ChatMessageSerializer getChatMessageSerializer() { + return chatMessageSerializer; + } + + public void setChatMessageSerializer(ChatMessageSerializer chatMessageSerializer) { + this.chatMessageSerializer = chatMessageSerializer; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamClient.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamClient.java new file mode 100644 index 0000000..3b70499 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamClient.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import com.easyagents.core.model.chat.ChatConfig; + +import java.util.Map; + +public interface StreamClient { + + void start(String url, Map headers, String payload, StreamClientListener listener, ChatConfig config); + + void stop(); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamClientListener.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamClientListener.java new file mode 100644 index 0000000..32c8132 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamClientListener.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +public interface StreamClientListener { + + void onStart(StreamClient client); + + void onMessage(StreamClient client, String response); + + void onStop(StreamClient client); + + void onFailure(StreamClient client, Throwable throwable); + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamContext.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamContext.java new file mode 100644 index 0000000..c61e4e7 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/StreamContext.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.model.chat.ChatContext; +import com.easyagents.core.model.chat.ChatModel; + +public class StreamContext { + private final ChatModel chatModel; + private final ChatContext chatContext; + private final StreamClient client; + private AiMessage fullMessage; + private Throwable throwable; + + + public StreamContext(ChatModel chatModel, ChatContext context, StreamClient client) { + this.chatModel = chatModel; + this.chatContext = context; + this.client = client; + } + + + public ChatModel getChatModel() { + return chatModel; + } + + public ChatContext getChatContext() { + return chatContext; + } + + public StreamClient getClient() { + return client; + } + + public AiMessage getFullMessage() { + return fullMessage; + } + + public void setFullMessage(AiMessage fullMessage) { + this.fullMessage = fullMessage; + } + + public Throwable getThrowable() { + return throwable; + } + + public void setThrowable(Throwable throwable) { + this.throwable = throwable; + } + + public boolean isError() { + return throwable != null; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/DnjsonClient.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/DnjsonClient.java new file mode 100644 index 0000000..bab13b9 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/DnjsonClient.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client.impl; + +import com.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.log.ChatMessageLogger; +import com.easyagents.core.model.client.OkHttpClientUtil; +import com.easyagents.core.model.client.StreamClient; +import com.easyagents.core.model.client.StreamClientListener; +import com.easyagents.core.util.StringUtil; +import okhttp3.*; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Map; + +public class DnjsonClient implements StreamClient, Callback { + + private static final Logger log = LoggerFactory.getLogger(DnjsonClient.class); + private static final MediaType JSON_TYPE = MediaType.parse("application/json; charset=utf-8"); + + private OkHttpClient okHttpClient; + private StreamClientListener listener; + private ChatConfig config; + private boolean isStop = false; + + public DnjsonClient() { + this(OkHttpClientUtil.buildDefaultClient()); + } + + public DnjsonClient(OkHttpClient okHttpClient) { + if (okHttpClient == null) { + throw new IllegalArgumentException("OkHttpClient must not be null"); + } + this.okHttpClient = okHttpClient; + } + + public OkHttpClient getOkHttpClient() { + return okHttpClient; + } + + public void setOkHttpClient(OkHttpClient okHttpClient) { + this.okHttpClient = okHttpClient; + } + + @Override + public void start(String url, Map headers, String payload, StreamClientListener listener, ChatConfig config) { + if (isStop) { + throw new IllegalStateException("DnjsonClient has been stopped and cannot be reused."); + } + + this.listener = listener; + this.config = config; + this.isStop = false; + + Request.Builder builder = new Request.Builder().url(url); + if (headers != null && !headers.isEmpty()) { + headers.forEach(builder::addHeader); + } + + RequestBody body = RequestBody.create(payload, JSON_TYPE); + Request request = builder.post(body).build(); + + ChatMessageLogger.logRequest(config, payload); + + if (this.listener != null) { + try { + this.listener.onStart(this); + } catch (Exception e) { + log.warn("Error in listener.onStart", e); + return; // 可选:是否继续请求? + } + } + + // 发起异步请求 + okHttpClient.newCall(request).enqueue(this); + } + + @Override + public void stop() { + // 注意:OkHttp 的 Call 无法取消已开始的 onResponse + // 所以 stop() 主要用于标记状态,防止后续回调处理 + markAsStopped(); + } + + + @Override + public void onFailure(@NotNull Call call, @NotNull IOException e) { + try { + if (listener != null && !isStop) { + Throwable error = Util.getFailureThrowable(e, null); + listener.onFailure(this, error); + } + } catch (Exception ex) { + log.warn("Error in listener.onFailure", ex); + } finally { + markAsStopped(); + } + } + + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { + try { + if (!response.isSuccessful()) { + if (listener != null && !isStop) { + Throwable error = Util.getFailureThrowable(null, response); + listener.onFailure(this, error); + } + return; + } + + ResponseBody body = response.body(); + if (body == null || isStop) { + return; + } + + // 使用 try-with-resources 确保流关闭 + try (ResponseBody responseBody = body; + BufferedReader reader = new BufferedReader(new InputStreamReader(responseBody.byteStream()))) { + + String line; + while ((line = reader.readLine()) != null) { + if (isStop) break; // 支持中途 stop() + + if (!StringUtil.hasText(line)) continue; + + String jsonLine = StringUtil.isJsonObject(line) ? line : "{" + line + "}"; + + if (listener != null && !isStop) { + try { + ChatMessageLogger.logResponse(config, jsonLine); + listener.onMessage(this, jsonLine); + } catch (Exception e) { + log.warn("Error in listener.onMessage", e); + } + } + } + } + } finally { + markAsStopped(); + } + } + + + private void markAsStopped() { + if (isStop) return; + synchronized (this) { + if (isStop) return; + isStop = true; + if (listener != null) { + try { + listener.onStop(this); + } catch (Exception e) { + log.warn("Error in listener.onStop", e); + } + } + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/SseClient.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/SseClient.java new file mode 100644 index 0000000..ce5eb0c --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/SseClient.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client.impl; + +import com.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.log.ChatMessageLogger; +import com.easyagents.core.model.client.OkHttpClientUtil; +import com.easyagents.core.model.client.StreamClient; +import com.easyagents.core.model.client.StreamClientListener; +import okhttp3.*; +import okhttp3.sse.EventSource; +import okhttp3.sse.EventSourceListener; +import okhttp3.sse.EventSources; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +public class SseClient extends EventSourceListener implements StreamClient { + + private OkHttpClient okHttpClient; + private EventSource eventSource; + private StreamClientListener listener; + private ChatConfig config; + private boolean isStop = false; + + public SseClient() { + this(OkHttpClientUtil.buildDefaultClient()); + } + + public SseClient(OkHttpClient okHttpClient) { + if (okHttpClient == null) { + throw new IllegalArgumentException("OkHttpClient must not be null"); + } + this.okHttpClient = okHttpClient; + } + + public OkHttpClient getOkHttpClient() { + return okHttpClient; + } + + public void setOkHttpClient(OkHttpClient okHttpClient) { + this.okHttpClient = okHttpClient; + } + + @Override + public void start(String url, Map headers, String payload, StreamClientListener listener, ChatConfig config) { + this.listener = listener; + this.config = config; + this.isStop = false; + + Request.Builder builder = new Request.Builder() + .url(url); + + if (headers != null && !headers.isEmpty()) { + headers.forEach(builder::addHeader); + } + + MediaType mediaType = MediaType.parse("application/json; charset=utf-8"); + RequestBody body = RequestBody.create(payload, mediaType); + Request request = builder.post(body).build(); + + + EventSource.Factory factory = EventSources.createFactory(this.okHttpClient); + this.eventSource = factory.newEventSource(request, this); + + ChatMessageLogger.logRequest(config, payload); + + if (this.listener != null) { + this.listener.onStart(this); + } + + } + + @Override + public void stop() { + tryToStop(); + } + + + @Override + public void onClosed(@NotNull EventSource eventSource) { + tryToStop(); + } + + @Override + public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { + ChatMessageLogger.logResponse(config, data); + this.listener.onMessage(this, data); + } + + @Override + public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { + try { + this.listener.onFailure(this, Util.getFailureThrowable(t, response)); + } finally { + tryToStop(); + } + } + + @Override + public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { + //super.onOpen(eventSource, response); + } + + + private void tryToStop() { + if (!this.isStop) { + try { + this.isStop = true; + this.listener.onStop(this); + } finally { + if (eventSource != null) { + eventSource.cancel(); + eventSource = null; + } + } + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/Util.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/Util.java new file mode 100644 index 0000000..e01a672 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/Util.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client.impl; + +import com.easyagents.core.model.exception.ModelException; +import com.easyagents.core.util.StringUtil; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import java.io.IOException; + +class Util { + + public static Throwable getFailureThrowable(Throwable t, Response response) { + if (t != null) { + return t; + } + + if (response != null) { + String errMessage = "Response code: " + response.code(); + String message = response.message(); + if (StringUtil.hasText(message)) { + errMessage += ", message: " + message; + } + try (ResponseBody body = response.body()) { + if (body != null) { + String string = body.string(); + if (StringUtil.hasText(string)) { + errMessage += ", body: " + string; + } + } + } catch (IOException e) { + // ignore + } + t = new ModelException(errMessage); + } + + return t; + + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/WebSocketClient.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/WebSocketClient.java new file mode 100644 index 0000000..8e18429 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/impl/WebSocketClient.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.client.impl; + +import com.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.log.ChatMessageLogger; +import com.easyagents.core.model.client.OkHttpClientUtil; +import com.easyagents.core.model.client.StreamClient; +import com.easyagents.core.model.client.StreamClientListener; +import okhttp3.*; +import okio.ByteString; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +public class WebSocketClient extends WebSocketListener implements StreamClient { + private static final Logger log = LoggerFactory.getLogger(WebSocketClient.class); + private OkHttpClient okHttpClient; + private WebSocket webSocket; + private StreamClientListener listener; + private ChatConfig config; + private boolean isStop = false; + private String payload; + + + public WebSocketClient() { + this(OkHttpClientUtil.buildDefaultClient()); + } + + public WebSocketClient(OkHttpClient okHttpClient) { + if (okHttpClient == null) { + throw new IllegalArgumentException("OkHttpClient must not be null"); + } + this.okHttpClient = okHttpClient; + } + + public OkHttpClient getOkHttpClient() { + return okHttpClient; + } + + public void setOkHttpClient(OkHttpClient okHttpClient) { + this.okHttpClient = okHttpClient; + } + + @Override + public void start(String url, Map headers, String payload, StreamClientListener listener, ChatConfig config) { + if (isStop) { + throw new IllegalStateException("WebSocketClient has been stopped and cannot be reused."); + } + + this.listener = listener; + this.payload = payload; + this.config = config; + this.isStop = false; + + Request.Builder builder = new Request.Builder().url(url); + if (headers != null && !headers.isEmpty()) { + headers.forEach(builder::addHeader); + } + + Request request = builder.build(); + + // 创建 WebSocket 连接 + this.webSocket = okHttpClient.newWebSocket(request, this); + ChatMessageLogger.logRequest(config, payload); + } + + @Override + public void stop() { + closeWebSocketAndNotify(); + } + + + //webSocket events + @Override + public void onOpen(WebSocket webSocket, Response response) { + webSocket.send(payload); + this.listener.onStart(this); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + ChatMessageLogger.logResponse(config, text); + this.listener.onMessage(this, text); + } + + @Override + public void onMessage(WebSocket webSocket, ByteString bytes) { + this.onMessage(webSocket, bytes.utf8()); + } + + @Override + public void onClosing(WebSocket webSocket, int code, String reason) { + closeWebSocketAndNotify(); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + try { + Throwable failureThrowable = Util.getFailureThrowable(t, response); + this.listener.onFailure(this, failureThrowable); + } finally { + closeWebSocketAndNotify(); + } + } + + @Override + public void onClosed(@NotNull WebSocket webSocket, int code, @NotNull String reason) { + closeWebSocketAndNotify(); + } + + + /** + * 安全关闭 WebSocket 并通知监听器(确保只执行一次) + */ + private void closeWebSocketAndNotify() { + if (isStop) return; + synchronized (this) { + if (isStop) return; + isStop = true; + + // 先通知 onStop + if (this.listener != null) { + try { + this.listener.onStop(this); + } catch (Exception e) { + log.warn(e.getMessage(), e); + } + } + + // 再关闭 WebSocket(幂等:close 多次无害,但避免空指针) + if (this.webSocket != null) { + try { + this.webSocket.close(1000, ""); // 正常关闭 + } catch (Exception e) { + // 忽略关闭异常(连接可能已断) + } finally { + this.webSocket = null; + } + } + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/config/BaseModelConfig.java b/easy-agents-core/src/main/java/com/easyagents/core/model/config/BaseModelConfig.java new file mode 100644 index 0000000..3308b43 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/config/BaseModelConfig.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.config; + +import java.io.Serializable; +import java.util.*; + +public class BaseModelConfig implements Serializable { + private static final long serialVersionUID = 1L; + + protected String provider; + protected String endpoint; + protected String requestPath; + protected String model; + protected String apiKey; + + protected Map customProperties; + + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + if (endpoint != null && endpoint.endsWith("/")) { + endpoint = endpoint.substring(0, endpoint.length() - 1); + } + this.endpoint = endpoint; + } + + public String getRequestPath() { + return requestPath; + } + + public void setRequestPath(String requestPath) { + if (requestPath != null && !requestPath.startsWith("/")) { + requestPath = "/" + requestPath; + } + this.requestPath = requestPath; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + if (model != null) model = model.trim(); + this.model = model; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + // ---------- Custom Properties ---------- + + public Map getCustomProperties() { + return customProperties == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(customProperties); + } + + public void setCustomProperties(Map customProperties) { + this.customProperties = customProperties == null + ? null + : new HashMap<>(customProperties); + } + + public void putCustomProperty(String key, Object value) { + if (customProperties == null) { + customProperties = new HashMap<>(); + } + customProperties.put(key, value); + } + + @SuppressWarnings("unchecked") + public T getCustomProperty(String key, Class type) { + Object value = customProperties == null ? null : customProperties.get(key); + if (value == null) return null; + if (type.isInstance(value)) { + return type.cast(value); + } + throw new ClassCastException("Property '" + key + "' is not of type " + type.getSimpleName()); + } + + public String getCustomPropertyString(String key) { + return getCustomProperty(key, String.class); + } + + // ---------- Utility: Full URL ---------- + + public String getFullUrl() { + return (endpoint != null ? endpoint : "") + + (requestPath != null ? requestPath : ""); + } + + // ---------- Object Methods ---------- + + @Override + public String toString() { + return "BaseModelConfig{" + + "provider='" + provider + '\'' + + ", endpoint='" + endpoint + '\'' + + ", requestPath='" + requestPath + '\'' + + ", model='" + model + '\'' + + ", apiKey='[REDACTED]'" + + ", customProperties=" + (customProperties == null ? "null" : customProperties.toString()) + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BaseModelConfig that = (BaseModelConfig) o; + return Objects.equals(provider, that.provider) && + Objects.equals(endpoint, that.endpoint) && + Objects.equals(requestPath, that.requestPath) && + Objects.equals(model, that.model) && + Objects.equals(apiKey, that.apiKey) && + Objects.equals(customProperties, that.customProperties); + } + + @Override + public int hashCode() { + return Objects.hash(provider, endpoint, requestPath, model, apiKey, customProperties); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/embedding/BaseEmbeddingModel.java b/easy-agents-core/src/main/java/com/easyagents/core/model/embedding/BaseEmbeddingModel.java new file mode 100644 index 0000000..ce5da0b --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/embedding/BaseEmbeddingModel.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.embedding; + +import com.easyagents.core.model.config.BaseModelConfig; + +public abstract class BaseEmbeddingModel implements EmbeddingModel { + + protected T config; + + public BaseEmbeddingModel(T config) { + this.config = config; + } + + public T getConfig() { + return config; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/embedding/EmbeddingModel.java b/easy-agents-core/src/main/java/com/easyagents/core/model/embedding/EmbeddingModel.java new file mode 100644 index 0000000..fb6463a --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/embedding/EmbeddingModel.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.embedding; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.exception.ModelException; +import com.easyagents.core.store.VectorData; + +public interface EmbeddingModel { + + default VectorData embed(String text) { + return embed(Document.of(text), EmbeddingOptions.DEFAULT); + } + + default VectorData embed(Document document) { + return embed(document, EmbeddingOptions.DEFAULT); + } + + VectorData embed(Document document, EmbeddingOptions options); + + default int dimensions() { + VectorData vectorData = embed(Document.of("easy-agents")); + if (vectorData == null) { + throw new ModelException("Embedding model does not contain vector data, maybe config is not correct."); + } + float[] vector = vectorData.getVector(); + if (vector == null || vector.length == 0) { + throw new ModelException("Embedding model does not contain vector data, maybe config is not correct."); + } + return vector.length; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/embedding/EmbeddingOptions.java b/easy-agents-core/src/main/java/com/easyagents/core/model/embedding/EmbeddingOptions.java new file mode 100644 index 0000000..287d6ce --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/embedding/EmbeddingOptions.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.embedding; + +import com.easyagents.core.util.StringUtil; + +public class EmbeddingOptions { + public static final EmbeddingOptions DEFAULT = new EmbeddingOptions() { + @Override + public void setModel(String model) { + throw new IllegalStateException("Can not set modal to the default instance."); + } + + @Override + public void setEncodingFormat(String encodingFormat) { + throw new IllegalStateException("Can not set modal to the default instance."); + } + }; + + /** + * 嵌入模型 + */ + private String model; + /** + * 嵌入编码格式,可用通常为float, base64 + */ + private String encodingFormat; + + private Integer dimensions; + + private String user; + + + public String getModel() { + return model; + } + + public String getModelOrDefault(String defaultModel) { + return StringUtil.noText(model) ? defaultModel : model; + } + + public void setModel(String model) { + this.model = model; + } + + + public String getEncodingFormat() { + return encodingFormat; + } + + public String getEncodingFormatOrDefault(String defaultEncodingFormat) { + return StringUtil.noText(encodingFormat) ? defaultEncodingFormat : encodingFormat; + } + + public void setEncodingFormat(String encodingFormat) { + this.encodingFormat = encodingFormat; + } + + public Integer getDimensions() { + return dimensions; + } + + public void setDimensions(Integer dimensions) { + this.dimensions = dimensions; + } + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + @Override + public String toString() { + return "EmbeddingOptions{" + + "model='" + model + '\'' + + ", encodingFormat='" + encodingFormat + '\'' + + ", dimensions=" + dimensions + + ", user='" + user + '\'' + + '}'; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/exception/ModelException.java b/easy-agents-core/src/main/java/com/easyagents/core/model/exception/ModelException.java new file mode 100644 index 0000000..7691f71 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/exception/ModelException.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.exception; + +public class ModelException extends RuntimeException { + + /** + * Constructs a new runtime exception with {@code null} as its + * detail message. The cause is not initialized, and may subsequently be + * initialized by a call to {@link #initCause}. + */ + public ModelException() { + } + + /** + * Constructs a new runtime exception with the specified detail message. + * The cause is not initialized, and may subsequently be initialized by a + * call to {@link #initCause}. + * + * @param message the detail message. The detail message is saved for + * later retrieval by the {@link #getMessage()} method. + */ + public ModelException(String message) { + super(message); + } + + /** + * Constructs a new runtime exception with the specified detail message and + * cause.

Note that the detail message associated with + * {@code cause} is not automatically incorporated in + * this runtime exception's detail message. + * + * @param message the detail message (which is saved for later retrieval + * by the {@link #getMessage()} method). + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public ModelException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new runtime exception with the specified cause and a + * detail message of (cause==null ? null : cause.toString()) + * (which typically contains the class and detail message of + * cause). This constructor is useful for runtime exceptions + * that are little more than wrappers for other throwables. + * + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public ModelException(Throwable cause) { + super(cause); + } + + /** + * Constructs a new runtime exception with the specified detail + * message, cause, suppression enabled or disabled, and writable + * stack trace enabled or disabled. + * + * @param message the detail message. + * @param cause the cause. (A {@code null} value is permitted, + * and indicates that the cause is nonexistent or unknown.) + * @param enableSuppression whether or not suppression is enabled + * or disabled + * @param writableStackTrace whether or not the stack trace should + * be writable + * @since 1.7 + */ + public ModelException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/image/BaseImageModel.java b/easy-agents-core/src/main/java/com/easyagents/core/model/image/BaseImageModel.java new file mode 100644 index 0000000..1ccb2cb --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/image/BaseImageModel.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.image; + +import com.easyagents.core.model.config.BaseModelConfig; + +public abstract class BaseImageModel implements ImageModel { + + protected T config; + + public BaseImageModel(T config) { + this.config = config; + } + + public T getConfig() { + return config; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/image/BaseImageRequest.java b/easy-agents-core/src/main/java/com/easyagents/core/model/image/BaseImageRequest.java new file mode 100644 index 0000000..547166e --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/image/BaseImageRequest.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.image; + +import java.util.HashMap; +import java.util.Map; + +public class BaseImageRequest { + private String model; + private Integer n; + private String responseFormat; + private String user; + private Integer width; + private Integer height; + private Map options; + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public Integer getN() { + return n; + } + + public void setN(Integer n) { + this.n = n; + } + + public String getResponseFormat() { + return responseFormat; + } + + public void setResponseFormat(String responseFormat) { + this.responseFormat = responseFormat; + } + + public Integer getWidth() { + return width; + } + + public void setWidth(Integer width) { + this.width = width; + } + + public Integer getHeight() { + return height; + } + + public void setHeight(Integer height) { + this.height = height; + } + + public void setSize(Integer width, Integer height) { + this.width = width; + this.height = height; + } + + public String getSize() { + if (this.width == null || this.height == null) { + return null; + } + return this.width + "x" + this.height; + } + + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public Map getOptions() { + return options; + } + + public void setOptions(Map options) { + this.options = options; + } + + public void addOption(String key, Object value) { + if (this.options == null) { + this.options = new HashMap<>(); + } + this.options.put(key, value); + } + + public Object getOption(String key) { + return this.options == null ? null : this.options.get(key); + } + + public Object getOptionOrDefault(String key, Object defaultValue) { + return this.options == null ? defaultValue : this.options.getOrDefault(key, defaultValue); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/image/EditImageRequest.java b/easy-agents-core/src/main/java/com/easyagents/core/model/image/EditImageRequest.java new file mode 100644 index 0000000..6120ac2 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/image/EditImageRequest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.image; + +public class EditImageRequest extends GenerateImageRequest { + private Image image; + private Image mask; + + public Image getImage() { + return image; + } + + public void setImage(Image image) { + this.image = image; + } + + public Image getMask() { + return mask; + } + + public void setMask(Image mask) { + this.mask = mask; + } +} + diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/image/GenerateImageRequest.java b/easy-agents-core/src/main/java/com/easyagents/core/model/image/GenerateImageRequest.java new file mode 100644 index 0000000..f48bf1b --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/image/GenerateImageRequest.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.image; + +public class GenerateImageRequest extends BaseImageRequest { + private String prompt; + private String negativePrompt; + private String quality; + private String style; + + public String getPrompt() { + return prompt; + } + + public void setPrompt(String prompt) { + this.prompt = prompt; + } + + public String getNegativePrompt() { + return negativePrompt; + } + + public void setNegativePrompt(String negativePrompt) { + this.negativePrompt = negativePrompt; + } + + public String getQuality() { + return quality; + } + + public void setQuality(String quality) { + this.quality = quality; + } + + public String getStyle() { + return style; + } + + public void setStyle(String style) { + this.style = style; + } +} + diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/image/Image.java b/easy-agents-core/src/main/java/com/easyagents/core/model/image/Image.java new file mode 100644 index 0000000..b643991 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/image/Image.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.image; + +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.util.IOUtil; +import com.easyagents.core.util.StringUtil; + +import java.io.File; +import java.util.Arrays; +import java.util.Base64; + +public class Image { + + /** + * The base64-encoded JSON of the generated image + */ + private String b64Json; + + /** + * The URL of the generated image + */ + private String url; + + /** + * The data of image + */ + private byte[] bytes; + + public static Image ofUrl(String url) { + Image image = new Image(); + image.setUrl(url); + return image; + } + + public static Image ofBytes(byte[] bytes) { + Image image = new Image(); + image.setBytes(bytes); + return image; + } + + public String getB64Json() { + return b64Json; + } + + public void setB64Json(String b64Json) { + this.b64Json = b64Json; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public byte[] getBytes() { + return bytes; + } + + public void setBytes(byte[] bytes) { + this.bytes = bytes; + } + + public byte[] readBytes() { + return bytes; + } + + public void writeToFile(File file) { + if (!file.getParentFile().exists() && !file.getParentFile().mkdirs()) { + throw new IllegalStateException("Can not mkdirs for path: " + file.getParentFile().getAbsolutePath()); + } + if (this.bytes != null && this.bytes.length > 0) { + IOUtil.writeBytes(this.bytes, file); + } else if (this.b64Json != null) { + byte[] bytes = Base64.getDecoder().decode(b64Json); + IOUtil.writeBytes(bytes, file); + } else if (StringUtil.hasText(this.url)) { + byte[] bytes = new HttpClient().getBytes(this.url); + IOUtil.writeBytes(bytes, file); + } + } + + @Override + public String toString() { + return "Image{" + + "b64Json='" + b64Json + '\'' + + ", url='" + url + '\'' + + ", bytes=" + Arrays.toString(bytes) + + '}'; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/image/ImageModel.java b/easy-agents-core/src/main/java/com/easyagents/core/model/image/ImageModel.java new file mode 100644 index 0000000..b2374b4 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/image/ImageModel.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.image; + +public interface ImageModel { + + ImageResponse generate(GenerateImageRequest request); + + ImageResponse img2imggenerate(GenerateImageRequest request); + + ImageResponse edit(EditImageRequest request); + + ImageResponse vary(VaryImageRequest request); + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/image/ImageResponse.java b/easy-agents-core/src/main/java/com/easyagents/core/model/image/ImageResponse.java new file mode 100644 index 0000000..eb19c46 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/image/ImageResponse.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.image; + +import com.easyagents.core.util.Metadata; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ImageResponse extends Metadata { + private List images; + private boolean error; + private String errorMessage; + + + public static ImageResponse error(String errMessage) { + ImageResponse imageResponse = new ImageResponse(); + imageResponse.setError(true); + imageResponse.setErrorMessage(errMessage); + return imageResponse; + } + + public List getImages() { + return images != null ? images : Collections.emptyList(); + } + + public void setImages(List images) { + this.images = images; + } + + + public void addImage(String url) { + if (this.images == null) { + this.images = new ArrayList<>(); + } + + this.images.add(Image.ofUrl(url)); + } + + public void addImage(byte[] bytes) { + if (this.images == null) { + this.images = new ArrayList<>(); + } + + this.images.add(Image.ofBytes(bytes)); + } + + public boolean isError() { + return error; + } + + public void setError(boolean error) { + this.error = error; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + public String toString() { + return "ImageResponse{" + + "images=" + images + + ", error=" + error + + ", errorMessage='" + errorMessage + '\'' + + ", metadataMap=" + metadataMap + + '}'; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/image/VaryImageRequest.java b/easy-agents-core/src/main/java/com/easyagents/core/model/image/VaryImageRequest.java new file mode 100644 index 0000000..ca4028f --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/image/VaryImageRequest.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.image; + +public class VaryImageRequest extends BaseImageRequest { + private Image image; + + public Image getImage() { + return image; + } + + public void setImage(Image image) { + this.image = image; + } +} + diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/BaseRerankModel.java b/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/BaseRerankModel.java new file mode 100644 index 0000000..5e149c1 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/BaseRerankModel.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.rerank; + +import com.easyagents.core.model.config.BaseModelConfig; + +public abstract class BaseRerankModel implements RerankModel { + + private T config; + + public BaseRerankModel(T config) { + this.config = config; + } + + public T getConfig() { + return config; + } + + public void setConfig(T config) { + this.config = config; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankConfig.java b/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankConfig.java new file mode 100644 index 0000000..378bac4 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankConfig.java @@ -0,0 +1,65 @@ +///* +// * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). +// *

+// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// *

+// * http://www.apache.org/licenses/LICENSE-2.0 +// *

+// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// */ +//package com.easyagents.core.model.rerank; +// +//public class RerankConfig { +// +// private String model; +// private String endpoint; +// private String basePath; +// private String apiKey; +// private boolean debug; +// +// public String getModel() { +// return model; +// } +// +// public void setModel(String model) { +// this.model = model; +// } +// +// public String getEndpoint() { +// return endpoint; +// } +// +// public void setEndpoint(String endpoint) { +// this.endpoint = endpoint; +// } +// +// public String getBasePath() { +// return basePath; +// } +// +// public void setBasePath(String basePath) { +// this.basePath = basePath; +// } +// +// public String getApiKey() { +// return apiKey; +// } +// +// public void setApiKey(String apiKey) { +// this.apiKey = apiKey; +// } +// +// public boolean isDebug() { +// return debug; +// } +// +// public void setDebug(boolean debug) { +// this.debug = debug; +// } +//} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankException.java b/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankException.java new file mode 100644 index 0000000..0f89dea --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankException.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.rerank; + +public class RerankException extends RuntimeException { + + /** + * Constructs a new runtime exception with {@code null} as its + * detail message. The cause is not initialized, and may subsequently be + * initialized by a call to {@link #initCause}. + */ + public RerankException() { + } + + /** + * Constructs a new runtime exception with the specified detail message. + * The cause is not initialized, and may subsequently be initialized by a + * call to {@link #initCause}. + * + * @param message the detail message. The detail message is saved for + * later retrieval by the {@link #getMessage()} method. + */ + public RerankException(String message) { + super(message); + } + + /** + * Constructs a new runtime exception with the specified detail message and + * cause.

Note that the detail message associated with + * {@code cause} is not automatically incorporated in + * this runtime exception's detail message. + * + * @param message the detail message (which is saved for later retrieval + * by the {@link #getMessage()} method). + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public RerankException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new runtime exception with the specified cause and a + * detail message of (cause==null ? null : cause.toString()) + * (which typically contains the class and detail message of + * cause). This constructor is useful for runtime exceptions + * that are little more than wrappers for other throwables. + * + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public RerankException(Throwable cause) { + super(cause); + } + + /** + * Constructs a new runtime exception with the specified detail + * message, cause, suppression enabled or disabled, and writable + * stack trace enabled or disabled. + * + * @param message the detail message. + * @param cause the cause. (A {@code null} value is permitted, + * and indicates that the cause is nonexistent or unknown.) + * @param enableSuppression whether or not suppression is enabled + * or disabled + * @param writableStackTrace whether or not the stack trace should + * be writable + * @since 1.7 + */ + public RerankException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankModel.java b/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankModel.java new file mode 100644 index 0000000..f9b9ddb --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankModel.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.rerank; + +import com.easyagents.core.document.Document; + +import java.util.List; + +public interface RerankModel { + + + default List rerank(String query, List documents) { + return rerank(query, documents, RerankOptions.DEFAULT); + } + + + List rerank(String query, List documents, RerankOptions options); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankOptions.java b/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankOptions.java new file mode 100644 index 0000000..0e340ad --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/rerank/RerankOptions.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.rerank; + +import com.easyagents.core.util.StringUtil; + +public class RerankOptions { + public static final RerankOptions DEFAULT = new RerankOptions() { + @Override + public void setModel(String model) { + throw new IllegalStateException("Can not set modal to the default instance."); + } + }; + + /** + * 重排模型模型 + */ + private String model; + + + + public String getModel() { + return model; + } + + public String getModelOrDefault(String defaultModel) { + return StringUtil.noText(model) ? defaultModel : model; + } + + public void setModel(String model) { + this.model = model; + } + + + @Override + public String toString() { + return "RerankOptions{" + + "model='" + model + '\'' + + '}'; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/observability/Observability.java b/easy-agents-core/src/main/java/com/easyagents/core/observability/Observability.java new file mode 100644 index 0000000..0487b32 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/observability/Observability.java @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.observability; + +import com.easyagents.core.Consts; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.exporter.logging.LoggingMetricExporter; +import io.opentelemetry.exporter.logging.LoggingSpanExporter; +import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SpanProcessor; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +/** + * OpenTelemetry 可观测性统一入口。 + * 支持 Tracing(链路追踪)和 Metrics(指标)。 + */ +public final class Observability { + private static final Logger logger = LoggerFactory.getLogger(Observability.class); + + + // === Tracing 相关 === + private static volatile Tracer globalTracer; + private static volatile SdkTracerProvider tracerProvider; + + // === Metrics 相关 === + private static volatile Meter globalMeter; + private static volatile SdkMeterProvider meterProvider; + + // === 共享状态 === + private static volatile boolean initialized = false; + private static volatile boolean shutdownHookRegistered = false; + private static volatile Throwable initError = null; + + private static volatile Boolean observabilityEnabled; + private static volatile java.util.Set excludedTools; + + + // === 自定义 Exporter 支持 === + private static volatile SpanExporter customSpanExporter; + private static volatile MetricExporter customMetricExporter; + + + private Observability() { + } + + /** + * 注入自定义 Exporter + */ + public static void setCustomExporters(SpanExporter spanExporter, MetricExporter metricExporter) { + customSpanExporter = spanExporter; + customMetricExporter = metricExporter; + } + + private static void init() { + if (initialized) return; + synchronized (Observability.class) { + if (initialized) return; + if (initError != null) { + throw new IllegalStateException("OpenTelemetry already failed to initialize", initError); + } + + try { + OpenTelemetry openTelemetry = GlobalOpenTelemetry.get(); + String propagatorsClassName = openTelemetry.getPropagators().getTextMapPropagator().getClass().getSimpleName(); + + // 检查是否已经被其他组件(如 SpringBoot)注册了 OpenTelemetry SDK + if (!"NoopTextMapPropagator".equals(propagatorsClassName)) { + logger.info("OpenTelemetry SDK already registered globally. Reusing existing instance."); + globalTracer = GlobalOpenTelemetry.getTracer("easy-agents"); + globalMeter = GlobalOpenTelemetry.getMeter("easy-agents"); + initialized = true; + // 注意:此处不需要注册 shutdown hook,由 GlobalOpenTelemetry 初始化的组件去关闭,比如 Spring 会管理生命周期 + return; + } else { + GlobalOpenTelemetry.resetForTest(); + } + + + Resource resource = Resource.getDefault() + .merge(Resource.create(Attributes.of( + AttributeKey.stringKey("service.name"), "easy-agents", + AttributeKey.stringKey("service.version"), Consts.VERSION + ))); + + + // 1. 创建 Span 相关组件 + SpanExporter spanExporter = customSpanExporter != null ? customSpanExporter : createSpanExporter(); + SpanProcessor spanProcessor = createSpanProcessor(spanExporter); + tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(spanProcessor) + .setResource(resource) + .build(); + + // 2. 创建 Metric 相关组件 + MetricExporter metricExporter = customMetricExporter != null ? customMetricExporter : createMetricExporter(); + meterProvider = SdkMeterProvider.builder() + .registerMetricReader(PeriodicMetricReader.builder(metricExporter) + .setInterval(Duration.ofSeconds(getMetricExportIntervalSeconds())) // 默认每60秒导出一次 + .build()) + .setResource(resource) + .build(); + + // 3. 构建并注册全局 OpenTelemetry 实例(同时包含 Trace 和 Metrics) + OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setMeterProvider(meterProvider) + .buildAndRegisterGlobal(); + + // 4. 获取全局 Tracer 和 Meter + globalTracer = GlobalOpenTelemetry.getTracer("easy-agents"); + globalMeter = GlobalOpenTelemetry.getMeter("easy-agents"); + + initialized = true; + + // 5. 注册 Shutdown Hook(统一关闭) + if (!shutdownHookRegistered) { + Runtime.getRuntime().addShutdownHook(new Thread(Observability::shutdown)); + shutdownHookRegistered = true; + } + } catch (Throwable e) { + initError = e; + throw new IllegalStateException("Failed to initialize OpenTelemetry", e); + } + } + } + + + /** + * 全局可观测性开关。默认开启。 + * 可通过系统属性 {@code easyagents.otel.enabled} 控制(true/false)。 + */ + public static boolean isEnabled() { + if (observabilityEnabled != null) { + return observabilityEnabled; + } + synchronized (Observability.class) { + if (observabilityEnabled != null) { + return observabilityEnabled; + } + // 默认 true,与现有行为一致 + String prop = System.getProperty("easyagents.otel.enabled", "true"); + observabilityEnabled = Boolean.parseBoolean(prop); + return observabilityEnabled; + } + } + + /** + * 判断指定工具是否被排除在可观测性之外。 + * 可通过系统属性 {@code easyagents.otel.tool.excluded} 配置(逗号分隔,如 "heartbeat,debug")。 + */ + public static boolean isToolExcluded(String toolName) { + if (toolName == null || toolName.isEmpty()) { + return false; + } + + java.util.Set excluded = excludedTools; + if (excluded != null) { + return excluded.contains(toolName); + } + + synchronized (Observability.class) { + if (excludedTools != null) { + return excludedTools.contains(toolName); + } + + String prop = System.getProperty("easyagents.otel.tool.excluded", ""); + java.util.Set set = new java.util.HashSet<>(); + if (!prop.trim().isEmpty()) { + for (String name : prop.split(",")) { + name = name.trim(); + if (!name.isEmpty()) { + set.add(name); + } + } + } + excludedTools = java.util.Collections.unmodifiableSet(set); + return excludedTools.contains(toolName); + } + } + + + private static long getMetricExportIntervalSeconds() { + String prop = System.getProperty("easyagents.otel.metric.export.interval", "60"); + try { + long interval = Long.parseLong(prop); + if (interval > 0) { + return interval; + } + } catch (NumberFormatException ignored) { + // fall through + } + return 60; // 默认 60 秒 + } + + private static String getExporterType() { + return System.getProperty("easyagents.otel.exporter.type", "logging").toLowerCase(); + } + + private static SpanExporter createSpanExporter() { + String exporterType = getExporterType(); + switch (exporterType) { + case "otlp": + return OtlpGrpcSpanExporter.getDefault(); + case "logging": + return LoggingSpanExporter.create(); + default: + return createSpanExporterByClassName(exporterType); + } + } + + private static SpanExporter createSpanExporterByClassName(String className) { + try { + Class clazz = Class.forName(className); + return (SpanExporter) clazz.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + logger.warn("Failed to create MetricExporter by className: " + className + ", use LoggingSpanExporter to replaced", e); + return LoggingSpanExporter.create(); + } + } + + private static SpanProcessor createSpanProcessor(SpanExporter exporter) { + String exporterType = getExporterType(); + if ("otlp".equals(exporterType)) { + return BatchSpanProcessor.builder(exporter) + .setScheduleDelay(Duration.ofSeconds(2)) + .setMaxQueueSize(4096) + .setMaxExportBatchSize(512) + .setExporterTimeout(Duration.ofSeconds(10)) + .build(); + } else { + return SimpleSpanProcessor.create(exporter); + } + } + + private static MetricExporter createMetricExporter() { + String exporterType = getExporterType(); + switch (exporterType) { + case "otlp": + return OtlpGrpcMetricExporter.getDefault(); + case "logging": + return LoggingMetricExporter.create(); + default: + return createMetricExporterByClassName(exporterType); + } + } + + public static MetricExporter createMetricExporterByClassName(String className) { + try { + Class clazz = Class.forName(className); + return (MetricExporter) clazz.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + logger.warn("Failed to create MetricExporter by className: " + className + ", use LoggingMetricExporter to replaced", e); + return LoggingMetricExporter.create(); + } + } + + + private static void shutdown() { + // 先关闭 TracerProvider + if (tracerProvider != null) { + tracerProvider.shutdown().join(10, TimeUnit.SECONDS); + } + // 再关闭 MeterProvider + if (meterProvider != null) { + meterProvider.shutdown().join(10, TimeUnit.SECONDS); + } + } + + + /** + * 获取全局 Tracer 实例(用于链路追踪)。 + * 首次调用时自动初始化 OpenTelemetry。 + */ + public static Tracer getTracer() { + Tracer tracer = globalTracer; + if (tracer != null) { + return tracer; + } + synchronized (Observability.class) { + if (globalTracer != null) { + return globalTracer; + } + if (initError != null) { + throw new IllegalStateException("OpenTelemetry initialization failed", initError); + } + init(); + return globalTracer; + } + } + + /** + * 获取全局 Meter 实例(用于指标收集)。 + * 首次调用时自动初始化 OpenTelemetry。 + */ + public static Meter getMeter() { + Meter meter = globalMeter; + if (meter != null) { + return meter; + } + synchronized (Observability.class) { + if (globalMeter != null) { + return globalMeter; + } + if (initError != null) { + throw new IllegalStateException("OpenTelemetry initialization failed", initError); + } + init(); + return globalMeter; + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/parser/AiMessageParser.java b/easy-agents-core/src/main/java/com/easyagents/core/parser/AiMessageParser.java new file mode 100644 index 0000000..e78a956 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/parser/AiMessageParser.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.parser; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.model.chat.ChatContext; + +public interface AiMessageParser { + AiMessage parse(T jsonObject, ChatContext context); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/parser/JSONArrayParser.java b/easy-agents-core/src/main/java/com/easyagents/core/parser/JSONArrayParser.java new file mode 100644 index 0000000..3d22556 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/parser/JSONArrayParser.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.parser; + +import com.alibaba.fastjson2.JSONArray; + +public interface JSONArrayParser extends Parser { + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/parser/JSONObjectParser.java b/easy-agents-core/src/main/java/com/easyagents/core/parser/JSONObjectParser.java new file mode 100644 index 0000000..c2285e7 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/parser/JSONObjectParser.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.parser; + +import com.alibaba.fastjson2.JSONObject; + +public interface JSONObjectParser extends Parser { + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/parser/Parser.java b/easy-agents-core/src/main/java/com/easyagents/core/parser/Parser.java new file mode 100644 index 0000000..535c7da --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/parser/Parser.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.parser; + +/** + * 解析器,用于解析输入的内容,并按输出的格式进行输出 + * + * @param 输入内容 + * @param 输出内容 + */ +public interface Parser { + + /** + * 解析输入的内容 + * + * @param content 输入的内容 + * @return 输出的内容 + */ + O parse(I content); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/parser/impl/DefaultAiMessageParser.java b/easy-agents-core/src/main/java/com/easyagents/core/parser/impl/DefaultAiMessageParser.java new file mode 100644 index 0000000..fd31027 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/parser/impl/DefaultAiMessageParser.java @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.parser.impl; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.ToolCall; +import com.easyagents.core.model.chat.ChatContext; +import com.easyagents.core.parser.AiMessageParser; +import com.easyagents.core.parser.JSONArrayParser; +import com.easyagents.core.util.JSONUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.alibaba.fastjson2.JSONPath; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + + +public class DefaultAiMessageParser implements AiMessageParser { + + private JSONPath contentPath; + private JSONPath deltaContentPath; + private JSONPath reasoningContentPath; + private JSONPath deltaReasoningContentPath; + private JSONPath indexPath; + private JSONPath totalTokensPath; + private JSONPath promptTokensPath; + private JSONPath completionTokensPath; + private JSONPath finishReasonPath; + private JSONPath stopReasonPath; + + private JSONPath toolCallsJsonPath; + private JSONPath deltaToolCallsJsonPath; + + private JSONArrayParser> callsParser; + + public JSONPath getContentPath() { + return contentPath; + } + + public void setContentPath(JSONPath contentPath) { + this.contentPath = contentPath; + } + + public JSONPath getDeltaContentPath() { + return deltaContentPath; + } + + public void setDeltaContentPath(JSONPath deltaContentPath) { + this.deltaContentPath = deltaContentPath; + } + + public JSONPath getReasoningContentPath() { + return reasoningContentPath; + } + + public void setReasoningContentPath(JSONPath reasoningContentPath) { + this.reasoningContentPath = reasoningContentPath; + } + + public JSONPath getDeltaReasoningContentPath() { + return deltaReasoningContentPath; + } + + public void setDeltaReasoningContentPath(JSONPath deltaReasoningContentPath) { + this.deltaReasoningContentPath = deltaReasoningContentPath; + } + + public JSONPath getIndexPath() { + return indexPath; + } + + public void setIndexPath(JSONPath indexPath) { + this.indexPath = indexPath; + } + + public JSONPath getTotalTokensPath() { + return totalTokensPath; + } + + public void setTotalTokensPath(JSONPath totalTokensPath) { + this.totalTokensPath = totalTokensPath; + } + + public JSONPath getPromptTokensPath() { + return promptTokensPath; + } + + public void setPromptTokensPath(JSONPath promptTokensPath) { + this.promptTokensPath = promptTokensPath; + } + + public JSONPath getCompletionTokensPath() { + return completionTokensPath; + } + + public void setCompletionTokensPath(JSONPath completionTokensPath) { + this.completionTokensPath = completionTokensPath; + } + + public JSONPath getFinishReasonPath() { + return finishReasonPath; + } + + public void setFinishReasonPath(JSONPath finishReasonPath) { + this.finishReasonPath = finishReasonPath; + } + + public JSONPath getStopReasonPath() { + return stopReasonPath; + } + + public void setStopReasonPath(JSONPath stopReasonPath) { + this.stopReasonPath = stopReasonPath; + } + + public JSONPath getToolCallsJsonPath() { + return toolCallsJsonPath; + } + + public void setToolCallsJsonPath(JSONPath toolCallsJsonPath) { + this.toolCallsJsonPath = toolCallsJsonPath; + } + + public JSONPath getDeltaToolCallsJsonPath() { + return deltaToolCallsJsonPath; + } + + public void setDeltaToolCallsJsonPath(JSONPath deltaToolCallsJsonPath) { + this.deltaToolCallsJsonPath = deltaToolCallsJsonPath; + } + + public JSONArrayParser> getCallsParser() { + return callsParser; + } + + public void setCallsParser(JSONArrayParser> callsParser) { + this.callsParser = callsParser; + } + + @Override + public AiMessage parse(JSONObject rootJson, ChatContext context) { + AiMessage aiMessage = new AiMessage(); + + JSONArray toolCallsJsonArray = null; + if (context.getOptions().isStreaming()) { + if (this.deltaContentPath != null) { + aiMessage.setContent((String) this.deltaContentPath.eval(rootJson)); + } + if (this.deltaReasoningContentPath != null) { + aiMessage.setReasoningContent((String) this.deltaReasoningContentPath.eval(rootJson)); + } + if (this.deltaToolCallsJsonPath != null) { + toolCallsJsonArray = (JSONArray) this.deltaToolCallsJsonPath.eval(rootJson); + } + } else { + if (this.contentPath != null) { + aiMessage.setContent((String) this.contentPath.eval(rootJson)); + } + + if (this.reasoningContentPath != null) { + aiMessage.setReasoningContent((String) this.reasoningContentPath.eval(rootJson)); + } + if (this.toolCallsJsonPath != null) { + toolCallsJsonArray = (JSONArray) this.toolCallsJsonPath.eval(rootJson); + } + } + + + if (this.indexPath != null) { + aiMessage.setIndex((Integer) this.indexPath.eval(rootJson)); + } + + if (this.promptTokensPath != null) { + aiMessage.setPromptTokens((Integer) this.promptTokensPath.eval(rootJson)); + } + + if (this.completionTokensPath != null) { + aiMessage.setCompletionTokens((Integer) this.completionTokensPath.eval(rootJson)); + } + + if (this.finishReasonPath != null) { + aiMessage.setFinishReason((String) this.finishReasonPath.eval(rootJson)); + } + + if (this.stopReasonPath != null) { + aiMessage.setStopReason((String) this.stopReasonPath.eval(rootJson)); + } + + if (this.totalTokensPath != null) { + aiMessage.setTotalTokens((Integer) this.totalTokensPath.eval(rootJson)); + } + //some LLMs like Ollama not response the total tokens + else if (aiMessage.getPromptTokens() != null && aiMessage.getCompletionTokens() != null) { + aiMessage.setTotalTokens(aiMessage.getPromptTokens() + aiMessage.getCompletionTokens()); + } + + if (toolCallsJsonArray != null && this.callsParser != null) { + aiMessage.setToolCalls(this.callsParser.parse(toolCallsJsonArray)); + } + + return aiMessage; + } + + + public static DefaultAiMessageParser getOpenAIMessageParser() { + DefaultAiMessageParser aiMessageParser = new DefaultAiMessageParser(); + aiMessageParser.setContentPath(JSONUtil.getJsonPath("$.choices[0].message.content")); + aiMessageParser.setDeltaContentPath(JSONUtil.getJsonPath("$.choices[0].delta.content")); + + aiMessageParser.setReasoningContentPath(JSONUtil.getJsonPath("$.choices[0].message.reasoning_content")); + aiMessageParser.setDeltaReasoningContentPath(JSONUtil.getJsonPath("$.choices[0].delta.reasoning_content")); + + aiMessageParser.setIndexPath(JSONUtil.getJsonPath("$.choices[0].index")); + aiMessageParser.setTotalTokensPath(JSONUtil.getJsonPath("$.usage.total_tokens")); + aiMessageParser.setPromptTokensPath(JSONUtil.getJsonPath("$.usage.prompt_tokens")); + aiMessageParser.setCompletionTokensPath(JSONUtil.getJsonPath("$.usage.completion_tokens")); + aiMessageParser.setFinishReasonPath(JSONUtil.getJsonPath("$.choices[0].finish_reason")); + aiMessageParser.setStopReasonPath(JSONUtil.getJsonPath("$.choices[0].stop_reason")); + + aiMessageParser.setToolCallsJsonPath(JSONUtil.getJsonPath("$.choices[0].message.tool_calls")); + aiMessageParser.setDeltaToolCallsJsonPath(JSONUtil.getJsonPath("$.choices[0].delta.tool_calls")); + + aiMessageParser.setCallsParser(toolCalls -> { + if (toolCalls == null || toolCalls.isEmpty()) { + return Collections.emptyList(); + } + List toolInfos = new ArrayList<>(); + for (int i = 0; i < toolCalls.size(); i++) { + JSONObject jsonObject = toolCalls.getJSONObject(i); + JSONObject functionObject = jsonObject.getJSONObject("function"); + if (functionObject != null) { + ToolCall toolCall = new ToolCall(); + toolCall.setId(jsonObject.getString("id")); + toolCall.setName(functionObject.getString("name")); + Object arguments = functionObject.get("arguments"); + if (arguments instanceof Map) { + toolCall.setArguments(JSON.toJSONString(arguments)); + } else if (arguments instanceof String) { + toolCall.setArguments((String) arguments); + } + toolInfos.add(toolCall); + } + } + return toolInfos; + }); + + return aiMessageParser; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/prompt/MemoryPrompt.java b/easy-agents-core/src/main/java/com/easyagents/core/prompt/MemoryPrompt.java new file mode 100644 index 0000000..b681f9f --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/prompt/MemoryPrompt.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.prompt; + +import com.easyagents.core.memory.ChatMemory; +import com.easyagents.core.memory.DefaultChatMemory; +import com.easyagents.core.message.AbstractTextMessage; +import com.easyagents.core.message.Message; +import com.easyagents.core.message.SystemMessage; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; + +public class MemoryPrompt extends Prompt { + + private ChatMemory memory = new DefaultChatMemory(); + + private SystemMessage systemMessage; + + private int maxAttachedMessageCount = 10; + + private boolean historyMessageTruncateEnable = false; + private int historyMessageTruncateLength = 1000; + private Function historyMessageTruncateProcessor; + + // 临时消息不回存入 memory,只会当做 “过程消息” 参与大模型交互 + // 比如用于 Function call 等场景 + private List temporaryMessages; + + public SystemMessage getSystemMessage() { + return systemMessage; + } + + public void setSystemMessage(SystemMessage systemMessage) { + this.systemMessage = systemMessage; + } + + public int getMaxAttachedMessageCount() { + return maxAttachedMessageCount; + } + + public void setMaxAttachedMessageCount(int maxAttachedMessageCount) { + this.maxAttachedMessageCount = maxAttachedMessageCount; + } + + public boolean isHistoryMessageTruncateEnable() { + return historyMessageTruncateEnable; + } + + public void setHistoryMessageTruncateEnable(boolean historyMessageTruncateEnable) { + this.historyMessageTruncateEnable = historyMessageTruncateEnable; + } + + public int getHistoryMessageTruncateLength() { + return historyMessageTruncateLength; + } + + public void setHistoryMessageTruncateLength(int historyMessageTruncateLength) { + this.historyMessageTruncateLength = historyMessageTruncateLength; + } + + public Function getHistoryMessageTruncateProcessor() { + return historyMessageTruncateProcessor; + } + + public void setHistoryMessageTruncateProcessor(Function historyMessageTruncateProcessor) { + this.historyMessageTruncateProcessor = historyMessageTruncateProcessor; + } + + public MemoryPrompt() { + } + + public MemoryPrompt(ChatMemory memory) { + this.memory = memory; + } + + public void addMessage(Message message) { + memory.addMessage(message); + } + + public void addMessageTemporary(Message message) { + if (temporaryMessages == null) { + temporaryMessages = new ArrayList<>(); + } + temporaryMessages.add(message); + } + + public void addMessages(Collection messages) { + memory.addMessages(messages); + } + + public ChatMemory getMemory() { + return memory; + } + + public void setMemory(ChatMemory memory) { + this.memory = memory; + } + + public List getTemporaryMessages() { + return temporaryMessages; + } + + public void setTemporaryMessages(List temporaryMessages) { + this.temporaryMessages = temporaryMessages; + } + + public void clearTemporaryMessages() { + temporaryMessages.clear(); + temporaryMessages = null; + } + + /** + * 清空所有消息 + */ + public void clear() { + memory.clear(); + if (temporaryMessages != null) { + temporaryMessages.clear(); + } + } + + @Override + public List getMessages() { + List messages = memory.getMessages(maxAttachedMessageCount); + if (messages == null) { + messages = new ArrayList<>(); + } + + if (historyMessageTruncateEnable) { + for (int i = 0; i < messages.size(); i++) { + Message msg = messages.get(i); + if (msg instanceof AbstractTextMessage) { + AbstractTextMessage textMsg = (AbstractTextMessage) msg; + String content = textMsg.getContent(); + if (content == null) continue; + + // 应用自定义处理器或默认截断 + if (historyMessageTruncateProcessor != null) { + content = historyMessageTruncateProcessor.apply(content); + } else if (content.length() > historyMessageTruncateLength) { + content = content.substring(0, historyMessageTruncateLength); + } + + // 创建新实例,避免修改原始消息 + AbstractTextMessage copied = textMsg.copy(); + copied.setContent(content); + messages.set(i, copied); + } + } + } + + // 插入系统消息 + if (systemMessage != null) { + if (messages.isEmpty() || !(messages.get(0) instanceof SystemMessage)) { + messages.add(0, systemMessage); + } + } + + // 添加临时消息(如果存在) + if (temporaryMessages != null && !temporaryMessages.isEmpty()) { + messages.addAll(new ArrayList<>(temporaryMessages)); + + // 使用后自动清理 + temporaryMessages.clear(); + temporaryMessages = null; + } + + return messages; + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/prompt/Prompt.java b/easy-agents-core/src/main/java/com/easyagents/core/prompt/Prompt.java new file mode 100644 index 0000000..60985cf --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/prompt/Prompt.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.prompt; + +import com.easyagents.core.message.Message; +import com.easyagents.core.util.Metadata; + +import java.util.List; + + +public abstract class Prompt extends Metadata { + + public abstract List getMessages(); + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/prompt/PromptTemplate.java b/easy-agents-core/src/main/java/com/easyagents/core/prompt/PromptTemplate.java new file mode 100644 index 0000000..0ec1492 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/prompt/PromptTemplate.java @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.prompt; + +import com.easyagents.core.util.MapUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONPath; +import com.alibaba.fastjson2.JSONWriter; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 文本模板引擎,用于将包含 {{xxx}} 占位符的字符串模板,动态渲染为最终文本。 + * 支持 JSONPath 取值语法与 “??” 空值兜底逻辑。 + *

+ * 例如: + * 模板: "Hello {{ user.name ?? 'Unknown' }}!" + * 数据: { "user": { "name": "Alice" } } + * 输出: "Hello Alice!" + *

+ * 支持缓存模板与 JSONPath 编译结果,提升性能。 + */ +public class PromptTemplate { + + /** + * 匹配 {{ expression }} 的正则表达式 + */ + private static final Pattern PLACEHOLDER_PATTERN = + Pattern.compile("\\{\\{\\s*([^{}]+?)\\s*}}"); + + /** + * 模板缓存(按原始模板字符串) + */ + private static final Map TEMPLATE_CACHE = new ConcurrentHashMap<>(); + + /** + * JSONPath 编译缓存,避免重复编译 + */ + private static final Map JSONPATH_CACHE = new ConcurrentHashMap<>(); + + /** + * 原始模板字符串 + */ + private final String originalTemplate; + + /** + * 模板中拆分出的静态与动态 token 列表 + */ + private final List tokens; + + public PromptTemplate(String template) { + this.originalTemplate = template != null ? template : ""; + this.tokens = Collections.unmodifiableList(parseTemplate(this.originalTemplate)); + } + + /** + * 从缓存中获取或新建模板实例 + */ + public static PromptTemplate of(String template) { + String finalTemplate = template != null ? template : ""; + return MapUtil.computeIfAbsent(TEMPLATE_CACHE, finalTemplate, k -> new PromptTemplate(finalTemplate)); + } + + /** + * 清空模板与 JSONPath 缓存 + */ + public static void clearCache() { + TEMPLATE_CACHE.clear(); + JSONPATH_CACHE.clear(); + } + + + /** + * 将模板格式化为字符串 + */ + public String format(Map rootMap) { + return format(rootMap, false); + } + + /** + * 将模板格式化为字符串,可选择是否对结果进行 JSON 转义 + * + * @param rootMap 数据上下文 + * @param escapeForJsonOutput 是否对结果进行 JSON 字符串转义 + */ + public String format(Map rootMap, boolean escapeForJsonOutput) { + if (tokens.isEmpty()) return originalTemplate; + if (rootMap == null) rootMap = Collections.emptyMap(); + + StringBuilder sb = new StringBuilder(originalTemplate.length() + 64); + + for (TemplateToken token : tokens) { + if (token.isStatic) { + // 静态文本,直接拼接 + sb.append(token.content); + continue; + } + + // 动态表达式求值 + String value = evaluate(token.parseResult, rootMap, escapeForJsonOutput); + + // 没有兜底且值为空时抛出异常 + if (!token.explicitEmptyFallback && value.isEmpty()) { + throw new IllegalArgumentException(String.format( + "Missing value for expression: \"%s\"%nTemplate: %s%nProvided parameters:%n%s", + token.rawExpression, + originalTemplate, + JSON.toJSONString(rootMap, JSONWriter.Feature.PrettyFormat) + )); + } + sb.append(value); + } + + return sb.toString(); + } + + /** + * 解析模板字符串,将其拆解为静态文本与动态占位符片段 + */ + private List parseTemplate(String template) { + List result = new ArrayList<>(template.length() / 8); + if (template == null || template.isEmpty()) return result; + + Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); + int lastEnd = 0; + + while (matcher.find()) { + int start = matcher.start(); + int end = matcher.end(); + + // 处理 {{ 前的静态文本 + if (start > lastEnd) { + result.add(TemplateToken.staticText(template.substring(lastEnd, start))); + } + + // 处理 {{ ... }} 动态部分 + String rawExpr = matcher.group(1); + TemplateParseResult parsed = parseTemplateExpression(rawExpr); + result.add(TemplateToken.dynamic(parsed.parseResult, rawExpr, parsed.explicitEmptyFallback)); + + lastEnd = end; + } + + // 末尾剩余静态文本 + if (lastEnd < template.length()) { + result.add(TemplateToken.staticText(template.substring(lastEnd))); + } + + return result; + } + + /** + * 解析单个表达式内容,处理 ?? 空值兜底逻辑。 + * 例如: user.name ?? user.nick ?? "未知" + */ + private TemplateParseResult parseTemplateExpression(String expr) { + // 无 ?? 表示该值必填 + if (!expr.contains("??")) { + return new TemplateParseResult(new ParseResult(expr.trim(), null), false); + } + + // 按 ?? 分割,支持链式兜底 + String[] parts = expr.split("\\s*\\?\\?\\s*", -1); + boolean explicitEmptyFallback = parts[parts.length - 1].trim().isEmpty(); + + // 从右往左构建兜底链 + ParseResult result = null; + for (int i = parts.length - 1; i >= 0; i--) { + String p = parts[i].trim(); + if (p.isEmpty()) p = "\"\""; // 空串转为 "" 字面量 + result = new ParseResult(p, result); + } + + return new TemplateParseResult(result, explicitEmptyFallback); + } + + /** + * 递归求值表达式(支持多级兜底) + */ + private String evaluate(ParseResult pr, Map root, boolean escapeForJsonOutput) { + if (pr == null) return ""; + + // 字面量直接返回 + if (pr.isLiteral) { + String literal = pr.getUnquotedLiteral(); + return escapeForJsonOutput ? escapeJsonString(literal) : literal; + } + + // 尝试从 JSONPath 取值 + Object value = getValueByJsonPath(root, pr.expression, escapeForJsonOutput); + if (value != null) { + return value.toString(); + } + + // 若未取到,则尝试 fallback + return evaluate(pr.defaultResult, root, escapeForJsonOutput); + } + + /** + * 根据 JSONPath 获取对象值 + */ + private Object getValueByJsonPath(Map root, String path, boolean escapeForJsonOutput) { + try { + String fullPath = path.startsWith("$") ? path : "$." + path; + JSONPath compiled = MapUtil.computeIfAbsent(JSONPATH_CACHE, fullPath, JSONPath::compile); + Object value = compiled.eval(root); + if (escapeForJsonOutput && value instanceof String) { + return escapeJsonString((String) value); + } + return value; + } catch (Exception ignored) { + return null; + } + } + + /** + * 将字符串进行 JSON 安全转义 + */ + private static String escapeJsonString(String input) { + if (input == null || input.isEmpty()) return input; + return input + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\b", "\\b") + .replace("\f", "\\f") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * 去掉字符串两端的引号 + */ + private static String unquote(String str) { + if (str == null || str.length() < 2) return str; + char first = str.charAt(0); + char last = str.charAt(str.length() - 1); + if ((first == '\'' && last == '\'') || (first == '"' && last == '"')) { + return str.substring(1, str.length() - 1); + } + return str; + } + + + /** + * 模板片段对象。 + * 每个模板字符串会被解析为若干个 TemplateToken: + * - 静态文本(isStatic = true) + * - 动态表达式(isStatic = false) + */ + private static class TemplateToken { + final boolean isStatic; // 是否为静态文本 + final String content; // 静态文本内容 + final ParseResult parseResult; // 动态解析结果(表达式树) + final String rawExpression; // 原始表达式字符串 + final boolean explicitEmptyFallback; // 是否显式声明空兜底(以 ?? 结尾) + + private TemplateToken(boolean isStatic, String content, + ParseResult parseResult, String rawExpression, + boolean explicitEmptyFallback) { + this.isStatic = isStatic; + this.content = content; + this.parseResult = parseResult; + this.rawExpression = rawExpression; + this.explicitEmptyFallback = explicitEmptyFallback; + } + + /** + * 创建静态文本 token + */ + static TemplateToken staticText(String text) { + return new TemplateToken(true, text, null, null, false); + } + + /** + * 创建动态表达式 token + */ + static TemplateToken dynamic(ParseResult parseResult, String rawExpression, boolean explicitEmptyFallback) { + return new TemplateToken(false, null, parseResult, rawExpression, explicitEmptyFallback); + } + } + + /** + * 表达式解析结果。 + * 支持嵌套的默认值链,如:user.name ?? user.nick ?? "匿名" + */ + private static class ParseResult { + final String expression; // 当前表达式内容(可能是 JSONPath 或字符串字面量) + final ParseResult defaultResult; // 默认值链的下一个节点 + final boolean isLiteral; // 是否为字面量字符串('xxx' 或 "xxx") + + ParseResult(String expression, ParseResult defaultResult) { + this.expression = expression; + this.defaultResult = defaultResult; + this.isLiteral = isLiteralExpression(expression); + } + + /** + * 判断是否是字符串字面量 + */ + private static boolean isLiteralExpression(String expr) { + if (expr == null || expr.length() < 2) return false; + char first = expr.charAt(0); + char last = expr.charAt(expr.length() - 1); + return (first == '\'' && last == '\'') || (first == '"' && last == '"'); + } + + /** + * 返回去除引号后的字符串字面量值 + */ + String getUnquotedLiteral() { + if (!isLiteral) throw new IllegalStateException("Not a literal: " + expression); + return unquote(expression); + } + } + + /** + * 模板解析的最终结果,包含: + * - 解析后的表达式树(ParseResult) + * - 是否显式声明空兜底 + */ + private static class TemplateParseResult { + final ParseResult parseResult; + final boolean explicitEmptyFallback; + + TemplateParseResult(ParseResult parseResult, boolean explicitEmptyFallback) { + this.parseResult = parseResult; + this.explicitEmptyFallback = explicitEmptyFallback; + } + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/prompt/SimplePrompt.java b/easy-agents-core/src/main/java/com/easyagents/core/prompt/SimplePrompt.java new file mode 100644 index 0000000..0d22f49 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/prompt/SimplePrompt.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.prompt; + +import com.easyagents.core.message.*; +import com.easyagents.core.model.chat.tool.Tool; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + + +public class SimplePrompt extends Prompt { + + protected SystemMessage systemMessage; + protected UserMessage userMessage; + protected AiMessage aiMessage; + protected List toolMessages; + + public SimplePrompt() { + this.userMessage = new UserMessage(); + } + + public SimplePrompt(String content) { + this.userMessage = new UserMessage(content); + } + + + /// /// Tools + public void addTool(Tool tool) { + userMessage.addTool(tool); + } + + public void addTools(Collection Tools) { + userMessage.addTools(Tools); + } + + public void addToolsFromClass(Class funcClass, String... methodNames) { + userMessage.addToolsFromClass(funcClass, methodNames); + } + + public void addToolsFromObject(Object funcObject, String... methodNames) { + userMessage.addToolsFromObject(funcObject, methodNames); + } + + + public List getTools() { + return userMessage.getTools(); + } + + + public String getToolChoice() { + return userMessage.getToolChoice(); + } + + public void setToolChoice(String toolChoice) { + userMessage.setToolChoice(toolChoice); + } + + + /// /// Audio + public List getAudioUrls() { + return userMessage.getAudioUrls(); + } + + public void setAudioUrls(List audioUrls) { + userMessage.setAudioUrls(audioUrls); + } + + public void addAudioUrl(String audioUrl) { + userMessage.addAudioUrl(audioUrl); + } + + + /// /// Video + public List getVideoUrls() { + return userMessage.getVideoUrls(); + } + + public void setVideoUrls(List videoUrls) { + userMessage.setVideoUrls(videoUrls); + } + + public void addVideoUrl(String videoUrl) { + userMessage.addVideoUrl(videoUrl); + } + + + /// /// Images + public List getImageUrls() { + return userMessage.getImageUrls(); + } + + public void setImageUrls(List imageUrls) { + userMessage.setImageUrls(imageUrls); + } + + public void addImageUrl(String imageUrl) { + userMessage.addImageUrl(imageUrl); + } + + public void addImageFile(File imageFile) { + userMessage.addImageFile(imageFile); + } + + public void addImageBytes(byte[] imageBytes, String mimeType) { + userMessage.addImageBytes(imageBytes, mimeType); + } + + + /// ////getter setter + public SystemMessage getSystemMessage() { + return systemMessage; + } + + public void setSystemMessage(SystemMessage systemMessage) { + this.systemMessage = systemMessage; + } + + public UserMessage getUserMessage() { + return userMessage; + } + + public void setUserMessage(UserMessage userMessage) { + this.userMessage = userMessage; + } + + public AiMessage getAiMessage() { + return aiMessage; + } + + public void setAiMessage(AiMessage aiMessage) { + this.aiMessage = aiMessage; + } + + public List getToolMessages() { + return toolMessages; + } + + public void setToolMessages(List toolMessages) { + this.toolMessages = toolMessages; + } + + @Override + public List getMessages() { + List messages = new ArrayList<>(2); + if (systemMessage != null) { + messages.add(systemMessage); + } + messages.add(userMessage); + + if (aiMessage != null) { + messages.add(aiMessage); + } + + if (toolMessages != null) { + messages.addAll(toolMessages); + } + return messages; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/prompt/package-info.java b/easy-agents-core/src/main/java/com/easyagents/core/prompt/package-info.java new file mode 100644 index 0000000..b0c6409 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/prompt/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * 提示词 + */ +package com.easyagents.core.prompt; diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/DocumentStore.java b/easy-agents-core/src/main/java/com/easyagents/core/store/DocumentStore.java new file mode 100644 index 0000000..5641ab7 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/DocumentStore.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store; + +import com.easyagents.core.model.embedding.EmbeddingModel; +import com.easyagents.core.document.Document; +import com.easyagents.core.document.DocumentSplitter; +import com.easyagents.core.document.id.DocumentIdGenerator; +import com.easyagents.core.document.id.DocumentIdGeneratorFactory; +import com.easyagents.core.model.exception.ModelException; + +import java.util.Collection; +import java.util.List; + +/** + * Document Store + */ +public abstract class DocumentStore extends VectorStore { + + /** + * DocumentStore can use external embeddings models or its own embeddings + * Many vector databases come with the ability to embed themselves + */ + private EmbeddingModel embeddingModel; + + private DocumentSplitter documentSplitter; + + private DocumentIdGenerator documentIdGenerator = DocumentIdGeneratorFactory.getDocumentIdGenerator(); + + public EmbeddingModel getEmbeddingModel() { + return embeddingModel; + } + + public void setEmbeddingModel(EmbeddingModel embeddingModel) { + this.embeddingModel = embeddingModel; + } + + public DocumentSplitter getDocumentSplitter() { + return documentSplitter; + } + + public void setDocumentSplitter(DocumentSplitter documentSplitter) { + this.documentSplitter = documentSplitter; + } + + public DocumentIdGenerator getDocumentIdGenerator() { + return documentIdGenerator; + } + + public void setDocumentIdGenerator(DocumentIdGenerator documentIdGenerator) { + this.documentIdGenerator = documentIdGenerator; + } + + @Override + public StoreResult store(List documents, StoreOptions options) { + if (options == null) { + options = StoreOptions.DEFAULT; + } + + if (documentSplitter != null) { + documents = documentSplitter.splitAll(documents, documentIdGenerator); + } + // use the documentIdGenerator create unique id for document + else if (documentIdGenerator != null) { + for (Document document : documents) { + if (document.getId() == null) { + Object id = documentIdGenerator.generateId(document); + document.setId(id); + } + } + } + + embedDocumentsIfNecessary(documents, options); + + return doStore(documents, options); + } + + @Override + public StoreResult delete(Collection ids, StoreOptions options) { + if (options == null) { + options = StoreOptions.DEFAULT; + } + return doDelete(ids, options); + } + + @Override + public StoreResult update(List documents, StoreOptions options) { + if (options == null) { + options = StoreOptions.DEFAULT; + } + + embedDocumentsIfNecessary(documents, options); + return doUpdate(documents, options); + } + + + @Override + public List search(SearchWrapper wrapper, StoreOptions options) { + if (options == null) { + options = StoreOptions.DEFAULT; + } + + if (wrapper.getVector() == null && embeddingModel != null && wrapper.isWithVector()) { + VectorData vectorData = embeddingModel.embed(Document.of(wrapper.getText()), options.getEmbeddingOptions()); + if (vectorData == null) { + throw new ModelException("Embedding model does not contain vector data"); + } + wrapper.setVector(vectorData.getVector()); + } + + return doSearch(wrapper, options); + } + + + protected void embedDocumentsIfNecessary(List documents, StoreOptions options) { + if (embeddingModel == null) { + return; + } + for (Document document : documents) { + if (document.getVector() == null) { + VectorData vectorData = embeddingModel.embed(document, options.getEmbeddingOptions()); + if (vectorData != null) { + document.setVector(vectorData.getVector()); + } + } + } + } + + + public abstract StoreResult doStore(List documents, StoreOptions options); + + public abstract StoreResult doDelete(Collection ids, StoreOptions options); + + public abstract StoreResult doUpdate(List documents, StoreOptions options); + + public abstract List doSearch(SearchWrapper wrapper, StoreOptions options); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/DocumentStoreConfig.java b/easy-agents-core/src/main/java/com/easyagents/core/store/DocumentStoreConfig.java new file mode 100644 index 0000000..93f9177 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/DocumentStoreConfig.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store; + +import java.io.Serializable; + +public interface DocumentStoreConfig extends Serializable { + + boolean checkAvailable(); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/SearchWrapper.java b/easy-agents-core/src/main/java/com/easyagents/core/store/SearchWrapper.java new file mode 100644 index 0000000..ae30767 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/SearchWrapper.java @@ -0,0 +1,340 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store; + +import com.easyagents.core.store.condition.*; + +import java.util.*; +import java.util.function.Consumer; + +public class SearchWrapper extends VectorData { + + /** + * the default value of search data count + */ + public static final int DEFAULT_MAX_RESULTS = 4; + + /** + * search text, Vector store will convert the text to vector data + */ + private String text; + + /** + * search max result, like the sql "limit" in mysql + */ + private Integer maxResults = DEFAULT_MAX_RESULTS; + + /** + * The lowest correlation score, ranging from 0 to 1 (including 0 and 1). Only embeddings with a score of this value or higher will be returned. + * 0.0 indicates accepting any similarity or disabling similarity threshold filtering. A threshold of 1.0 indicates the need for a perfect match. + */ + private Double minScore; + + /** + * The flag of include vector data queries. If the current value is true and the vector content is null, + * the query text will be automatically converted into vector data through the vector store. + */ + private boolean withVector = true; + + /** + * query condition + */ + private Condition condition; + + /** + * query fields + */ + private List outputFields; + + /** + * whether to output vector data + */ + private boolean outputVector = false; + + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public SearchWrapper text(String text) { + setText(text); + return this; + } + + public Integer getMaxResults() { + return maxResults; + } + + public void setMaxResults(Integer maxResults) { + this.maxResults = maxResults; + } + + public SearchWrapper maxResults(Integer maxResults) { + setMaxResults(maxResults); + return this; + } + + public Double getMinScore() { + return minScore; + } + + public void setMinScore(Double minScore) { + this.minScore = minScore; + } + + public SearchWrapper minScore(Double minScore) { + setMinScore(minScore); + return this; + } + + public boolean isWithVector() { + return withVector; + } + + public void setWithVector(boolean withVector) { + this.withVector = withVector; + } + + public SearchWrapper withVector(Boolean withVector) { + setWithVector(withVector); + return this; + } + + public Condition getCondition() { + return condition; + } + + public void setCondition(Condition condition) { + this.condition = condition; + } + + public List getOutputFields() { + return outputFields; + } + + public void setOutputFields(List outputFields) { + this.outputFields = outputFields; + } + + public SearchWrapper outputFields(Collection outputFields) { + setOutputFields(new ArrayList<>(outputFields)); + return this; + } + + public SearchWrapper outputFields(String... outputFields) { + setOutputFields(Arrays.asList(outputFields)); + return this; + } + + public boolean isOutputVector() { + return outputVector; + } + + public void setOutputVector(boolean outputVector) { + this.outputVector = outputVector; + } + + public SearchWrapper outputVector(boolean outputVector) { + setOutputVector(outputVector); + return this; + } + + + public SearchWrapper eq(String key, Object value) { + return eq(Connector.AND, key, value); + } + + public SearchWrapper eq(Connector connector, String key, Object value) { + if (this.condition == null) { + this.condition = new Condition(ConditionType.EQ, new Key(key), new Value(value)); + } else { + this.condition.connect(new Condition(ConditionType.EQ, new Key(key), new Value(value)), connector); + } + return this; + } + + public SearchWrapper ne(String key, Object value) { + return ne(Connector.AND, key, value); + } + + public SearchWrapper ne(Connector connector, String key, Object value) { + if (this.condition == null) { + this.condition = new Condition(ConditionType.NE, new Key(key), new Value(value)); + } else { + this.condition.connect(new Condition(ConditionType.NE, new Key(key), new Value(value)), connector); + } + return this; + } + + public SearchWrapper gt(String key, Object value) { + return gt(Connector.AND, key, value); + } + + public SearchWrapper gt(Connector connector, String key, Object value) { + if (this.condition == null) { + this.condition = new Condition(ConditionType.GT, new Key(key), new Value(value)); + } else { + this.condition.connect(new Condition(ConditionType.GT, new Key(key), new Value(value)), connector); + } + return this; + } + + + public SearchWrapper ge(String key, Object value) { + return ge(Connector.AND, key, value); + } + + public SearchWrapper ge(Connector connector, String key, Object value) { + if (this.condition == null) { + this.condition = new Condition(ConditionType.GE, new Key(key), new Value(value)); + } else { + this.condition.connect(new Condition(ConditionType.GE, new Key(key), new Value(value)), connector); + } + return this; + } + + + public SearchWrapper lt(String key, Object value) { + return lt(Connector.AND, key, value); + } + + public SearchWrapper lt(Connector connector, String key, Object value) { + if (this.condition == null) { + this.condition = new Condition(ConditionType.LT, new Key(key), new Value(value)); + } else { + this.condition.connect(new Condition(ConditionType.LT, new Key(key), new Value(value)), connector); + } + return this; + } + + + public SearchWrapper le(String key, Object value) { + return le(Connector.AND, key, value); + } + + public SearchWrapper le(Connector connector, String key, Object value) { + if (this.condition == null) { + this.condition = new Condition(ConditionType.LE, new Key(key), new Value(value)); + } else { + this.condition.connect(new Condition(ConditionType.LE, new Key(key), new Value(value)), connector); + } + return this; + } + + + public SearchWrapper in(String key, Collection values) { + return in(Connector.AND, key, values); + } + + public SearchWrapper in(Connector connector, String key, Collection values) { + if (this.condition == null) { + this.condition = new Condition(ConditionType.IN, new Key(key), new Value(values.toArray())); + } else { + this.condition.connect(new Condition(ConditionType.IN, new Key(key), new Value(values.toArray())), connector); + } + return this; + } + + public SearchWrapper min(String key, Object value) { + return min(Connector.AND, key, value); + } + + public SearchWrapper min(Connector connector, String key, Object value) { + if (this.condition == null) { + this.condition = new Condition(ConditionType.NIN, new Key(key), new Value(value)); + } else { + this.condition.connect(new Condition(ConditionType.NIN, new Key(key), new Value(value)), connector); + } + return this; + } + + public SearchWrapper between(String key, Object start, Object end) { + return between(Connector.AND, key, start, end); + } + + public SearchWrapper between(Connector connector, String key, Object start, Object end) { + if (this.condition == null) { + this.condition = new Condition(ConditionType.BETWEEN, new Key(key), new Value(start, end)); + } else { + this.condition.connect(new Condition(ConditionType.BETWEEN, new Key(key), new Value(start, end)), connector); + } + return this; + } + + + public SearchWrapper group(SearchWrapper wrapper) { + return group(wrapper.condition); + } + + public SearchWrapper group(Condition condition) { + if (this.condition == null) { + this.condition = new Group(condition); + } else { + this.condition.connect(new Group(condition), Connector.AND); + } + return this; + } + + public SearchWrapper group(Consumer consumer) { + SearchWrapper newWrapper = new SearchWrapper(); + consumer.accept(newWrapper); + Condition condition = newWrapper.condition; + if (condition != null) { + group(condition); + } + return this; + } + + public SearchWrapper andCriteria(Consumer consumer) { + return group(consumer); + } + + public SearchWrapper orCriteria(Consumer consumer) { + SearchWrapper newWrapper = new SearchWrapper(); + consumer.accept(newWrapper); + Condition condition = newWrapper.condition; + if (condition != null) { + if (this.condition == null) { + this.condition = new Group(condition); + } else { + this.condition.connect(new Group(condition), Connector.OR); + } + } + return this; + } + + /** + * Convert to expressions for filtering conditions, with different expression requirements for each vendor. + * Customized adaptor can be achieved through ExpressionAdaptor + */ + public String toFilterExpression() { + return toFilterExpression(ExpressionAdaptor.DEFAULT); + } + + public String toFilterExpression(ExpressionAdaptor adaptor) { + if (this.condition == null) { + return null; + } else { + Objects.requireNonNull(adaptor, "adaptor must not be null"); + return this.condition.toExpression(adaptor); + } + } + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/StoreOptions.java b/easy-agents-core/src/main/java/com/easyagents/core/store/StoreOptions.java new file mode 100644 index 0000000..4d0051d --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/StoreOptions.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store; + +import com.easyagents.core.model.embedding.EmbeddingOptions; +import com.easyagents.core.util.Metadata; +import com.easyagents.core.util.StringUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Store Options, Each store can have its own Options implementation. + */ +public class StoreOptions extends Metadata { + + public static final StoreOptions DEFAULT = new StoreOptions() { + @Override + public void setCollectionName(String collectionName) { + throw new IllegalStateException("Can not set collectionName to the default instance."); + } + + @Override + public void setPartitionNames(List partitionNames) { + throw new IllegalStateException("Can not set partitionName to the default instance."); + } + + @Override + public void setEmbeddingOptions(EmbeddingOptions embeddingOptions) { + throw new IllegalStateException("Can not set embeddingOptions to the default instance."); + } + }; + + /** + * store collection name + */ + private String collectionName; + + /** + * store index name + */ + private String indexName; + + /** + * store partition name + */ + private List partitionNames; + + /** + * store embedding options + */ + private EmbeddingOptions embeddingOptions = EmbeddingOptions.DEFAULT; + + + public String getCollectionName() { + return collectionName; + } + + public String getCollectionNameOrDefault(String other) { + return StringUtil.hasText(collectionName) ? collectionName : other; + } + + public void setCollectionName(String collectionName) { + this.collectionName = collectionName; + } + + public List getPartitionNames() { + return partitionNames; + } + + public String getPartitionName() { + return partitionNames != null && !partitionNames.isEmpty() ? partitionNames.get(0) : null; + } + + public List getPartitionNamesOrEmpty() { + return partitionNames == null ? Collections.emptyList() : partitionNames; + } + + public void setPartitionNames(List partitionNames) { + this.partitionNames = partitionNames; + } + + public StoreOptions partitionName(String partitionName) { + if (this.partitionNames == null) { + this.partitionNames = new ArrayList<>(1); + } + this.partitionNames.add(partitionName); + return this; + } + + + public EmbeddingOptions getEmbeddingOptions() { + return embeddingOptions; + } + + public void setEmbeddingOptions(EmbeddingOptions embeddingOptions) { + this.embeddingOptions = embeddingOptions; + } + + + public static StoreOptions ofCollectionName(String collectionName) { + StoreOptions storeOptions = new StoreOptions(); + storeOptions.setCollectionName(collectionName); + return storeOptions; + } + + public String getIndexName() { + return indexName; + } + + public void setIndexName(String indexName) { + this.indexName = indexName; + } + + public String getIndexNameOrDefault(String other) { + return StringUtil.hasText(indexName) ? indexName : other; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/StoreResult.java b/easy-agents-core/src/main/java/com/easyagents/core/store/StoreResult.java new file mode 100644 index 0000000..3decad1 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/StoreResult.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store; + +import com.easyagents.core.util.Metadata; +import com.easyagents.core.document.Document; + +import java.util.ArrayList; +import java.util.List; + +public class StoreResult extends Metadata { + private final boolean success; + private String failReason; + private List ids; + + public StoreResult(boolean success) { + this.success = success; + this.failReason = ""; + } + + public StoreResult(boolean success, String failReason) { + this.success = success; + this.failReason = failReason == null ? "" : failReason; + } + + public boolean isSuccess() { + return success; + } + + public String getFailReason() {return failReason;} + + public void setFailReason(String failReason) {this.failReason = failReason;} + + public List ids() { + return ids; + } + + public static StoreResult fail() { + return new StoreResult(false); + } + + public static StoreResult fail(String failReason) { + return new StoreResult(false, failReason); + } + + public static StoreResult success() { + return new StoreResult(true); + } + + public static StoreResult successWithIds(List documents) { + StoreResult result = success(); + result.ids = new ArrayList<>(documents.size()); + for (Document document : documents) { + result.ids.add(document.getId()); + } + return result; + } + + @Override + public String toString() { + return "StoreResult{" + + "success=" + success + + ", ids=" + ids + + ", failReason='" + failReason + '\'' + + '}'; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/VectorData.java b/easy-agents-core/src/main/java/com/easyagents/core/store/VectorData.java new file mode 100644 index 0000000..95c2491 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/VectorData.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store; + +import com.easyagents.core.util.Metadata; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + + +public class VectorData extends Metadata { + + private float[] vector; + + /** + * 0 ~ 1, 数值越大,相似度越高 + */ + private Double score; + + public float[] getVector() { + return vector; + } + + public List getVectorAsList() { + if (vector == null) { + return null; + } + List result = new ArrayList<>(vector.length); + for (float v : vector) { + result.add(v); + } + return result; + } + + public List getVectorAsDoubleList() { + if (vector == null) { + return null; + } + List result = new ArrayList<>(vector.length); + for (float v : vector) { + result.add((double) v); + } + return result; + } + + public double[] getVectorAsDoubleArray() { + if (vector == null) { + return null; + } + double[] result = new double[vector.length]; + for (int i = 0; i < vector.length; i++) { + result[i] = vector[i]; + } + return result; + } + + + public void setVector(float[] vector) { + this.vector = vector; + } + + public void setVector(Float[] vector) { + this.vector = new float[vector.length]; + for (int i = 0; i < vector.length; i++) { + this.vector[i] = vector[i]; + } + } + + public void setVector(double[] vector) { + this.vector = new float[vector.length]; + for (int i = 0; i < vector.length; i++) { + this.vector[i] = (float) vector[i]; + } + } + + public void setVector(Double[] vector) { + this.vector = new float[vector.length]; + for (int i = 0; i < vector.length; i++) { + this.vector[i] = vector[i].floatValue(); + } + } + + public void setVector(Collection vector) { + this.vector = new float[vector.size()]; + int index = 0; + for (Number num : vector) { + this.vector[index++] = num.floatValue(); + } + } + + public Double getScore() { + return score; + } + + public void setScore(Double score) { + this.score = score; + } + + @Override + public String toString() { + return "VectorData{" + + "vector=" + Arrays.toString(vector) + + ", score=" + score + + ", metadataMap=" + metadataMap + + '}'; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/VectorStore.java b/easy-agents-core/src/main/java/com/easyagents/core/store/VectorStore.java new file mode 100644 index 0000000..b8cfb95 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/VectorStore.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Vector Store + * + * @param The Vector Data + */ +public abstract class VectorStore { + + /** + * Store Vector Data + * + * @param vectorData The Vector Data + * @return Store Result + */ + public StoreResult store(T vectorData) { + return store(vectorData, StoreOptions.DEFAULT); + } + + /** + * Store Vector Data With Options + * + * @param vectorData The Vector Data + * @param options Store Options + * @return Store Result + */ + public StoreResult store(T vectorData, StoreOptions options) { + return store(Collections.singletonList(vectorData), options); + } + + /** + * Store Vector Data List + * + * @param vectorDataList The Vector Data List + * @return Store Result + */ + public StoreResult store(List vectorDataList) { + return store(vectorDataList, StoreOptions.DEFAULT); + } + + /** + * Store Vector Data list wit options + * + * @param vectorDataList vector data list + * @param options options + * @return store result + */ + public abstract StoreResult store(List vectorDataList, StoreOptions options); + + + /** + * delete store data by ids + * + * @param ids the data ids + * @return store result + */ + public StoreResult delete(String... ids) { + return delete(Arrays.asList(ids), StoreOptions.DEFAULT); + } + + + /** + * delete store data by ids + * + * @param ids the data ids + * @return store result + */ + public StoreResult delete(Number... ids) { + return delete(Arrays.asList(ids), StoreOptions.DEFAULT); + } + + + /** + * delete store data by id collection + * + * @param ids the ids + * @return store result + */ + public StoreResult delete(Collection ids) { + return delete(ids, StoreOptions.DEFAULT); + } + + /** + * delete store data by ids with options + * + * @param ids ids + * @param options store options + * @return store result + */ + public abstract StoreResult delete(Collection ids, StoreOptions options); + + /** + * update the vector data by id + * + * @param vectorData the vector data + * @return store result + */ + public StoreResult update(T vectorData) { + return update(vectorData, StoreOptions.DEFAULT); + } + + + /** + * update the vector data by id with options + * + * @param vectorData vector data + * @param options store options + * @return store result + */ + public StoreResult update(T vectorData, StoreOptions options) { + return update(Collections.singletonList(vectorData), options); + } + + /** + * update vector data list + * + * @param vectorDataList vector data list + * @return store result + */ + public StoreResult update(List vectorDataList) { + return update(vectorDataList, StoreOptions.DEFAULT); + } + + /** + * update store data list with options + * + * @param vectorDataList vector data list + * @param options store options + * @return store result + */ + public abstract StoreResult update(List vectorDataList, StoreOptions options); + + /** + * search vector data by SearchWrapper + * + * @param wrapper SearchWrapper + * @return the vector data list + */ + public List search(SearchWrapper wrapper) { + return search(wrapper, StoreOptions.DEFAULT); + } + + + /** + * search vector data by SearchWrapper with options + * + * @param wrapper SearchWrapper + * @param options Store Options + * @return the vector data list + */ + public abstract List search(SearchWrapper wrapper, StoreOptions options); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Condition.java b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Condition.java new file mode 100644 index 0000000..094aa58 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Condition.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store.condition; + +public class Condition implements Operand { + + protected ConditionType type; + protected Operand left; + protected Operand right; + protected boolean effective = true; + protected Connector connector; + protected Condition prev; + protected Condition next; + + public Condition() { + } + + public Condition(ConditionType type, Operand left, Operand right) { + this.type = type; + this.left = left; + this.right = right; + + if (left instanceof Value) { + ((Value) left).setCondition(this); + } + if (right instanceof Value) { + ((Value) right).setCondition(this); + } + } + + + public void connect(Condition nextCondition, Connector connector) { + if (this.next != null) { + this.next.connect(nextCondition, connector); + } else { + nextCondition.connector = connector; + this.next = nextCondition; + nextCondition.prev = this; + } + } + + public boolean checkEffective() { + return effective; + } + + protected Condition getPrevEffectiveCondition() { + if (prev == null) { + return null; + } + return prev.checkEffective() ? prev : prev.getPrevEffectiveCondition(); + } + + protected Condition getNextEffectiveCondition() { + if (next == null) { + return null; + } + return next.checkEffective() ? next : next.getNextEffectiveCondition(); + } + + + @Override + public String toExpression(ExpressionAdaptor adaptor) { + StringBuilder expr = new StringBuilder(); + if (checkEffective()) { + Condition prevEffectiveCondition = getPrevEffectiveCondition(); + if (prevEffectiveCondition != null && this.connector != null) { + expr.append(adaptor.toConnector(this.connector)); + } + expr.append(adaptor.toCondition(this)); + } + + if (this.next != null) { + expr.append(this.next.toExpression(adaptor)); + } + + return expr.toString(); + } + + + public ConditionType getType() { + return type; + } + + public void setType(ConditionType type) { + this.type = type; + } + + public Operand getLeft() { + return left; + } + + public void setLeft(Operand left) { + this.left = left; + } + + public Operand getRight() { + return right; + } + + public void setRight(Operand right) { + this.right = right; + } + + public boolean isEffective() { + return effective; + } + + public void setEffective(boolean effective) { + this.effective = effective; + } + + public Connector getConnector() { + return connector; + } + + public void setConnector(Connector connector) { + this.connector = connector; + } + + public Condition getPrev() { + return prev; + } + + public void setPrev(Condition prev) { + this.prev = prev; + } + + public Condition getNext() { + return next; + } + + public void setNext(Condition next) { + this.next = next; + } + + @Override + public String toString() { + return "Condition{" + + "type=" + type + + ", left=" + left + + ", right=" + right + + ", effective=" + effective + + ", connector=" + connector + + ", prev=" + prev + + ", next=" + next + + '}'; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/condition/ConditionType.java b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/ConditionType.java new file mode 100644 index 0000000..536935e --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/ConditionType.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store.condition; + +public enum ConditionType { + EQ(" = "), + NE(" != "), + GT(" > "), + GE(" >= "), + LT(" < "), + LE(" <= "), + IN(" IN "), + NIN(" MIN "), + BETWEEN(" BETWEEN "), + ; + + private final String defaultSymbol; + + ConditionType(String defaultSymbol) { + this.defaultSymbol = defaultSymbol; + } + + public String getDefaultSymbol() { + return defaultSymbol; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Connector.java b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Connector.java new file mode 100644 index 0000000..7411628 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Connector.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store.condition; + +/** + * @author michael + */ +public enum Connector { + + + /** + * AND + */ + AND(" AND "), + + /** + * AND NOT + */ + AND_NOT(" AND NOT "), + + /** + * OR + */ + OR(" OR "), + + /** + * OR NOT + */ + OR_NOT(" OR NOT "), + + /** + * NOT + */ + NOT(" NOT "), + ; + + + private final String value; + + Connector(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/condition/ExpressionAdaptor.java b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/ExpressionAdaptor.java new file mode 100644 index 0000000..6317aad --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/ExpressionAdaptor.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store.condition; + +import java.util.StringJoiner; + +public interface ExpressionAdaptor { + + ExpressionAdaptor DEFAULT = new ExpressionAdaptor() { + }; + + default String toCondition(Condition condition) { + return toLeft(condition.left) + + toOperationSymbol(condition.type) + + toRight(condition.right); + } + + default String toLeft(Operand operand) { + return operand.toExpression(this); + } + + default String toOperationSymbol(ConditionType type) { + return type.getDefaultSymbol(); + } + + default String toRight(Operand operand) { + return operand.toExpression(this); + } + + default String toValue(Condition condition, Object value) { + // between + if (condition.getType() == ConditionType.BETWEEN) { + Object[] values = (Object[]) value; + return "\"" + values[0] + "\" AND \"" + values[1] + "\""; + } + + // in + else if (condition.getType() == ConditionType.IN) { + Object[] values = (Object[]) value; + StringJoiner stringJoiner = new StringJoiner(",", "(", ")"); + for (Object v : values) { + if (v != null) { + stringJoiner.add("\"" + v + "\""); + } + } + return stringJoiner.toString(); + } + + return value == null ? "" : "\"" + value + "\""; + } + + + default String toConnector(Connector connector) { + return connector.getValue(); + } + + default String toGroupStart(Group group) { + return "("; + } + + default String toGroupEnd(Group group) { + return ")"; + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Group.java b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Group.java new file mode 100644 index 0000000..fbcb287 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Group.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store.condition; + +import com.easyagents.core.util.StringUtil; + +public class Group extends Condition { + + private String prevOperand = ""; + private final Condition childCondition; + + public Group(Condition condition) { + this.childCondition = condition; + } + + public Group(String prevOperand, Condition childCondition) { + this.prevOperand = prevOperand; + this.childCondition = childCondition; + } + + + @Override + public boolean checkEffective() { + boolean effective = super.checkEffective(); + if (!effective) { + return false; + } + Condition condition = this.childCondition; + while (condition != null) { + if (condition.checkEffective()) { + return true; + } + condition = condition.next; + } + return false; + } + + + @Override + public String toExpression(ExpressionAdaptor adaptor) { + StringBuilder expr = new StringBuilder(); + if (checkEffective()) { + String childExpr = childCondition.toExpression(adaptor); + Condition prevEffectiveCondition = getPrevEffectiveCondition(); + if (prevEffectiveCondition != null && this.connector != null) { + childExpr = adaptor.toConnector(this.connector) + this.prevOperand + adaptor.toGroupStart(this) + childExpr + adaptor.toGroupEnd(this); + } else if (StringUtil.hasText(childExpr)) { + childExpr = this.prevOperand + adaptor.toGroupStart(this) + childExpr + adaptor.toGroupEnd(this); + } + expr.append(childExpr); + } + + if (this.next != null) { + expr.append(next.toExpression(adaptor)); + } + return expr.toString(); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Key.java b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Key.java new file mode 100644 index 0000000..af9b764 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Key.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store.condition; + +public class Key implements Operand { + + private Object key; + + public Key(Object key) { + this.key = key; + } + + public Object getKey() { + return key; + } + + public void setKey(Object key) { + this.key = key; + } + + @Override + public String toExpression(ExpressionAdaptor adaptor) { + return key != null ? key.toString() : ""; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Not.java b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Not.java new file mode 100644 index 0000000..4d9438b --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Not.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store.condition; + +public class Not extends Group { + + public Not(Condition condition) { + super("NOT", condition); + } + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Operand.java b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Operand.java new file mode 100644 index 0000000..ed1be9a --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Operand.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store.condition; + +import java.io.Serializable; + +public interface Operand extends Serializable { + String toExpression(ExpressionAdaptor adaptor); + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Value.java b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Value.java new file mode 100644 index 0000000..5ec9b26 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/condition/Value.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store.condition; + +public class Value implements Operand { + + private Condition condition; + private Object value; + + public Value(Object value) { + this.value = value; + } + + public Value(Object... values){ + this.value = values; + } + + public Object getValue() { + return value; + } + + public void setValue(Object value) { + this.value = value; + } + + public Condition getCondition() { + return condition; + } + + public void setCondition(Condition condition) { + this.condition = condition; + } + + @Override + public String toExpression(ExpressionAdaptor adaptor) { + if (value instanceof Operand) { + return adaptor.toRight(this); + } + return adaptor.toValue(condition, value); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/store/exception/StoreException.java b/easy-agents-core/src/main/java/com/easyagents/core/store/exception/StoreException.java new file mode 100644 index 0000000..22e73d8 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/store/exception/StoreException.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.store.exception; + +/** + * 存储异常 + * + * @author songyinyin + * @since 2024/8/10 下午8:53 + */ +public class StoreException extends RuntimeException { + + public StoreException(String message) { + super(message); + } + + public StoreException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/ArrayUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/util/ArrayUtil.java new file mode 100644 index 0000000..94af9cf --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/ArrayUtil.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import java.util.Arrays; +import java.util.Objects; + +public class ArrayUtil { + + private ArrayUtil() { + } + + + /** + * 判断数组是否为空 + * + * @param array 数组 + * @param 数组类型 + * @return {@code true} 数组为空,{@code false} 数组不为空 + */ + public static boolean isEmpty(T[] array) { + return array == null || array.length == 0; + } + + + /** + * 判断数组是否不为空 + * + * @param array 数组 + * @param 数组类型 + * @return {@code true} 数组不为空,{@code false} 数组为空 + */ + public static boolean isNotEmpty(T[] array) { + return !isEmpty(array); + } + + + /** + * 合并两个数组为一个新的数组 + * + * @param first 第一个数组 + * @param second 第二个数组 + * @param + * @return 新的数组 + */ + public static T[] concat(T[] first, T[] second) { + if (first == null && second == null) { + throw new IllegalArgumentException("not allow first and second are null."); + } else if (isEmpty(first) && second != null) { + return second; + } else if (isEmpty(second)) { + return first; + } else { + T[] result = Arrays.copyOf(first, first.length + second.length); + System.arraycopy(second, 0, result, first.length, second.length); + return result; + } + } + + + public static T[] concat(T[] first, T[] second, T[] third, T[]... others) { + T[] results = concat(first, second); + results = concat(results, third); + + if (others != null && others.length > 0) { + for (T[] other : others) { + results = concat(results, other); + } + } + return results; + } + + + /** + * 可变长参形式数组 + * + * @param first 第一个数组 + * @param second 第二个数组 + * @param + * @return 新的数组 + */ + @SafeVarargs + public static T[] append(T[] first, T... second) { + if (first == null && second == null) { + throw new IllegalArgumentException("not allow first and second are null."); + } else if (isEmpty(first) && second != null) { + return second; + } else if (isEmpty(second)) { + return first; + } else { + T[] result = Arrays.copyOf(first, first.length + second.length); + System.arraycopy(second, 0, result, first.length, second.length); + return result; + } + } + + + /** + * 查看数组中是否包含某一个值 + * + * @param arrays 数组 + * @param object 用于检测的值 + * @param + * @return true 包含 + */ + public static boolean contains(T[] arrays, T object) { + if (isEmpty(arrays)) { + return false; + } + for (T array : arrays) { + if (Objects.equals(array, object)) { + return true; + } + } + return false; + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/ClassUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/util/ClassUtil.java new file mode 100644 index 0000000..950e13c --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/ClassUtil.java @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import java.lang.reflect.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Predicate; + +/** + * 类实例创建者创建者 + * + * @author michael + * @date 17/3/21 + */ +public class ClassUtil { + + private ClassUtil() { + } + + private static final String[] OBJECT_METHODS = new String[]{ + "toString", + "getClass", + "equals", + "hashCode", + "wait", + "notify", + "notifyAll", + "clone", + "finalize" + }; + + //proxy frameworks + private static final List PROXY_CLASS_NAMES = Arrays.asList("net.sf.cglib.proxy.Factory" + // cglib + , "org.springframework.cglib.proxy.Factory" + + // javassist + , "javassist.util.proxy.ProxyObject" + , "org.apache.ibatis.javassist.util.proxy.ProxyObject"); + private static final String ENHANCER_BY = "$$EnhancerBy"; + private static final String JAVASSIST_BY = "_$$_"; + + public static boolean isProxy(Class clazz) { + for (Class cls : clazz.getInterfaces()) { + if (PROXY_CLASS_NAMES.contains(cls.getName())) { + return true; + } + } + //java proxy + return Proxy.isProxyClass(clazz); + } + + private static Class getJdkProxySuperClass(Class clazz) { + final Class proxyClass = Proxy.getProxyClass(clazz.getClassLoader(), clazz.getInterfaces()); + return (Class) proxyClass.getInterfaces()[0]; + } + + public static Class getUsefulClass(Class clazz) { + if (isProxy(clazz)) { + return getJdkProxySuperClass(clazz); + } + + //ControllerTest$ServiceTest$$EnhancerByGuice$$40471411#hello -------> Guice + //com.demo.blog.Blog$$EnhancerByCGLIB$$69a17158 ----> CGLIB + //io.jboot.test.app.TestAppListener_$$_jvstb9f_0 ------> javassist + final String name = clazz.getName(); + if (name.contains(ENHANCER_BY) || name.contains(JAVASSIST_BY)) { + return (Class) clazz.getSuperclass(); + } + + return clazz; + } + + + public static Class getWrapType(Class clazz) { + if (clazz == null || !clazz.isPrimitive()) { + return clazz; + } + if (clazz == Integer.TYPE) { + return Integer.class; + } else if (clazz == Long.TYPE) { + return Long.class; + } else if (clazz == Boolean.TYPE) { + return Boolean.class; + } else if (clazz == Float.TYPE) { + return Float.class; + } else if (clazz == Double.TYPE) { + return Double.class; + } else if (clazz == Short.TYPE) { + return Short.class; + } else if (clazz == Character.TYPE) { + return Character.class; + } else if (clazz == Byte.TYPE) { + return Byte.class; + } else if (clazz == Void.TYPE) { + return Void.class; + } + return clazz; + } + + + public static boolean isArray(Class clazz) { + return clazz.isArray() + || clazz == int[].class + || clazz == long[].class + || clazz == short[].class + || clazz == float[].class + || clazz == double[].class; + } + + + public static List getAllFields(Class clazz) { + List fields = new ArrayList<>(); + doGetFields(clazz, fields, null, false); + return fields; + } + + public static List getAllFields(Class clazz, Predicate predicate) { + List fields = new ArrayList<>(); + doGetFields(clazz, fields, predicate, false); + return fields; + } + + public static Field getFirstField(Class clazz, Predicate predicate) { + List fields = new ArrayList<>(); + doGetFields(clazz, fields, predicate, true); + return fields.isEmpty() ? null : fields.get(0); + } + + private static void doGetFields(Class clazz, List fields, Predicate predicate, boolean firstOnly) { + if (clazz == null || clazz == Object.class) { + return; + } + + Field[] declaredFields = clazz.getDeclaredFields(); + for (Field declaredField : declaredFields) { + if (predicate == null || predicate.test(declaredField)) { + fields.add(declaredField); + if (firstOnly) { + break; + } + } + } + + if (firstOnly && !fields.isEmpty()) { + return; + } + + doGetFields(clazz.getSuperclass(), fields, predicate, firstOnly); + } + + public static List getAllMethods(Class clazz) { + List methods = new ArrayList<>(); + doGetMethods(clazz, methods, null, false); + return methods; + } + + public static List getAllMethods(Class clazz, Predicate predicate) { + List methods = new ArrayList<>(); + doGetMethods(clazz, methods, predicate, false); + return methods; + } + + public static Method getFirstMethod(Class clazz, Predicate predicate) { + List methods = new ArrayList<>(); + doGetMethods(clazz, methods, predicate, true); + return methods.isEmpty() ? null : methods.get(0); + } + + + private static void doGetMethods(Class clazz, List methods, Predicate predicate, boolean firstOnly) { + if (clazz == null || clazz == Object.class) { + return; + } + + Method[] declaredMethods = clazz.getDeclaredMethods(); + for (Method method : declaredMethods) { + if (predicate == null || predicate.test(method)) { + methods.add(method); + if (firstOnly) { + break; + } + } + } + + if (firstOnly && !methods.isEmpty()) { + return; + } + + doGetMethods(clazz.getSuperclass(), methods, predicate, firstOnly); + } + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/CollectionUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/util/CollectionUtil.java new file mode 100644 index 0000000..c848190 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/CollectionUtil.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import java.util.Collection; +import java.util.List; + +public class CollectionUtil { + + private CollectionUtil() { + } + + + public static boolean noItems(Collection collection) { + return collection == null || collection.isEmpty(); + } + + + public static boolean hasItems(Collection collection) { + return !noItems(collection); + } + + public static T firstItem(List list) { + if (list == null || list.isEmpty()) { + return null; + } + return list.get(0); + } + + + public static T lastItem(List list) { + if (list == null || list.isEmpty()) { + return null; + } + return list.get(list.size() - 1); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/Copyable.java b/easy-agents-core/src/main/java/com/easyagents/core/util/Copyable.java new file mode 100644 index 0000000..c69690d --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/Copyable.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +/** + * 表示对象支持创建一个独立副本(深拷贝语义)。 + * 修改副本不应影响原对象,反之亦然。 + * + * @param 副本的具体类型,通常为当前类自身 + */ +public interface Copyable { + /** + * 创建并返回当前对象的副本。 + * + * @return 一个新的、内容相同但内存独立的对象 + */ + T copy(); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/HashUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/util/HashUtil.java new file mode 100644 index 0000000..494d942 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/HashUtil.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; + +public class HashUtil { + private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray(); + private static final char[] CHAR_ARRAY = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); + + public static String md5(String srcStr) { + return hash("MD5", srcStr); + } + + + public static String sha256(String srcStr) { + return hash("SHA-256", srcStr); + } + + public static String hmacSHA256ToBase64(String content, String secret) { + try { + Mac hmacSHA256 = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + hmacSHA256.init(secretKey); + byte[] bytes = hmacSHA256.doFinal(content.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(bytes); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static String hash(String algorithm, String srcStr) { + try { + MessageDigest md = MessageDigest.getInstance(algorithm); + byte[] bytes = md.digest(srcStr.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(bytes); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static String bytesToHex(byte[] bytes) { + StringBuilder ret = new StringBuilder(bytes.length * 2); + for (byte aByte : bytes) { + ret.append(HEX_DIGITS[(aByte >> 4) & 0x0f]); + ret.append(HEX_DIGITS[aByte & 0x0f]); + } + return ret.toString(); + } + + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/IOUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/util/IOUtil.java new file mode 100644 index 0000000..220c014 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/IOUtil.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import okio.BufferedSink; + +import java.io.*; +import java.nio.charset.StandardCharsets; + +public class IOUtil { + private static final int DEFAULT_BUFFER_SIZE = 8192; + + public static void writeBytes(byte[] bytes, File toFile) { + try (FileOutputStream stream = new FileOutputStream(toFile)) { + stream.write(bytes); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public static byte[] readBytes(File file) { + try (FileInputStream inputStream = new FileInputStream(file)) { + return readBytes(inputStream); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public static byte[] readBytes(InputStream inputStream) { + try { + ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + copy(inputStream, outStream); + return outStream.toByteArray(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public static void copy(InputStream inputStream, BufferedSink sink) throws IOException { + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + for (int len; (len = inputStream.read(buffer)) != -1; ) { + sink.write(buffer, 0, len); + } + } + + public static void copy(InputStream inputStream, OutputStream outStream) throws IOException { + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + for (int len; (len = inputStream.read(buffer)) != -1; ) { + outStream.write(buffer, 0, len); + } + } + + public static String readUtf8(InputStream inputStream) throws IOException { + ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + copy(inputStream, outStream); + return new String(outStream.toByteArray(), StandardCharsets.UTF_8); + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/ImageUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/util/ImageUtil.java new file mode 100644 index 0000000..97a6819 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/ImageUtil.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import com.easyagents.core.model.client.HttpClient; + +import java.io.File; +import java.net.URLConnection; +import java.util.Base64; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +public class ImageUtil { + + private static final HttpClient imageHttpClient = new HttpClient(); + + // 手动维护的扩展名 -> MIME 类型映射(覆盖 JDK 未识别的格式) + private static final Map EXTENSION_TO_MIME = new ConcurrentHashMap<>(); + + static { + // 常见图片格式(包括现代格式) + EXTENSION_TO_MIME.put("jpg", "image/jpeg"); + EXTENSION_TO_MIME.put("jpeg", "image/jpeg"); + EXTENSION_TO_MIME.put("png", "image/png"); + EXTENSION_TO_MIME.put("gif", "image/gif"); + EXTENSION_TO_MIME.put("bmp", "image/bmp"); + EXTENSION_TO_MIME.put("svg", "image/svg+xml"); + EXTENSION_TO_MIME.put("webp", "image/webp"); + EXTENSION_TO_MIME.put("avif", "image/avif"); + EXTENSION_TO_MIME.put("jxl", "image/jxl"); // JPEG XL + EXTENSION_TO_MIME.put("tiff", "image/tiff"); + EXTENSION_TO_MIME.put("tif", "image/tiff"); + EXTENSION_TO_MIME.put("ico", "image/x-icon"); + // 可根据项目需要继续扩展 + } + + /** + * 将图片 URL 转换为 Data URI 格式的字符串(例如:image/jpeg;base64,...) + * + * @throws IllegalArgumentException 如果 URL 无效或无法获取内容 + */ + public static String imageUrlToDataUri(String imageUrl) { + Objects.requireNonNull(imageUrl, "Image URL must not be null"); + try { + byte[] bytes = imageHttpClient.getBytes(imageUrl); + String mimeType = guessMimeTypeFromName(imageUrl); + return imageBytesToDataUri(bytes, mimeType); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to convert image URL to Data URI: " + imageUrl, e); + } + } + + /** + * 将图片文件转换为 Data URI 格式的字符串 + * + * @throws IllegalArgumentException 如果文件不存在或读取失败 + */ + public static String imageFileToDataUri(File imageFile) { + Objects.requireNonNull(imageFile, "Image file must not be null"); + byte[] bytes = IOUtil.readBytes(imageFile); + String mimeType = guessMimeTypeFromName(imageFile.getName()); + return imageBytesToDataUri(bytes, mimeType); + } + + + /** + * 根据文件名(或 URL)提取扩展名,并返回对应的 MIME 类型。 + * 优先使用内置映射,其次尝试 JDK 的 guessContentTypeFromName,最后 fallback 到 image/jpeg。 + */ + private static String guessMimeTypeFromName(String name) { + if (name == null || name.isEmpty()) { + return "image/jpeg"; + } + + // 提取扩展名(最后一个 '.' 之后的部分) + int lastDotIndex = name.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == name.length() - 1) { + // 无扩展名,尝试让 JDK 猜测(虽然大概率失败) + String mime = URLConnection.guessContentTypeFromName(name); + return mime != null ? mime : "image/jpeg"; + } + + String ext = name.substring(lastDotIndex + 1).toLowerCase(Locale.ROOT); + String mime = EXTENSION_TO_MIME.get(ext); + if (mime != null) { + return mime; + } + + // JDK 可能认识一些格式(如 .png, .jpg),作为后备 + mime = URLConnection.guessContentTypeFromName(name); + return mime != null ? mime : "image/jpeg"; + } + + public static String imageBytesToDataUri(byte[] data, String mimeType) { + String base64 = Base64.getEncoder().encodeToString(data); + return mimeType + ";base64," + base64; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/JSONUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/util/JSONUtil.java new file mode 100644 index 0000000..3d064fb --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/JSONUtil.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import com.alibaba.fastjson2.JSONObject; +import com.alibaba.fastjson2.JSONPath; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class JSONUtil { + + private static final Map jsonPaths = new ConcurrentHashMap<>(); + + public static JSONPath getJsonPath(String path) { + return MapUtil.computeIfAbsent(jsonPaths, path, JSONPath::of); + } + + public static double[] readDoubleArray(JSONObject jsonObject, String path) { + if (jsonObject == null || path == null) { + return null; + } + + JSONPath jsonPath = getJsonPath(path); + Object result = jsonPath.eval(jsonObject); + + if (result == null) { + return null; + } + + if (result instanceof List) { + List list = (List) result; + double[] array = new double[list.size()]; + for (int i = 0; i < list.size(); i++) { + Object item = list.get(i); + if (item instanceof Number) { + array[i] = ((Number) item).doubleValue(); + } else if (item == null) { + array[i] = 0.0; // 或抛异常,根据需求 + } else { + // 尝试转换字符串?或报错 + try { + array[i] = Double.parseDouble(item.toString()); + } catch (NumberFormatException e) { + return null; // 或抛异常 + } + } + } + return array; + } + + return null; + } + + public static String readString(JSONObject jsonObject, String path) { + if (jsonObject == null || path == null) { + return null; + } + + JSONPath jsonPath = getJsonPath(path); + Object result = jsonPath.eval(jsonObject); + + if (result == null) { + return null; + } + + if (result instanceof String) { + return (String) result; + } + + return null; + } + + public static Integer readInteger(JSONObject jsonObject, String path) { + if (jsonObject == null || path == null) { + return null; + } + JSONPath jsonPath = getJsonPath(path); + Object result = jsonPath.eval(jsonObject); + if (result == null) { + return null; + } + if (result instanceof Number) { + return ((Number) result).intValue(); + } + if (result instanceof String) { + return Integer.parseInt((String) result); + } + throw new IllegalArgumentException("Invalid JSON path result type: " + result.getClass().getName()); + } + + public static Long readLong(JSONObject jsonObject, String path) { + if (jsonObject == null || path == null) { + return null; + } + JSONPath jsonPath = getJsonPath(path); + Object result = jsonPath.eval(jsonObject); + if (result == null) { + return null; + } + if (result instanceof Number) { + return ((Number) result).longValue(); + } + if (result instanceof String) { + return Long.getLong((String) result); + } + throw new IllegalArgumentException("Invalid JSON path result type: " + result.getClass().getName()); + } + + public static String detectErrorMessage(JSONObject jsonObject) { + JSONObject errorObject = jsonObject.getJSONObject("error"); + if (errorObject == null) { + return null; + } + String errorMessage = errorObject.getString("message"); + String errorCode = errorObject.getString("code"); + return errorCode == null ? errorMessage : (errorCode + ": " + errorMessage); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/LocalTokenCounter.java b/easy-agents-core/src/main/java/com/easyagents/core/util/LocalTokenCounter.java new file mode 100644 index 0000000..c121413 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/LocalTokenCounter.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.Message; +import com.easyagents.core.message.ToolCall; +import com.knuddels.jtokkit.Encodings; +import com.knuddels.jtokkit.api.Encoding; +import com.knuddels.jtokkit.api.EncodingType; + +import java.util.List; + +/** + * 静态工具类:更精确的本地 token 统计工具,模拟 OpenAI ChatCompletion 格式。 + * 支持 function calls,按消息 role/name/内容序列化计数。 + */ +public class LocalTokenCounter { + + // 静态 Encoder,线程安全 + private static Encoding ENCODING = + Encodings.newDefaultEncodingRegistry().getEncoding(EncodingType.CL100K_BASE); + + public static void init(Encoding encoding) { + ENCODING = encoding; + } + + /** + * 基于完整对话历史,为最后一条 AiMessage 计算并设置本地 token 字段。 + * + * @param messages 完整的对话消息列表(按顺序,包含 system/user/ai) + * @param aiMessage 要设置 token 的 AiMessage(应为 messages 中的最后一条 assistant 消息) + */ + public static void computeAndSetLocalTokens(List messages, AiMessage aiMessage) { + if (messages == null || messages.isEmpty() || aiMessage == null) { + return; + } + + int promptTokens = countPromptTokens(messages); + int completionTokens = countCompletionTokens(aiMessage); + + aiMessage.setLocalPromptTokens(promptTokens); + aiMessage.setLocalCompletionTokens(completionTokens); + aiMessage.setLocalTotalTokens(promptTokens + completionTokens); + } + + /** + * 计算 prompt token(对话历史) + * 按 OpenAI ChatCompletion 格式,每条消息 role+content+name 固定 token + */ + public static int countPromptTokens(List messages) { + if (messages == null || messages.isEmpty()) return 0; + + int total = 0; + for (Message msg : messages) { + total += countMessageTokens(msg); + } + // 结尾通常多一个 token,模拟 OpenAI 格式 + total += 2; + return total; + } + + /** + * 计算单条消息 token + */ + private static int countMessageTokens(Message msg) { + int count = 0; + + // role token + count += 1; + + // content token + Object content = msg.getTextContent(); + if (content != null) { + count += ENCODING.countTokens(content.toString()); + } + + return count; + } + + /** + * 计算 AiMessage completion token + * 包含 fullContent / reasoningContent / functionCall + */ + public static int countCompletionTokens(AiMessage aiMsg) { + if (aiMsg == null) return 0; + + int count = 0; + + // 生成的文本 + if (aiMsg.getFullContent() != null) { + count += ENCODING.countTokens(aiMsg.getFullContent()); + } + + // 推理内容 + if (aiMsg.getFullReasoningContent() != null) { + count += ENCODING.countTokens(aiMsg.getFullReasoningContent()); + } else if (aiMsg.getReasoningContent() != null) { + count += ENCODING.countTokens(aiMsg.getReasoningContent()); + } + + // function call(按 JSON 序列化计算) + List toolCalls = aiMsg.getToolCalls(); + if (toolCalls != null && !toolCalls.isEmpty()) { + for (ToolCall toolCall : toolCalls) { + String serialized = toolCall.toJsonString(); + count += ENCODING.countTokens(serialized); + } + } + + // completion 固定尾部 token + count += 2; + return count; + } + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/MapUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/util/MapUtil.java new file mode 100644 index 0000000..b1332be --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/MapUtil.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import java.util.Map; +import java.util.function.Function; + +public class MapUtil { + private static final boolean IS_JDK8 = (8 == getJvmVersion0()); + + private MapUtil() { + } + + private static String tryTrim(String string) { + return string != null ? string.trim() : ""; + } + + private static int getJvmVersion0() { + int jvmVersion = -1; + try { + String javaSpecVer = tryTrim(System.getProperty("java.specification.version")); + if (StringUtil.hasText(javaSpecVer)) { + if (javaSpecVer.startsWith("1.")) { + javaSpecVer = javaSpecVer.substring(2); + } + if (javaSpecVer.indexOf('.') == -1) { + jvmVersion = Integer.parseInt(javaSpecVer); + } + } + } catch (Throwable ignore) { + // ignore + } + // default is jdk8 + if (jvmVersion == -1) { + jvmVersion = 8; + } + return jvmVersion; + } + + /** + * A temporary workaround for Java 8 specific performance issue JDK-8161372 .
+ * This class should be removed once we drop Java 8 support. + * + * @see https://bugs.openjdk.java.net/browse/JDK-8161372 + */ + public static V computeIfAbsent(Map map, K key, Function mappingFunction) { + if (IS_JDK8) { + V value = map.get(key); + if (value != null) { + return value; + } + } + return map.computeIfAbsent(key, mappingFunction); + } + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/Maps.java b/easy-agents-core/src/main/java/com/easyagents/core/util/Maps.java new file mode 100644 index 0000000..2651bdf --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/Maps.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import com.alibaba.fastjson2.JSON; + +import java.lang.reflect.Array; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +public class Maps extends HashMap { + + public static Maps of() { + return new Maps(); + } + + public static Maps of(String key, Object value) { + Maps maps = Maps.of(); + maps.put(key, value); + return maps; + } + + public static Maps ofNotNull(String key, Object value) { + return new Maps().setIfNotNull(key, value); + } + + public static Maps ofNotEmpty(String key, Object value) { + return new Maps().setIfNotEmpty(key, value); + } + + public static Maps ofNotEmpty(String key, Maps value) { + return new Maps().setIfNotEmpty(key, value); + } + + + public Maps set(String key, Object value) { + super.put(key, value); + return this; + } + + public Maps setChild(String key, Object value) { + if (key.contains(".")) { + String[] keys = key.split("\\."); + Map currentMap = this; + for (int i = 0; i < keys.length; i++) { + String currentKey = keys[i].trim(); + if (currentKey.isEmpty()) { + continue; + } + if (i == keys.length - 1) { + currentMap.put(currentKey, value); + } else { + //noinspection unchecked + currentMap = (Map) currentMap.computeIfAbsent(currentKey, k -> Maps.of()); + } + } + } else { + super.put(key, value); + } + + return this; + } + + public Maps setOrDefault(String key, Object value, Object orDefault) { + if (isNullOrEmpty(value)) { + return this.set(key, orDefault); + } else { + return this.set(key, value); + } + } + + public Maps setIf(boolean condition, String key, Object value) { + if (condition) put(key, value); + return this; + } + + public Maps setIf(Function func, String key, Object value) { + if (func.apply(this)) put(key, value); + return this; + } + + public Maps setIfNotNull(String key, Object value) { + if (value != null) put(key, value); + return this; + } + + public Maps setIfNotEmpty(String key, Object value) { + if (!isNullOrEmpty(value)) { + put(key, value); + } + return this; + } + + public Maps setIfNotEmpty(Map source) { + if (!isNullOrEmpty(source)) { + this.putAll(source); + } + return this; + } + + + public Maps setIfContainsKey(String checkKey, String key, Object value) { + if (this.containsKey(checkKey)) { + this.put(key, value); + } + return this; + } + + public Maps setIfNotContainsKey(String checkKey, String key, Object value) { + if (!this.containsKey(checkKey)) { + this.put(key, value); + } + return this; + } + + public String toJSON() { + return JSON.toJSONString(this); + } + + + private static boolean isNullOrEmpty(Object value) { + if (value == null) { + return true; + } + + if (value instanceof Collection && ((Collection) value).isEmpty()) { + return true; + } + + if (value instanceof Map && ((Map) value).isEmpty()) { + return true; + } + + if (value.getClass().isArray() && Array.getLength(value) == 0) { + return true; + } + + return value instanceof String && ((String) value).trim().isEmpty(); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/MessageUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/util/MessageUtil.java new file mode 100644 index 0000000..3fa636d --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/MessageUtil.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import com.easyagents.core.message.UserMessage; +import com.easyagents.core.message.Message; + +import java.util.List; + +public class MessageUtil { + + public static UserMessage findLastUserMessage(List messages) { + if (messages == null || messages.isEmpty()) { + return null; + } + for (int i = messages.size() - 1; i >= 0; i--) { + Message message = messages.get(i); + if (message instanceof UserMessage) { + return (UserMessage) message; + } + } + return null; + } + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/Metadata.java b/easy-agents-core/src/main/java/com/easyagents/core/util/Metadata.java new file mode 100644 index 0000000..b96f614 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/Metadata.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +public class Metadata implements Serializable { + + protected Map metadataMap; + + public Object getMetadata(String key) { + return metadataMap != null ? metadataMap.get(key) : null; + } + + public Object getMetadata(String key, Object defaultValue) { + Object value = getMetadata(key); + return value != null ? value : defaultValue; + } + + public void addMetadata(String key, Object value) { + if (metadataMap == null) { + metadataMap = new HashMap<>(); + } + metadataMap.put(key, value); + } + + public void addMetadata(Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return; + } + if (metadataMap == null) { + metadataMap = new HashMap<>(); + } + metadataMap.putAll(metadata); + } + + public Object removeMetadata(String key) { + if (this.metadataMap == null) { + return null; + } + return this.metadataMap.remove(key); + } + + public Map getMetadataMap() { + return metadataMap; + } + + public void setMetadataMap(Map metadatas) { + this.metadataMap = metadatas; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/NamedThreadFactory.java b/easy-agents-core/src/main/java/com/easyagents/core/util/NamedThreadFactory.java new file mode 100644 index 0000000..ad58ccb --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/NamedThreadFactory.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * @author michael yang (fuhai999@gmail.com) + */ +public class NamedThreadFactory implements ThreadFactory { + + protected static final AtomicInteger POOL_COUNTER = new AtomicInteger(1); + protected final AtomicInteger mThreadCounter; + protected final String mPrefix; + protected final boolean mDaemon; + protected final ThreadGroup mGroup; + + public NamedThreadFactory() { + this("pool-" + POOL_COUNTER.getAndIncrement(), false); + } + + public NamedThreadFactory(String prefix) { + this(prefix, false); + } + + public NamedThreadFactory(String prefix, boolean daemon) { + this.mThreadCounter = new AtomicInteger(1); + this.mPrefix = prefix + "-thread-"; + this.mDaemon = daemon; + SecurityManager s = System.getSecurityManager(); + this.mGroup = s == null ? Thread.currentThread().getThreadGroup() : s.getThreadGroup(); + } + + @Override + public Thread newThread(Runnable runnable) { + String name = this.mPrefix + this.mThreadCounter.getAndIncrement(); + Thread ret = new Thread(this.mGroup, runnable, name, 0L); + ret.setDaemon(this.mDaemon); + return ret; + } + + public ThreadGroup getThreadGroup() { + return this.mGroup; + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/NamedThreadPools.java b/easy-agents-core/src/main/java/com/easyagents/core/util/NamedThreadPools.java new file mode 100644 index 0000000..bcd3212 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/NamedThreadPools.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import java.util.concurrent.*; + +/** + * @author michael yang (fuhai999@gmail.com) + */ +public class NamedThreadPools { + + public static ExecutorService newFixedThreadPool(String prefix) { + int nThreads = Runtime.getRuntime().availableProcessors(); + return newFixedThreadPool(nThreads, prefix); + } + + + public static ExecutorService newFixedThreadPool(int nThreads, String name) { + return new ThreadPoolExecutor(nThreads, nThreads, + 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(nThreads * 2), + new NamedThreadFactory(name)); + } + + + public static ExecutorService newCachedThreadPool(String name) { + return newCachedThreadPool(new NamedThreadFactory(name)); + } + + + public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { + return new ThreadPoolExecutor(0, Integer.MAX_VALUE, + 60L, TimeUnit.SECONDS, + new SynchronousQueue(), + threadFactory); + } + + + public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize, String name) { + return newScheduledThreadPool(corePoolSize, new NamedThreadFactory(name)); + } + + + public static ScheduledExecutorService newScheduledThreadPool( + int corePoolSize, ThreadFactory threadFactory) { + return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/RetryException.java b/easy-agents-core/src/main/java/com/easyagents/core/util/RetryException.java new file mode 100644 index 0000000..79d7615 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/RetryException.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +public class RetryException extends RuntimeException { + /** + * Constructs a new runtime exception with the specified detail + * message, cause, suppression enabled or disabled, and writable + * stack trace enabled or disabled. + * + * @param message the detail message. + * @param cause the cause. (A {@code null} value is permitted, + * and indicates that the cause is nonexistent or unknown.) + * @param enableSuppression whether or not suppression is enabled + * or disabled + * @param writableStackTrace whether or not the stack trace should + * be writable + * @since 1.7 + */ + protected RetryException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } + + /** + * Constructs a new runtime exception with the specified cause and a + * detail message of (cause==null ? null : cause.toString()) + * (which typically contains the class and detail message of + * cause). This constructor is useful for runtime exceptions + * that are little more than wrappers for other throwables. + * + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public RetryException(Throwable cause) { + super(cause); + } + + /** + * Constructs a new runtime exception with the specified detail message and + * cause.

Note that the detail message associated with + * {@code cause} is not automatically incorporated in + * this runtime exception's detail message. + * + * @param message the detail message (which is saved for later retrieval + * by the {@link #getMessage()} method). + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public RetryException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new runtime exception with the specified detail message. + * The cause is not initialized, and may subsequently be initialized by a + * call to {@link #initCause}. + * + * @param message the detail message. The detail message is saved for + * later retrieval by the {@link #getMessage()} method. + */ + public RetryException(String message) { + super(message); + } + + /** + * Constructs a new runtime exception with {@code null} as its + * detail message. The cause is not initialized, and may subsequently be + * initialized by a call to {@link #initCause}. + */ + public RetryException() { + super(); + } +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/Retryer.java b/easy-agents-core/src/main/java/com/easyagents/core/util/Retryer.java new file mode 100644 index 0000000..34c8c2b --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/Retryer.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +/** + * 重试执行器。 + *

+ * 设计原则: + * - Builder 仅用于构造配置 + * - Retryer 实例是线程安全的(无状态),可全局复用 + */ +public final class Retryer { + + private final int maxRetries; + private final long initialDelayMs; + private final long maxDelayMs; + private final boolean exponentialBackoff; + private final long totalTimeoutMs; + private final Predicate retryOnException; + private final Predicate retryOnResult; + private final String operationName; + + private Retryer(Builder builder) { + this.maxRetries = builder.maxRetries; + this.initialDelayMs = builder.initialDelayMs; + this.maxDelayMs = builder.maxDelayMs; + this.exponentialBackoff = builder.exponentialBackoff; + this.totalTimeoutMs = builder.totalTimeoutMs; + this.retryOnException = builder.retryOnException; + this.retryOnResult = builder.retryOnResult; + this.operationName = builder.operationName; + } + + public static Builder builder() { + return new Builder(); + } + + + public static T retry(Callable task, int maxRetries, long initialDelayMs) { + return builder() + .maxRetries(maxRetries) + .initialDelayMs(initialDelayMs) + .build() + .execute(task); + } + + public static void retry(Runnable task, int maxRetries, long initialDelayMs) { + builder() + .maxRetries(maxRetries) + .initialDelayMs(initialDelayMs) + .build() + .execute(task); + } + + + public T execute(Callable task) { + if (task == null) { + throw new IllegalArgumentException("Task must not be null"); + } + + long deadline = totalTimeoutMs > 0 ? System.currentTimeMillis() + totalTimeoutMs : Long.MAX_VALUE; + long currentDelay = initialDelayMs; + Exception lastException = null; + + for (int attempt = 0; attempt <= maxRetries; attempt++) { + if (Thread.interrupted()) { + throw new RetryException( + "Retry interrupted at attempt " + attempt + " for: " + operationName, + new InterruptedException()); + } + + if (System.currentTimeMillis() > deadline) { + throw new RetryException( + "Retry deadline exceeded after " + attempt + " attempts for: " + operationName, + new java.util.concurrent.TimeoutException()); + } + + try { + T result = task.call(); + if (attempt < maxRetries && retryOnResult.test(result)) { + lastException = new RuntimeException("Retry triggered by result predicate"); + sleepSafely(Math.min(currentDelay, deadline - System.currentTimeMillis())); + if (exponentialBackoff) { + currentDelay = Math.min(currentDelay * 2, maxDelayMs); + } + continue; + } + return result; + } catch (Exception e) { + lastException = e; + if (attempt < maxRetries && retryOnException.test(e)) { + sleepSafely(Math.min(currentDelay, deadline - System.currentTimeMillis())); + if (exponentialBackoff) { + currentDelay = Math.min(currentDelay * 2, maxDelayMs); + } + } else { + break; + } + } + } + + if (lastException != null) { + throw new RetryException( + "Retry failed for: " + operationName + " after " + (maxRetries + 1) + " attempts", + lastException); + } + + throw new IllegalStateException("Retry loop exited without result or exception"); + } + + public void execute(Runnable task) { + try { + execute(() -> { + task.run(); + return null; + }); + } catch (Exception e) { + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } + throw new RuntimeException("Error in retryable Runnable", e); + } + } + + private void sleepSafely(long sleepMs) { + if (sleepMs <= 0) return; + try { + TimeUnit.MILLISECONDS.sleep(sleepMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during retry backoff", e); + } + } + + + public static class Builder { + private int maxRetries = 2; + private long initialDelayMs = 100; + private long maxDelayMs = 5000; + private boolean exponentialBackoff = false; + private long totalTimeoutMs = 0; + private Predicate retryOnException = DEFAULT_RETRYABLE_EXCEPTION; + private Predicate retryOnResult = r -> false; + private String operationName = "retryable_operation"; + + public Builder maxRetries(int maxRetries) { + this.maxRetries = Math.max(0, maxRetries); + return this; + } + + public Builder initialDelayMs(long delayMs) { + this.initialDelayMs = Math.max(0, delayMs); + return this; + } + + public Builder maxDelayMs(long maxDelayMs) { + this.maxDelayMs = Math.max(this.initialDelayMs, maxDelayMs); + return this; + } + + public Builder exponentialBackoff() { + this.exponentialBackoff = true; + return this; + } + + public Builder totalTimeoutMs(long totalTimeoutMs) { + this.totalTimeoutMs = Math.max(0, totalTimeoutMs); + return this; + } + + public Builder retryOnException(Predicate predicate) { + this.retryOnException = predicate != null ? predicate : DEFAULT_RETRYABLE_EXCEPTION; + return this; + } + + public Builder retryOnResult(Predicate predicate) { + this.retryOnResult = predicate != null ? predicate : (r -> false); + return this; + } + + public Builder operationName(String name) { + this.operationName = name != null ? name : "retryable_operation"; + return this; + } + + public Retryer build() { + return new Retryer(this); + } + } + + + private static final Predicate DEFAULT_RETRYABLE_EXCEPTION = e -> + e instanceof java.net.SocketTimeoutException || + e instanceof java.net.ConnectException || + e instanceof java.net.UnknownHostException || + e instanceof java.io.IOException || + (e.getCause() instanceof java.net.SocketTimeoutException) || + (e.getCause() instanceof java.net.ConnectException); +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/StringUtil.java b/easy-agents-core/src/main/java/com/easyagents/core/util/StringUtil.java new file mode 100644 index 0000000..c7c37c5 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/StringUtil.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.util; + +public class StringUtil { + + public static boolean noText(String string) { + return !hasText(string); + } + + public static boolean hasText(String string) { + return string != null && !string.isEmpty() && containsText(string); + } + + public static boolean hasText(String... strings) { + for (String string : strings) { + if (!hasText(string)) { + return false; + } + } + return true; + } + + private static boolean containsText(CharSequence str) { + for (int i = 0; i < str.length(); i++) { + if (!Character.isWhitespace(str.charAt(i))) { + return true; + } + } + return false; + } + + public static String getFirstWithText(String... strings) { + if (strings == null) { + return null; + } + for (String str : strings) { + if (hasText(str)) { + return str; + } + } + return null; + } + + public static boolean isJsonObject(String jsonString) { + if (noText(jsonString)) { + return false; + } + + jsonString = jsonString.trim(); + return jsonString.startsWith("{") && jsonString.endsWith("}"); + } + + public static boolean notJsonObject(String jsonString) { + return !isJsonObject(jsonString); + } + + +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/util/package-info.java b/easy-agents-core/src/main/java/com/easyagents/core/util/package-info.java new file mode 100644 index 0000000..9061597 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/util/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * 工具类 + */ +package com.easyagents.core.util; diff --git a/easy-agents-core/src/test/java/com/easyagents/core/message/ToolCallTest.java b/easy-agents-core/src/test/java/com/easyagents/core/message/ToolCallTest.java new file mode 100644 index 0000000..60a9517 --- /dev/null +++ b/easy-agents-core/src/test/java/com/easyagents/core/message/ToolCallTest.java @@ -0,0 +1,86 @@ +package com.easyagents.core.message; + +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.*; + +/** + * 测试 ToolCall 类的 getArgsMap 方法 + */ +public class ToolCallTest { + + /** + * 测试当 arguments 为 null 时,应该返回 null + */ + @Test + public void testGetArgsMap_ArgumentsIsNull_ReturnsNull() { + ToolCall toolCall = new ToolCall(); +// toolCall.arguments = null; + assertNull(toolCall.getArgsMap()); + } + + /** + * 测试当 arguments 为空字符串时,应该返回 null + */ + @Test + public void testGetArgsMap_ArgumentsIsEmpty_ReturnsNull() { + ToolCall toolCall = new ToolCall(); + toolCall.setArguments(""); + assertNull(toolCall.getArgsMap()); + } + + /** + * 测试正常格式的 JSON 字符串能正确解析为 Map + */ + @Test + public void testGetArgsMap_ValidJsonString_ParsesSuccessfully() { + ToolCall toolCall = new ToolCall(); + toolCall.setArguments("{\"name\":\"张三\", \"age\":25}"); + Map result = toolCall.getArgsMap(); + assertNotNull(result); + assertEquals("张三", result.get("name")); + assertEquals(25, result.get("age")); + } + + /** + * 测试带有前缀和后缀的内容能够提取核心 JSON 进行解析 + */ + @Test + public void testGetArgsMap_WithPrefixSuffix_ExtractionWorks() { + ToolCall toolCall = new ToolCall(); + toolCall.setArguments("some prefix {\"name\":\"李四\", \"score\":90} some suffix"); + Map result = toolCall.getArgsMap(); + assertNotNull(result); + assertEquals("李四", result.get("name")); + assertEquals(90, result.get("score")); + } + + /** + * 测试缺少起始大括号的情况,自动补充后应能解析 + */ + @Test + public void testGetArgsMap_MissingStartBrace_AutoCompleteAndParse() { + ToolCall toolCall = new ToolCall(); + toolCall.setArguments("\"name\":\"王五\", \"active\":true}"); + Map result = toolCall.getArgsMap(); + assertNotNull(result); + assertEquals("王五", result.get("name")); + assertTrue((Boolean) result.get("active")); + } + + /** + * 测试缺少结束大括号的情况,自动补充后应能解析 + */ + @Test + public void testGetArgsMap_MissingEndBrace_AutoCompleteAndParse() { + ToolCall toolCall = new ToolCall(); + toolCall.setArguments("{\"id\":123,\"status\":\"pending\""); + Map result = toolCall.getArgsMap(); + assertNotNull(result); + assertEquals(123, result.get("id")); + assertEquals("pending", result.get("status")); + } + +} diff --git a/easy-agents-core/src/test/java/com/easyagents/core/test/PromptTemplateTest.java b/easy-agents-core/src/test/java/com/easyagents/core/test/PromptTemplateTest.java new file mode 100644 index 0000000..4d9477f --- /dev/null +++ b/easy-agents-core/src/test/java/com/easyagents/core/test/PromptTemplateTest.java @@ -0,0 +1,77 @@ +package com.easyagents.core.test; + +import com.easyagents.core.prompt.PromptTemplate; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +public class PromptTemplateTest { + + @Test + public void test002() { + Map map = new HashMap<>(); + map.put("useName", "Michael"); + map.put("aaa", "星期3"); + PromptTemplate promptTemplate = PromptTemplate.of("你好,{{ useName }} 今天是星期 :{{aaa }}}----- {a}aa"); + String string = promptTemplate.format(map); + System.out.println(string); + } + + + @Test + public void test003() { + String templateStr = "你好 {{ user.name ?? '匿名' }},欢迎来到 {{ site ?? 'EasyAgents.com' }}!"; + PromptTemplate template = new PromptTemplate(templateStr); + + Map params = new HashMap<>(); + Map user = new HashMap<>(); + user.put("name", "Michael"); + params.put("user", user); + params.put("site", "AIFlowy.tech"); + + System.out.println(template.format(params)); + // 输出:你好 Michael,欢迎来到 AIFlowy.tech! + + System.out.println(template.format(new HashMap<>())); + // 输出:你好 匿名,欢迎来到 EasyAgents.com! + } + + @Test + public void test004() { + String jsonTemplate = "{\n" + + "\"prompt\":\"{{prompt}}\",\n" + + "\"image_url\":\"{{image }}\"\n" + + "}"; + + String prompt = jsonTemplate; + String image = "http://image.jpg"; + PromptTemplate template = new PromptTemplate(jsonTemplate); + + + Map params = new HashMap<>(); + params.put("prompt", prompt); + params.put("image", image); + + System.out.println(template.format(params, true)); + } + + + @Test + public void test005() { + String jsonTemplate = "{\n" + + "\"prompt\":\"{{prompt}}\",\n" + + "\"image_url\":\"{{image ?? ccc ?? 'haha'}}\"\n" + + "}"; + + String prompt = "你好"; + PromptTemplate template = new PromptTemplate(jsonTemplate); + + + Map params = new HashMap<>(); + params.put("prompt", prompt); +// params.put("image", image); + + System.out.println(template.format(params, true)); + } +} diff --git a/easy-agents-core/src/test/java/com/easyagents/core/test/SearchWrapperTest.java b/easy-agents-core/src/test/java/com/easyagents/core/test/SearchWrapperTest.java new file mode 100644 index 0000000..b55ff9e --- /dev/null +++ b/easy-agents-core/src/test/java/com/easyagents/core/test/SearchWrapperTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.test; + +import java.util.Arrays; + +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.condition.Connector; +import org.junit.Assert; +import org.junit.Test; + +public class SearchWrapperTest { + + @Test + public void test01() { + SearchWrapper rw = new SearchWrapper(); + rw.eq("akey", "avalue").eq(Connector.OR, "bkey", "bvalue").group(rw1 -> { + rw1.eq("ckey", "avalue").in(Connector.AND_NOT, "dkey", Arrays.asList("aa", "bb")); + }).eq("a", "b"); + + String expr = "akey = \"avalue\" OR bkey = \"bvalue\" AND (ckey = \"avalue\" AND NOT dkey IN (\"aa\",\"bb\")) AND a = \"b\""; + Assert.assertEquals(expr, rw.toFilterExpression()); + + System.out.println(rw.toFilterExpression()); + } + + @Test + public void test02() { + SearchWrapper rw = new SearchWrapper(); + rw.eq("akey", "avalue").between(Connector.OR, "bkey", "1", "100").in("ckey", Arrays.asList("aa", "bb")); + + String expr = "akey = \"avalue\" OR bkey BETWEEN \"1\" AND \"100\" AND ckey IN (\"aa\",\"bb\")"; + Assert.assertEquals(expr, rw.toFilterExpression()); + + System.out.println(rw.toFilterExpression()); + } + + @Test + public void test03() { + SearchWrapper rw = new SearchWrapper(); + rw.eq("ak", "av") + // and ( 子条件 ) + .andCriteria(rw1 -> { + rw1.eq("bk", "bv").in("x1", Arrays.asList("1", "2")); + }) + // or ( 子条件 ) + .orCriteria(rw1 -> { + rw1.eq("ck", "cv").eq("ck1", "cv1"); + }) + .eq("a", "b"); + + String expr = "ak = \"av\" AND (bk = \"bv\" AND x1 IN (\"1\",\"2\")) OR (ck = \"cv\" AND ck1 = \"cv1\") AND a = \"b\""; + Assert.assertEquals(expr, rw.toFilterExpression()); + + System.out.println(rw.toFilterExpression()); + } + +} diff --git a/easy-agents-core/src/test/java/com/easyagents/core/test/splitter/MarkdownHeaderSplitterTest.java b/easy-agents-core/src/test/java/com/easyagents/core/test/splitter/MarkdownHeaderSplitterTest.java new file mode 100644 index 0000000..96ca1c9 --- /dev/null +++ b/easy-agents-core/src/test/java/com/easyagents/core/test/splitter/MarkdownHeaderSplitterTest.java @@ -0,0 +1,37 @@ +package com.easyagents.core.test.splitter; + +import com.easyagents.core.document.Document; +import com.easyagents.core.document.splitter.MarkdownHeaderSplitter; + +import java.util.List; + +public class MarkdownHeaderSplitterTest { + + public static void main(String[] args) { + + String markdown = "# Intro\n" + + "Text\n" + + "\n" + + "## Real Section 1\n" + + "\n" + + "```java\n" + + "// ## Not a header\n" + + "public class Test {}\n" + + "```\n" + + "\n" + + "## Real Section 2\n" + + "\n" + + "```md\n" + + "## Fake header in code\n" + + "```"; + + MarkdownHeaderSplitter splitter = new MarkdownHeaderSplitter(2); + List documents = splitter.split(Document.of(markdown)); + + for (Document document : documents) { + System.out.println("-------------------"); + System.out.println(document.getContent()); + } + + } +} diff --git a/easy-agents-core/src/test/java/com/easyagents/core/test/splitter/SimpleDocumentSplitterTest.java b/easy-agents-core/src/test/java/com/easyagents/core/test/splitter/SimpleDocumentSplitterTest.java new file mode 100644 index 0000000..184f876 --- /dev/null +++ b/easy-agents-core/src/test/java/com/easyagents/core/test/splitter/SimpleDocumentSplitterTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.test.splitter; + +import com.easyagents.core.document.Document; +import com.easyagents.core.document.splitter.SimpleDocumentSplitter; + +import org.junit.Test; + +import java.util.List; + +public class SimpleDocumentSplitterTest { + String text = "MyBatis-Flex 是一个优雅的 MyBatis 增强框架,它非常轻量、同时拥有极高的性能与灵活性。我们可以轻松的使用 Mybaits-Flex 链接任何数据库,其内置的 QueryWrapper^亮点 帮助我们极大的减少了 SQL 编写的工作的同时,减少出错的可能性。\n" + + "总而言之,MyBatis-Flex 能够极大地提高我们的开发效率和开发体验,让我们有更多的时间专注于自己的事情。"; + + String text2 = "AiEditor is a next-generation rich text editor for AI. It is developed based on Web Component and therefore supports almost any front-end framework such as Vue, React, Angular, Svelte, etc. It is adapted to PC Web and mobile terminals, and provides two themes: light and dark. In addition, it also provides flexible configuration, and developers can easily use it to develop any text editing application."; + + + @Test + public void test01() { + SimpleDocumentSplitter splitter = new SimpleDocumentSplitter(20); + List chunks = splitter.split(Document.of(text)); + + for (Document chunk : chunks) { + System.out.println(">>>>>" + chunk.getContent()); + } + } + + @Test + public void test02() { + SimpleDocumentSplitter splitter = new SimpleDocumentSplitter(20, 3); + List chunks = splitter.split(Document.of(text)); + + for (Document chunk : chunks) { + System.out.println(">>>>>" + chunk.getContent()); + } + } + + @Test + public void test03() { + SimpleDocumentSplitter splitter = new SimpleDocumentSplitter(20); + List chunks = splitter.split(Document.of(text2)); + + for (Document chunk : chunks) { + System.out.println(">>>>>" + chunk.getContent()); + } + } + + @Test + public void test04() { + SimpleDocumentSplitter splitter = new SimpleDocumentSplitter(20, 3); + List chunks = splitter.split(Document.of(text2)); + + for (Document chunk : chunks) { + System.out.println(">>>>>" + chunk.getContent()); + } + } + + +} diff --git a/easy-agents-core/src/test/java/com/easyagents/core/test/splitter/SimpleTokenizeSplitterTest.java b/easy-agents-core/src/test/java/com/easyagents/core/test/splitter/SimpleTokenizeSplitterTest.java new file mode 100644 index 0000000..a05956d --- /dev/null +++ b/easy-agents-core/src/test/java/com/easyagents/core/test/splitter/SimpleTokenizeSplitterTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.test.splitter; + +import com.easyagents.core.document.Document; +import com.easyagents.core.document.splitter.SimpleTokenizeSplitter; + +import org.junit.Test; + +import java.util.List; + +public class SimpleTokenizeSplitterTest { + String text = "MyBatis-Flex 是一个优雅的 MyBatis 增强框架,它非常轻量、同时拥有极高的性能与灵活性。我们可以轻松的使用 Mybaits-Flex 链接任何数据库,其内置的 QueryWrapper帮助我们极大的减少了 SQL 编写的工作的同时,减少出错的可能性。\n" + + "总而言之,MyBatis-Flex 能够极大地提高我们的开发效率和开发体验,让我们有更多的时间专注于自己的事情。"; + + String text2 = "AiEditor is a next-generation rich text editor for AI. It is developed based on Web Component and therefore supports almost any front-end framework such as Vue, React, Angular, Svelte, etc. It is adapted to PC Web and mobile terminals, and provides two themes: light and dark. In addition, it also provides flexible configuration, and developers can easily use it to develop any text editing application."; + + + @Test + public void test01() { + SimpleTokenizeSplitter splitter = new SimpleTokenizeSplitter(20); + List chunks = splitter.split(Document.of(text)); + + for (Document chunk : chunks) { + System.out.println(">>>>>" + chunk.getContent()); + } + } + + @Test + public void test02() { + SimpleTokenizeSplitter splitter = new SimpleTokenizeSplitter(20, 4); + List chunks = splitter.split(Document.of(text)); + + for (Document chunk : chunks) { + System.out.println(">>>>>" + chunk.getContent()); + } + } + + @Test + public void test03() { + SimpleTokenizeSplitter splitter = new SimpleTokenizeSplitter(20); + List chunks = splitter.split(Document.of(text2)); + + for (Document chunk : chunks) { + System.out.println(">>>>>" + chunk.getContent()); + } + } + + @Test + public void test04() { + SimpleTokenizeSplitter splitter = new SimpleTokenizeSplitter(20, 3); + List chunks = splitter.split(Document.of(text2)); + + for (Document chunk : chunks) { + System.out.println(">>>>>" + chunk.getContent()); + } + } + + +} diff --git a/easy-agents-core/src/test/java/com/easyagents/core/test/tool/ToolTest.java b/easy-agents-core/src/test/java/com/easyagents/core/test/tool/ToolTest.java new file mode 100644 index 0000000..257f059 --- /dev/null +++ b/easy-agents-core/src/test/java/com/easyagents/core/test/tool/ToolTest.java @@ -0,0 +1,24 @@ +package com.easyagents.core.test.tool; + +import com.easyagents.core.model.chat.tool.Parameter; +import com.easyagents.core.model.chat.tool.Tool; + +public class ToolTest { + + public static void main(String[] args1) { + Tool getWeather = Tool.builder() + .name("getWeather") + .description("查询指定城市某天的天气") + .addParameter(Parameter.builder() + .name("city") + .type("string") + .description("城市名称") + .required(true) + .build()) + .function(args -> { + String city = (String) args.get("city"); + return city + " 晴,22°C"; // 简化实现 + }) + .build(); + } +} diff --git a/easy-agents-core/src/test/java/com/easyagents/core/test/util/HttpClientTest.java b/easy-agents-core/src/test/java/com/easyagents/core/test/util/HttpClientTest.java new file mode 100644 index 0000000..668fb47 --- /dev/null +++ b/easy-agents-core/src/test/java/com/easyagents/core/test/util/HttpClientTest.java @@ -0,0 +1,15 @@ +package com.easyagents.core.test.util; + +import com.easyagents.core.model.client.HttpClient; + +public class HttpClientTest { + + public static void main(String[] args) throws InterruptedException { + HttpClient httpClient = new HttpClient(); + for (int i = 0; i < 10; i++){ + httpClient.get("https://agentsflex.com/"); + } + Thread.sleep(61000); + System.out.println("finished!!!"); + } +} diff --git a/easy-agents-core/src/test/java/com/easyagents/core/test/util/MapsTest.java b/easy-agents-core/src/test/java/com/easyagents/core/test/util/MapsTest.java new file mode 100644 index 0000000..da2b913 --- /dev/null +++ b/easy-agents-core/src/test/java/com/easyagents/core/test/util/MapsTest.java @@ -0,0 +1,19 @@ +package com.easyagents.core.test.util; + +import com.easyagents.core.util.Maps; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Map; + +public class MapsTest { + + @Test + public void testMaps() { + Map map1 = Maps.of("key", "value") + .setChild("options.aaa", 1); + + Assert.assertEquals(1, ((Map) map1.get("options")).get("aaa")); + System.out.println(map1); + } +} diff --git a/easy-agents-embedding/easy-agents-embedding-ollama/pom.xml b/easy-agents-embedding/easy-agents-embedding-ollama/pom.xml new file mode 100644 index 0000000..f345a3d --- /dev/null +++ b/easy-agents-embedding/easy-agents-embedding-ollama/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + com.easyagents + easy-agents-embedding + ${revision} + + + easy-agents-embedding-ollama + easy-agents-embedding-ollama + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + + + + diff --git a/easy-agents-embedding/easy-agents-embedding-ollama/src/main/java/com/easyagents/embedding/ollama/OllamaEmbeddingConfig.java b/easy-agents-embedding/easy-agents-embedding-ollama/src/main/java/com/easyagents/embedding/ollama/OllamaEmbeddingConfig.java new file mode 100644 index 0000000..906b415 --- /dev/null +++ b/easy-agents-embedding/easy-agents-embedding-ollama/src/main/java/com/easyagents/embedding/ollama/OllamaEmbeddingConfig.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.embedding.ollama; + +import com.easyagents.core.model.config.BaseModelConfig; + +public class OllamaEmbeddingConfig extends BaseModelConfig { + + private static final String DEFAULT_EMBEDDING_MODEL = "text-embedding-ada-002"; + private static final String DEFAULT_ENDPOINT = "https://api.openai.com"; + private static final String DEFAULT_REQUEST_PATH = "/v1/embeddings"; + + + public OllamaEmbeddingConfig() { + super(); + this.setModel(DEFAULT_EMBEDDING_MODEL); + this.setEndpoint(DEFAULT_ENDPOINT); + this.setRequestPath(DEFAULT_REQUEST_PATH); + } + +} diff --git a/easy-agents-embedding/easy-agents-embedding-ollama/src/main/java/com/easyagents/embedding/ollama/OllamaEmbeddingModel.java b/easy-agents-embedding/easy-agents-embedding-ollama/src/main/java/com/easyagents/embedding/ollama/OllamaEmbeddingModel.java new file mode 100644 index 0000000..a922f1c --- /dev/null +++ b/easy-agents-embedding/easy-agents-embedding-ollama/src/main/java/com/easyagents/embedding/ollama/OllamaEmbeddingModel.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.embedding.ollama; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.model.embedding.BaseEmbeddingModel; +import com.easyagents.core.model.embedding.EmbeddingOptions; +import com.easyagents.core.model.exception.ModelException; +import com.easyagents.core.store.VectorData; +import com.easyagents.core.util.JSONUtil; +import com.easyagents.core.util.Maps; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; + +import java.util.HashMap; +import java.util.Map; + +public class OllamaEmbeddingModel extends BaseEmbeddingModel { + + private HttpClient httpClient = new HttpClient(); + + public OllamaEmbeddingModel(OllamaEmbeddingConfig config) { + super(config); + } + + public HttpClient getHttpClient() { + return httpClient; + } + + public void setHttpClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + + @Override + public VectorData embed(Document document, EmbeddingOptions options) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + + if (StringUtil.hasText(getConfig().getApiKey())) { + headers.put("Authorization", "Bearer " + getConfig().getApiKey()); + } + + String payload = Maps.of("model", options.getModelOrDefault(config.getModel())) + .set("input", document.getContent()) + .setIfNotEmpty("dimensions", options.getDimensions()) + .toJSON(); + + + String endpoint = config.getEndpoint(); + // https://github.com/ollama/ollama/blob/main/docs/api.md#generate-embeddings + String response = httpClient.post(endpoint + "/api/embed", headers, payload); + + if (StringUtil.noText(response)) { + throw new ModelException("response is null or empty."); + } + + JSONObject jsonObject = JSON.parseObject(response); + String errorMessage = JSONUtil.detectErrorMessage(jsonObject); + if (errorMessage != null) { + throw new ModelException(errorMessage); + } + + VectorData vectorData = new VectorData(); + + double[] embedding = JSONUtil.readDoubleArray(jsonObject, "$.embeddings[0]"); + vectorData.setVector(embedding); + + vectorData.addMetadata("total_duration", JSONUtil.readLong(jsonObject, "$.total_duration")); + vectorData.addMetadata("load_duration", JSONUtil.readLong(jsonObject, "$.load_duration")); + vectorData.addMetadata("prompt_eval_count", JSONUtil.readInteger(jsonObject, "$.prompt_eval_count")); + vectorData.addMetadata("model", JSONUtil.readString(jsonObject, "$.model")); + + return vectorData; + } + +} diff --git a/easy-agents-embedding/easy-agents-embedding-openai/pom.xml b/easy-agents-embedding/easy-agents-embedding-openai/pom.xml new file mode 100644 index 0000000..13988de --- /dev/null +++ b/easy-agents-embedding/easy-agents-embedding-openai/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + com.easyagents + easy-agents-embedding + ${revision} + + + easy-agents-embedding-openai + easy-agents-embedding-openai + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + + + + diff --git a/easy-agents-embedding/easy-agents-embedding-openai/src/main/java/com/easyagents/embedding/openai/OpenAIEmbeddingConfig.java b/easy-agents-embedding/easy-agents-embedding-openai/src/main/java/com/easyagents/embedding/openai/OpenAIEmbeddingConfig.java new file mode 100644 index 0000000..7844fc3 --- /dev/null +++ b/easy-agents-embedding/easy-agents-embedding-openai/src/main/java/com/easyagents/embedding/openai/OpenAIEmbeddingConfig.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.embedding.openai; + +import com.easyagents.core.model.config.BaseModelConfig; + +public class OpenAIEmbeddingConfig extends BaseModelConfig { + + private static final String DEFAULT_EMBEDDING_MODEL = "text-embedding-ada-002"; + private static final String DEFAULT_ENDPOINT = "https://api.openai.com"; + private static final String DEFAULT_REQUEST_PATH = "/v1/embeddings"; + + + public OpenAIEmbeddingConfig() { + super(); + this.setModel(DEFAULT_EMBEDDING_MODEL); + this.setEndpoint(DEFAULT_ENDPOINT); + this.setRequestPath(DEFAULT_REQUEST_PATH); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String model = DEFAULT_EMBEDDING_MODEL; + private String endpoint = DEFAULT_ENDPOINT; + private String requestPath = DEFAULT_REQUEST_PATH; + private String apiKey; + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder endpoint(String endpoint) { + this.endpoint = endpoint; + return this; + } + + public Builder requestPath(String requestPath) { + this.requestPath = requestPath; + return this; + } + + public Builder apiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + public OpenAIEmbeddingConfig build() { + OpenAIEmbeddingConfig config = new OpenAIEmbeddingConfig(); + config.setModel(this.model); + config.setEndpoint(this.endpoint); + config.setRequestPath(this.requestPath); + if (this.apiKey != null) { + config.setApiKey(this.apiKey); + } + return config; + } + } +} diff --git a/easy-agents-embedding/easy-agents-embedding-openai/src/main/java/com/easyagents/embedding/openai/OpenAIEmbeddingModel.java b/easy-agents-embedding/easy-agents-embedding-openai/src/main/java/com/easyagents/embedding/openai/OpenAIEmbeddingModel.java new file mode 100644 index 0000000..8e0f044 --- /dev/null +++ b/easy-agents-embedding/easy-agents-embedding-openai/src/main/java/com/easyagents/embedding/openai/OpenAIEmbeddingModel.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.embedding.openai; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.model.embedding.BaseEmbeddingModel; +import com.easyagents.core.model.embedding.EmbeddingOptions; +import com.easyagents.core.model.exception.ModelException; +import com.easyagents.core.store.VectorData; +import com.easyagents.core.util.JSONUtil; +import com.easyagents.core.util.Maps; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; + +import java.util.HashMap; +import java.util.Map; + +public class OpenAIEmbeddingModel extends BaseEmbeddingModel { + + private HttpClient httpClient = new HttpClient(); + + public OpenAIEmbeddingModel(OpenAIEmbeddingConfig config) { + super(config); + } + + public HttpClient getHttpClient() { + return httpClient; + } + + public void setHttpClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + public VectorData embed(Document document, EmbeddingOptions options) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer " + getConfig().getApiKey()); + + String payload = promptToEmbeddingsPayload(document, options, config); + String endpoint = config.getEndpoint(); + // https://platform.openai.com/docs/api-reference/embeddings/create + String response = httpClient.post(endpoint + config.getRequestPath(), headers, payload); + + if (StringUtil.noText(response)) { + throw new ModelException("response is null or empty."); + } + + JSONObject jsonObject = JSON.parseObject(response); + String errorMessage = JSONUtil.detectErrorMessage(jsonObject); + if (errorMessage != null) { + throw new ModelException(errorMessage); + } + + VectorData vectorData = new VectorData(); + double[] embedding = JSONUtil.readDoubleArray(jsonObject, "$.data[0].embedding"); + vectorData.setVector(embedding); + + return vectorData; + } + + + public static String promptToEmbeddingsPayload(Document text, EmbeddingOptions options, OpenAIEmbeddingConfig config) { + // https://platform.openai.com/docs/api-reference/making-requests + return Maps.of("model", options.getModelOrDefault(config.getModel())) + .set("encoding_format", options.getEncodingFormatOrDefault("float")) + .set("input", text.getContent()) + .setIfNotEmpty("user", options.getUser()) + .setIfNotEmpty("dimensions", options.getDimensions()) + .toJSON(); + } +} diff --git a/easy-agents-embedding/easy-agents-embedding-qwen/pom.xml b/easy-agents-embedding/easy-agents-embedding-qwen/pom.xml new file mode 100644 index 0000000..701de01 --- /dev/null +++ b/easy-agents-embedding/easy-agents-embedding-qwen/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + com.easyagents + easy-agents-embedding + ${revision} + + + easy-agents-embedding-qwen + easy-agents-embedding-qwen + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + + + + diff --git a/easy-agents-embedding/easy-agents-embedding-qwen/src/main/java/com/easyagents/embedding/qwen/QwenEmbeddingConfig.java b/easy-agents-embedding/easy-agents-embedding-qwen/src/main/java/com/easyagents/embedding/qwen/QwenEmbeddingConfig.java new file mode 100644 index 0000000..cb9b7a0 --- /dev/null +++ b/easy-agents-embedding/easy-agents-embedding-qwen/src/main/java/com/easyagents/embedding/qwen/QwenEmbeddingConfig.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.embedding.qwen; + +import com.easyagents.core.model.config.BaseModelConfig; + +public class QwenEmbeddingConfig extends BaseModelConfig { + + private static final String DEFAULT_EMBEDDING_MODEL = "text-embedding-v1"; + private static final String DEFAULT_ENDPOINT = "https://dashscope.aliyuncs.com"; + private static final String DEFAULT_REQUEST_PATH = "/compatible-mode/v1/embeddings"; + + public QwenEmbeddingConfig() { + super(); + this.setModel(DEFAULT_EMBEDDING_MODEL); + this.setEndpoint(DEFAULT_ENDPOINT); + this.setRequestPath(DEFAULT_REQUEST_PATH); + } + +} diff --git a/easy-agents-embedding/easy-agents-embedding-qwen/src/main/java/com/easyagents/embedding/qwen/QwenEmbeddingModel.java b/easy-agents-embedding/easy-agents-embedding-qwen/src/main/java/com/easyagents/embedding/qwen/QwenEmbeddingModel.java new file mode 100644 index 0000000..5caf131 --- /dev/null +++ b/easy-agents-embedding/easy-agents-embedding-qwen/src/main/java/com/easyagents/embedding/qwen/QwenEmbeddingModel.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.embedding.qwen; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.model.embedding.BaseEmbeddingModel; +import com.easyagents.core.model.embedding.EmbeddingOptions; +import com.easyagents.core.model.exception.ModelException; +import com.easyagents.core.store.VectorData; +import com.easyagents.core.util.JSONUtil; +import com.easyagents.core.util.Maps; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; + +import java.util.HashMap; +import java.util.Map; + +public class QwenEmbeddingModel extends BaseEmbeddingModel { + + private HttpClient httpClient = new HttpClient(); + + public QwenEmbeddingModel(QwenEmbeddingConfig config) { + super(config); + } + + public HttpClient getHttpClient() { + return httpClient; + } + + public void setHttpClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + public VectorData embed(Document document, EmbeddingOptions options) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer " + getConfig().getApiKey()); + + String payload = promptToEmbeddingsPayload(document, options, config); + String endpoint = config.getEndpoint(); + // https://platform.openai.com/docs/api-reference/embeddings/create + String response = httpClient.post(endpoint + config.getRequestPath(), headers, payload); + + if (StringUtil.noText(response)) { + throw new ModelException("response is null or empty."); + } + + JSONObject jsonObject = JSON.parseObject(response); + String errorMessage = JSONUtil.detectErrorMessage(jsonObject); + if (errorMessage != null) { + throw new ModelException(errorMessage); + } + + VectorData vectorData = new VectorData(); + double[] embedding = JSONUtil.readDoubleArray(jsonObject, "$.data[0].embedding"); + vectorData.setVector(embedding); + + return vectorData; + } + + + public static String promptToEmbeddingsPayload(Document text, EmbeddingOptions options, QwenEmbeddingConfig config) { + //https://help.aliyun.com/zh/model-studio/developer-reference/embedding-interfaces-compatible-with-openai?spm=a2c4g.11186623.0.i3 + return Maps.of("model", options.getModelOrDefault(config.getModel())) + .set("encoding_format", options.getEncodingFormatOrDefault("float")) + .set("input", text.getContent()) + .setIfNotEmpty("user", options.getUser()) + .setIfNotEmpty("dimensions", options.getDimensions()) + .toJSON(); + } +} diff --git a/easy-agents-embedding/pom.xml b/easy-agents-embedding/pom.xml new file mode 100644 index 0000000..3da8a0a --- /dev/null +++ b/easy-agents-embedding/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-embedding + easy-agents-embedding + pom + + easy-agents-embedding-openai + easy-agents-embedding-qwen + easy-agents-embedding-ollama + + + + 8 + 8 + UTF-8 + + + diff --git a/easy-agents-flow/pom.xml b/easy-agents-flow/pom.xml new file mode 100644 index 0000000..3dcc0ee --- /dev/null +++ b/easy-agents-flow/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-flow + easy-agents-flow + + + 8 + 8 + UTF-8 + + + + + + org.slf4j + slf4j-api + 1.7.29 + + + + com.squareup.okhttp3 + okhttp + 4.9.3 + + + + com.alibaba + fastjson + 2.0.58 + + + + com.jfinal + enjoy + 5.1.3 + + + + org.graalvm.js + js-scriptengine + 21.3.3.1 + + + + org.graalvm.js + js + 21.3.3.1 + + + + com.ibm.icu + icu4j + 77.1 + + + + + diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java new file mode 100644 index 0000000..ea8b93e --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Chain.java @@ -0,0 +1,748 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +import com.easyagents.flow.core.chain.event.*; +import com.easyagents.flow.core.chain.repository.*; +import com.easyagents.flow.core.chain.runtime.*; +import com.easyagents.flow.core.util.CollectionUtil; +import com.easyagents.flow.core.util.StringUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + + +public class Chain { + + private static final Logger log = LoggerFactory.getLogger(Chain.class); + private static final ThreadLocal EXECUTION_THREAD_LOCAL = new ThreadLocal<>(); + + + protected final ChainDefinition definition; + protected String stateInstanceId; + + // protected final ChainState state; + protected ChainStateRepository chainStateRepository; + protected NodeStateRepository nodeStateRepository; + protected EventManager eventManager; + protected TriggerScheduler triggerScheduler; + + public static Chain currentChain() { + return EXECUTION_THREAD_LOCAL.get(); + } + + public Chain(ChainDefinition definition, String stateInstanceId) { + this.definition = definition; + this.stateInstanceId = stateInstanceId; + } + + public void notifyEvent(Event event) { + eventManager.notifyEvent(event, this); + } + + public void setStatusAndNotifyEvent(ChainStatus status) { + AtomicReference before = new AtomicReference<>(); + updateStateSafely(state -> { + before.set(state.getStatus()); + state.setStatus(status); + return EnumSet.of(ChainStateField.STATUS); + }); + notifyEvent(new ChainStatusChangeEvent(this, status, before.get())); + } + + public void setStatusAndNotifyEvent(String stateInstanceId, ChainStatus status) { + AtomicReference before = new AtomicReference<>(); + updateStateSafely(stateInstanceId, state -> { + before.set(state.getStatus()); + state.setStatus(status); + return EnumSet.of(ChainStateField.STATUS); + }); + notifyEvent(new ChainStatusChangeEvent(this, status, before.get())); + } + + /** + * Safely updates the chain state with optimistic locking and retry-on-conflict. + * + * @param modifier the modifier that applies changes and declares updated fields + * @throws ChainUpdateTimeoutException if update cannot succeed within timeout + */ + public ChainState updateStateSafely(ChainStateModifier modifier) { + return updateStateSafely(this.stateInstanceId, modifier); + } + + + public ChainState updateStateSafely(String stateInstanceId, ChainStateModifier modifier) { + final long timeoutMs = 30_000; // 30 seconds total timeout + final long maxRetryDelayMs = 100; // Maximum delay between retries + + long startTime = System.currentTimeMillis(); + int attempt = 0; + ChainState current = null; + while (System.currentTimeMillis() - startTime < timeoutMs) { + current = chainStateRepository.load(stateInstanceId); + if (current == null) { + throw new IllegalStateException("Chain state not found: " + stateInstanceId); + } + + EnumSet updatedFields = modifier.modify(current); + if (updatedFields == null || updatedFields.isEmpty()) { + return current; // No actual changes, exit early + } + + if (chainStateRepository.tryUpdate(current, updatedFields)) { + return current; + } + + // Prepare next retry + attempt++; + long nextDelay = calculateNextRetryDelay(attempt, maxRetryDelayMs); + sleepUninterruptibly(nextDelay); + } + + // Timeout reached + assert current != null; + String msg = String.format( + "Chain state update timeout after %d ms (instanceId: %s)", + timeoutMs, current.getInstanceId() + ); + log.warn(msg); + throw new ChainUpdateTimeoutException(msg); + } + + + public NodeState updateNodeStateSafely(String nodeId, NodeStateModifier modifier) { + return this.updateNodeStateSafely(this.stateInstanceId, nodeId, modifier); + } + + public NodeState updateNodeStateSafely(String stateInstanceId, String nodeId, NodeStateModifier modifier) { + final long timeoutMs = 30_000; + final long maxRetryDelayMs = 100; + long startTime = System.currentTimeMillis(); + int attempt = 0; + + while (System.currentTimeMillis() - startTime < timeoutMs) { + // 1. 加载最新 ChainState(获取 chainVersion) + ChainState chainState = chainStateRepository.load(stateInstanceId); + if (chainState == null) { + throw new IllegalStateException("Chain state not found"); + } + + // 2. 加载 NodeState + NodeState nodeState = nodeStateRepository.load(stateInstanceId, nodeId); + if (nodeState == null) { + nodeState = new NodeState(); + nodeState.setChainInstanceId(chainState.getInstanceId()); + nodeState.setNodeId(nodeId); + } + + // 3. 应用修改 + EnumSet updatedFields = modifier.modify(nodeState); + + if (updatedFields == null || updatedFields.isEmpty()) { + return nodeState; + } + + // 4. 尝试更新(传入 chainVersion 保证一致性) + if (nodeStateRepository.tryUpdate(nodeState, updatedFields, chainState.getVersion())) { + return nodeState; + } + + // 5. 退避重试 + attempt++; + sleepUninterruptibly(calculateNextRetryDelay(attempt, maxRetryDelayMs)); + } + + throw new ChainUpdateTimeoutException("Node state update timeout"); + } + + + /** + * Calculates the next retry delay using exponential backoff with jitter. + * + * @param attempt the current retry attempt (1-based) + * @param maxDelayMs the maximum delay in milliseconds + * @return the delay in milliseconds to wait before next retry + */ + private long calculateNextRetryDelay(int attempt, long maxDelayMs) { + // Base delay: 10ms * (2^(attempt-1)) + long baseDelay = 10L * (1L << (attempt - 1)); + + // Add jitter: ±25% randomness to avoid thundering herd + double jitterFactor = 0.75 + (Math.random() * 0.5); // [0.75, 1.25) + long delayWithJitter = (long) (baseDelay * jitterFactor); + + // Clamp between 1ms and maxDelayMs + return Math.max(1L, Math.min(delayWithJitter, maxDelayMs)); + } + + /** + * Sleeps for the specified duration, silently ignoring interrupts + * but preserving the interrupt status. + * + * @param millis the length of time to sleep in milliseconds + */ + private void sleepUninterruptibly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // Preserve interrupt status + // Do NOT throw here — we want to continue retrying + } + } + + + public void start(Map variables) { + Trigger prev = TriggerContext.getCurrentTrigger(); + try { + // start 可能在 node 里执行一个新的 chain 的情况, + // 需要清空父级 chain 的 Trigger + TriggerContext.setCurrentTrigger(null); + updateStateSafely(state -> { + EnumSet fields = EnumSet.of(ChainStateField.STATUS); + state.setStatus(ChainStatus.RUNNING); + + if (variables != null && !variables.isEmpty()) { + state.getMemory().putAll(variables); + fields.add(ChainStateField.MEMORY); + } + + if (StringUtil.noText(state.getChainDefinitionId())) { + state.setChainDefinitionId(definition.getId()); + fields.add(ChainStateField.CHAIN_DEFINITION_ID); + } + + return fields; + }); + + notifyEvent(new ChainStartEvent(this, variables)); + setStatusAndNotifyEvent(ChainStatus.RUNNING); + + // 调度入口节点 + List startNodes = definition.getStartNodes(); + for (Node startNode : startNodes) { + scheduleNode(startNode, null, TriggerType.START, 0); + } + } finally { + // 恢复父级 chain 的 Trigger + TriggerContext.setCurrentTrigger(prev); + } + } + + public void executeNode(Node node, Trigger trigger) { + try { + EXECUTION_THREAD_LOCAL.set(this); + ChainState chainState = getState(); + + // 当前处于挂起状态 + if (chainState.getStatus() == ChainStatus.SUSPEND) { + updateStateSafely(state -> { + chainState.addSuspendNodeId(node.getId()); + return EnumSet.of(ChainStateField.SUSPEND_NODE_IDS); + }); + return; + } + // 处于非运行状态,比如错误状态 + else if (chainState.getStatus() != ChainStatus.RUNNING) { + return; + } + + String triggerEdgeId = trigger.getEdgeId(); + if (shouldSkipNode(node, triggerEdgeId)) { + return; + } + + Map nodeResult = null; + Throwable error = null; + try { + NodeState nodeState = getNodeState(node.id); + + // 如果节点状态不是运行中,则更新为运行中 + // 目前只有 Loop 节点会处于 Running 状态,因为它会多次触发 + if (nodeState.getStatus() != NodeStatus.RUNNING) { + updateNodeStateSafely(node.id, s -> { + s.setStatus(NodeStatus.RUNNING); + s.recordExecute(triggerEdgeId); + return EnumSet.of(NodeStateField.EXECUTE_COUNT, NodeStateField.EXECUTE_EDGE_IDS, NodeStateField.STATUS); + }); + TriggerType type = trigger.getType(); + notifyEvent(new NodeStartEvent(this, node)); + } + // 只需记录执行次数 + else { + updateNodeStateSafely(node.id, s -> { + s.recordExecute(triggerEdgeId); + return EnumSet.of(NodeStateField.EXECUTE_COUNT, NodeStateField.EXECUTE_EDGE_IDS); + }); + } + + updateStateSafely(state -> { + state.addTriggerNodeId(node.id); + return EnumSet.of(ChainStateField.TRIGGER_NODE_IDS); + }); + + nodeResult = node.execute(this); + } catch (Throwable throwable) { + log.error("Node execute error", throwable); + error = throwable; + } + handleNodeResult(node, nodeResult, triggerEdgeId, error); + } finally { + EXECUTION_THREAD_LOCAL.remove(); + } + } + + public NodeState getNodeState(String nodeId) { + return getNodeState(this.stateInstanceId, nodeId); + } + + public NodeState getNodeState(String stateInstanceId, String nodeId) { + return nodeStateRepository.load(stateInstanceId, nodeId); + } + + public T executeWithLock(String instanceId, long timeout, TimeUnit unit, Supplier action) { + try (ChainLock lock = chainStateRepository.getLock(instanceId, timeout, unit)) { + if (!lock.isAcquired()) { + throw new ChainLockTimeoutException("Failed to acquire lock for instance: " + instanceId); + } + return action.get(); + } + } + + private boolean shouldSkipNode(Node node, String edgeId) { + return executeWithLock(stateInstanceId, 10, TimeUnit.SECONDS, () -> { + NodeState newState = updateNodeStateSafely(node.id, s -> { + s.recordTrigger(edgeId); + return EnumSet.of(NodeStateField.TRIGGER_COUNT, NodeStateField.TRIGGER_EDGE_IDS); + }); + + NodeCondition condition = node.getCondition(); + if (condition == null) { + return false; + } + Map prevResult = Collections.emptyMap(); + boolean shouldSkipNode = !condition.check(this, newState, prevResult); + if (shouldSkipNode) { + updateStateSafely(state -> { + state.addUncheckedNodeId(node.id); + return EnumSet.of(ChainStateField.UNCHECKED_NODE_IDS); + }); + } else { + updateStateSafely(state -> { + if (state.removeUncheckedNodeId(node.id)) { + return EnumSet.of(ChainStateField.UNCHECKED_NODE_IDS); + } else { + return null; + } + }); + } + return shouldSkipNode; + }); + } + + + private void handleNodeResult(Node node, Map prevNodeResult, String triggerEdgeId, Throwable error) { + ChainStatus finalChainStatus = null; + NodeStatus finalNodeStatus = null; + try { + if (error == null) { + // 更新 state 数据 + updateStateSafely(state -> { + EnumSet fields = EnumSet.of(ChainStateField.EXECUTE_RESULT); + state.setExecuteResult(prevNodeResult); + + if (prevNodeResult != null && !prevNodeResult.isEmpty()) { + prevNodeResult.forEach((k, v) -> { + if (v != null) { + state.getMemory().put(node.getId() + "." + k, v); + } + }); + fields.add(ChainStateField.MEMORY); + } + + return fields; + }); + + if (node.isRetryEnable() && node.isResetRetryCountAfterNormal()) { + updateNodeStateSafely(node.id, state -> { + state.setRetryCount(0); + return EnumSet.of(NodeStateField.RETRY_COUNT); + }); + } + + finalNodeStatus = prevNodeResult == null ? null : (NodeStatus) prevNodeResult.get(ChainConsts.NODE_STATE_STATUS_KEY); + + // 不调度下一个节点,由 node 自行调度,比如 Loop 循环 + Boolean scheduleNextNodeDisabled = prevNodeResult == null ? null : (Boolean) prevNodeResult.get(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY); + if (scheduleNextNodeDisabled != null && scheduleNextNodeDisabled) { + return; + } + + // 结束节点 + finalChainStatus = prevNodeResult != null ? (ChainStatus) prevNodeResult.get(ChainConsts.CHAIN_STATE_STATUS_KEY) : null; + if (finalChainStatus != null && finalChainStatus.isTerminal()) { + return; + } + + // 调度下一个节点 + scheduleNextForNode(node, prevNodeResult, triggerEdgeId); + } else { + // 挂起 + if (error instanceof ChainSuspendException) { + updateNodeStateSafely(node.id, s -> { + s.setStatus(NodeStatus.SUSPEND); + return EnumSet.of(NodeStateField.STATUS); + }); + + updateStateSafely(s -> { + s.addSuspendNodeId(node.getId()); + s.addSuspendForParameters(((ChainSuspendException) error).getSuspendParameters()); + return EnumSet.of(ChainStateField.SUSPEND_NODE_IDS, ChainStateField.SUSPEND_FOR_PARAMETERS); + }); + + finalNodeStatus = NodeStatus.SUSPEND; + finalChainStatus = ChainStatus.SUSPEND; + } + // 失败 + else { + NodeState newState = updateNodeStateSafely(node.getId(), s -> { + s.setStatus(NodeStatus.ERROR); + s.setError(new ExceptionSummary(error)); + return EnumSet.of(NodeStateField.ERROR, NodeStateField.STATUS); + }); + + eventManager.notifyNodeError(error, node, prevNodeResult, this); + + if (node.isRetryEnable() + && node.getMaxRetryCount() > 0 + && newState.getRetryCount() < node.getMaxRetryCount()) { + + updateNodeStateSafely(node.getId(), s -> { + s.setRetryCount(s.getRetryCount() + 1); + return EnumSet.of(NodeStateField.RETRY_COUNT); + }); + + scheduleNode(node, triggerEdgeId, TriggerType.RETRY, node.getRetryIntervalMs()); + } else { + finalChainStatus = handleNodeError(node.id, error); + } + } + } + } finally { + // 如果当前的工作流正在执行中,则不发送 NodeEndEvent 事件 + if (finalNodeStatus != NodeStatus.RUNNING) { + NodeStatus nodeStatus = finalNodeStatus == null ? NodeStatus.SUCCEEDED : finalNodeStatus; + updateNodeStateSafely(node.id, state -> { + state.setStatus(nodeStatus); + return EnumSet.of(NodeStateField.STATUS); + }); + notifyEvent(new NodeEndEvent(this, node, prevNodeResult, error)); + } + + if (finalChainStatus != null) { + setStatusAndNotifyEvent(finalChainStatus); + + // chain 执行结束 + if (finalChainStatus.isTerminal()) { + eventManager.notifyEvent(new ChainEndEvent(this), this); + + // 执行结束,但是未执行成功,失败和取消等 + // 更新父级链的状态 + if (!finalChainStatus.isSuccess()) { + ChainState currentState = getState(); + ChainStatus currentStatus = finalChainStatus; + while (currentState != null && StringUtil.hasText(currentState.getParentInstanceId())) { + updateStateSafely(currentState.getParentInstanceId(), state -> { + state.setStatus(currentStatus); + return EnumSet.of(ChainStateField.STATUS); + }); + setStatusAndNotifyEvent(currentState.getParentInstanceId(), currentStatus); + currentState = getState(currentState.getParentInstanceId()); + } + } + } + } + } + } + + /** + * 为指定节点调度下一次执行 + * + * @param node 要调度的节点 + * @param result 节点执行结果 + * @param byEdigeId 触发边的ID + */ + private void scheduleNextForNode(Node node, Map result, String byEdigeId) { + // 如果节点不支持循环,则直接调度向外的节点 + if (!node.isLoopEnable()) { + scheduleOutwardNodes(node, result); + return; + } + + NodeState nodeState = getNodeState(node.getId()); + // 如果达到最大循环次数限制,则调度向外的节点 + if (node.getMaxLoopCount() > 0 && nodeState.getLoopCount() >= node.getMaxLoopCount()) { + scheduleOutwardNodes(node, result); + return; + } + + // 检查循环中断条件,如果满足则调度向外的节点 + NodeCondition breakCondition = node.getLoopBreakCondition(); + if (breakCondition != null && breakCondition.check(this, nodeState, result)) { + scheduleOutwardNodes(node, result); + return; + } + + // 增加循环计数并重新调度当前节点 + updateNodeStateSafely(node.getId(), s -> { + s.setLoopCount(s.getLoopCount() + 1); + return EnumSet.of(NodeStateField.LOOP_COUNT); + }); + + scheduleNode(node, byEdigeId, TriggerType.LOOP, node.getLoopIntervalMs()); + } + + + private void scheduleOutwardNodes(Node node, Map result) { + List edges = definition.getOutwardEdge(node.getId()); + if (!CollectionUtil.hasItems(edges)) { + // 当前节点没有向外的边,则调度父节点(自动回归父节点) 用在 Loop 循环等场景 + if (StringUtil.hasText(node.getParentId())) { + Node parent = definition.getNodeById(node.getParentId()); + scheduleNode(parent, null, TriggerType.PARENT, 0L); + } + return; + } + + // 检查所有向外的边是不是同一个父节点 + boolean allNotSameParent = false; + boolean scheduleSuccess = false; + for (Edge edge : edges) { + Node nextNode = definition.getNodeById(edge.getTarget()); + if (nextNode == null) { + throw new ChainException("Invalid edge target: " + edge.getTarget()); + } + + // 如果存在不同父节点的边,则跳过, 比如 Loop 节点可能只有子节点,没有后续的节点 + if (!isSameParent(node, nextNode)) { + allNotSameParent = true; + continue; + } + + allNotSameParent = false; + EdgeCondition edgeCondition = edge.getCondition(); + if (edgeCondition == null) { + scheduleNode(nextNode, edge.getId(), TriggerType.NEXT, 0L); + scheduleSuccess = true; + continue; + } + + if (edgeCondition.check(this, edge, result)) { + updateStateSafely(state -> { + if (state.removeUncheckedEdgeId(edge.getId())) { + return EnumSet.of(ChainStateField.UNCHECKED_EDGE_IDS); + } else { + return null; + } + }); + scheduleNode(nextNode, edge.getId(), TriggerType.NEXT, 0L); + scheduleSuccess = true; + } else { + updateStateSafely(state -> { + state.addUncheckedEdgeId(edge.getId()); + return EnumSet.of(ChainStateField.UNCHECKED_EDGE_IDS); + }); + eventManager.notifyEvent(new EdgeConditionCheckFailedEvent(this, edge, node, result), this); + } + } + + // 如果所有向外的边都不满足条件,则调度父节点(自动回归父节点) 用在 Loop 循环嵌套等场景(Loop 下的第一个节点是 Loop) + if (allNotSameParent && !scheduleSuccess) { + if (StringUtil.hasText(node.getParentId())) { + Node parent = definition.getNodeById(node.getParentId()); + scheduleNode(parent, null, TriggerType.PARENT, 0L); + } + } + } + + /** + * 判断两个节点是否具有相同的父节点 + * + * @param node 第一个节点 + * @param next 第二个节点 + * @return 如果两个节点的父节点ID相同则返回true,否则返回false + */ + private boolean isSameParent(Node node, Node next) { + // 如果两个节点的父节点ID都为空或空白,则认为是相同父节点 + if (StringUtil.noText(node.getParentId()) && StringUtil.noText(next.getParentId())) { + return true; + } + + // 比较两个节点的父节点ID是否相等 + return node.getParentId() != null && node.getParentId().equals(next.getParentId()); + } + + + public void scheduleNode(Node node, String edgeId, TriggerType type, long delayMs) { + Trigger trigger = new Trigger(); + trigger.setStateInstanceId(stateInstanceId); + trigger.setEdgeId(edgeId); + trigger.setNodeId(node.getId()); + trigger.setType(type); + trigger.setTriggerAt(System.currentTimeMillis() + delayMs); + + if (edgeId != null) { + updateStateSafely(state -> { + state.addTriggerEdgeId(edgeId); + return EnumSet.of(ChainStateField.TRIGGER_EDGE_IDS); + }); + + eventManager.notifyEvent(new EdgeTriggerEvent(this, trigger), this); + } + + getTriggerScheduler().schedule(trigger); + } + + + private ChainStatus handleNodeError(String nodeId, Throwable throwable) { + updateNodeStateSafely(nodeId, s -> { + s.setStatus(NodeStatus.FAILED); + s.setError(new ExceptionSummary(throwable)); + return EnumSet.of(NodeStateField.ERROR, NodeStateField.STATUS); + }); + + updateStateSafely(state -> { + state.setError(new ExceptionSummary(throwable)); + return EnumSet.of(ChainStateField.ERROR); + }); + + setStatusAndNotifyEvent(ChainStatus.FAILED); + eventManager.notifyChainError(throwable, this); + return ChainStatus.FAILED; + } + + public void suspend() { + setStatusAndNotifyEvent(ChainStatus.SUSPEND); + } + + public void suspend(Node node) { + updateStateSafely(state -> { + state.addSuspendNodeId(node.getId()); + return EnumSet.of(ChainStateField.SUSPEND_NODE_IDS); + }); + setStatusAndNotifyEvent(ChainStatus.SUSPEND); + } + + + public void resume(Map variables) { + ChainState newState = updateStateSafely(state -> { + if (variables != null) { + state.getMemory().putAll(variables); + return EnumSet.of(ChainStateField.MEMORY); + } else { + return null; + } + }); + + notifyEvent(new ChainResumeEvent(this, variables)); + setStatusAndNotifyEvent(ChainStatus.RUNNING); + + Set suspendNodeIds = newState.getSuspendNodeIds(); + if (suspendNodeIds != null && !suspendNodeIds.isEmpty()) { + // 移除 suspend 状态,方便二次 suspend 时,不带有旧数据 + updateStateSafely(state -> { + state.setSuspendNodeIds(null); + state.setSuspendForParameters(null); + return EnumSet.of(ChainStateField.SUSPEND_NODE_IDS, ChainStateField.SUSPEND_FOR_PARAMETERS); + }); + + for (String id : suspendNodeIds) { + Node node = definition.getNodeById(id); + if (node == null) { + throw new ChainException("Node not found: " + id); + } + scheduleNode(node, null, TriggerType.RESUME, 0L); + } + } + } + + public void resume() { + resume(Collections.emptyMap()); + } + + public void output(Node node, Object response) { + eventManager.notifyOutput(this, node, response); + } + + + public EventManager getEventManager() { + return eventManager; + } + + public void setEventManager(EventManager eventManager) { + this.eventManager = eventManager; + } + + public ChainStateRepository getChainStateRepository() { + return chainStateRepository; + } + + public void setChainStateRepository(ChainStateRepository chainStateRepository) { + this.chainStateRepository = chainStateRepository; + } + + public ChainDefinition getDefinition() { + return definition; + } + + public TriggerScheduler getTriggerScheduler() { + if (this.triggerScheduler == null) { + this.triggerScheduler = ChainRuntime.triggerScheduler(); + } + return triggerScheduler; + } + + public void setTriggerScheduler(TriggerScheduler triggerScheduler) { + this.triggerScheduler = triggerScheduler; + } + + public NodeStateRepository getNodeStateRepository() { + return nodeStateRepository; + } + + public void setNodeStateRepository(NodeStateRepository nodeStateRepository) { + this.nodeStateRepository = nodeStateRepository; + } + + public String getStateInstanceId() { + return stateInstanceId; + } + + public ChainState getState() { + return chainStateRepository.load(stateInstanceId); + } + + public ChainState getState(String stateInstanceId) { + return chainStateRepository.load(stateInstanceId); + } + + public void setStateInstanceId(String stateInstanceId) { + this.stateInstanceId = stateInstanceId; + } +} \ No newline at end of file diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainConsts.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainConsts.java new file mode 100644 index 0000000..bc322ef --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainConsts.java @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +public class ChainConsts { + + public static final String SCHEDULE_NEXT_NODE_DISABLED_KEY = "__schedule_next_node_disabled"; + public static final String NODE_STATE_STATUS_KEY = "__node_state_status"; + public static final String CHAIN_STATE_STATUS_KEY = "__chain_state_status"; + public static final String CHAIN_STATE_MESSAGE_KEY = "__chain_state_message"; +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainDefinition.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainDefinition.java new file mode 100644 index 0000000..a791cf0 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainDefinition.java @@ -0,0 +1,212 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +import com.easyagents.flow.core.util.StringUtil; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + + +public class ChainDefinition implements Serializable { + protected String id; + protected String name; + protected String description; + protected List nodes; + protected List edges; + + public ChainDefinition() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getNodes() { + return nodes; + } + + public void setNodes(List nodes) { + this.nodes = nodes; + } + + public List getEdges() { + return edges; + } + + public void setEdges(List edges) { + this.edges = edges; + } + + public List getOutwardEdge(String nodeId) { + List result = new ArrayList<>(); + for (Edge edge : edges) { + if (nodeId.equals(edge.getSource())) { + result.add(edge); + } + } + return result; + } + + + public List getInwardEdge(String nodeId) { + List result = new ArrayList<>(); + for (Edge edge : edges) { + if (nodeId.equals(edge.getTarget())) { + result.add(edge); + } + } + return result; + } + + public void addNode(Node node) { + if (nodes == null) { + this.nodes = new ArrayList<>(); + } + + if (StringUtil.noText(node.getId())) { + node.setId(UUID.randomUUID().toString()); + } + + nodes.add(node); + +// if (this.edges != null) { +// for (Edge edge : edges) { +// if (node.getId().equals(edge.getSource())) { +// node.addOutwardEdge(edge); +// } else if (node.getId().equals(edge.getTarget())) { +// node.addInwardEdge(edge); +// } +// } +// } + } + + + public Node getNodeById(String id) { + if (id == null || StringUtil.noText(id)) { + return null; + } + + for (Node node : this.nodes) { + if (id.equals(node.getId())) { + return node; + } + } + + return null; + } + + + public void addEdge(Edge edge) { + if (this.edges == null) { + this.edges = new ArrayList<>(); + } + this.edges.add(edge); + +// boolean findSource = false, findTarget = false; +// for (Node node : this.nodes) { +// if (node.getId().equals(edge.getSource())) { +// node.addOutwardEdge(edge); +// findSource = true; +// } else if (node.getId().equals(edge.getTarget())) { +// node.addInwardEdge(edge); +// findTarget = true; +// } +// if (findSource && findTarget) { +// break; +// } +// } + } + + + public Edge getEdgeById(String edgeId) { + for (Edge edge : this.edges) { + if (edgeId.equals(edge.getId())) { + return edge; + } + } + return null; + } + + public List getStartNodes() { + if (nodes == null || nodes.isEmpty()) { + return null; + } + + List result = new ArrayList<>(); + + for (Node node : nodes) { +// if (CollectionUtil.noItems(node.getInwardEdges())) { +// result.add(node); +// } + List inwardEdge = getInwardEdge(node.getId()); + if (inwardEdge == null || inwardEdge.isEmpty()) { + result.add(node); + } + } + return result; + } + + + public List getStartParameters() { + List startNodes = this.getStartNodes(); + if (startNodes == null || startNodes.isEmpty()) { + return Collections.emptyList(); + } + + List parameters = new ArrayList<>(); + for (Node node : startNodes) { + List nodeParameters = node.getParameters(); + if (nodeParameters != null) parameters.addAll(nodeParameters); + } + return parameters; + } + + + @Override + public String toString() { + return "ChainDefinition{" + + "id='" + id + '\'' + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", nodes=" + nodes + + ", edges=" + edges + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainException.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainException.java new file mode 100644 index 0000000..aad43ef --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainException.java @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +public class ChainException extends RuntimeException { + /** + * Constructs a new runtime exception with {@code null} as its + * detail message. The cause is not initialized, and may subsequently be + * initialized by a call to {@link #initCause}. + */ + public ChainException() { + } + + /** + * Constructs a new runtime exception with the specified detail message. + * The cause is not initialized, and may subsequently be initialized by a + * call to {@link #initCause}. + * + * @param message the detail message. The detail message is saved for + * later retrieval by the {@link #getMessage()} method. + */ + public ChainException(String message) { + super(message); + } + + /** + * Constructs a new runtime exception with the specified detail message and + * cause.

Note that the detail message associated with + * {@code cause} is not automatically incorporated in + * this runtime exception's detail message. + * + * @param message the detail message (which is saved for later retrieval + * by the {@link #getMessage()} method). + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public ChainException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new runtime exception with the specified cause and a + * detail message of (cause==null ? null : cause.toString()) + * (which typically contains the class and detail message of + * cause). This constructor is useful for runtime exceptions + * that are little more than wrappers for other throwables. + * + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public ChainException(Throwable cause) { + super(cause); + } + + /** + * Constructs a new runtime exception with the specified detail + * message, cause, suppression enabled or disabled, and writable + * stack trace enabled or disabled. + * + * @param message the detail message. + * @param cause the cause. (A {@code null} value is permitted, + * and indicates that the cause is nonexistent or unknown.) + * @param enableSuppression whether or not suppression is enabled + * or disabled + * @param writableStackTrace whether or not the stack trace should + * be writable + * @since 1.7 + */ + public ChainException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainState.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainState.java new file mode 100644 index 0000000..9c1f0dc --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainState.java @@ -0,0 +1,486 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.parser.DefaultJSONParser; +import com.alibaba.fastjson.parser.Feature; +import com.alibaba.fastjson.parser.ParserConfig; +import com.alibaba.fastjson.parser.deserializer.ObjectDeserializer; +import com.alibaba.fastjson.serializer.JSONSerializer; +import com.alibaba.fastjson.serializer.ObjectSerializer; +import com.alibaba.fastjson.serializer.SerializeConfig; +import com.alibaba.fastjson.serializer.SerializerFeature; +import com.easyagents.flow.core.util.MapUtil; +import com.easyagents.flow.core.util.StringUtil; +import com.easyagents.flow.core.util.TextTemplate; + +import java.io.IOException; +import java.io.Serializable; +import java.lang.reflect.Type; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public class ChainState implements Serializable { + + private String instanceId; + private String parentInstanceId; + private String chainDefinitionId; + private ConcurrentHashMap memory = new ConcurrentHashMap<>(); + + private Map executeResult; + private Map environment; + + private List triggerEdgeIds; + private List triggerNodeIds; + + private List uncheckedEdgeIds; + private List uncheckedNodeIds; + + // 算力消耗定义,积分消耗 + private long computeCost; + private Set suspendNodeIds; + private List suspendForParameters; + private ChainStatus status; + private String message; + private ExceptionSummary error; + private long version; + + public ChainState() { + this.instanceId = UUID.randomUUID().toString(); + this.status = ChainStatus.READY; + this.computeCost = 0; + } + + public String getInstanceId() { + return instanceId; + } + + public void setInstanceId(String instanceId) { + this.instanceId = instanceId; + } + + public String getParentInstanceId() { + return parentInstanceId; + } + + public void setParentInstanceId(String parentInstanceId) { + this.parentInstanceId = parentInstanceId; + } + + public String getChainDefinitionId() { + return chainDefinitionId; + } + + public void setChainDefinitionId(String chainDefinitionId) { + this.chainDefinitionId = chainDefinitionId; + } + + public ConcurrentHashMap getMemory() { + return memory; + } + + public void setMemory(ConcurrentHashMap memory) { + this.memory = memory; + } + + public Map getExecuteResult() { + return executeResult; + } + + public void setExecuteResult(Map executeResult) { + this.executeResult = executeResult; + } + + public Map getEnvironment() { + return environment; + } + + public void setEnvironment(Map environment) { + this.environment = environment; + } + + + public List getTriggerEdgeIds() { + return triggerEdgeIds; + } + + public void setTriggerEdgeIds(List triggerEdgeIds) { + this.triggerEdgeIds = triggerEdgeIds; + } + + public void addTriggerEdgeId(String edgeId) { + if (triggerEdgeIds == null) { + triggerEdgeIds = new ArrayList<>(); + } + triggerEdgeIds.add(edgeId); + } + + public List getTriggerNodeIds() { + return triggerNodeIds; + } + + public void setTriggerNodeIds(List triggerNodeIds) { + this.triggerNodeIds = triggerNodeIds; + } + + public void addTriggerNodeId(String nodeId) { + if (triggerNodeIds == null) { + triggerNodeIds = new ArrayList<>(); + } + triggerNodeIds.add(nodeId); + } + + public List getUncheckedEdgeIds() { + return uncheckedEdgeIds; + } + + public void setUncheckedEdgeIds(List uncheckedEdgeIds) { + this.uncheckedEdgeIds = uncheckedEdgeIds; + } + + public void addUncheckedEdgeId(String edgeId) { + if (uncheckedEdgeIds == null) { + uncheckedEdgeIds = new ArrayList<>(); + } + uncheckedEdgeIds.add(edgeId); + } + + public boolean removeUncheckedEdgeId(String edgeId) { + if (uncheckedEdgeIds == null) { + return false; + } + return uncheckedEdgeIds.remove(edgeId); + } + + public List getUncheckedNodeIds() { + return uncheckedNodeIds; + } + + public void setUncheckedNodeIds(List uncheckedNodeIds) { + this.uncheckedNodeIds = uncheckedNodeIds; + } + + public void addUncheckedNodeId(String nodeId) { + if (uncheckedNodeIds == null) { + uncheckedNodeIds = new ArrayList<>(); + } + uncheckedNodeIds.add(nodeId); + } + + public boolean removeUncheckedNodeId(String nodeId) { + if (uncheckedNodeIds == null) { + return false; + } + return uncheckedNodeIds.remove(nodeId); + } + + public Long getComputeCost() { + return computeCost; + } + + public void setComputeCost(Long computeCost) { + this.computeCost = computeCost; + } + + public void setComputeCost(long computeCost) { + this.computeCost = computeCost; + } + + public Set getSuspendNodeIds() { + return suspendNodeIds; + } + + public void setSuspendNodeIds(Set suspendNodeIds) { + this.suspendNodeIds = suspendNodeIds; + } + + public void addSuspendNodeId(String nodeId) { + if (suspendNodeIds == null) { + suspendNodeIds = new HashSet<>(); + } + suspendNodeIds.add(nodeId); + } + + public void removeSuspendNodeId(String nodeId) { + if (suspendNodeIds == null) { + return; + } + suspendNodeIds.remove(nodeId); + } + + public List getSuspendForParameters() { + return suspendForParameters; + } + + public void setSuspendForParameters(List suspendForParameters) { + this.suspendForParameters = suspendForParameters; + } + + public void addSuspendForParameter(Parameter parameter) { + if (suspendForParameters == null) { + suspendForParameters = new ArrayList<>(); + } + suspendForParameters.add(parameter); + } + + public void addSuspendForParameters(List parameters) { + if (parameters == null) { + return; + } + if (suspendForParameters == null) { + suspendForParameters = new ArrayList<>(); + } + suspendForParameters.addAll(parameters); + } + + public ChainStatus getStatus() { + return status; + } + + public void setStatus(ChainStatus status) { + this.status = status; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public ExceptionSummary getError() { + return error; + } + + public void setError(ExceptionSummary error) { + this.error = error; + } + + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } + + public static ChainState fromJSON(String jsonString) { + ParserConfig config = new ParserConfig(); + config.putDeserializer(ChainState.class, new ChainDeserializer()); + return JSON.parseObject(jsonString, ChainState.class, config, Feature.SupportAutoType); + } + + public String toJSON() { + SerializeConfig config = new SerializeConfig(); + config.put(ChainState.class, new ChainSerializer()); + return JSON.toJSONString(this, config, SerializerFeature.WriteClassName); + } + + public void reset() { + this.instanceId = null; + this.chainDefinitionId = null; + this.memory.clear(); + this.executeResult = null; + this.environment = null; + this.computeCost = 0; + this.suspendNodeIds = null; + this.suspendForParameters = null; + this.status = ChainStatus.READY; + this.message = null; + this.error = null; + } + + + public void addComputeCost(Long value) { + if (value == null) { + value = 0L; + } + this.computeCost += value; + } + + + public Map getNodeExecuteResult(String nodeId) { + if (memory == null || memory.isEmpty()) { + return Collections.emptyMap(); + } + Map result = new HashMap<>(); + memory.forEach((k, v) -> { + if (k.startsWith(nodeId + ".")) { + String newKey = k.substring(nodeId.length() + 1); + result.put(newKey, v); + } + }); + return result; + } + +// public Map getTriggerVariables() { +// Trigger trigger = TriggerContext.getCurrentTrigger(); +// if (trigger != null) { +// return trigger.getVariables(); +// } +// return Collections.emptyMap(); +// } + + + public Object resolveValue(String path) { + Object result = MapUtil.getByPath(getMemory(), path); + if (result == null) result = MapUtil.getByPath(getEnvironment(), path); +// if (result == null) result = MapUtil.getByPath(getTriggerVariables(), path); + return result; + } + + public Map resolveParameters(Node node) { + return resolveParameters(node, node.getParameters()); + } + + public Map resolveParameters(Node node, List parameters) { + return resolveParameters(node, parameters, null); + } + + public Map resolveParameters(Node node, List parameters, Map formatArgs) { + return resolveParameters(node, parameters, formatArgs, false); + } + + private boolean isNullOrBlank(Object value) { + return value == null || value instanceof String && StringUtil.noText((String) value); + } + + + public Map getEnvMap() { + Map formatArgsMap = new HashMap<>(); + formatArgsMap.put("env", getEnvironment()); + formatArgsMap.put("env.sys", System.getenv()); + return formatArgsMap; + } + + public Map resolveParameters(Node node, List parameters, Map formatArgs, boolean ignoreRequired) { + if (parameters == null || parameters.isEmpty()) { + return Collections.emptyMap(); + } + Map variables = new LinkedHashMap<>(); + List suspendParameters = null; + for (Parameter parameter : parameters) { + RefType refType = parameter.getRefType(); + Object value = null; + if (refType == RefType.FIXED) { + value = TextTemplate.of(parameter.getValue()) + .formatToString(Arrays.asList(formatArgs, getEnvMap())); + } else if (refType == RefType.REF) { + value = this.resolveValue(parameter.getRef()); + } + // 单节点执行时,参数只会传入 name 内容。 + if (value == null) { + value = this.resolveValue(parameter.getName()); + } + + if (value == null && parameter.getDefaultValue() != null) { + value = parameter.getDefaultValue(); + } + + if (refType == RefType.INPUT && isNullOrBlank(value)) { + if (!ignoreRequired && parameter.isRequired()) { + if (suspendParameters == null) { + suspendParameters = new ArrayList<>(); + } + suspendParameters.add(parameter); + continue; + } + } + + if (parameter.isRequired() && isNullOrBlank(value)) { + if (!ignoreRequired) { + throw new ChainException(node.getName() + " Missing required parameter:" + parameter.getName()); + } + } + + if (value instanceof String) { + value = ((String) value).trim(); + if (parameter.getDataType() == DataType.Boolean) { + value = "true".equalsIgnoreCase((String) value) || "1".equalsIgnoreCase((String) value); + } else if (parameter.getDataType() == DataType.Number) { + value = Long.parseLong((String) value); + } else if (parameter.getDataType() == DataType.Array) { + value = JSON.parseArray((String) value); + } + } + + variables.put(parameter.getName(), value); + } + + if (suspendParameters != null && !suspendParameters.isEmpty()) { + // 构建参数名称列表 + String missingParams = suspendParameters.stream() + .map(Parameter::getName) + .collect(Collectors.joining("', '", "'", "'")); + + String errorMessage = String.format( + "Node '%s' (type: %s) is suspended. Waiting for input parameters: %s.", + StringUtil.getFirstWithText(node.getName(), node.getId()), + node.getClass().getSimpleName(), + missingParams + ); + + throw new ChainSuspendException(errorMessage, suspendParameters); + } + + return variables; + } + + + public static class ChainSerializer implements ObjectSerializer { + @Override + public void write(JSONSerializer serializer, Object object, Object fieldName, Type fieldType, int features) throws IOException { + if (object == null) { + serializer.writeNull(); + return; + } + ChainState chain = (ChainState) object; + serializer.write(chain.toJSON()); + } + } + + public static class ChainDeserializer implements ObjectDeserializer { + @Override + public T deserialze(DefaultJSONParser parser, Type type, Object fieldName) { + String value = parser.parseObject(String.class); + //noinspection unchecked + return (T) ChainState.fromJSON(value); + } + } + + + @Override + public String toString() { + return "ChainState{" + + "instanceId='" + instanceId + '\'' + + ", chainDefinitionId='" + chainDefinitionId + '\'' + + ", memory=" + memory + + ", executeResult=" + executeResult + + ", environment=" + environment + + ", computeCost=" + computeCost + + ", suspendNodeIds=" + suspendNodeIds + + ", suspendForParameters=" + suspendForParameters + + ", status=" + status + + ", message='" + message + '\'' + + ", error=" + error + + ", version=" + version + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainStatus.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainStatus.java new file mode 100644 index 0000000..2dcf690 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainStatus.java @@ -0,0 +1,151 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +/** + * 链(Chain)执行生命周期状态枚举 + *

+ * 该状态机描述了一个 Chain 实例从创建到终止的完整生命周期。 + * 状态分为三类: + * - 初始状态:READY + * - 运行中状态:RUNNING, SUSPEND, WAITING + * - 终态(Terminal):SUCCEEDED, FAILED, CANCELLED(不可再变更) + *

+ * 设计原则: + * - 使用行业通用术语(如 SUCCEEDED/FAILED,而非 FINISHED_NORMAL/ABNORMAL) + * - 明确区分人工干预(SUSPEND)与系统调度(WAITING) + * - 终态互斥且不可逆,便于状态判断与持久化恢复 + */ +public enum ChainStatus { + + /** + * 初始状态:Chain 已创建,但尚未开始执行。 + *

+ * 此状态下,Chain 的内存为空,节点尚未触发。 + * 调用 {@link Chain#execute} 或 {@link Chain#doExecute} 后进入 {@link #RUNNING}。 + */ + READY(0), + + /** + * 运行中:Chain 正在执行节点逻辑(同步或异步)。 + *

+ * 只要至少一个节点仍在处理(包括等待 Phaser 同步),状态即为 RUNNING。 + * 遇到挂起条件(如缺少参数、loop 间隔)时,会转为 {@link #SUSPEND} + */ + RUNNING(1), + + /** + * 暂停(人工干预):Chain 因缺少外部输入而暂停,需用户主动恢复。 + *

+ * 典型场景: + * - 节点参数缺失且标记为 required(等待用户提交) + * - 人工审批节点(等待管理员操作) + *

+ * 恢复方式:调用 {@link Chain#resume(Map)} 注入所需变量。 + * 监听器:通过 {@link com.easyagents.flow.core.chain.listener.ChainSuspendListener} 感知。 + */ + SUSPEND(5), + + + /** + * 错误(中间状态):执行中发生异常,但尚未终结(例如正在重试)。 + *

+ * 此状态表示:Chain 遇到错误,但仍在尝试恢复(如重试机制触发)。 + * 如果重试成功,可回到 RUNNING;如果重试耗尽,则进入 {@link #FAILED}。 + *

+ * ⚠️ 注意:此状态 不是终态,Chain 仍可恢复。 + */ + ERROR(10), + + /** + * 成功完成:Chain 所有节点正常执行完毕,无错误发生。 + *

+ * 终态(Terminal State)—— 状态不可再变更。 + * 此状态下,Chain 的执行结果(executeResult)有效。 + */ + SUCCEEDED(20), + + /** + * 失败结束:Chain 因未处理的异常或错误条件而终止。 + *

+ * 终态(Terminal State)—— 状态不可再变更。 + * 常见原因:节点抛出异常、重试耗尽、条件校验失败等。 + * 错误详情可通过 {@link ChainState#getError()} 获取。 + */ + FAILED(21), + + /** + * 已取消:Chain 被用户或系统主动终止,非因错误。 + *

+ * 终态(Terminal State)—— 状态不可再变更。 + * 典型场景: + * - 用户点击“取消”按钮 + * - 超时自动取消(如审批超时) + * - 父流程终止子流程 + *

+ * 与 {@link #FAILED} 的区别:CANCELLED 是预期行为,通常不计入错误率。 + */ + CANCELLED(22); + + /** + * 状态的数值标识,可用于数据库存储或网络传输 + */ + private final int value; + + ChainStatus(int value) { + this.value = value; + } + + /** + * 判断当前状态是否为终态(Terminal State) + *

+ * 终态包括:SUCCEEDED, FAILED, CANCELLED + * 一旦进入终态,Chain 不可再恢复或继续执行。 + * + * @return 如果是终态,返回 true;否则返回 false + */ + public boolean isTerminal() { + return this == SUCCEEDED || this == FAILED || this == CANCELLED; + } + + /** + * 判断当前状态是否表示成功完成 + * + * @return 如果是 {@link #SUCCEEDED},返回 true;否则返回 false + */ + public boolean isSuccess() { + return this == SUCCEEDED; + } + + + /** + * 获取状态对应的数值标识 + * + * @return 状态值 + */ + public int getValue() { + return value; + } + + public static ChainStatus fromValue(int value) { + for (ChainStatus status : values()) { + if (status.value == value) { + return status; + } + } + return null; + } +} \ No newline at end of file diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainSuspendException.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainSuspendException.java new file mode 100644 index 0000000..e2b112c --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainSuspendException.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +import java.util.List; + +public class ChainSuspendException extends RuntimeException { + + private final List suspendParameters; + + public ChainSuspendException(String message, List suspendParameters) { + super(message); + this.suspendParameters = suspendParameters; + } + + public List getSuspendParameters() { + return suspendParameters; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainUpdateTimeoutException.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainUpdateTimeoutException.java new file mode 100644 index 0000000..eae3d84 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ChainUpdateTimeoutException.java @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +public class ChainUpdateTimeoutException extends RuntimeException{ + + /** + * Constructs a new runtime exception with {@code null} as its + * detail message. The cause is not initialized, and may subsequently be + * initialized by a call to {@link #initCause}. + */ + public ChainUpdateTimeoutException() { + } + + /** + * Constructs a new runtime exception with the specified detail message. + * The cause is not initialized, and may subsequently be initialized by a + * call to {@link #initCause}. + * + * @param message the detail message. The detail message is saved for + * later retrieval by the {@link #getMessage()} method. + */ + public ChainUpdateTimeoutException(String message) { + super(message); + } + + /** + * Constructs a new runtime exception with the specified detail message and + * cause.

Note that the detail message associated with + * {@code cause} is not automatically incorporated in + * this runtime exception's detail message. + * + * @param message the detail message (which is saved for later retrieval + * by the {@link #getMessage()} method). + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public ChainUpdateTimeoutException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new runtime exception with the specified cause and a + * detail message of (cause==null ? null : cause.toString()) + * (which typically contains the class and detail message of + * cause). This constructor is useful for runtime exceptions + * that are little more than wrappers for other throwables. + * + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public ChainUpdateTimeoutException(Throwable cause) { + super(cause); + } + + /** + * Constructs a new runtime exception with the specified detail + * message, cause, suppression enabled or disabled, and writable + * stack trace enabled or disabled. + * + * @param message the detail message. + * @param cause the cause. (A {@code null} value is permitted, + * and indicates that the cause is nonexistent or unknown.) + * @param enableSuppression whether or not suppression is enabled + * or disabled + * @param writableStackTrace whether or not the stack trace should + * be writable + * @since 1.7 + */ + public ChainUpdateTimeoutException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/DataType.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/DataType.java new file mode 100644 index 0000000..bc7fa25 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/DataType.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +public enum DataType { + Array("Array"), + Object("Object"), + String("String"), + Number("Number"), + Boolean("Boolean"), + File("File"), + + Array_Object("Array"), + Array_String("Array"), + Array_Number("Array"), + Array_Boolean("Array"), + Array_File("Array"), + ; + + private final String value; + + DataType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } + + public static DataType ofValue(String value) { + for (DataType type : DataType.values()) { + if (type.value.equalsIgnoreCase(value)) { + return type; + } + } + return null; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Edge.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Edge.java new file mode 100644 index 0000000..b559e63 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Edge.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + + +public class Edge { + private String id; + private String source; + private String target; + private EdgeCondition condition; + + public Edge() { + } + + public Edge(String id) { + this.id = id; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public String getTarget() { + return target; + } + + public void setTarget(String target) { + this.target = target; + } + + public EdgeCondition getCondition() { + return condition; + } + + public void setCondition(EdgeCondition condition) { + this.condition = condition; + } + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EdgeCondition.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EdgeCondition.java new file mode 100644 index 0000000..3629807 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EdgeCondition.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + + +import java.util.Map; + +public interface EdgeCondition { + + boolean check(Chain chain, Edge edge, Map executeResult); + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Event.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Event.java new file mode 100644 index 0000000..a8d0332 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Event.java @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +public interface Event { +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EventManager.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EventManager.java new file mode 100644 index 0000000..50787cd --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/EventManager.java @@ -0,0 +1,135 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +import com.easyagents.flow.core.chain.listener.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class EventManager { + + private static final Logger log = LoggerFactory.getLogger(EventManager.class); + + protected final Map, List> eventListeners = new ConcurrentHashMap<>(); + protected final List outputListeners = Collections.synchronizedList(new ArrayList<>()); + protected final List chainErrorListeners = Collections.synchronizedList(new ArrayList<>()); + protected final List nodeErrorListeners = Collections.synchronizedList(new ArrayList<>()); + + /** + * ---------- 通用事件监听器 ---------- + */ + public void addEventListener(Class eventClass, ChainEventListener listener) { + eventListeners.computeIfAbsent(eventClass, k -> Collections.synchronizedList(new ArrayList<>())).add(listener); + } + + public void addEventListener(ChainEventListener listener) { + addEventListener(Event.class, listener); + } + + public void removeEventListener(Class eventClass, ChainEventListener listener) { + List list = eventListeners.get(eventClass); + if (list != null) list.remove(listener); + } + + public void removeEventListener(ChainEventListener listener) { + for (List list : eventListeners.values()) { + list.remove(listener); + } + } + + public void notifyEvent(Event event, Chain chain) { + for (Map.Entry, List> entry : eventListeners.entrySet()) { + if (entry.getKey().isInstance(event)) { + for (ChainEventListener listener : entry.getValue()) { + try { + listener.onEvent(event, chain); + } catch (Exception e) { + log.error("Error in event listener: {}", e.toString(), e); + } + } + } + } + } + + /** + * ---------- Output Listener ---------- + */ + public void addOutputListener(ChainOutputListener listener) { + outputListeners.add(listener); + } + + public void removeOutputListener(ChainOutputListener listener) { + outputListeners.remove(listener); + } + + public void notifyOutput(Chain chain, Node node, Object response) { + for (ChainOutputListener listener : outputListeners) { + try { + listener.onOutput(chain, node, response); + } catch (Exception e) { + log.error("Error in output listener: {}", e.toString(), e); + } + } + } + + /** + * ---------- Chain Error Listener ---------- + */ + public void addChainErrorListener(ChainErrorListener listener) { + chainErrorListeners.add(listener); + } + + public void removeChainErrorListener(ChainErrorListener listener) { + chainErrorListeners.remove(listener); + } + + public void notifyChainError(Throwable error, Chain chain) { + for (ChainErrorListener listener : chainErrorListeners) { + try { + listener.onError(error, chain); + } catch (Exception e) { + log.error("Error in chain error listener: {}", e.toString(), e); + } + } + } + + /** + * ---------- Node Error Listener ---------- + */ + public void addNodeErrorListener(NodeErrorListener listener) { + nodeErrorListeners.add(listener); + } + + public void removeNodeErrorListener(NodeErrorListener listener) { + nodeErrorListeners.remove(listener); + } + + public void notifyNodeError(Throwable error, Node node, Map result, Chain chain) { + for (NodeErrorListener listener : nodeErrorListeners) { + try { + listener.onError(error, node, result, chain); + } catch (Exception e) { + log.error("Error in node error listener: {}", e.toString(), e); + } + } + } + + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ExceptionSummary.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ExceptionSummary.java new file mode 100644 index 0000000..60eb033 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/ExceptionSummary.java @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +import java.io.PrintWriter; +import java.io.Serializable; +import java.io.StringWriter; + +public class ExceptionSummary implements Serializable { + + private String exceptionClass; + private String message; + private String stackTrace; + + private String rootCauseClass; + private String rootCauseMessage; + + private String chainId; + private String nodeId; + + private String errorCode; // 可选 + + private long timestamp; + + public ExceptionSummary(Throwable error) { + this.exceptionClass = error.getClass().getName(); + this.message = error.getMessage(); + this.stackTrace = getStackTraceAsString(error); + + Throwable root = getRootCause(error); + this.rootCauseClass = root.getClass().getName(); + this.rootCauseMessage = root.getMessage(); + + this.timestamp = System.currentTimeMillis(); + } + + private static Throwable getRootCause(Throwable t) { + Throwable result = t; + while (result.getCause() != null) { + result = result.getCause(); + } + return result; + } + + private static String getStackTraceAsString(Throwable t) { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + t.printStackTrace(pw); + return sw.toString(); + } + + public String getExceptionClass() { + return exceptionClass; + } + + public void setExceptionClass(String exceptionClass) { + this.exceptionClass = exceptionClass; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getStackTrace() { + return stackTrace; + } + + public void setStackTrace(String stackTrace) { + this.stackTrace = stackTrace; + } + + public String getRootCauseClass() { + return rootCauseClass; + } + + public void setRootCauseClass(String rootCauseClass) { + this.rootCauseClass = rootCauseClass; + } + + public String getRootCauseMessage() { + return rootCauseMessage; + } + + public void setRootCauseMessage(String rootCauseMessage) { + this.rootCauseMessage = rootCauseMessage; + } + + public String getChainId() { + return chainId; + } + + public void setChainId(String chainId) { + this.chainId = chainId; + } + + public String getNodeId() { + return nodeId; + } + + public void setNodeId(String nodeId) { + this.nodeId = nodeId; + } + + public String getErrorCode() { + return errorCode; + } + + public void setErrorCode(String errorCode) { + this.errorCode = errorCode; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } +} + diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/JsCodeCondition.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/JsCodeCondition.java new file mode 100644 index 0000000..bb1ea71 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/JsCodeCondition.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +import com.easyagents.flow.core.util.JsConditionUtil; +import com.easyagents.flow.core.util.Maps; + +import java.util.Map; + +public class JsCodeCondition implements NodeCondition, EdgeCondition { + private String code; + + public JsCodeCondition() { + } + + public JsCodeCondition(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + @Override + public boolean check(Chain chain, Edge edge, Map executeResult) { + Maps map = Maps.of("_edge", edge).set("_chain", chain); + if (executeResult != null) { + map.putAll(executeResult); + } + return JsConditionUtil.eval(code, chain, map); + } + + @Override + public boolean check(Chain chain, NodeState state, Map executeResult) { + Maps map = Maps.of("_state", state).set("_chain", chain); + if (executeResult != null) { + map.putAll(executeResult); + } + return JsConditionUtil.eval(code, chain, map); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Node.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Node.java new file mode 100644 index 0000000..62fcc0b --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Node.java @@ -0,0 +1,245 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +import com.easyagents.flow.core.util.JsConditionUtil; +import com.easyagents.flow.core.util.StringUtil; +import org.slf4j.Logger; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public abstract class Node implements Serializable { + private static final Logger log = org.slf4j.LoggerFactory.getLogger(Node.class); + + protected String id; + protected String parentId; + protected String name; + protected String description; + +// protected List inwardEdges; +// protected List outwardEdges; + + protected NodeCondition condition; + protected NodeValidator validator; + + // 循环执行相关属性 + protected boolean loopEnable = false; // 是否启用循环执行 + protected long loopIntervalMs = 3000; // 循环间隔时间(毫秒) + protected NodeCondition loopBreakCondition; // 跳出循环的条件 + protected int maxLoopCount = 0; // 0 表示不限制循环次数 + + protected boolean retryEnable = false; + protected boolean resetRetryCountAfterNormal = false; + protected int maxRetryCount = 0; + protected long retryIntervalMs = 3000; + + // 算力消耗定义,积分消耗 + protected String computeCostExpr; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getParentId() { + return parentId; + } + + public void setParentId(String parentId) { + this.parentId = parentId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + +// public List getInwardEdges() { +// return inwardEdges; +// } +// +// public void setInwardEdges(List inwardEdges) { +// this.inwardEdges = inwardEdges; +// } +// +// public List getOutwardEdges() { +//// return outwardEdges; +// } +// +// public void setOutwardEdges(List outwardEdges) { +// this.outwardEdges = outwardEdges; +// } + + public NodeCondition getCondition() { + return condition; + } + + public void setCondition(NodeCondition condition) { + this.condition = condition; + } + + public NodeValidator getValidator() { + return validator; + } + + public void setValidator(NodeValidator validator) { + this.validator = validator; + } + +// protected void addOutwardEdge(Edge edge) { +// if (this.outwardEdges == null) { +// this.outwardEdges = new ArrayList<>(); +// } +// this.outwardEdges.add(edge); +// } +// +// protected void addInwardEdge(Edge edge) { +// if (this.inwardEdges == null) { +// this.inwardEdges = new ArrayList<>(); +// } +// this.inwardEdges.add(edge); +// } + + public boolean isLoopEnable() { + return loopEnable; + } + + public void setLoopEnable(boolean loopEnable) { + this.loopEnable = loopEnable; + } + + public long getLoopIntervalMs() { + return loopIntervalMs; + } + + public void setLoopIntervalMs(long loopIntervalMs) { + this.loopIntervalMs = loopIntervalMs; + } + + public NodeCondition getLoopBreakCondition() { + return loopBreakCondition; + } + + public void setLoopBreakCondition(NodeCondition loopBreakCondition) { + this.loopBreakCondition = loopBreakCondition; + } + + public int getMaxLoopCount() { + return maxLoopCount; + } + + public void setMaxLoopCount(int maxLoopCount) { + this.maxLoopCount = maxLoopCount; + } + + public List getParameters() { + return null; + } + + public boolean isRetryEnable() { + return retryEnable; + } + + public void setRetryEnable(boolean retryEnable) { + this.retryEnable = retryEnable; + } + + public boolean isResetRetryCountAfterNormal() { + return resetRetryCountAfterNormal; + } + + public void setResetRetryCountAfterNormal(boolean resetRetryCountAfterNormal) { + this.resetRetryCountAfterNormal = resetRetryCountAfterNormal; + } + + public int getMaxRetryCount() { + return maxRetryCount; + } + + public void setMaxRetryCount(int maxRetryCount) { + this.maxRetryCount = maxRetryCount; + } + + public long getRetryIntervalMs() { + return retryIntervalMs; + } + + public void setRetryIntervalMs(long retryIntervalMs) { + this.retryIntervalMs = retryIntervalMs; + } + + public String getComputeCostExpr() { + return computeCostExpr; + } + + public void setComputeCostExpr(String computeCostExpr) { + if (computeCostExpr != null) { + computeCostExpr = computeCostExpr.trim(); + } + this.computeCostExpr = computeCostExpr; + } + + public NodeValidResult validate() throws Exception { + return validator != null ? validator.validate(this) : NodeValidResult.ok(); + } + + + public abstract Map execute(Chain chain); + + public long calculateComputeCost(Chain chain, Map executeResult) { + + if (StringUtil.noText(computeCostExpr)) { + return 0; + } + + if (computeCostExpr.startsWith("{{") && computeCostExpr.endsWith("}}")) { + String expr = computeCostExpr.substring(2, computeCostExpr.length() - 2); + return doCalculateComputeCost(expr, chain, executeResult); + } else { + try { + return Long.parseLong(computeCostExpr); + } catch (NumberFormatException e) { + log.error(e.toString(), e); + } + return 0; + } + } + + protected long doCalculateComputeCost(String expr, Chain chain, Map result) { +// Map parameterValues = chain.getState().getParameterValuesOnly(this, this.getParameters(), null); + Map parameterValues = chain.getState().resolveParameters(this, this.getParameters(), null,true); + Map newMap = new HashMap<>(result); + newMap.putAll(parameterValues); + return JsConditionUtil.evalLong(expr, chain, newMap); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeCondition.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeCondition.java new file mode 100644 index 0000000..f3adc40 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeCondition.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + + +import java.util.Map; + +public interface NodeCondition { + + boolean check(Chain chain, NodeState context, Map executeResult); + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeState.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeState.java new file mode 100644 index 0000000..ab1a7de --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeState.java @@ -0,0 +1,193 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +public class NodeState implements Serializable { + + private String nodeId; + private String chainInstanceId; + + protected ConcurrentHashMap memory = new ConcurrentHashMap<>(); + protected NodeStatus status = NodeStatus.READY; + + private int retryCount = 0; + private int loopCount = 0; + + private AtomicInteger triggerCount = new AtomicInteger(0); + private List triggerEdgeIds = new ArrayList<>(); + + private AtomicInteger executeCount = new AtomicInteger(0); + private List executeEdgeIds = new ArrayList<>(); + + ExceptionSummary error; + + private long version; + + public NodeState() { + } + + public String getNodeId() { + return nodeId; + } + + public void setNodeId(String nodeId) { + this.nodeId = nodeId; + } + + public String getChainInstanceId() { + return chainInstanceId; + } + + public void setChainInstanceId(String chainInstanceId) { + this.chainInstanceId = chainInstanceId; + } + + public ConcurrentHashMap getMemory() { + return memory; + } + + public void setMemory(ConcurrentHashMap memory) { + this.memory = memory; + } + + public void addMemory(String key, Object value) { + memory.put(key, value); + } + + public T getMemoryOrDefault(String key, T defaultValue) { + Object value = memory.get(key); + if (value == null) { + return defaultValue; + } + //noinspection unchecked + return (T) value; + } + + public NodeStatus getStatus() { + return status; + } + + public int getRetryCount() { + return retryCount; + } + + public void setRetryCount(int retryCount) { + this.retryCount = retryCount; + } + + public int getLoopCount() { + return loopCount; + } + + public void setLoopCount(int loopCount) { + this.loopCount = loopCount; + } + + public AtomicInteger getTriggerCount() { + return triggerCount; + } + + public void setTriggerCount(AtomicInteger triggerCount) { + this.triggerCount = triggerCount; + } + + public List getTriggerEdgeIds() { + return triggerEdgeIds; + } + + public void setTriggerEdgeIds(List triggerEdgeIds) { + this.triggerEdgeIds = triggerEdgeIds; + } + + public AtomicInteger getExecuteCount() { + return executeCount; + } + + public void setExecuteCount(AtomicInteger executeCount) { + this.executeCount = executeCount; + } + + public List getExecuteEdgeIds() { + return executeEdgeIds; + } + + public void setExecuteEdgeIds(List executeEdgeIds) { + this.executeEdgeIds = executeEdgeIds; + } + + public ExceptionSummary getError() { + return error; + } + + public void setError(ExceptionSummary error) { + this.error = error; + } + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } + + public boolean isUpstreamFullyExecuted() { + ChainDefinition definition = Chain.currentChain().getDefinition(); + List inwardEdges = definition.getInwardEdge(nodeId); + if (inwardEdges == null || inwardEdges.isEmpty()) { + return true; + } + + List shouldBeTriggerIds = inwardEdges.stream().map(Edge::getId).collect(Collectors.toList()); + List triggerEdgeIds = this.triggerEdgeIds; + return triggerEdgeIds.size() >= shouldBeTriggerIds.size() + && shouldBeTriggerIds.parallelStream().allMatch(triggerEdgeIds::contains); + } + + public void recordTrigger(String fromEdgeId) { + triggerCount.incrementAndGet(); + if (fromEdgeId == null) { + fromEdgeId = "none"; + } + triggerEdgeIds.add(fromEdgeId); + } + + public void recordExecute(String fromEdgeId) { + executeCount.incrementAndGet(); + if (fromEdgeId == null) { + fromEdgeId = "none"; + } + executeEdgeIds.add(fromEdgeId); + } + + public void setStatus(NodeStatus status) { + this.status = status; + } + + public String getLastExecuteEdgeId() { + if (!executeEdgeIds.isEmpty()) { + return executeEdgeIds.get(executeEdgeIds.size() - 1); + } + return null; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeStatus.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeStatus.java new file mode 100644 index 0000000..881c8a5 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeStatus.java @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +public enum NodeStatus { + READY(0), // 未开始执行 + RUNNING(1), // 已开始执行,执行中... + SUSPEND(5), + ERROR(10), //发生错误 + SUCCEEDED(20), //正常结束 + FAILED(21); //错误结束 + + final int value; + + NodeStatus(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + public static NodeStatus fromValue(int value) { + for (NodeStatus status : NodeStatus.values()) { + if (status.value == value) { + return status; + } + } + return null; + } + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidResult.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidResult.java new file mode 100644 index 0000000..73031e3 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidResult.java @@ -0,0 +1,213 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +import java.io.Serializable; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +/** + * 表示一个链式节点校验结果。 + * 包含校验是否成功、消息说明以及附加的详细信息(如失败字段、原因等)。 + *

+ * 实例是不可变的(immutable),线程安全。 + */ +public class NodeValidResult implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final NodeValidResult SUCCESS = new NodeValidResult(true, null, null); + public static final NodeValidResult FAILURE = new NodeValidResult(false, null, null); + + private final boolean success; + private final String message; + private final Map details; + + /** + * 私有构造器,确保通过工厂方法创建实例。 + */ + private NodeValidResult(boolean success, String message, Map details) { + this.success = success; + this.message = message; + // 防御性拷贝,防止外部修改 + this.details = details != null ? Collections.unmodifiableMap(new java.util.HashMap<>(details)) : null; + } + + /** + * 获取校验是否成功。 + */ + public boolean isSuccess() { + return success; + } + + /** + * 获取结果消息(可为 null)。 + */ + public String getMessage() { + return message; + } + + /** + * 获取详细信息(如校验失败的字段、原因等),不可变 Map。 + * 如果无详情,则返回 null。 + */ + public Map getDetails() { + return details; + } + + // ------------------ 静态工厂方法 ------------------ + + /** + * 创建一个成功的校验结果(无消息、无详情)。 + */ + public static NodeValidResult ok() { + return SUCCESS; + } + + /** + * 创建一个成功的校验结果,附带消息。 + */ + public static NodeValidResult ok(String message) { + return new NodeValidResult(true, message, null); + } + + /** + * 创建一个成功的校验结果,附带消息和详情。 + */ + public static NodeValidResult ok(String message, Map details) { + return new NodeValidResult(true, message, details); + } + + /** + * 创建一个成功的校验结果,支持键值对形式传入 details。 + *

+ * 示例:success("验证通过", "userId", 123, "role", "admin") + * + * @param message 消息 + * @param kvPairs 键值对(必须成对:key1, value1, key2, value2...) + * @return ChainNodeValidResult + * @throws IllegalArgumentException 如果 kvPairs 数量为奇数 + */ + public static NodeValidResult ok(String message, Object... kvPairs) { + Map details = toMapFromPairs(kvPairs); + return new NodeValidResult(true, message, details); + } + + + /** + * 创建一个失败的校验结果(无消息、无详情)。 + */ + public static NodeValidResult fail() { + return FAILURE; + } + + /** + * 创建一个失败的校验结果,仅包含消息。 + */ + public static NodeValidResult fail(String message) { + return new NodeValidResult(false, message, null); + } + + /** + * 创建一个失败的校验结果,包含消息和详情。 + */ + public static NodeValidResult fail(String message, Map details) { + return new NodeValidResult(false, message, details); + } + + /** + * 创建一个失败的校验结果,支持键值对形式传入 details。 + *

+ * 示例:fail("验证失败", "field", "email", "reason", "格式错误") + */ + public static NodeValidResult fail(String message, Object... kvPairs) { + Map details = toMapFromPairs(kvPairs); + return new NodeValidResult(false, message, details); + } + + /** + * 快捷方法:创建包含字段错误的失败结果。 + * 适用于表单/参数校验场景。 + * + * @param field 错误字段名 + * @param reason 错误原因 + * @return 失败结果 + */ + public static NodeValidResult failOnField(String field, String reason) { + Map details = Collections.singletonMap("fieldError", field + ": " + reason); + return fail(reason, details); + } + + /** + * 快捷方法:基于布尔值返回成功或失败结果。 + * + * @param condition 条件 + * @param messageIfFail 条件不满足时的消息 + * @return 根据条件返回对应结果 + */ + public static NodeValidResult require(boolean condition, String messageIfFail) { + return condition ? ok() : fail(messageIfFail); + } + + + private static Map toMapFromPairs(Object... kvPairs) { + if (kvPairs == null || kvPairs.length == 0) { + return null; + } + + if (kvPairs.length % 2 != 0) { + throw new IllegalArgumentException("kvPairs must be even-sized: key1, value1, key2, value2..."); + } + + Map map = new java.util.HashMap<>(); + for (int i = 0; i < kvPairs.length; i += 2) { + Object key = kvPairs[i]; + Object value = kvPairs[i + 1]; + + if (!(key instanceof String)) { + throw new IllegalArgumentException("Key must be a String, but got: " + key); + } + + map.put((String) key, value); + } + return Collections.unmodifiableMap(map); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof NodeValidResult)) return false; + NodeValidResult that = (NodeValidResult) o; + return success == that.success && + Objects.equals(message, that.message) && + Objects.equals(details, that.details); + } + + @Override + public int hashCode() { + return Objects.hash(success, message, details); + } + + @Override + public String toString() { + return "ChainNodeValidResult{" + + "success=" + success + + ", message='" + message + '\'' + + ", details=" + details + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidator.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidator.java new file mode 100644 index 0000000..0cb2a51 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/NodeValidator.java @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + + +public interface NodeValidator { + NodeValidResult validate(Node node); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Parameter.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Parameter.java new file mode 100644 index 0000000..d3eedf2 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/Parameter.java @@ -0,0 +1,304 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +public class Parameter implements Serializable, Cloneable { + protected String id; + protected String name; + protected String description; + protected DataType dataType = DataType.String; + + /** + * 数据类型:文字内容、图片、音频、视频、文件 + */ + protected String contentType; + protected String ref; + protected RefType refType; + protected String value; + protected boolean required; + protected String defaultValue; + protected List children; + + /** + * 枚举值列表 + */ + protected List enums; + + /** + * 用户输入的表单类型,例如:"input" "textarea" "select" "radio" "checkbox" 等等 + */ + protected String formType; + + /** + * 用户界面上显示的提示文字,用于引导用户进行选择 + */ + protected String formLabel; + + + /** + * 表单的提示文字,用于引导用户进行选择或填写 + */ + protected String formPlaceholder; + + /** + * 用户界面上显示的描述文字,用于引导用户进行选择 + */ + protected String formDescription; + + /** + * 表单的其他属性 + */ + protected String formAttrs; + + + public Parameter() { + } + + public Parameter(String name) { + this.name = name; + } + + public Parameter(String name, DataType dataType) { + this.name = name; + this.dataType = dataType; + } + + public Parameter(String name, boolean required) { + this.name = name; + this.required = required; + } + + public Parameter(String name, DataType dataType, boolean required) { + this.name = name; + this.dataType = dataType; + this.required = required; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public DataType getDataType() { + return dataType; + } + + public void setDataType(DataType dataType) { + this.dataType = dataType; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getRef() { + return ref; + } + + public void setRef(String ref) { + this.ref = ref; + } + + public RefType getRefType() { + return refType; + } + + public void setRefType(RefType refType) { + this.refType = refType; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + } + + public boolean isRequired() { + return required; + } + + public void setRequired(boolean required) { + this.required = required; + } + + public List getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } + + public void addChild(Parameter parameter) { + if (children == null) { + children = new ArrayList<>(); + } + children.add(parameter); + } + + public void addChildren(Collection parameters) { + if (children == null) { + children = new ArrayList<>(); + } + children.addAll(parameters); + } + + public List getEnums() { + return enums; + } + + public void setEnums(List enums) { + this.enums = enums; + } + + public void setEnumsObject(Object enumsObject) { + if (enumsObject == null) { + this.enums = null; + } else if (enumsObject instanceof Collection) { + this.enums = new ArrayList<>(); + this.enums.addAll((Collection) enumsObject); + } else if (enumsObject.getClass().isArray()) { + this.enums = new ArrayList<>(); + this.enums.addAll(Arrays.asList((Object[]) enumsObject)); + } else { + this.enums = new ArrayList<>(1); + this.enums.add(enumsObject); + } + } + + public String getFormType() { + return formType; + } + + public void setFormType(String formType) { + this.formType = formType; + } + + public String getFormLabel() { + return formLabel; + } + + public void setFormLabel(String formLabel) { + this.formLabel = formLabel; + } + + public String getFormPlaceholder() { + return formPlaceholder; + } + + public void setFormPlaceholder(String formPlaceholder) { + this.formPlaceholder = formPlaceholder; + } + + public String getFormDescription() { + return formDescription; + } + + public void setFormDescription(String formDescription) { + this.formDescription = formDescription; + } + + public String getFormAttrs() { + return formAttrs; + } + + public void setFormAttrs(String formAttrs) { + this.formAttrs = formAttrs; + } + + @Override + public String toString() { + return "Parameter{" + + "id='" + id + '\'' + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", dataType=" + dataType + + ", contentType='" + contentType + '\'' + + ", ref='" + ref + '\'' + + ", refType=" + refType + + ", value='" + value + '\'' + + ", required=" + required + + ", defaultValue='" + defaultValue + '\'' + + ", children=" + children + + ", enums=" + enums + + ", formType='" + formType + '\'' + + ", formLabel='" + formLabel + '\'' + + ", formPlaceholder='" + formPlaceholder + '\'' + + ", formDescription='" + formDescription + '\'' + + ", formAttrs='" + formAttrs + '\'' + + '}'; + } + + @Override + public Parameter clone() { + try { + Parameter clone = (Parameter) super.clone(); + if (this.children != null) { + clone.children = new ArrayList<>(this.children.size()); + for (Parameter child : this.children) { + clone.children.add(child.clone()); // 递归克隆 + } + } + if (this.enums != null) { + clone.enums = new ArrayList<>(this.enums.size()); + clone.enums.addAll(this.enums); + } + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/RefType.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/RefType.java new file mode 100644 index 0000000..7a53192 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/RefType.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain; + +import com.alibaba.fastjson.annotation.JSONType; + +@JSONType(typeName = "RefType") +public enum RefType { + REF("ref"), + FIXED("fixed"), + INPUT("input"), + ; + + private final String value; + + RefType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } + + public static RefType ofValue(String value) { + for (RefType type : RefType.values()) { + if (type.value.equals(value)) { + return type; + } + } + return null; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/BaseEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/BaseEvent.java new file mode 100644 index 0000000..d0cb52a --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/BaseEvent.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.event; + + +import com.easyagents.flow.core.chain.Event; +import com.easyagents.flow.core.chain.Chain; + +public class BaseEvent implements Event { + + protected final Chain chain; + + public BaseEvent(Chain chain) { + this.chain = chain; + } + + public Chain getChain() { + return chain; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainEndEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainEndEvent.java new file mode 100644 index 0000000..972c924 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainEndEvent.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.event; + + +import com.easyagents.flow.core.chain.Chain; + +public class ChainEndEvent extends BaseEvent { + + public ChainEndEvent(Chain chain) { + super(chain); + } + + @Override + public String toString() { + return "ChainEndEvent{" + + "chain=" + chain + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainResumeEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainResumeEvent.java new file mode 100644 index 0000000..ba0b44d --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainResumeEvent.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.event; + + +import com.easyagents.flow.core.chain.Chain; + +import java.util.Map; + +public class ChainResumeEvent extends BaseEvent { + + private final Map variables; + + public ChainResumeEvent(Chain chain, Map variables) { + super(chain); + this.variables = variables; + } + + public Map getVariables() { + return variables; + } + + @Override + public String toString() { + return "ChainResumeEvent{" + + "variables=" + variables + + ", chain=" + chain + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainStartEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainStartEvent.java new file mode 100644 index 0000000..82481cd --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainStartEvent.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.event; + + +import com.easyagents.flow.core.chain.Chain; + +import java.util.Map; + +public class ChainStartEvent extends BaseEvent { + + private final Map variables; + + public ChainStartEvent(Chain chain, Map variables) { + super(chain); + this.variables = variables; + } + + public Map getVariables() { + return variables; + } + + @Override + public String toString() { + return "ChainStartEvent{" + + "variables=" + variables + + ", chain=" + chain + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainStatusChangeEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainStatusChangeEvent.java new file mode 100644 index 0000000..c5c86b3 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/ChainStatusChangeEvent.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.event; + + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainStatus; + +public class ChainStatusChangeEvent extends BaseEvent { + + private final ChainStatus status; + private final ChainStatus before; + + public ChainStatusChangeEvent(Chain chain, ChainStatus status, ChainStatus before) { + super(chain); + this.status = status; + this.before = before; + } + + public ChainStatus getStatus() { + return status; + } + + public ChainStatus getBefore() { + return before; + } + + + @Override + public String toString() { + return "ChainStatusChangeEvent{" + + "status=" + status + + ", before=" + before + + ", chain=" + chain + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/EdgeConditionCheckFailedEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/EdgeConditionCheckFailedEvent.java new file mode 100644 index 0000000..356a48b --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/EdgeConditionCheckFailedEvent.java @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.event; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.Edge; +import com.easyagents.flow.core.chain.Node; + +import java.util.Map; + +public class EdgeConditionCheckFailedEvent extends BaseEvent { + + private final Edge edge; + private final Node node; + private final Map nodeExecuteResult; + + public EdgeConditionCheckFailedEvent(Chain chain, Edge edge, Node node, Map nodeExecuteResult) { + super(chain); + this.edge = edge; + this.node = node; + this.nodeExecuteResult = nodeExecuteResult; + } + + public Edge getEdge() { + return edge; + } + + public Node getNode() { + return node; + } + + public Map getNodeExecuteResult() { + return nodeExecuteResult; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/EdgeTriggerEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/EdgeTriggerEvent.java new file mode 100644 index 0000000..92eaa14 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/EdgeTriggerEvent.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.event; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.runtime.Trigger; + +public class EdgeTriggerEvent extends BaseEvent { + + private final Trigger trigger; + + public EdgeTriggerEvent(Chain chain, Trigger trigger) { + super(chain); + this.trigger = trigger; + } + + public Trigger getTrigger() { + return trigger; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeEndEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeEndEvent.java new file mode 100644 index 0000000..97a1c30 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeEndEvent.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.event; + + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.Node; + +import java.util.Map; + +public class NodeEndEvent extends BaseEvent { + + private final Node node; + private final Map result; + private final Throwable error; + + public NodeEndEvent(Chain chain, Node node, Map result, Throwable error) { + super(chain); + this.node = node; + this.result = result; + this.error = error; + } + + public Node getNode() { + return node; + } + + public Map getResult() { + return result; + } + + public Throwable getError() { + return error; + } + + @Override + public String toString() { + return "NodeEndEvent{" + + "node=" + node + + ", result=" + result + + ", error=" + error + + ", chain=" + chain + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeStartEvent.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeStartEvent.java new file mode 100644 index 0000000..9e4358b --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/event/NodeStartEvent.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.event; + + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.Node; + +public class NodeStartEvent extends BaseEvent { + + private final Node node; + + public NodeStartEvent(Chain chain, Node node) { + super(chain); + this.node = node; + } + + public Node getNode() { + return node; + } + + + @Override + public String toString() { + return "NodeStartEvent{" + + "node=" + node + + ", chain=" + chain + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainErrorListener.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainErrorListener.java new file mode 100644 index 0000000..261a884 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainErrorListener.java @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.listener; + + +import com.easyagents.flow.core.chain.Chain; + +public interface ChainErrorListener { + void onError(Throwable error, Chain chain); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainEventListener.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainEventListener.java new file mode 100644 index 0000000..a2c416a --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainEventListener.java @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.listener; + + +import com.easyagents.flow.core.chain.Event; +import com.easyagents.flow.core.chain.Chain; + +public interface ChainEventListener { + void onEvent(Event event, Chain chain); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainOutputListener.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainOutputListener.java new file mode 100644 index 0000000..90eaf38 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/ChainOutputListener.java @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.listener; + + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.Node; + +public interface ChainOutputListener { + void onOutput(Chain chain, Node node, Object outputMessage); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/NodeErrorListener.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/NodeErrorListener.java new file mode 100644 index 0000000..c875cd0 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/listener/NodeErrorListener.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.listener; + + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.Node; + +import java.util.Map; + +public interface NodeErrorListener { + void onError(Throwable error, Node node, Map nodeResult, Chain chain); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/package-info.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/package-info.java new file mode 100644 index 0000000..e51456a --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/package-info.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Chain 执行链 + */ +package com.easyagents.flow.core.chain; diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainDefinitionRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainDefinitionRepository.java new file mode 100644 index 0000000..9eb988b --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainDefinitionRepository.java @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +import com.easyagents.flow.core.chain.ChainDefinition; + +public interface ChainDefinitionRepository { + + ChainDefinition getChainDefinitionById(String id); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLock.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLock.java new file mode 100644 index 0000000..1bbcf17 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLock.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +/** + * 分布式锁句柄,用于确保锁的正确释放。 + * 使用 try-with-resources 模式保证释放。 + */ +public interface ChainLock extends AutoCloseable { + /** + * 锁是否成功获取(用于判断是否超时) + */ + boolean isAcquired(); + + /** + * 释放锁(幂等) + */ + @Override + void close(); +} \ No newline at end of file diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLockTimeoutException.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLockTimeoutException.java new file mode 100644 index 0000000..a79a628 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainLockTimeoutException.java @@ -0,0 +1,673 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +import java.io.PrintStream; +import java.io.PrintWriter; +import java.lang.ref.PhantomReference; +import java.lang.ref.WeakReference; +import java.util.HashMap; + +public class ChainLockTimeoutException extends RuntimeException{ + /** + * Constructs a new runtime exception with {@code null} as its + * detail message. The cause is not initialized, and may subsequently be + * initialized by a call to {@link #initCause}. + */ + public ChainLockTimeoutException() { + super(); + } + + /** + * Constructs a new runtime exception with the specified detail message. + * The cause is not initialized, and may subsequently be initialized by a + * call to {@link #initCause}. + * + * @param message the detail message. The detail message is saved for + * later retrieval by the {@link #getMessage()} method. + */ + public ChainLockTimeoutException(String message) { + super(message); + } + + /** + * Constructs a new runtime exception with the specified detail message and + * cause.

Note that the detail message associated with + * {@code cause} is not automatically incorporated in + * this runtime exception's detail message. + * + * @param message the detail message (which is saved for later retrieval + * by the {@link #getMessage()} method). + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public ChainLockTimeoutException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new runtime exception with the specified cause and a + * detail message of (cause==null ? null : cause.toString()) + * (which typically contains the class and detail message of + * cause). This constructor is useful for runtime exceptions + * that are little more than wrappers for other throwables. + * + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A null value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public ChainLockTimeoutException(Throwable cause) { + super(cause); + } + + /** + * Constructs a new runtime exception with the specified detail + * message, cause, suppression enabled or disabled, and writable + * stack trace enabled or disabled. + * + * @param message the detail message. + * @param cause the cause. (A {@code null} value is permitted, + * and indicates that the cause is nonexistent or unknown.) + * @param enableSuppression whether or not suppression is enabled + * or disabled + * @param writableStackTrace whether or not the stack trace should + * be writable + * @since 1.7 + */ + protected ChainLockTimeoutException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } + + /** + * Returns the detail message string of this throwable. + * + * @return the detail message string of this {@code Throwable} instance + * (which may be {@code null}). + */ + @Override + public String getMessage() { + return super.getMessage(); + } + + /** + * Creates a localized description of this throwable. + * Subclasses may override this method in order to produce a + * locale-specific message. For subclasses that do not override this + * method, the default implementation returns the same result as + * {@code getMessage()}. + * + * @return The localized description of this throwable. + * @since JDK1.1 + */ + @Override + public String getLocalizedMessage() { + return super.getLocalizedMessage(); + } + + /** + * Returns the cause of this throwable or {@code null} if the + * cause is nonexistent or unknown. (The cause is the throwable that + * caused this throwable to get thrown.) + * + *

This implementation returns the cause that was supplied via one of + * the constructors requiring a {@code Throwable}, or that was set after + * creation with the {@link #initCause(Throwable)} method. While it is + * typically unnecessary to override this method, a subclass can override + * it to return a cause set by some other means. This is appropriate for + * a "legacy chained throwable" that predates the addition of chained + * exceptions to {@code Throwable}. Note that it is not + * necessary to override any of the {@code PrintStackTrace} methods, + * all of which invoke the {@code getCause} method to determine the + * cause of a throwable. + * + * @return the cause of this throwable or {@code null} if the + * cause is nonexistent or unknown. + * @since 1.4 + */ + @Override + public synchronized Throwable getCause() { + return super.getCause(); + } + + /** + * Initializes the cause of this throwable to the specified value. + * (The cause is the throwable that caused this throwable to get thrown.) + * + *

This method can be called at most once. It is generally called from + * within the constructor, or immediately after creating the + * throwable. If this throwable was created + * with {@link #Throwable(Throwable)} or + * {@link #Throwable(String, Throwable)}, this method cannot be called + * even once. + * + *

An example of using this method on a legacy throwable type + * without other support for setting the cause is: + * + *

+     * try {
+     *     lowLevelOp();
+     * } catch (LowLevelException le) {
+     *     throw (HighLevelException)
+     *           new HighLevelException().initCause(le); // Legacy constructor
+     * }
+     * 
+ * + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A {@code null} value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @return a reference to this {@code Throwable} instance. + * @throws IllegalArgumentException if {@code cause} is this + * throwable. (A throwable cannot be its own cause.) + * @throws IllegalStateException if this throwable was + * created with {@link #Throwable(Throwable)} or + * {@link #Throwable(String, Throwable)}, or this method has already + * been called on this throwable. + * @since 1.4 + */ + @Override + public synchronized Throwable initCause(Throwable cause) { + return super.initCause(cause); + } + + /** + * Returns a short description of this throwable. + * The result is the concatenation of: + *
    + *
  • the {@linkplain Class#getName() name} of the class of this object + *
  • ": " (a colon and a space) + *
  • the result of invoking this object's {@link #getLocalizedMessage} + * method + *
+ * If {@code getLocalizedMessage} returns {@code null}, then just + * the class name is returned. + * + * @return a string representation of this throwable. + */ + @Override + public String toString() { + return super.toString(); + } + + /** + * Prints this throwable and its backtrace to the + * standard error stream. This method prints a stack trace for this + * {@code Throwable} object on the error output stream that is + * the value of the field {@code System.err}. The first line of + * output contains the result of the {@link #toString()} method for + * this object. Remaining lines represent data previously recorded by + * the method {@link #fillInStackTrace()}. The format of this + * information depends on the implementation, but the following + * example may be regarded as typical: + *
+     * java.lang.NullPointerException
+     *         at MyClass.mash(MyClass.java:9)
+     *         at MyClass.crunch(MyClass.java:6)
+     *         at MyClass.main(MyClass.java:3)
+     * 
+ * This example was produced by running the program: + *
+     * class MyClass {
+     *     public static void main(String[] args) {
+     *         crunch(null);
+     *     }
+     *     static void crunch(int[] a) {
+     *         mash(a);
+     *     }
+     *     static void mash(int[] b) {
+     *         System.out.println(b[0]);
+     *     }
+     * }
+     * 
+ * The backtrace for a throwable with an initialized, non-null cause + * should generally include the backtrace for the cause. The format + * of this information depends on the implementation, but the following + * example may be regarded as typical: + *
+     * HighLevelException: MidLevelException: LowLevelException
+     *         at Junk.a(Junk.java:13)
+     *         at Junk.main(Junk.java:4)
+     * Caused by: MidLevelException: LowLevelException
+     *         at Junk.c(Junk.java:23)
+     *         at Junk.b(Junk.java:17)
+     *         at Junk.a(Junk.java:11)
+     *         ... 1 more
+     * Caused by: LowLevelException
+     *         at Junk.e(Junk.java:30)
+     *         at Junk.d(Junk.java:27)
+     *         at Junk.c(Junk.java:21)
+     *         ... 3 more
+     * 
+ * Note the presence of lines containing the characters {@code "..."}. + * These lines indicate that the remainder of the stack trace for this + * exception matches the indicated number of frames from the bottom of the + * stack trace of the exception that was caused by this exception (the + * "enclosing" exception). This shorthand can greatly reduce the length + * of the output in the common case where a wrapped exception is thrown + * from same method as the "causative exception" is caught. The above + * example was produced by running the program: + *
+     * public class Junk {
+     *     public static void main(String args[]) {
+     *         try {
+     *             a();
+     *         } catch(HighLevelException e) {
+     *             e.printStackTrace();
+     *         }
+     *     }
+     *     static void a() throws HighLevelException {
+     *         try {
+     *             b();
+     *         } catch(MidLevelException e) {
+     *             throw new HighLevelException(e);
+     *         }
+     *     }
+     *     static void b() throws MidLevelException {
+     *         c();
+     *     }
+     *     static void c() throws MidLevelException {
+     *         try {
+     *             d();
+     *         } catch(LowLevelException e) {
+     *             throw new MidLevelException(e);
+     *         }
+     *     }
+     *     static void d() throws LowLevelException {
+     *        e();
+     *     }
+     *     static void e() throws LowLevelException {
+     *         throw new LowLevelException();
+     *     }
+     * }
+     *
+     * class HighLevelException extends Exception {
+     *     HighLevelException(Throwable cause) { super(cause); }
+     * }
+     *
+     * class MidLevelException extends Exception {
+     *     MidLevelException(Throwable cause)  { super(cause); }
+     * }
+     *
+     * class LowLevelException extends Exception {
+     * }
+     * 
+ * As of release 7, the platform supports the notion of + * suppressed exceptions (in conjunction with the {@code + * try}-with-resources statement). Any exceptions that were + * suppressed in order to deliver an exception are printed out + * beneath the stack trace. The format of this information + * depends on the implementation, but the following example may be + * regarded as typical: + * + *
+     * Exception in thread "main" java.lang.Exception: Something happened
+     *  at Foo.bar(Foo.java:10)
+     *  at Foo.main(Foo.java:5)
+     *  Suppressed: Resource$CloseFailException: Resource ID = 0
+     *          at Resource.close(Resource.java:26)
+     *          at Foo.bar(Foo.java:9)
+     *          ... 1 more
+     * 
+ * Note that the "... n more" notation is used on suppressed exceptions + * just at it is used on causes. Unlike causes, suppressed exceptions are + * indented beyond their "containing exceptions." + * + *

An exception can have both a cause and one or more suppressed + * exceptions: + *

+     * Exception in thread "main" java.lang.Exception: Main block
+     *  at Foo3.main(Foo3.java:7)
+     *  Suppressed: Resource$CloseFailException: Resource ID = 2
+     *          at Resource.close(Resource.java:26)
+     *          at Foo3.main(Foo3.java:5)
+     *  Suppressed: Resource$CloseFailException: Resource ID = 1
+     *          at Resource.close(Resource.java:26)
+     *          at Foo3.main(Foo3.java:5)
+     * Caused by: java.lang.Exception: I did it
+     *  at Foo3.main(Foo3.java:8)
+     * 
+ * Likewise, a suppressed exception can have a cause: + *
+     * Exception in thread "main" java.lang.Exception: Main block
+     *  at Foo4.main(Foo4.java:6)
+     *  Suppressed: Resource2$CloseFailException: Resource ID = 1
+     *          at Resource2.close(Resource2.java:20)
+     *          at Foo4.main(Foo4.java:5)
+     *  Caused by: java.lang.Exception: Rats, you caught me
+     *          at Resource2$CloseFailException.<init>(Resource2.java:45)
+     *          ... 2 more
+     * 
+ */ + @Override + public void printStackTrace() { + super.printStackTrace(); + } + + /** + * Prints this throwable and its backtrace to the specified print stream. + * + * @param s {@code PrintStream} to use for output + */ + @Override + public void printStackTrace(PrintStream s) { + super.printStackTrace(s); + } + + /** + * Prints this throwable and its backtrace to the specified + * print writer. + * + * @param s {@code PrintWriter} to use for output + * @since JDK1.1 + */ + @Override + public void printStackTrace(PrintWriter s) { + super.printStackTrace(s); + } + + /** + * Fills in the execution stack trace. This method records within this + * {@code Throwable} object information about the current state of + * the stack frames for the current thread. + * + *

If the stack trace of this {@code Throwable} {@linkplain + * Throwable#Throwable(String, Throwable, boolean, boolean) is not + * writable}, calling this method has no effect. + * + * @return a reference to this {@code Throwable} instance. + * @see Throwable#printStackTrace() + */ + @Override + public synchronized Throwable fillInStackTrace() { + return super.fillInStackTrace(); + } + + /** + * Provides programmatic access to the stack trace information printed by + * {@link #printStackTrace()}. Returns an array of stack trace elements, + * each representing one stack frame. The zeroth element of the array + * (assuming the array's length is non-zero) represents the top of the + * stack, which is the last method invocation in the sequence. Typically, + * this is the point at which this throwable was created and thrown. + * The last element of the array (assuming the array's length is non-zero) + * represents the bottom of the stack, which is the first method invocation + * in the sequence. + * + *

Some virtual machines may, under some circumstances, omit one + * or more stack frames from the stack trace. In the extreme case, + * a virtual machine that has no stack trace information concerning + * this throwable is permitted to return a zero-length array from this + * method. Generally speaking, the array returned by this method will + * contain one element for every frame that would be printed by + * {@code printStackTrace}. Writes to the returned array do not + * affect future calls to this method. + * + * @return an array of stack trace elements representing the stack trace + * pertaining to this throwable. + * @since 1.4 + */ + @Override + public StackTraceElement[] getStackTrace() { + return super.getStackTrace(); + } + + /** + * Sets the stack trace elements that will be returned by + * {@link #getStackTrace()} and printed by {@link #printStackTrace()} + * and related methods. + *

+ * This method, which is designed for use by RPC frameworks and other + * advanced systems, allows the client to override the default + * stack trace that is either generated by {@link #fillInStackTrace()} + * when a throwable is constructed or deserialized when a throwable is + * read from a serialization stream. + * + *

If the stack trace of this {@code Throwable} {@linkplain + * Throwable#Throwable(String, Throwable, boolean, boolean) is not + * writable}, calling this method has no effect other than + * validating its argument. + * + * @param stackTrace the stack trace elements to be associated with + * this {@code Throwable}. The specified array is copied by this + * call; changes in the specified array after the method invocation + * returns will have no affect on this {@code Throwable}'s stack + * trace. + * @throws NullPointerException if {@code stackTrace} is + * {@code null} or if any of the elements of + * {@code stackTrace} are {@code null} + * @since 1.4 + */ + @Override + public void setStackTrace(StackTraceElement[] stackTrace) { + super.setStackTrace(stackTrace); + } + + /** + * Returns a hash code value for the object. This method is + * supported for the benefit of hash tables such as those provided by + * {@link HashMap}. + *

+ * The general contract of {@code hashCode} is: + *

    + *
  • Whenever it is invoked on the same object more than once during + * an execution of a Java application, the {@code hashCode} method + * must consistently return the same integer, provided no information + * used in {@code equals} comparisons on the object is modified. + * This integer need not remain consistent from one execution of an + * application to another execution of the same application. + *
  • If two objects are equal according to the {@code equals(Object)} + * method, then calling the {@code hashCode} method on each of + * the two objects must produce the same integer result. + *
  • It is not required that if two objects are unequal + * according to the {@link Object#equals(Object)} + * method, then calling the {@code hashCode} method on each of the + * two objects must produce distinct integer results. However, the + * programmer should be aware that producing distinct integer results + * for unequal objects may improve the performance of hash tables. + *
+ *

+ * As much as is reasonably practical, the hashCode method defined by + * class {@code Object} does return distinct integers for distinct + * objects. (This is typically implemented by converting the internal + * address of the object into an integer, but this implementation + * technique is not required by the + * Java™ programming language.) + * + * @return a hash code value for this object. + * @see Object#equals(Object) + * @see System#identityHashCode + */ + @Override + public int hashCode() { + return super.hashCode(); + } + + /** + * Indicates whether some other object is "equal to" this one. + *

+ * The {@code equals} method implements an equivalence relation + * on non-null object references: + *

    + *
  • It is reflexive: for any non-null reference value + * {@code x}, {@code x.equals(x)} should return + * {@code true}. + *
  • It is symmetric: for any non-null reference values + * {@code x} and {@code y}, {@code x.equals(y)} + * should return {@code true} if and only if + * {@code y.equals(x)} returns {@code true}. + *
  • It is transitive: for any non-null reference values + * {@code x}, {@code y}, and {@code z}, if + * {@code x.equals(y)} returns {@code true} and + * {@code y.equals(z)} returns {@code true}, then + * {@code x.equals(z)} should return {@code true}. + *
  • It is consistent: for any non-null reference values + * {@code x} and {@code y}, multiple invocations of + * {@code x.equals(y)} consistently return {@code true} + * or consistently return {@code false}, provided no + * information used in {@code equals} comparisons on the + * objects is modified. + *
  • For any non-null reference value {@code x}, + * {@code x.equals(null)} should return {@code false}. + *
+ *

+ * The {@code equals} method for class {@code Object} implements + * the most discriminating possible equivalence relation on objects; + * that is, for any non-null reference values {@code x} and + * {@code y}, this method returns {@code true} if and only + * if {@code x} and {@code y} refer to the same object + * ({@code x == y} has the value {@code true}). + *

+ * Note that it is generally necessary to override the {@code hashCode} + * method whenever this method is overridden, so as to maintain the + * general contract for the {@code hashCode} method, which states + * that equal objects must have equal hash codes. + * + * @param obj the reference object with which to compare. + * @return {@code true} if this object is the same as the obj + * argument; {@code false} otherwise. + * @see #hashCode() + * @see HashMap + */ + @Override + public boolean equals(Object obj) { + return super.equals(obj); + } + + /** + * Creates and returns a copy of this object. The precise meaning + * of "copy" may depend on the class of the object. The general + * intent is that, for any object {@code x}, the expression: + *

+ *
+     * x.clone() != x
+ * will be true, and that the expression: + *
+ *
+     * x.clone().getClass() == x.getClass()
+ * will be {@code true}, but these are not absolute requirements. + * While it is typically the case that: + *
+ *
+     * x.clone().equals(x)
+ * will be {@code true}, this is not an absolute requirement. + *

+ * By convention, the returned object should be obtained by calling + * {@code super.clone}. If a class and all of its superclasses (except + * {@code Object}) obey this convention, it will be the case that + * {@code x.clone().getClass() == x.getClass()}. + *

+ * By convention, the object returned by this method should be independent + * of this object (which is being cloned). To achieve this independence, + * it may be necessary to modify one or more fields of the object returned + * by {@code super.clone} before returning it. Typically, this means + * copying any mutable objects that comprise the internal "deep structure" + * of the object being cloned and replacing the references to these + * objects with references to the copies. If a class contains only + * primitive fields or references to immutable objects, then it is usually + * the case that no fields in the object returned by {@code super.clone} + * need to be modified. + *

+ * The method {@code clone} for class {@code Object} performs a + * specific cloning operation. First, if the class of this object does + * not implement the interface {@code Cloneable}, then a + * {@code CloneNotSupportedException} is thrown. Note that all arrays + * are considered to implement the interface {@code Cloneable} and that + * the return type of the {@code clone} method of an array type {@code T[]} + * is {@code T[]} where T is any reference or primitive type. + * Otherwise, this method creates a new instance of the class of this + * object and initializes all its fields with exactly the contents of + * the corresponding fields of this object, as if by assignment; the + * contents of the fields are not themselves cloned. Thus, this method + * performs a "shallow copy" of this object, not a "deep copy" operation. + *

+ * The class {@code Object} does not itself implement the interface + * {@code Cloneable}, so calling the {@code clone} method on an object + * whose class is {@code Object} will result in throwing an + * exception at run time. + * + * @return a clone of this instance. + * @throws CloneNotSupportedException if the object's class does not + * support the {@code Cloneable} interface. Subclasses + * that override the {@code clone} method can also + * throw this exception to indicate that an instance cannot + * be cloned. + * @see Cloneable + */ + @Override + protected Object clone() throws CloneNotSupportedException { + return super.clone(); + } + + /** + * Called by the garbage collector on an object when garbage collection + * determines that there are no more references to the object. + * A subclass overrides the {@code finalize} method to dispose of + * system resources or to perform other cleanup. + *

+ * The general contract of {@code finalize} is that it is invoked + * if and when the Java™ virtual + * machine has determined that there is no longer any + * means by which this object can be accessed by any thread that has + * not yet died, except as a result of an action taken by the + * finalization of some other object or class which is ready to be + * finalized. The {@code finalize} method may take any action, including + * making this object available again to other threads; the usual purpose + * of {@code finalize}, however, is to perform cleanup actions before + * the object is irrevocably discarded. For example, the finalize method + * for an object that represents an input/output connection might perform + * explicit I/O transactions to break the connection before the object is + * permanently discarded. + *

+ * The {@code finalize} method of class {@code Object} performs no + * special action; it simply returns normally. Subclasses of + * {@code Object} may override this definition. + *

+ * The Java programming language does not guarantee which thread will + * invoke the {@code finalize} method for any given object. It is + * guaranteed, however, that the thread that invokes finalize will not + * be holding any user-visible synchronization locks when finalize is + * invoked. If an uncaught exception is thrown by the finalize method, + * the exception is ignored and finalization of that object terminates. + *

+ * After the {@code finalize} method has been invoked for an object, no + * further action is taken until the Java virtual machine has again + * determined that there is no longer any means by which this object can + * be accessed by any thread that has not yet died, including possible + * actions by other objects or classes which are ready to be finalized, + * at which point the object may be discarded. + *

+ * The {@code finalize} method is never invoked more than once by a Java + * virtual machine for any given object. + *

+ * Any exception thrown by the {@code finalize} method causes + * the finalization of this object to be halted, but is otherwise + * ignored. + * + * @throws Throwable the {@code Exception} raised by this method + * @jls 12.6 Finalization of Class Instances + * @see WeakReference + * @see PhantomReference + */ + @Override + protected void finalize() throws Throwable { + super.finalize(); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateField.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateField.java new file mode 100644 index 0000000..1509d23 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateField.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +public enum ChainStateField { + INSTANCE_ID, + STATUS, + MESSAGE, + ERROR, + MEMORY, + PAYLOAD, + NODE_STATES, + COMPUTE_COST, + SUSPEND_NODE_IDS, + SUSPEND_FOR_PARAMETERS, + EXECUTE_RESULT, + CHAIN_DEFINITION_ID, + ENVIRONMENT, + CHILD_STATE_IDS, + PARENT_INSTANCE_ID, + TRIGGER_NODE_IDS, + TRIGGER_EDGE_IDS, + UNCHECKED_EDGE_IDS, + UNCHECKED_NODE_IDS; +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateModifier.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateModifier.java new file mode 100644 index 0000000..90b7734 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateModifier.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +import com.easyagents.flow.core.chain.ChainState; + +import java.util.EnumSet; + +public interface ChainStateModifier { + + EnumSet modify(ChainState state); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateRepository.java new file mode 100644 index 0000000..45df7c7 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/ChainStateRepository.java @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +import com.easyagents.flow.core.chain.ChainState; + +import java.util.EnumSet; +import java.util.concurrent.TimeUnit; + +public interface ChainStateRepository { + + ChainState load(String instanceId); + + boolean tryUpdate(ChainState newState, EnumSet fields); + + /** + * 获取指定 instanceId 的分布式锁 + * + * @param instanceId 链实例 ID + * @param timeout 获取锁的超时时间 + * @param unit 时间单位 + * @return ChainLock 句柄,调用方必须负责 close() + * @throws IllegalArgumentException if instanceId is blank + */ + default ChainLock getLock(String instanceId, long timeout, TimeUnit unit) { + return new LocalChainLock(instanceId, timeout, unit); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryChainStateRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryChainStateRepository.java new file mode 100644 index 0000000..65f1d08 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryChainStateRepository.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +import com.easyagents.flow.core.chain.ChainState; +import com.easyagents.flow.core.util.MapUtil; + +import java.util.EnumSet; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryChainStateRepository implements ChainStateRepository { + private static final Map chainStateMap = new ConcurrentHashMap<>(); + + @Override + public ChainState load(String instanceId) { + return MapUtil.computeIfAbsent(chainStateMap, instanceId, k -> { + ChainState state = new ChainState(); + state.setInstanceId(instanceId); + return state; + }); + } + + @Override + public boolean tryUpdate(ChainState chainState, EnumSet fields) { + chainStateMap.put(chainState.getInstanceId(), chainState); + return true; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryNodeStateRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryNodeStateRepository.java new file mode 100644 index 0000000..3bbfd38 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/InMemoryNodeStateRepository.java @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +import com.easyagents.flow.core.chain.NodeState; +import com.easyagents.flow.core.util.MapUtil; + +import java.util.EnumSet; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryNodeStateRepository implements NodeStateRepository { + + private static final Map chainStateMap = new ConcurrentHashMap<>(); + + @Override + public NodeState load(String instanceId, String nodeId) { + String key = instanceId + "." + nodeId; + return MapUtil.computeIfAbsent(chainStateMap, key, k -> { + NodeState nodeState = new NodeState(); + nodeState.setChainInstanceId(instanceId); + nodeState.setNodeId(nodeId); + return nodeState; + }); + } + + @Override + public boolean tryUpdate(NodeState newState, EnumSet fields, long version) { + chainStateMap.put(newState.getChainInstanceId() + "." + newState.getNodeId(), newState); + return true; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LocalChainLock.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LocalChainLock.java new file mode 100644 index 0000000..3d4753d --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/LocalChainLock.java @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +public class LocalChainLock implements ChainLock { + + private static final Map GLOBAL_LOCKS = new ConcurrentHashMap<>(); + + private final String instanceId; + private final ReentrantLock lock; + private final boolean acquired; + + public LocalChainLock(String instanceId, long timeout, TimeUnit unit) { + if (instanceId == null || instanceId.isEmpty()) { + throw new IllegalArgumentException("instanceId must not be blank"); + } + this.instanceId = instanceId; + + // 获取或创建锁(带引用计数) + LockRef lockRef = GLOBAL_LOCKS.compute(instanceId, (key, ref) -> { + if (ref == null) { + return new LockRef(new ReentrantLock()); + } else { + ref.refCount.incrementAndGet(); + return ref; + } + }); + + this.lock = lockRef.lock; + boolean locked = false; + try { + if (timeout <= 0) { + lock.lock(); + locked = true; + } else { + locked = lock.tryLock(timeout, unit); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + this.acquired = locked; + + // 如果获取失败,清理引用计数 + if (!locked) { + releaseRef(); + } + } + + @Override + public boolean isAcquired() { + return acquired; + } + + @Override + public void close() { + if (acquired) { + lock.unlock(); + releaseRef(); + } + } + + private void releaseRef() { + GLOBAL_LOCKS.computeIfPresent(instanceId, (key, ref) -> { + if (ref.refCount.decrementAndGet() <= 0) { + return null; // 移除,允许 GC + } + return ref; + }); + } + + // 内部类:带引用计数的锁包装 + private static class LockRef { + final ReentrantLock lock; + final java.util.concurrent.atomic.AtomicInteger refCount = new java.util.concurrent.atomic.AtomicInteger(1); + + LockRef(ReentrantLock lock) { + this.lock = lock; + } + } +} \ No newline at end of file diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateField.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateField.java new file mode 100644 index 0000000..e5d902c --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateField.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +public enum NodeStateField { + INSTANCE_ID, + STATUS, + MESSAGE, + ERROR, + MEMORY, + PAYLOAD, + NODE_STATES, + COMPUTE_COST, + SUSPEND_NODE_IDS, + SUSPEND_FOR_PARAMETERS, + EXECUTE_RESULT, + RETRY_COUNT, EXECUTE_COUNT, EXECUTE_EDGE_IDS, LOOP_COUNT, TRIGGER_COUNT, TRIGGER_EDGE_IDS, ENVIRONMENT +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateModifier.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateModifier.java new file mode 100644 index 0000000..1958b31 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateModifier.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +import com.easyagents.flow.core.chain.NodeState; + +import java.util.EnumSet; + +public interface NodeStateModifier { + + EnumSet modify(NodeState state); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateRepository.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateRepository.java new file mode 100644 index 0000000..369395d --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/repository/NodeStateRepository.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.repository; + +import com.easyagents.flow.core.chain.NodeState; + +import java.util.EnumSet; + +public interface NodeStateRepository { + + NodeState load(String instanceId, String nodeId); + + boolean tryUpdate(NodeState newState, EnumSet fields, long chainStateVersion); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java new file mode 100644 index 0000000..938671e --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java @@ -0,0 +1,310 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.runtime; + +import com.easyagents.flow.core.chain.*; +import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent; +import com.easyagents.flow.core.chain.listener.ChainErrorListener; +import com.easyagents.flow.core.chain.listener.ChainEventListener; +import com.easyagents.flow.core.chain.listener.ChainOutputListener; +import com.easyagents.flow.core.chain.listener.NodeErrorListener; +import com.easyagents.flow.core.chain.repository.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.*; + +/** + * TinyFlow 最新 ChainExecutor + *

+ * 说明: + * * 负责触发 Chain 执行 / 恢复 + * * 不持有长时间运行的 Chain 实例 + * * 支持 async-only 架构 + */ +public class ChainExecutor { + + private static final Logger log = LoggerFactory.getLogger(ChainExecutor.class); + private final ChainDefinitionRepository definitionRepository; + private final ChainStateRepository chainStateRepository; + private final NodeStateRepository nodeStateRepository; + private final TriggerScheduler triggerScheduler; + private final EventManager eventManager = new EventManager(); + + public ChainExecutor(ChainDefinitionRepository definitionRepository + , ChainStateRepository chainStateRepository + , NodeStateRepository nodeStateRepository + ) { + this.definitionRepository = definitionRepository; + this.chainStateRepository = chainStateRepository; + this.nodeStateRepository = nodeStateRepository; + this.triggerScheduler = ChainRuntime.triggerScheduler(); + this.triggerScheduler.registerConsumer(this::accept); + } + + + public ChainExecutor(ChainDefinitionRepository definitionRepository + , ChainStateRepository chainStateRepository + , NodeStateRepository nodeStateRepository + , TriggerScheduler triggerScheduler) { + this.definitionRepository = definitionRepository; + this.chainStateRepository = chainStateRepository; + this.nodeStateRepository = nodeStateRepository; + this.triggerScheduler = triggerScheduler; + this.triggerScheduler.registerConsumer(this::accept); + } + + + public Map execute(String definitionId, Map variables) { + return execute(definitionId, variables, Long.MAX_VALUE, TimeUnit.SECONDS); + } + + + public Map execute(String definitionId, Map variables, long timeout, TimeUnit unit) { + Chain chain = createChain(definitionId); + String stateInstanceId = chain.getStateInstanceId(); + CompletableFuture> future = new CompletableFuture<>(); + + ChainEventListener listener = (event, c) -> { + if (event instanceof ChainStatusChangeEvent) { + if (((ChainStatusChangeEvent) event).getStatus().isTerminal() + && c.getStateInstanceId().equals(stateInstanceId)) { + ChainState state = chainStateRepository.load(stateInstanceId); + Map execResult = state.getExecuteResult(); + future.complete(execResult != null ? execResult : Collections.emptyMap()); + } + } + }; + + ChainErrorListener errorListener = (error, c) -> { + if (c.getStateInstanceId().equals(stateInstanceId)) { + future.completeExceptionally(error); + } + }; + + try { + this.addEventListener(listener); + this.addErrorListener(errorListener); + chain.start(variables); + Map result = future.get(timeout, unit); + clearDefaultStates(result); + return result; + } catch (TimeoutException e) { + future.cancel(true); + throw new RuntimeException("Execution timed out", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + future.cancel(true); + throw new RuntimeException("Execution interrupted", e); + } catch (Throwable e) { + future.cancel(true); + throw new RuntimeException("Execution failed", e.getCause()); + } finally { + this.removeEventListener(listener); + this.removeErrorListener(errorListener); + } + } + + /** + * 清理默认状态 + * + * @param result 执行结果 + */ + public void clearDefaultStates(Map result) { + if (result == null || result.isEmpty()) { + return; + } + result.remove(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY); + result.remove(ChainConsts.NODE_STATE_STATUS_KEY); + result.remove(ChainConsts.CHAIN_STATE_STATUS_KEY); + result.remove(ChainConsts.CHAIN_STATE_MESSAGE_KEY); + } + + public String executeAsync(String definitionId, Map variables) { + Chain chain = createChain(definitionId); + chain.start(variables); + return chain.getStateInstanceId(); + } + + + /** + * 执行指定节点的业务逻辑 + * + * @param definitionId 流程定义ID,用于标识哪个流程定义 + * @param nodeId 节点ID,用于标识要执行的具体节点 + * @param variables 执行上下文变量集合,包含节点执行所需的参数和数据 + * @return 执行结果映射表,包含节点执行后的输出数据 + */ + public Map executeNode(String definitionId, String nodeId, Map variables) { + ChainDefinition chainDefinitionById = definitionRepository.getChainDefinitionById(definitionId); + Node node = chainDefinitionById.getNodeById(nodeId); + Chain temp = createChain(definitionId); + if (variables != null && !variables.isEmpty()) { + temp.updateStateSafely(s -> { + s.getMemory().putAll(variables); + return EnumSet.of(ChainStateField.MEMORY); + }); + } + return node.execute(temp); + } + + + /** + * 获取指定节点的参数列表 + * + * @param definitionId 链定义ID,用于定位具体的链定义 + * @param nodeId 节点ID,用于在链定义中定位具体节点 + * @return 返回指定节点的参数列表 + */ + public List getNodeParameters(String definitionId, String nodeId) { + ChainDefinition chainDefinitionById = definitionRepository.getChainDefinitionById(definitionId); + Node node = chainDefinitionById.getNodeById(nodeId); + return node.getParameters(); + } + + + public void resumeAsync(String stateInstanceId) { + this.resumeAsync(stateInstanceId, Collections.emptyMap()); + } + + + public void resumeAsync(String stateInstanceId, Map variables) { + ChainState state = chainStateRepository.load(stateInstanceId); + if (state == null) { + return; + } + + ChainDefinition definition = definitionRepository.getChainDefinitionById(state.getChainDefinitionId()); + if (definition == null) { + return; + } + + Chain chain = new Chain(definition, state.getInstanceId()); + chain.setTriggerScheduler(triggerScheduler); + chain.setChainStateRepository(chainStateRepository); + chain.setNodeStateRepository(nodeStateRepository); + chain.setEventManager(eventManager); + + chain.resume(variables); + } + + + private Chain createChain(String definitionId) { + ChainDefinition definition = definitionRepository.getChainDefinitionById(definitionId); + if (definition == null) { + throw new RuntimeException("Chain definition not found"); + } + + String stateInstanceId = UUID.randomUUID().toString(); + Chain chain = new Chain(definition, stateInstanceId); + chain.setTriggerScheduler(triggerScheduler); + chain.setChainStateRepository(chainStateRepository); + chain.setNodeStateRepository(nodeStateRepository); + chain.setEventManager(eventManager); + + return chain; + } + + + private void accept(Trigger trigger, ExecutorService worker) { + ChainState state = chainStateRepository.load(trigger.getStateInstanceId()); + if (state == null) { + throw new ChainException("Chain state not found"); + } + + + ChainDefinition definition = definitionRepository.getChainDefinitionById(state.getChainDefinitionId()); + if (definition == null) { + throw new ChainException("Chain definition not found"); + } + + Chain chain = new Chain(definition, trigger.getStateInstanceId()); + chain.setTriggerScheduler(triggerScheduler); + chain.setChainStateRepository(chainStateRepository); + chain.setNodeStateRepository(nodeStateRepository); + chain.setEventManager(eventManager); + + String nodeId = trigger.getNodeId(); + if (nodeId == null) { + throw new ChainException("Node ID not found in trigger."); + } + + Node node = definition.getNodeById(nodeId); + if (node == null) { + throw new ChainException("Node not found in definition(id: " + definition.getId() + ")"); + } + + chain.executeNode(node, trigger); + } + + + public synchronized void addEventListener(Class eventClass, ChainEventListener listener) { + eventManager.addEventListener(eventClass, listener); + } + + public synchronized void addEventListener(ChainEventListener listener) { + eventManager.addEventListener(listener); + } + + public synchronized void removeEventListener(ChainEventListener listener) { + eventManager.removeEventListener(listener); + } + + public synchronized void removeEventListener(Class eventClass, ChainEventListener listener) { + eventManager.removeEventListener(eventClass, listener); + } + + public synchronized void addErrorListener(ChainErrorListener listener) { + eventManager.addChainErrorListener(listener); + } + + public synchronized void removeErrorListener(ChainErrorListener listener) { + eventManager.removeChainErrorListener(listener); + } + + public synchronized void addNodeErrorListener(NodeErrorListener listener) { + eventManager.addNodeErrorListener(listener); + } + + public synchronized void removeNodeErrorListener(NodeErrorListener listener) { + eventManager.removeNodeErrorListener(listener); + } + + public void addOutputListener(ChainOutputListener outputListener) { + eventManager.addOutputListener(outputListener); + } + + public ChainDefinitionRepository getDefinitionRepository() { + return definitionRepository; + } + + public ChainStateRepository getChainStateRepository() { + return chainStateRepository; + } + + public NodeStateRepository getNodeStateRepository() { + return nodeStateRepository; + } + + public TriggerScheduler getTriggerScheduler() { + return triggerScheduler; + } + + public EventManager getEventManager() { + return eventManager; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainRuntime.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainRuntime.java new file mode 100644 index 0000000..a1c720b --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainRuntime.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.runtime; + +import java.util.concurrent.*; + +public class ChainRuntime { + + private static final int NODE_POOL_CORE = 32; + private static final int NODE_POOL_MAX = 512; + private static final int CHAIN_POOL_CORE = 8; + private static final int CHAIN_POOL_MAX = 64; + + private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1, r -> { + Thread t = new Thread(r, "tinyflow-trigger-scheduler"); + t.setDaemon(true); + return t; + }); + + private static final ExecutorService ASYNC_NODE_EXECUTORS = + new ThreadPoolExecutor( + NODE_POOL_CORE, NODE_POOL_MAX, + 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(10000), + r -> new Thread(r, "tinyflow-node-exec")); + + + private static final TriggerScheduler TRIGGER_SCHEDULER = new TriggerScheduler( + new InMemoryTriggerStore() + , scheduler + , ASYNC_NODE_EXECUTORS + , 10000L); + + + public static TriggerScheduler triggerScheduler() { + return TRIGGER_SCHEDULER; + } + + static { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + TRIGGER_SCHEDULER.shutdown(); + ASYNC_NODE_EXECUTORS.shutdownNow(); + })); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/InMemoryTriggerStore.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/InMemoryTriggerStore.java new file mode 100644 index 0000000..550b4d6 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/InMemoryTriggerStore.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.runtime; + + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryTriggerStore implements TriggerStore { + + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + + @Override + public Trigger save(Trigger trigger) { + if (trigger.getId() == null) { + trigger.setId(UUID.randomUUID().toString()); + } + store.put(trigger.getId(), trigger); + return trigger; + } + + @Override + public boolean remove(String triggerId) { + return store.remove(triggerId) != null; + } + + @Override + public Trigger find(String triggerId) { + return store.get(triggerId); + } + + @Override + public List findDue(long uptoTimestamp) { + return null; + } + + @Override + public List findAllPending() { + return new ArrayList<>(store.values()); + } +} + diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/Trigger.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/Trigger.java new file mode 100644 index 0000000..702e97c --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/Trigger.java @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.runtime; + +import java.io.Serializable; + +public class Trigger implements Serializable { + private String id; + private String stateInstanceId; + private String edgeId; + private String nodeId; // 可以为 null,代表触发整个 chain + private TriggerType type; + private long triggerAt; // epoch ms + + public Trigger() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + + public String getStateInstanceId() { + return stateInstanceId; + } + + public void setStateInstanceId(String stateInstanceId) { + this.stateInstanceId = stateInstanceId; + } + + public String getEdgeId() { + return edgeId; + } + + public void setEdgeId(String edgeId) { + this.edgeId = edgeId; + } + + public String getNodeId() { + return nodeId; + } + + public void setNodeId(String nodeId) { + this.nodeId = nodeId; + } + + public TriggerType getType() { + return type; + } + + public void setType(TriggerType type) { + this.type = type; + } + + public long getTriggerAt() { + return triggerAt; + } + + public void setTriggerAt(long triggerAt) { + this.triggerAt = triggerAt; + } + + @Override + public String toString() { + return "Trigger{" + + "id='" + id + '\'' + + ", stateInstanceId='" + stateInstanceId + '\'' + + ", edgeId='" + edgeId + '\'' + + ", nodeId='" + nodeId + '\'' + + ", type=" + type + + ", triggerAt=" + triggerAt + + '}'; + } +} + diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerContext.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerContext.java new file mode 100644 index 0000000..677a56a --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerContext.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.runtime; + +public class TriggerContext { + private static final ThreadLocal currentTrigger = new ThreadLocal<>(); + public static Trigger getCurrentTrigger() { + return currentTrigger.get(); + } + public static void setCurrentTrigger(Trigger trigger) { + currentTrigger.set(trigger); + } + public static void clearCurrentTrigger() { + currentTrigger.remove(); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerScheduler.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerScheduler.java new file mode 100644 index 0000000..7d07a03 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerScheduler.java @@ -0,0 +1,246 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.runtime; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * + * 功能: + * - schedule trigger (持久化到 TriggerStore 并 schedule) + * - cancel trigger + * - fire(triggerId) 用于 webhook/event/manual 主动触发 + * - recoverAndSchedulePending() 启动时恢复未执行的 trigger + * - periodical scan findDue(upto) 以保证重启/宕机后补偿触发 + *

+ * 注意: 分布式环境下需要在 TriggerStore 层提供抢占/锁逻辑(例如 lease/owner 字段)。 + */ +public class TriggerScheduler { + + private static final Logger log = LoggerFactory.getLogger(TriggerScheduler.class); + private final TriggerStore store; + private final ScheduledExecutorService scheduler; + private final ExecutorService worker; + private final AtomicBoolean closed = new AtomicBoolean(false); + + // map 用于管理取消:triggerId -> ScheduledFuture + private final ConcurrentMap> scheduledFutures = new ConcurrentHashMap<>(); + + // consumer 来把 trigger 交给 ChainExecutor(或 ChainRuntime)去处理 + private volatile TriggerConsumer consumer; + + // 周期扫查间隔(ms) + private final long scanIntervalMs; + + // 扫描任务 future + private ScheduledFuture scanFuture; + + public interface TriggerConsumer { + void accept(Trigger trigger, ExecutorService worker); + } + + public TriggerScheduler(TriggerStore store, ScheduledExecutorService scheduler, ExecutorService worker, long scanIntervalMs) { + this.store = Objects.requireNonNull(store, "TriggerStore required"); + this.scheduler = Objects.requireNonNull(scheduler, "ScheduledExecutorService required"); + this.worker = Objects.requireNonNull(worker, "ExecutorService required"); + this.scanIntervalMs = Math.max(1000, scanIntervalMs); + + // 恢复并 schedule + recoverAndSchedulePending(); + + // 启动周期扫查 findDue + startPeriodicScan(); + } + + + public void registerConsumer(TriggerConsumer consumer) { + this.consumer = consumer; + } + + /** + * schedule a trigger: persist -> schedule (单机语义) + */ + public Trigger schedule(Trigger trigger) { + if (closed.get()) throw new IllegalStateException("TriggerScheduler closed"); + if (trigger.getId() == null) { + trigger.setId(UUID.randomUUID().toString()); + } + store.save(trigger); + scheduleInternal(trigger); + return trigger; + } + + /** + * cancel trigger (从 store 删除并尝试取消已 schedule 的 future) + */ + public boolean cancel(String triggerId) { + boolean removed = store.remove(triggerId); + ScheduledFuture f = scheduledFutures.remove(triggerId); + if (f != null) { + f.cancel(false); + } + return removed; + } + + /** + * 主动触发(webhook/event/manual 场景) + */ + public boolean fire(String triggerId) { + if (closed.get()) return false; + Trigger t = store.find(triggerId); + if (t == null) return false; + if (consumer == null) { + // 无 consumer,仍从 store 中移除 + store.remove(triggerId); + return false; + } + // 在 worker 线程触发 consumer + worker.submit(() -> { + try { + consumer.accept(t, worker); + } catch (Exception e) { + log.error(e.toString(), e); + } finally { + // 默认语义:触发后移除 + store.remove(triggerId); + ScheduledFuture sf = scheduledFutures.remove(triggerId); + if (sf != null) sf.cancel(false); + } + }); + return true; + } + + /** + * internal scheduling for a trigger (单机 scheduled semantics) + */ + private void scheduleInternal(Trigger trigger) { + if (closed.get()) return; + + long delay = Math.max(0, trigger.getTriggerAt() - System.currentTimeMillis()); + + // cancel any existing scheduled future for same id + ScheduledFuture prev = scheduledFutures.remove(trigger.getId()); + if (prev != null) prev.cancel(false); + + ScheduledFuture future = scheduler.schedule(() -> { + // double-check existence in store (可能已被 cancel) + Trigger existing = store.find(trigger.getId()); + if (existing == null) { + scheduledFutures.remove(trigger.getId()); + return; + } + + if (consumer != null) { + worker.submit(() -> { + try { + TriggerContext.setCurrentTrigger(existing); + consumer.accept(existing, worker); + } catch (Throwable e) { + log.error(e.toString(), e); + } finally { + TriggerContext.clearCurrentTrigger(); + store.remove(existing.getId()); + scheduledFutures.remove(existing.getId()); + } + }); + } else { + // 无 consumer,则移除 + store.remove(existing.getId()); + scheduledFutures.remove(existing.getId()); + } + }, delay, TimeUnit.MILLISECONDS); + + scheduledFutures.put(trigger.getId(), future); + } + + private void recoverAndSchedulePending() { + try { + List list = store.findAllPending(); + if (list == null || list.isEmpty()) return; + for (Trigger t : list) { + scheduleInternal(t); + } + } catch (Throwable t) { + // 忽略单次恢复错误,继续运行 + t.printStackTrace(); + } + } + + private void startPeriodicScan() { + if (closed.get()) return; + scanFuture = scheduler.scheduleAtFixedRate(() -> { + try { + long upto = System.currentTimeMillis(); + List due = store.findDue(upto); + if (due == null || due.isEmpty()) return; + for (Trigger t : due) { + // 如果已被 schedule 到未来(scheduledFutures 包含且尚未到期),跳过 + ScheduledFuture sf = scheduledFutures.get(t.getId()); + if (sf != null && !sf.isDone() && !sf.isCancelled()) { + continue; + } + // 直接提交到 worker,让 consumer 处理;并从 store 中移除 + if (consumer != null) { + worker.submit(() -> { + try { + consumer.accept(t, worker); + } finally { + store.remove(t.getId()); + ScheduledFuture f2 = scheduledFutures.remove(t.getId()); + if (f2 != null) f2.cancel(false); + } + }); + } else { + store.remove(t.getId()); + } + } + } catch (Throwable tt) { + tt.printStackTrace(); + } + }, scanIntervalMs, scanIntervalMs, TimeUnit.MILLISECONDS); + } + + public void shutdown() { + if (closed.compareAndSet(false, true)) { + if (scanFuture != null) scanFuture.cancel(false); + // cancel scheduled futures + for (Map.Entry> e : scheduledFutures.entrySet()) { + try { + e.getValue().cancel(false); + } catch (Throwable ignored) { + } + } + scheduledFutures.clear(); + + try { + scheduler.shutdownNow(); + } catch (Throwable ignored) { + } + try { + worker.shutdownNow(); + } catch (Throwable ignored) { + } + } + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerStore.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerStore.java new file mode 100644 index 0000000..fdb3dd9 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerStore.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.runtime; + +import java.util.List; + +public interface TriggerStore { + Trigger save(Trigger trigger); + + boolean remove(String triggerId); + + Trigger find(String triggerId); + + List findDue(long uptoTimestamp); + + List findAllPending(); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerType.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerType.java new file mode 100644 index 0000000..d9f4116 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/TriggerType.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.chain.runtime; + +public enum TriggerType { + START, + NEXT, + PARENT, + CHILD, + SELF, + LOOP, + RETRY, + TIMER, + CRON, + EVENT, + DELAY, + RESUME +} \ No newline at end of file diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngine.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngine.java new file mode 100644 index 0000000..21a853e --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngine.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.code; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.node.CodeNode; + +import java.util.Map; + +public interface CodeRuntimeEngine { + Map execute(String code, CodeNode node, Chain chain); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngineManager.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngineManager.java new file mode 100644 index 0000000..23d6b9a --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngineManager.java @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.code; + +import com.easyagents.flow.core.code.impl.JavascriptRuntimeEngine; + +import java.util.ArrayList; +import java.util.List; + +public class CodeRuntimeEngineManager { + + public List providers = new ArrayList<>(); + + private static class ManagerHolder { + private static final CodeRuntimeEngineManager INSTANCE = new CodeRuntimeEngineManager(); + } + + private CodeRuntimeEngineManager() { + JavascriptRuntimeEngine javascriptRuntimeEngine = new JavascriptRuntimeEngine(); + providers.add(engineId -> { + if ("js".equals(engineId) || "javascript".equals(engineId)) { + return javascriptRuntimeEngine; + } + return null; + }); + } + + public static CodeRuntimeEngineManager getInstance() { + return ManagerHolder.INSTANCE; + } + + public void registerProvider(CodeRuntimeEngineProvider provider) { + providers.add(provider); + } + + public void removeProvider(CodeRuntimeEngineProvider provider) { + providers.remove(provider); + } + + public CodeRuntimeEngine getCodeRuntimeEngine(Object engineId) { + for (CodeRuntimeEngineProvider provider : providers) { + CodeRuntimeEngine codeRuntimeEngine = provider.getCodeRuntimeEngine(engineId); + if (codeRuntimeEngine != null) { + return codeRuntimeEngine; + } + } + return null; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngineProvider.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngineProvider.java new file mode 100644 index 0000000..578c8dc --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/CodeRuntimeEngineProvider.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.code; + +public interface CodeRuntimeEngineProvider { + CodeRuntimeEngine getCodeRuntimeEngine(Object engineId); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/GraalvmToFastJSONUtils.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/GraalvmToFastJSONUtils.java new file mode 100644 index 0000000..097cf2a --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/GraalvmToFastJSONUtils.java @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.code.impl; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.graalvm.polyglot.Value; + +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public class GraalvmToFastJSONUtils { + public static Object toFastJsonValue(Object obj) { + if (obj == null) { + return null; + } + + if (obj instanceof Value) { + Value value = (Value) obj; + + if (value.isNull()) { + return null; + } + + if (value.isBoolean()) { + return value.as(Boolean.class); + } + + if (value.isNumber()) { + if (value.fitsInLong()) { + return value.as(Long.class); + } else if (value.fitsInDouble()) { + return value.as(Double.class); + } else { + return value.toString(); // e.g., BigInt + } + } + + if (value.isString()) { + return value.as(String.class); + } + + if (value.hasArrayElements()) { + long size = value.getArraySize(); + JSONArray array = new JSONArray(); + for (long i = 0; i < size; i++) { + array.add(toFastJsonValue(value.getArrayElement(i))); + } + return array; + } + + if (value.hasMembers()) { + JSONObject object = new JSONObject(); + Set keys = value.getMemberKeys(); + for (String key : keys) { + Value member = value.getMember(key); + // 排除函数 + if (!member.canExecute()) { + object.put(key, toFastJsonValue(member)); + } + } + return object; + } + + return value.toString(); + } + + // 处理标准 Java 类型 --------------------------------------- + + if (obj instanceof Map) { + Map map = (Map) obj; + JSONObject jsonObject = new JSONObject(); + for (Map.Entry entry : map.entrySet()) { + String key = Objects.toString(entry.getKey(), "null"); + Object convertedValue = toFastJsonValue(entry.getValue()); + jsonObject.put(key, convertedValue); + } + return jsonObject; + } + + if (obj instanceof Collection) { + Collection coll = (Collection) obj; + JSONArray array = new JSONArray(); + for (Object item : coll) { + array.add(toFastJsonValue(item)); + } + return array; + } + + if (obj.getClass().isArray()) { + int length = java.lang.reflect.Array.getLength(obj); + JSONArray array = new JSONArray(); + for (int i = 0; i < length; i++) { + Object item = java.lang.reflect.Array.get(obj, i); + array.add(toFastJsonValue(item)); + } + return array; + } + + // 基本类型直接返回 + if (obj instanceof String || + obj instanceof Boolean || + obj instanceof Byte || + obj instanceof Short || + obj instanceof Integer || + obj instanceof Long || + obj instanceof Float || + obj instanceof Double) { + return obj; + } + + // 兜底:调用 toString + return obj.toString(); + } + + /** + * 转为 JSONObject(适合根是对象) + */ + public static JSONObject toJSONObject(Object obj) { + Object result = toFastJsonValue(obj); + if (result instanceof JSONObject) { + return (JSONObject) result; + } else if (result instanceof Map) { + return new JSONObject((Map) result); + } else { + return new JSONObject().fluentPut("value", result); + } + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/JavascriptRuntimeEngine.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/JavascriptRuntimeEngine.java new file mode 100644 index 0000000..9d94608 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/code/impl/JavascriptRuntimeEngine.java @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.code.impl; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.code.CodeRuntimeEngine; +import com.easyagents.flow.core.node.CodeNode; +import com.easyagents.flow.core.util.graalvm.JsInteropUtils; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.Value; + +import java.util.Map; + +public class JavascriptRuntimeEngine implements CodeRuntimeEngine { + + // 使用 Context.Builder 构建上下文,线程安全 + private static final Context.Builder CONTEXT_BUILDER = Context.newBuilder("js") + .option("engine.WarnInterpreterOnly", "false") + .allowHostAccess(HostAccess.ALL) // 允许访问 Java 对象的方法和字段 + .allowHostClassLookup(className -> false) // 禁止动态加载任意 Java 类 + .option("js.ecmascript-version", "2021"); // 使用较新的 ECMAScript 版本 + + + @Override + public Map execute(String code, CodeNode node, Chain chain) { + try (Context context = CONTEXT_BUILDER.build()) { + Value bindings = context.getBindings("js"); + + Map all = chain.getState().getMemory(); + all.forEach((key, value) -> { + if (!key.contains(".")) { + bindings.putMember(key, JsInteropUtils.wrapJavaValueForJS(context, value)); + } + }); + + // 注入参数 + Map parameterValues = chain.getState().resolveParameters(node); + if (parameterValues != null) { + for (Map.Entry entry : parameterValues.entrySet()) { + bindings.putMember(entry.getKey(), JsInteropUtils.wrapJavaValueForJS(context, entry.getValue())); + } + } + + bindings.putMember("_chain", chain); + bindings.putMember("_state", chain.getNodeState(node.getId())); + + + // 在 JS 中创建 _result 对象 + context.eval("js", "var _result = {};"); + + // 注入 _chain 和 _context + bindings.putMember("_chain", chain); + bindings.putMember("_state", chain.getNodeState(node.getId())); + + // 执行用户脚本 + context.eval("js", code); + + Value resultValue = bindings.getMember("_result"); + + return GraalvmToFastJSONUtils.toJSONObject(resultValue); + + } catch (Exception e) { + throw new RuntimeException("Polyglot JS 脚本执行失败: " + e.getMessage(), e); + } + } + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorage.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorage.java new file mode 100644 index 0000000..e3bd0e2 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorage.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.filestoreage; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.node.BaseNode; + +import java.io.InputStream; +import java.util.Map; + +public interface FileStorage { + + String saveFile(InputStream stream, Map headers, BaseNode node, Chain chain); + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorageManager.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorageManager.java new file mode 100644 index 0000000..0c2fbf5 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorageManager.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.filestoreage; + +import java.util.ArrayList; +import java.util.List; + +public class FileStorageManager { + + public List providers = new ArrayList<>(); + + private static class ManagerHolder { + private static final FileStorageManager INSTANCE = new FileStorageManager(); + } + + private FileStorageManager() { + } + + public static FileStorageManager getInstance() { + return ManagerHolder.INSTANCE; + } + + public void registerProvider(FileStorageProvider provider) { + providers.add(provider); + } + + public void removeProvider(FileStorageProvider provider) { + providers.remove(provider); + } + + public FileStorage getFileStorage() { + for (FileStorageProvider provider : providers) { + FileStorage fileStorage = provider.getFileStorage(); + if (fileStorage != null) { + return fileStorage; + } + } + return null; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorageProvider.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorageProvider.java new file mode 100644 index 0000000..77d94da --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/filestoreage/FileStorageProvider.java @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.filestoreage; + + +public interface FileStorageProvider { + FileStorage getFileStorage(); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/Knowledge.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/Knowledge.java new file mode 100644 index 0000000..56ffb71 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/Knowledge.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.knowledge; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.node.KnowledgeNode; + +import java.util.List; +import java.util.Map; + +public interface Knowledge { + List> search(String keyword, int limit, KnowledgeNode knowledgeNode, Chain chain); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/KnowledgeManager.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/KnowledgeManager.java new file mode 100644 index 0000000..f8b14f9 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/KnowledgeManager.java @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.knowledge; + + +import java.util.ArrayList; +import java.util.List; + +public class KnowledgeManager { + + public List providers = new ArrayList<>(); + + private static class ManagerHolder { + private static final KnowledgeManager INSTANCE = new KnowledgeManager(); + } + + private KnowledgeManager() { + } + + public static KnowledgeManager getInstance() { + return ManagerHolder.INSTANCE; + } + + public void registerProvider(KnowledgeProvider provider) { + providers.add(provider); + } + + public void removeProvider(KnowledgeProvider provider) { + providers.remove(provider); + } + + public Knowledge getKnowledge(Object knowledgeId) { + for (KnowledgeProvider provider : providers) { + Knowledge knowledge = provider.getKnowledge(knowledgeId); + if (knowledge != null) { + return knowledge; + } + } + return null; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/KnowledgeProvider.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/KnowledgeProvider.java new file mode 100644 index 0000000..798249a --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/knowledge/KnowledgeProvider.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.knowledge; + +public interface KnowledgeProvider { + Knowledge getKnowledge(Object id); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java new file mode 100644 index 0000000..551cc33 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/Llm.java @@ -0,0 +1,206 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.llm; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.node.LlmNode; + +import java.io.Serializable; +import java.util.List; + +/** + * Llm接口定义了大语言模型的基本功能规范 + *

+ * 该接口提供了与大型语言模型交互的标准方法, + * 包括文本生成、对话处理等核心功能 + */ +public interface Llm { + + + /** + * 执行聊天对话操作 + * + * @param messageInfo 消息信息对象,包含用户输入的原始消息内容及相关元数据 + * @param options 聊天配置选项,用于控制对话行为和模型参数 + * @param llmNode 大语言模型节点,指定使用的具体语言模型实例 + * @param chain 对话链对象,管理对话历史和上下文状态 + * @return 返回模型生成的回复字符串 + */ + String chat(MessageInfo messageInfo, ChatOptions options, LlmNode llmNode, Chain chain); + + + /** + * 消息信息类,用于封装消息相关的信息 + */ + class MessageInfo implements Serializable { + private static final long serialVersionUID = 1L; + private String message; + private String systemMessage; + private List images; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getSystemMessage() { + return systemMessage; + } + + public void setSystemMessage(String systemMessage) { + this.systemMessage = systemMessage; + } + + public List getImages() { + return images; + } + + public void setImages(List images) { + this.images = images; + } + } + + /** + * ChatOptions 类用于存储聊天相关的配置选项 + * 该类实现了Serializable接口,支持序列化操作 + */ + /** + * ChatOptions类用于配置聊天模型的参数选项 + * 实现了Serializable接口,支持序列化 + */ + class ChatOptions implements Serializable { + + private String seed; + private Float temperature = 0.8f; + private Float topP; + private Integer topK; + private Integer maxTokens; + private List stop; + + /** + * 获取随机种子值 + * + * @return 返回随机种子字符串,用于控制生成结果的随机性 + */ + public String getSeed() { + return seed; + } + + /** + * 设置随机种子值 + * + * @param seed 随机种子字符串,用于控制生成结果的随机性 + */ + public void setSeed(String seed) { + this.seed = seed; + } + + /** + * 获取温度参数 + * + * @return 返回温度值,控制生成文本的随机性,值越高越随机 + */ + public Float getTemperature() { + return temperature; + } + + /** + * 设置温度参数 + * + * @param temperature 温度值,控制生成文本的随机性,值越高越随机 + */ + public void setTemperature(Float temperature) { + this.temperature = temperature; + } + + /** + * 获取Top-P参数 + * + * @return 返回Top-P值,用于 nucleus sampling,控制生成词汇的概率阈值 + */ + public Float getTopP() { + return topP; + } + + /** + * 设置Top-P参数 + * + * @param topP Top-P值,用于 nucleus sampling,控制生成词汇的概率阈值 + */ + public void setTopP(Float topP) { + this.topP = topP; + } + + /** + * 获取Top-K参数 + * + * @return 返回Top-K值,限制每步选择词汇的范围 + */ + public Integer getTopK() { + return topK; + } + + /** + * 设置Top-K参数 + * + * @param topK Top-K值,限制每步选择词汇的范围 + */ + public void setTopK(Integer topK) { + this.topK = topK; + } + + /** + * 获取最大令牌数 + * + * @return 返回最大令牌数,限制生成文本的最大长度 + */ + public Integer getMaxTokens() { + return maxTokens; + } + + /** + * 设置最大令牌数 + * + * @param maxTokens 最大令牌数,限制生成文本的最大长度 + */ + public void setMaxTokens(Integer maxTokens) { + this.maxTokens = maxTokens; + } + + /** + * 获取停止词列表 + * + * @return 返回停止词字符串列表,当生成文本遇到这些词时会停止生成 + */ + public List getStop() { + return stop; + } + + /** + * 设置停止词列表 + * + * @param stop 停止词字符串列表,当生成文本遇到这些词时会停止生成 + */ + public void setStop(List stop) { + this.stop = stop; + } + } + + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/LlmManager.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/LlmManager.java new file mode 100644 index 0000000..75a086f --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/LlmManager.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.llm; + +import java.util.ArrayList; +import java.util.List; + +public class LlmManager { + + public List providers = new ArrayList<>(); + + private static class ManagerHolder { + private static final LlmManager INSTANCE = new LlmManager(); + } + + private LlmManager() { + } + + public static LlmManager getInstance() { + return ManagerHolder.INSTANCE; + } + + public void registerProvider(LlmProvider provider) { + providers.add(provider); + } + + public void removeProvider(LlmProvider provider) { + providers.remove(provider); + } + + public Llm getChatModel(Object modelId) { + for (LlmProvider provider : providers) { + Llm llm = provider.getChatModel(modelId); + if (llm != null) { + return llm; + } + } + return null; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/LlmProvider.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/LlmProvider.java new file mode 100644 index 0000000..4dbd8d9 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/llm/LlmProvider.java @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.llm; + + + +public interface LlmProvider { + + Llm getChatModel(Object modelId); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/BaseNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/BaseNode.java new file mode 100644 index 0000000..c8ca930 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/BaseNode.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.node; + + +import com.easyagents.flow.core.chain.Node; +import com.easyagents.flow.core.chain.Parameter; + +import java.util.Collection; +import java.util.List; + +public abstract class BaseNode extends Node { + + protected List parameters; + protected List outputDefs; + + public void setParameters(List parameters) { + this.parameters = parameters; + } + + public List getParameters() { + return parameters; + } + + public void addInputParameter(Parameter parameter) { + if (parameters == null) { + parameters = new java.util.ArrayList<>(); + } + parameters.add(parameter); + } + + + public List getOutputDefs() { + return outputDefs; + } + + public void setOutputDefs(List outputDefs) { + this.outputDefs = outputDefs; + } + + public void addOutputDef(Parameter parameter) { + if (outputDefs == null) { + outputDefs = new java.util.ArrayList<>(); + } + outputDefs.add(parameter); + } + + public void addOutputDefs(Collection parameters) { + if (outputDefs == null) { + outputDefs = new java.util.ArrayList<>(); + } + outputDefs.addAll(parameters); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/CodeNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/CodeNode.java new file mode 100644 index 0000000..5cd2798 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/CodeNode.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.node; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainState; +import com.easyagents.flow.core.code.CodeRuntimeEngine; +import com.easyagents.flow.core.code.CodeRuntimeEngineManager; +import com.easyagents.flow.core.util.StringUtil; +import com.easyagents.flow.core.util.TextTemplate; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class CodeNode extends BaseNode { + protected String engine; + protected String code; + + public String getEngine() { + return engine; + } + + public void setEngine(String engine) { + this.engine = engine; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + @Override + public Map execute(Chain chain) { + if (StringUtil.noText(code)) { + throw new IllegalArgumentException("code is empty"); + } + + ChainState chainState = chain.getState(); + List> variables = Arrays.asList(chainState.resolveParameters(this), chainState.getEnvMap()); + String newCode = TextTemplate.of(code).formatToString(variables); + + CodeRuntimeEngine codeRuntimeEngine = CodeRuntimeEngineManager.getInstance().getCodeRuntimeEngine(this.engine); + return codeRuntimeEngine.execute(newCode, this, chain); + } + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/ConfirmNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/ConfirmNode.java new file mode 100644 index 0000000..52682c1 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/ConfirmNode.java @@ -0,0 +1,145 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.node; + + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainSuspendException; +import com.easyagents.flow.core.chain.Parameter; +import com.easyagents.flow.core.chain.RefType; +import com.easyagents.flow.core.chain.repository.ChainStateField; + +import java.util.*; + +public class ConfirmNode extends BaseNode { + + private String message; + private List confirms; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public List getConfirms() { + return confirms; + } + + public void setConfirms(List confirms) { + if (confirms != null) { + for (Parameter confirm : confirms) { + confirm.setRefType(RefType.INPUT); + confirm.setRequired(true); // 必填,才能正确通过 getParameterValuesOnly 获取参数值 + confirm.setName(confirm.getName()); + } + } + this.confirms = confirms; + } + + + @Override + public Map execute(Chain chain) { + + List confirmParameters = new ArrayList<>(); + addConfirmParameter(confirmParameters); + + if (confirms != null) { + for (Parameter confirm : confirms) { + Parameter clone = confirm.clone(); + clone.setName(confirm.getName() + "__" + getId()); + clone.setRefType(RefType.INPUT); + confirmParameters.add(clone); + } + } + + Map values; + try { + values = chain.getState().resolveParameters(this, confirmParameters); + // 移除 confirm 参数,方便在其他节点二次确认,或者在 for 循环中第二次获取 + chain.updateStateSafely(state -> { + for (Parameter confirmParameter : confirmParameters) { + state.getMemory().remove(confirmParameter.getName()); + } + return EnumSet.of(ChainStateField.MEMORY); + }); + } catch (ChainSuspendException e) { + chain.updateStateSafely(state -> { + state.setMessage(message); + return EnumSet.of(ChainStateField.MESSAGE); + }); + + if (confirms != null) { + List newParameters = new ArrayList<>(); + for (Parameter confirm : confirms) { + Parameter clone = confirm.clone(); + clone.setName(confirm.getName() + "__" + getId()); + clone.setRefType(RefType.REF); // 固定为 REF + newParameters.add(clone); + } + + // 获取参数值,不会触发 ChainSuspendException 错误 + Map parameterValues = chain.getState().resolveParameters(this, newParameters, null, true); + + // 设置 enums,方便前端给用户进行选择 + for (Parameter confirmParameter : confirmParameters) { + if (confirmParameter.getEnums() == null) { + Object enumsObject = parameterValues.get(confirmParameter.getName()); + confirmParameter.setEnumsObject(enumsObject); + } + } + } + + throw e; + } + + + Map results = new HashMap<>(values.size()); + values.forEach((key, value) -> { + int index = key.lastIndexOf("__"); + if (index >= 0) { + results.put(key.substring(0, index), value); + } else { + results.put(key, value); + } + }); + + return results; + } + + + private void addConfirmParameter(List parameters) { + // “确认 和 取消” 的参数 + Parameter parameter = new Parameter(); + parameter.setRefType(RefType.INPUT); + parameter.setId("confirm"); + parameter.setName("confirm__" + getId()); + parameter.setRequired(true); + + List selectionData = new ArrayList<>(); + selectionData.add("yes"); + selectionData.add("no"); + + parameter.setEnums(selectionData); + parameter.setContentType("text"); + parameter.setFormType("confirm"); + parameters.add(parameter); + } + + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/EndNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/EndNode.java new file mode 100644 index 0000000..c6e39f2 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/EndNode.java @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.node; + +import com.easyagents.flow.core.chain.*; +import com.easyagents.flow.core.util.StringUtil; + +import java.util.HashMap; +import java.util.Map; + +public class EndNode extends BaseNode { + private boolean normal = true; + private String message; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public boolean isNormal() { + return normal; + } + + public void setNormal(boolean normal) { + this.normal = normal; + } + + public EndNode() { + this.name = "end"; + } + + @Override + public Map execute(Chain chain) { + + Map output = new HashMap<>(); + if (normal) { + output.put(ChainConsts.CHAIN_STATE_STATUS_KEY, ChainStatus.SUCCEEDED); + } else { + output.put(ChainConsts.CHAIN_STATE_STATUS_KEY, ChainStatus.FAILED); + } + + if (StringUtil.hasText(message)) { + output.put(ChainConsts.CHAIN_STATE_MESSAGE_KEY, message); + } + + if (this.outputDefs != null) { + for (Parameter outputDef : this.outputDefs) { + if (outputDef.getRefType() == RefType.REF) { + output.put(outputDef.getName(), chain.getState().resolveValue(outputDef.getRef())); + } else if (outputDef.getRefType() == RefType.INPUT) { + output.put(outputDef.getName(), outputDef.getRef()); + } else if (outputDef.getRefType() == RefType.FIXED) { + output.put(outputDef.getName(), StringUtil.getFirstWithText(outputDef.getValue(), outputDef.getDefaultValue())); + } + // default is ref type + else if (StringUtil.hasText(outputDef.getRef())) { + output.put(outputDef.getName(), chain.getState().resolveValue(outputDef.getRef())); + } + } + } + + return output; + } + + + @Override + public String toString() { + return "EndNode{" + + "normal=" + normal + + ", message='" + message + '\'' + + ", parameters=" + parameters + + ", outputDefs=" + outputDefs + + ", id='" + id + '\'' + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", condition=" + condition + + ", validator=" + validator + + ", loopEnable=" + loopEnable + + ", loopIntervalMs=" + loopIntervalMs + + ", loopBreakCondition=" + loopBreakCondition + + ", maxLoopCount=" + maxLoopCount + + ", retryEnable=" + retryEnable + + ", resetRetryCountAfterNormal=" + resetRetryCountAfterNormal + + ", maxRetryCount=" + maxRetryCount + + ", retryIntervalMs=" + retryIntervalMs + + ", computeCostExpr='" + computeCostExpr + '\'' + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/HttpNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/HttpNode.java new file mode 100644 index 0000000..a4f93ab --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/HttpNode.java @@ -0,0 +1,370 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.node; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.DataType; +import com.easyagents.flow.core.chain.Parameter; +import com.easyagents.flow.core.filestoreage.FileStorage; +import com.easyagents.flow.core.filestoreage.FileStorageManager; +import com.easyagents.flow.core.util.OkHttpClientUtil; +import com.easyagents.flow.core.util.StringUtil; +import com.easyagents.flow.core.util.TextTemplate; +import okhttp3.*; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class HttpNode extends BaseNode { + + private String url; + private String method; + + private List headers; + + private String bodyType; + private List formData; + private List formUrlencoded; + private String bodyJson; + private String rawBody; + + public static String mapToQueryString(Map map) { + if (map == null || map.isEmpty()) { + return ""; + } + + StringBuilder stringBuilder = new StringBuilder(); + + for (String key : map.keySet()) { + if (StringUtil.noText(key)) { + continue; + } + if (stringBuilder.length() > 0) { + stringBuilder.append("&"); + } + stringBuilder.append(key.trim()); + stringBuilder.append("="); + Object value = map.get(key); + stringBuilder.append(value == null ? "" : urlEncode(value.toString().trim())); + } + return stringBuilder.toString(); + } + + public static String urlEncode(String string) { + try { + return URLEncoder.encode(string, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } + + public List getHeaders() { + return headers; + } + + public void setHeaders(List headers) { + this.headers = headers; + } + + public String getBodyType() { + return bodyType; + } + + public void setBodyType(String bodyType) { + this.bodyType = bodyType; + } + + public List getFormData() { + return formData; + } + + public void setFormData(List formData) { + this.formData = formData; + } + + public List getFormUrlencoded() { + return formUrlencoded; + } + + public void setFormUrlencoded(List formUrlencoded) { + this.formUrlencoded = formUrlencoded; + } + + public String getBodyJson() { + return bodyJson; + } + + public void setBodyJson(String bodyJson) { + this.bodyJson = bodyJson; + } + + public String getRawBody() { + return rawBody; + } + + public void setRawBody(String rawBody) { + this.rawBody = rawBody; + } + + @Override + public Map execute(Chain chain) { + int maxRetry = 5; + long retryInterval = 2000L; + + int attempt = 0; + Throwable lastError = null; + + while (attempt < maxRetry) { + attempt++; + + try { + return doExecute(chain); + } catch (Throwable ex) { + + lastError = ex; + + // 判断是否需要重试 + if (!shouldRetry(ex)) { + throw wrapAsRuntime(ex, attempt); + } + + try { + long waitMs = Math.min( + retryInterval * (1L << (attempt - 1)), + 10_000L // 最大 10 秒 + ); + Thread.sleep(waitMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("HTTP retry interrupted", ie); + } + } + } + + // 理论上不会走到这里 + throw wrapAsRuntime(lastError, attempt); + } + + + protected boolean shouldRetry(Throwable ex) { + if (ex instanceof HttpServerErrorException) { + int code = ((HttpServerErrorException) ex).getStatusCode(); + return code == 503 || code == 504; // 只对特定 5xx 重试 + } + + // 1. IO 异常(超时、连接失败、Socket 问题) + if (ex instanceof IOException) { + return true; + } + + // 2. 包装过的异常 + Throwable cause = ex.getCause(); + return cause instanceof IOException; + } + + private RuntimeException wrapAsRuntime(Throwable ex, int attempt) { + if (ex instanceof RuntimeException) { + return (RuntimeException) ex; + } + return new RuntimeException( + String.format("HttpNode[%s] failed after %d attempt(s)", getName(), attempt), + ex + ); + } + + + public Map doExecute(Chain chain) throws IOException { + + Map argsMap = chain.getState().resolveParameters(this); + String newUrl = TextTemplate.of(url).formatToString(Arrays.asList(argsMap, chain.getState().getEnvMap())); + + Request.Builder reqBuilder = new Request.Builder().url(newUrl); + + Map headersMap = chain.getState().resolveParameters(this, headers, argsMap); + headersMap.forEach((s, o) -> reqBuilder.addHeader(s, String.valueOf(o))); + + if (StringUtil.noText(method) || "GET".equalsIgnoreCase(method)) { + reqBuilder.method("GET", null); + } else { + reqBuilder.method(method.toUpperCase(), getRequestBody(chain, argsMap)); + } + + OkHttpClient okHttpClient = OkHttpClientUtil.buildDefaultClient(); + try (Response response = okHttpClient.newCall(reqBuilder.build()).execute()) { + + // 服务器异常 + if (response.code() >= 500 && response.code() < 600) { + throw new HttpServerErrorException(response.code(), response.message()); + } + + Map result = new HashMap<>(); + result.put("statusCode", response.code()); + + Map responseHeaders = new HashMap<>(); + Headers headers = response.headers(); + for (String name : headers.names()) { + responseHeaders.put(name, response.header(name)); + } + result.put("headers", responseHeaders); + + ResponseBody body = response.body(); + if (body == null) { + result.put("body", null); + return result; + } + + DataType bodyDataType = null; + List outputDefs = getOutputDefs(); + if (outputDefs != null) { + for (Parameter outputDef : outputDefs) { + if ("body".equalsIgnoreCase(outputDef.getName())) { + bodyDataType = outputDef.getDataType(); + break; + } + } + } + + if (bodyDataType == null) { + result.put("body", body.string()); + } else if (bodyDataType == DataType.Object || bodyDataType.getValue().startsWith("Array")) { + result.put("body", JSON.parse(body.string())); + } else if (bodyDataType == DataType.File) { + try (InputStream stream = body.byteStream()) { + FileStorage fileStorage = FileStorageManager.getInstance().getFileStorage(); + String fileUrl = fileStorage.saveFile(stream, responseHeaders, this, chain); + result.put("body", fileUrl); + } + } else { + result.put("body", body.string()); + } + return result; + } + } + + private RequestBody getRequestBody(Chain chain, Map formatArgs) { + if ("json".equals(bodyType)) { + String bodyJsonString = TextTemplate.of(bodyJson).formatToString(formatArgs, true); + JSONObject jsonObject = JSON.parseObject(bodyJsonString); + return RequestBody.create(jsonObject.toString(), MediaType.parse("application/json")); + } + + if ("x-www-form-urlencoded".equals(bodyType)) { + Map formUrlencodedMap = chain.getState().resolveParameters(this, formUrlencoded); + String bodyString = mapToQueryString(formUrlencodedMap); + return RequestBody.create(bodyString, MediaType.parse("application/x-www-form-urlencoded")); + } + + if ("form-data".equals(bodyType)) { + Map formDataMap = chain.getState().resolveParameters(this, formData, formatArgs); + + MultipartBody.Builder builder = new MultipartBody.Builder() + .setType(MultipartBody.FORM); + + formDataMap.forEach((s, o) -> { +// if (o instanceof File) { +// File f = (File) o; +// RequestBody body = RequestBody.create(f, MediaType.parse("application/octet-stream")); +// builder.addFormDataPart(s, f.getName(), body); +// } else if (o instanceof InputStream) { +// RequestBody body = new HttpClient.InputStreamRequestBody(MediaType.parse("application/octet-stream"), (InputStream) o); +// builder.addFormDataPart(s, s, body); +// } else if (o instanceof byte[]) { +// builder.addFormDataPart(s, s, RequestBody.create((byte[]) o)); +// } else { +// builder.addFormDataPart(s, String.valueOf(o)); +// } + builder.addFormDataPart(s, String.valueOf(o)); + }); + + return builder.build(); + } + + if ("raw".equals(bodyType)) { + String rawBodyString = TextTemplate.of(rawBody).formatToString(Arrays.asList(formatArgs, chain.getState().getEnvMap())); + return RequestBody.create(rawBodyString, null); + } + //none + return RequestBody.create("", null); + } + + public static class HttpServerErrorException extends IOException { + private final int statusCode; + + public HttpServerErrorException(int statusCode, String message) { + super("HTTP " + statusCode + ": " + message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } + } + + + @Override + public String toString() { + return "HttpNode{" + + "url='" + url + '\'' + + ", method='" + method + '\'' + + ", headers=" + headers + + ", bodyType='" + bodyType + '\'' + + ", formData=" + formData + + ", formUrlencoded=" + formUrlencoded + + ", bodyJson='" + bodyJson + '\'' + + ", rawBody='" + rawBody + '\'' + + ", parameters=" + parameters + + ", outputDefs=" + outputDefs + + ", id='" + id + '\'' + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", condition=" + condition + + ", validator=" + validator + + ", loopEnable=" + loopEnable + + ", loopIntervalMs=" + loopIntervalMs + + ", loopBreakCondition=" + loopBreakCondition + + ", maxLoopCount=" + maxLoopCount + + ", retryEnable=" + retryEnable + + ", resetRetryCountAfterNormal=" + resetRetryCountAfterNormal + + ", maxRetryCount=" + maxRetryCount + + ", retryIntervalMs=" + retryIntervalMs + + ", computeCostExpr='" + computeCostExpr + '\'' + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/KnowledgeNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/KnowledgeNode.java new file mode 100644 index 0000000..1b9c7ae --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/KnowledgeNode.java @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.node; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.knowledge.Knowledge; +import com.easyagents.flow.core.knowledge.KnowledgeManager; +import com.easyagents.flow.core.util.Maps; +import com.easyagents.flow.core.util.StringUtil; +import com.easyagents.flow.core.util.TextTemplate; +import org.slf4j.Logger; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class KnowledgeNode extends BaseNode { + + private static final Logger logger = org.slf4j.LoggerFactory.getLogger(KnowledgeNode.class); + + private Object knowledgeId; + private String keyword; + private String limit; + + public Object getKnowledgeId() { + return knowledgeId; + } + + public void setKnowledgeId(Object knowledgeId) { + this.knowledgeId = knowledgeId; + } + + public String getKeyword() { + return keyword; + } + + public void setKeyword(String keyword) { + this.keyword = keyword; + } + + public String getLimit() { + return limit; + } + + public void setLimit(String limit) { + this.limit = limit; + } + + @Override + public Map execute(Chain chain) { + Map argsMap = chain.getState().resolveParameters(this); + String realKeyword = TextTemplate.of(keyword).formatToString(Arrays.asList(argsMap, chain.getState().getEnvMap())); + String realLimitString = TextTemplate.of(limit).formatToString(Arrays.asList(argsMap, chain.getState().getEnvMap())); + int realLimit = 10; + if (StringUtil.hasText(realLimitString)) { + try { + realLimit = Integer.parseInt(realLimitString); + } catch (Exception e) { + logger.error(e.toString(), e); + } + } + + Knowledge knowledge = KnowledgeManager.getInstance().getKnowledge(knowledgeId); + + if (knowledge == null) { + return Collections.emptyMap(); + } + + List> result = knowledge.search(realKeyword, realLimit, this, chain); + return Maps.of("documents", result); + } + + @Override + public String toString() { + return "KnowledgeNode{" + + "knowledgeId=" + knowledgeId + + ", keyword='" + keyword + '\'' + + ", limit='" + limit + '\'' + + ", parameters=" + parameters + + ", outputDefs=" + outputDefs + + ", id='" + id + '\'' + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", condition=" + condition + + ", validator=" + validator + + ", loopEnable=" + loopEnable + + ", loopIntervalMs=" + loopIntervalMs + + ", loopBreakCondition=" + loopBreakCondition + + ", maxLoopCount=" + maxLoopCount + + ", retryEnable=" + retryEnable + + ", resetRetryCountAfterNormal=" + resetRetryCountAfterNormal + + ", maxRetryCount=" + maxRetryCount + + ", retryIntervalMs=" + retryIntervalMs + + ", computeCostExpr='" + computeCostExpr + '\'' + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java new file mode 100644 index 0000000..98ce2f3 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LlmNode.java @@ -0,0 +1,214 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.node; + +import com.alibaba.fastjson.JSON; +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.Parameter; +import com.easyagents.flow.core.llm.Llm; +import com.easyagents.flow.core.llm.LlmManager; +import com.easyagents.flow.core.util.*; + +import java.io.File; +import java.util.*; + +public class LlmNode extends BaseNode { + + protected String llmId; + protected Llm.ChatOptions chatOptions; + protected String userPrompt; + + protected String systemPrompt; + protected String outType = "text"; //text markdown json + protected List images; + + public LlmNode() { + } + + public String getLlmId() { + return llmId; + } + + public void setLlmId(String llmId) { + this.llmId = llmId; + } + + public String getUserPrompt() { + return userPrompt; + } + + public void setUserPrompt(String userPrompt) { + this.userPrompt = userPrompt; + } + + public String getSystemPrompt() { + return systemPrompt; + } + + public void setSystemPrompt(String systemPrompt) { + this.systemPrompt = systemPrompt; + } + + public Llm.ChatOptions getChatOptions() { + return chatOptions; + } + + public void setChatOptions(Llm.ChatOptions chatOptions) { + this.chatOptions = chatOptions; + } + + public String getOutType() { + return outType; + } + + public void setOutType(String outType) { + this.outType = outType; + } + + public List getImages() { + return images; + } + + public void setImages(List images) { + this.images = images; + } + + @Override + public Map execute(Chain chain) { + Map parameterValues = chain.getState().resolveParameters(this); + + if (StringUtil.noText(userPrompt)) { + throw new RuntimeException("Can not find user prompt"); + } + + String userPromptString = TextTemplate.of(userPrompt).formatToString(Arrays.asList(parameterValues, chain.getState().getEnvMap())); + + + Llm llm = LlmManager.getInstance().getChatModel(this.llmId); + if (llm == null) { + throw new RuntimeException("Can not find llm: " + this.llmId); + } + + String systemPromptString = TextTemplate.of(this.systemPrompt).formatToString(Arrays.asList(parameterValues, chain.getState().getEnvMap())); + + Llm.MessageInfo messageInfo = new Llm.MessageInfo(); + messageInfo.setMessage(userPromptString); + messageInfo.setSystemMessage(systemPromptString); + + if (images != null && !images.isEmpty()) { + Map filesMap = chain.getState().resolveParameters(this, images); + List imagesUrls = new ArrayList<>(); + filesMap.forEach((s, o) -> { + if (o instanceof String) { + imagesUrls.add((String) o); + } else if (o instanceof File) { + byte[] bytes = IOUtil.readBytes((File) o); + String base64 = Base64.getEncoder().encodeToString(bytes); + imagesUrls.add(base64); + } + }); + messageInfo.setImages(imagesUrls); + } + + + String responseContent = llm.chat(messageInfo, chatOptions, this, chain); + + if (StringUtil.noText(responseContent)) { + throw new RuntimeException("Can not get response from llm"); + } else { + responseContent = responseContent.trim(); + } + + + if ("json".equalsIgnoreCase(outType)) { + Object jsonObjectOrArray; + try { + jsonObjectOrArray = JSON.parse(unWrapMarkdown(responseContent)); + } catch (Exception e) { + throw new RuntimeException("Can not parse json: " + responseContent + " " + e.getMessage()); + } + + if (CollectionUtil.noItems(this.outputDefs)) { + return Maps.of("root", jsonObjectOrArray); + } else { + Parameter parameter = this.outputDefs.get(0); + return Maps.of(parameter.getName(), jsonObjectOrArray); + } + } else { + if (CollectionUtil.noItems(this.outputDefs)) { + return Maps.of("output", responseContent); + } else { + Parameter parameter = this.outputDefs.get(0); + return Maps.of(parameter.getName(), responseContent); + } + } + } + + + /** + * 移除 ``` 或者 ```json 等 + * + * @param markdown json内容 + * @return 方法 json 内容 + */ + public static String unWrapMarkdown(String markdown) { + // 移除开头的 ```json 或 ``` + if (markdown.startsWith("```")) { + int newlineIndex = markdown.indexOf('\n'); + if (newlineIndex != -1) { + markdown = markdown.substring(newlineIndex + 1); + } else { + // 如果没有换行符,直接去掉 ``` 部分 + markdown = markdown.substring(3); + } + } + + // 移除结尾的 ``` + if (markdown.endsWith("```")) { + markdown = markdown.substring(0, markdown.length() - 3); + } + return markdown.trim(); + } + + + @Override + public String toString() { + return "LlmNode{" + + "llmId='" + llmId + '\'' + + ", chatOptions=" + chatOptions + + ", userPrompt='" + userPrompt + '\'' + + ", systemPrompt='" + systemPrompt + '\'' + + ", outType='" + outType + '\'' + + ", images=" + images + + ", parameters=" + parameters + + ", outputDefs=" + outputDefs + + ", id='" + id + '\'' + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", condition=" + condition + + ", validator=" + validator + + ", loopEnable=" + loopEnable + + ", loopIntervalMs=" + loopIntervalMs + + ", loopBreakCondition=" + loopBreakCondition + + ", maxLoopCount=" + maxLoopCount + + ", retryEnable=" + retryEnable + + ", resetRetryCountAfterNormal=" + resetRetryCountAfterNormal + + ", maxRetryCount=" + maxRetryCount + + ", retryIntervalMs=" + retryIntervalMs + + ", computeCostExpr='" + computeCostExpr + '\'' + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LoopNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LoopNode.java new file mode 100644 index 0000000..63c4fd7 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/LoopNode.java @@ -0,0 +1,253 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.node; + + +import com.easyagents.flow.core.chain.*; +import com.easyagents.flow.core.chain.repository.ChainStateField; +import com.easyagents.flow.core.chain.repository.NodeStateField; +import com.easyagents.flow.core.chain.runtime.Trigger; +import com.easyagents.flow.core.chain.runtime.TriggerContext; +import com.easyagents.flow.core.chain.runtime.TriggerType; +import com.easyagents.flow.core.util.IterableUtil; +import com.easyagents.flow.core.util.Maps; +import com.easyagents.flow.core.util.StringUtil; + +import java.io.Serializable; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class LoopNode extends BaseNode { + + private Parameter loopVar; + + public Parameter getLoopVar() { + return loopVar; + } + + public void setLoopVar(Parameter loopVar) { + this.loopVar = loopVar; + } + + @Override + public Map execute(Chain chain) { + Trigger prevTrigger = TriggerContext.getCurrentTrigger(); + Deque loopStack = getOrCreateLoopStack(chain); + + LoopContext loopContext; + + // 判断是否是首次进入该 LoopNode(即不是由子节点返回) + TriggerType triggerType = prevTrigger.getType(); + boolean isFirstEntry = triggerType != TriggerType.PARENT && triggerType != TriggerType.SELF; + + if (isFirstEntry) { + // 首次触发:创建新的 LoopContext 并压入堆栈 + loopContext = new LoopContext(); + loopContext.currentIndex = 0; + loopContext.subResult = new HashMap<>(); + // 保存原始触发上下文(用于循环结束后恢复) + loopStack.offerLast(loopContext); + + chain.updateNodeStateSafely(this.id, state -> { + state.getMemory().put(buildLoopStackId(), loopStack); + return EnumSet.of(NodeStateField.MEMORY); + }); + + if (loopStack.size() > 1) { + // 不执行,等等其他节点唤起 + return Maps.of(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY, true) + .set(ChainConsts.NODE_STATE_STATUS_KEY, NodeStatus.RUNNING); + } + } + // 由子节点返回:从堆栈低部获取当前循环上下文 + else { + if (loopStack.isEmpty()) { + throw new IllegalStateException("Loop stack is empty when returning from child node."); + } + loopContext = loopStack.peekFirst(); + } + + +// LoopContext loopContext = getLoopContext(prevTrigger, chain); +// int triggerLoopIndex = getTriggerLoopIndex(prevTrigger); +// +// if (loopContext.currentIndex != triggerLoopIndex) { +// // 不执行,子流程有分叉,已经被其他的分叉节点触发了 +// return Maps.of(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY, true) +// .set(ChainConsts.NODE_STATE_STATUS_KEY, NodeStatus.RUNNING); +// } + + Map loopVars = chain.getState().resolveParameters(this, Collections.singletonList(loopVar)); + Object loopValue = loopVars.get(loopVar.getName()); + + int shouldLoopCount; + if (loopValue instanceof Iterable) { + shouldLoopCount = IterableUtil.size((Iterable) loopValue); + } else if (loopValue instanceof Number || (loopValue instanceof String && StringUtil.isNumeric(loopValue.toString()))) { + shouldLoopCount = loopValue instanceof Number ? ((Number) loopValue).intValue() : Integer.parseInt(loopValue.toString().trim()); + } else { + throw new IllegalArgumentException("loopValue must be Iterable or Number or String, but loopValue is \"" + loopValue + "\""); + } + + // 不是第一次执行,合并结果到 subResult + if (loopContext.currentIndex != 0) { + ChainState subState = chain.getState(); + mergeResult(loopContext.subResult, subState); + } + + + // 执行的次数够了, 恢复父级触发 + if (loopContext.currentIndex >= shouldLoopCount) { + loopStack.pollFirst(); // 移除最顶部部的 LoopContext + chain.updateNodeStateSafely(this.id, state -> { + ConcurrentHashMap memory = state.getMemory(); + memory.put(buildLoopStackId(), loopStack); + memory.remove(this.id + ".index"); + memory.remove(this.id + ".loopItem"); + return EnumSet.of(NodeStateField.MEMORY); + }); + if (!loopStack.isEmpty()) { + chain.scheduleNode(this, null, TriggerType.SELF, 0); + } + return loopContext.subResult; + } + + int loopIndex = loopContext.currentIndex; + loopContext.currentIndex++; + + chain.updateNodeStateSafely(this.id, state -> { + state.getMemory().put(buildLoopStackId(), loopStack); + return EnumSet.of(NodeStateField.MEMORY); + }); + + + if (loopValue instanceof Iterable) { + Object loopItem = IterableUtil.get((Iterable) loopValue, loopIndex); + executeLoopChain(chain, loopContext, loopItem); + } else if (loopValue instanceof Number || (loopValue instanceof String && StringUtil.isNumeric(loopValue.toString()))) { + executeLoopChain(chain, loopContext, loopIndex); + } else { + throw new IllegalArgumentException("loopValue must be Iterable or Number or String, but loopValue is \"" + loopValue + "\""); + } + + // 禁用调度下个节点 + return Maps.of(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY, true) + .set(ChainConsts.NODE_STATE_STATUS_KEY, NodeStatus.RUNNING); + } + + + /** + * 获取或创建当前节点的 LoopContext 堆栈(每个 LoopNode 实例独立) + */ + @SuppressWarnings("unchecked") + private Deque getOrCreateLoopStack(Chain chain) { + NodeState nodeState = chain.getNodeState(this.id); + String key = buildLoopStackId(); + Object stackObj = nodeState.getMemory().get(key); + Deque stack; + if (stackObj instanceof Deque) { + stack = (Deque) stackObj; + } else { + stack = new ArrayDeque<>(); + chain.updateNodeStateSafely(this.id, state -> { + state.getMemory().put(key, stack); + return EnumSet.of(NodeStateField.MEMORY); + }); + } + return stack; + } + + + private void executeLoopChain(Chain chain, LoopContext loopContext, Object loopItem) { + + chain.updateStateSafely(state -> { + ConcurrentHashMap memory = state.getMemory(); + memory.put(this.id + ".index", (loopContext.currentIndex - 1)); + memory.put(this.id + ".loopItem", loopItem); + return EnumSet.of(ChainStateField.MEMORY); + }); + + + ChainDefinition definition = chain.getDefinition(); + List outwardEdges = definition.getOutwardEdge(this.id); + for (Edge edge : outwardEdges) { + Node childNode = definition.getNodeById(edge.getTarget()); + if (childNode.getParentId() != null && childNode.getParentId().equals(this.id)) { + chain.scheduleNode(childNode, edge.getId(), TriggerType.CHILD, 0); + } + } + } + + + /** + * 把子流程执行的结果填充到主流程的输出参数中 + * + * @param toResult 主流程的输出参数 + * @param subState 子流程的 + */ + private void mergeResult(Map toResult, ChainState subState) { + List outputDefs = getOutputDefs(); + if (outputDefs != null) { + for (Parameter outputDef : outputDefs) { + Object value = null; + + //引用 + if (outputDef.getRefType() == RefType.REF) { + value = subState.resolveValue(outputDef.getRef()); + } + //固定值 + else if (outputDef.getRefType() == RefType.FIXED) { + value = outputDef.getValue(); + } + + @SuppressWarnings("unchecked") List existList = (List) toResult.get(outputDef.getName()); + if (existList == null) { + existList = new ArrayList<>(); + } + existList.add(value); + toResult.put(outputDef.getName(), existList); + } + } + } + + + private String buildLoopStackId() { + return this.getId() + "__loop__context"; + } + + + public static class LoopContext implements Serializable { + int currentIndex; + Map subResult; + + public int getCurrentIndex() { + return currentIndex; + } + + public void setCurrentIndex(int currentIndex) { + this.currentIndex = currentIndex; + } + + public Map getSubResult() { + return subResult; + } + + public void setSubResult(Map subResult) { + this.subResult = subResult; + } + + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/SearchEngineNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/SearchEngineNode.java new file mode 100644 index 0000000..7d863da --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/SearchEngineNode.java @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.node; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.searchengine.SearchEngine; +import com.easyagents.flow.core.searchengine.SearchEngineManager; +import com.easyagents.flow.core.util.Maps; +import com.easyagents.flow.core.util.StringUtil; +import com.easyagents.flow.core.util.TextTemplate; +import org.slf4j.Logger; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class SearchEngineNode extends BaseNode { + + private static final Logger logger = org.slf4j.LoggerFactory.getLogger(SearchEngineNode.class); + + private String engine; + private String limit; + private String keyword; + + public String getEngine() { + return engine; + } + + public void setEngine(String engine) { + this.engine = engine; + } + + public String getLimit() { + return limit; + } + + public void setLimit(String limit) { + this.limit = limit; + } + + public String getKeyword() { + return keyword; + } + + public void setKeyword(String keyword) { + this.keyword = keyword; + } + + @Override + public Map execute(Chain chain) { + Map argsMap = chain.getState().resolveParameters(this); + String realKeyword = TextTemplate.of(keyword).formatToString(Arrays.asList(argsMap, chain.getState().getEnvMap())); + String realLimitString = TextTemplate.of(limit).formatToString(Arrays.asList(argsMap, chain.getState().getEnvMap())); + int realLimit = 10; + if (StringUtil.hasText(realLimitString)) { + try { + realLimit = Integer.parseInt(realLimitString); + } catch (Exception e) { + logger.error(e.toString(), e); + } + } + + SearchEngine searchEngine = SearchEngineManager.getInstance().geSearchEngine(engine); + + if (searchEngine == null) { + return Collections.emptyMap(); + } + + List> result = searchEngine.search(realKeyword, realLimit, this, chain); + return Maps.of("documents", result); + } + + + @Override + public String toString() { + return "SearchEngineNode{" + + "engine='" + engine + '\'' + + ", limit='" + limit + '\'' + + ", keyword='" + keyword + '\'' + + ", parameters=" + parameters + + ", outputDefs=" + outputDefs + + ", id='" + id + '\'' + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", condition=" + condition + + ", validator=" + validator + + ", loopEnable=" + loopEnable + + ", loopIntervalMs=" + loopIntervalMs + + ", loopBreakCondition=" + loopBreakCondition + + ", maxLoopCount=" + maxLoopCount + + ", retryEnable=" + retryEnable + + ", resetRetryCountAfterNormal=" + resetRetryCountAfterNormal + + ", maxRetryCount=" + maxRetryCount + + ", retryIntervalMs=" + retryIntervalMs + + ", computeCostExpr='" + computeCostExpr + '\'' + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/StartNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/StartNode.java new file mode 100644 index 0000000..566ca31 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/StartNode.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.node; + + +import com.easyagents.flow.core.chain.Chain; + +import java.util.Map; + +public class StartNode extends BaseNode { + @Override + public Map execute(Chain chain) { + return chain.getState().resolveParameters(this); + } + + @Override + public String toString() { + return super.toString(); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/TemplateNode.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/TemplateNode.java new file mode 100644 index 0000000..3709cac --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/node/TemplateNode.java @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.node; + +import com.jfinal.template.Engine; +import com.jfinal.template.Template; +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.Parameter; +import com.easyagents.flow.core.util.Maps; +import com.easyagents.flow.core.util.StringUtil; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Map; + +public class TemplateNode extends BaseNode { + + private static final Engine engine; + private String template; + + static { + engine = Engine.create("template", e -> { + e.addSharedStaticMethod(StringUtil.class); + }); + } + + public String getTemplate() { + return template; + } + + public void setTemplate(String template) { + this.template = template; + } + + + @Override + public Map execute(Chain chain) { + Map parameters = chain.getState().resolveParameters(this); + + ByteArrayOutputStream result = new ByteArrayOutputStream(); + + Template templateByString = engine.getTemplateByString(template); + templateByString.render(parameters, result); + + String outputDef = "output"; + List outputDefs = getOutputDefs(); + if (outputDefs != null && !outputDefs.isEmpty()) { + String parameterName = outputDefs.get(0).getName(); + if (StringUtil.hasText(parameterName)) outputDef = parameterName; + } + + return Maps.of(outputDef, result.toString()); + } + + + @Override + public String toString() { + return "TemplateNode{" + + "template='" + template + '\'' + + ", parameters=" + parameters + + ", outputDefs=" + outputDefs + + ", id='" + id + '\'' + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", condition=" + condition + + ", validator=" + validator + + ", loopEnable=" + loopEnable + + ", loopIntervalMs=" + loopIntervalMs + + ", loopBreakCondition=" + loopBreakCondition + + ", maxLoopCount=" + maxLoopCount + + ", retryEnable=" + retryEnable + + ", resetRetryCountAfterNormal=" + resetRetryCountAfterNormal + + ", maxRetryCount=" + maxRetryCount + + ", retryIntervalMs=" + retryIntervalMs + + ", computeCostExpr='" + computeCostExpr + '\'' + + '}'; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/BaseNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/BaseNodeParser.java new file mode 100644 index 0000000..2ca2c8a --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/BaseNodeParser.java @@ -0,0 +1,205 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser; + + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.chain.DataType; +import com.easyagents.flow.core.chain.JsCodeCondition; +import com.easyagents.flow.core.chain.Parameter; +import com.easyagents.flow.core.chain.RefType; +import com.easyagents.flow.core.node.BaseNode; +import com.easyagents.flow.core.util.StringUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + + +public abstract class BaseNodeParser implements NodeParser { + + private static final JSONObject EMPTY_JSON_OBJECT = new JSONObject(Collections.emptyMap()); + + private ChainParser chainParser; + + @Override + public ChainParser getChainParser() { + return chainParser; + } + + public JSONObject getData(JSONObject nodeObject) { + JSONObject jsonObject = nodeObject.getJSONObject("data"); + return jsonObject != null ? jsonObject : EMPTY_JSON_OBJECT; + } + + public void addParameters(BaseNode node, JSONObject data) { + List inputParameters = getParameters(data, "parameters"); + node.setParameters(inputParameters); + } + + public List getParameters(JSONObject data, String key) { + return getParameters(data.getJSONArray(key)); + } + + public List getParameters(JSONArray parametersJsonArray) { + if (parametersJsonArray == null || parametersJsonArray.isEmpty()) { + return Collections.emptyList(); + } + List parameters = new ArrayList<>(parametersJsonArray.size()); + for (int i = 0; i < parametersJsonArray.size(); i++) { + JSONObject parameterJsonObject = parametersJsonArray.getJSONObject(i); + Parameter parameter = new Parameter(); + parameter.setId(parameterJsonObject.getString("id")); + parameter.setName(parameterJsonObject.getString("name")); + parameter.setDescription(parameterJsonObject.getString("description")); + parameter.setDataType(DataType.ofValue(parameterJsonObject.getString("dataType"))); + parameter.setRef(parameterJsonObject.getString("ref")); + parameter.setValue(parameterJsonObject.getString("value")); + parameter.setDefaultValue(parameterJsonObject.getString("defaultValue")); + parameter.setRefType(RefType.ofValue(parameterJsonObject.getString("refType"))); + parameter.setDataType(DataType.ofValue(parameterJsonObject.getString("dataType"))); + parameter.setRequired(parameterJsonObject.getBooleanValue("required")); + parameter.setDefaultValue(parameterJsonObject.getString("defaultValue")); + + //新增 + parameter.setContentType(parameterJsonObject.getString("contentType")); + parameter.setEnums(parameterJsonObject.getJSONArray("enums")); + parameter.setFormType(parameterJsonObject.getString("formType")); + parameter.setFormLabel(parameterJsonObject.getString("formLabel")); + parameter.setFormPlaceholder(parameterJsonObject.getString("formPlaceholder")); + parameter.setFormAttrs(parameterJsonObject.getString("formAttrs")); + parameter.setFormDescription(parameterJsonObject.getString("formDescription")); + + JSONArray children = parameterJsonObject.getJSONArray("children"); + if (children != null && !children.isEmpty()) { + parameter.addChildren(getParameters(children)); + } + + parameters.add(parameter); + } + + return parameters; + } + + + public void addOutputDefs(BaseNode node, JSONObject data) { + List outputDefs = getParameters(data, "outputDefs"); + if (outputDefs == null || outputDefs.isEmpty()) { + return; + } + node.setOutputDefs(outputDefs); + } + + + @Override + public T parse(JSONObject nodeJSONObject, JSONObject chainJSONObject, ChainParser chainParser) { + this.chainParser = chainParser; + JSONObject data = getData(nodeJSONObject); + T node = doParse(nodeJSONObject, data, chainJSONObject); + if (node != null) { + + node.setId(nodeJSONObject.getString("id")); + node.setParentId(nodeJSONObject.getString("parentId")); + node.setName(nodeJSONObject.getString("label")); + node.setDescription(nodeJSONObject.getString("description")); + + if (!data.isEmpty()) { + + addParameters(node, data); + addOutputDefs(node, data); + + String conditionString = data.getString("condition"); + + if (StringUtil.hasText(conditionString)) { + node.setCondition(new JsCodeCondition(conditionString.trim())); + } + +// Boolean async = data.getBoolean("async"); +// if (async != null) { +// node.setAsync(async); +// } + + String name = data.getString("title"); + if (StringUtil.hasText(name)) { + node.setName(name); + } + + String description = data.getString("description"); + if (StringUtil.hasText(description)) { + node.setDescription(description); + } + + // 循环执行 start ======= + Boolean loopEnable = data.getBoolean("loopEnable"); + if (loopEnable != null) { + node.setLoopEnable(loopEnable); + } + + if (loopEnable != null && loopEnable) { + Long loopIntervalMs = data.getLong("loopIntervalMs"); + if (loopIntervalMs == null) { + loopIntervalMs = 3000L; + } + node.setLoopIntervalMs(loopIntervalMs); + + Integer maxLoopCount = data.getInteger("maxLoopCount"); + if (maxLoopCount != null) { + node.setMaxLoopCount(maxLoopCount); + } + + String loopBreakCondition = data.getString("loopBreakCondition"); + if (StringUtil.hasText(loopBreakCondition)) { + node.setLoopBreakCondition(new JsCodeCondition(loopBreakCondition.trim())); + } + } + // 循环执行 end ======= + + + // 错误重试 start ======= + Boolean retryEnable = data.getBoolean("retryEnable"); + if (retryEnable != null) { + node.setRetryEnable(retryEnable); + } + + if (retryEnable != null && retryEnable) { + Boolean resetRetryCountAfterNormal = data.getBoolean("resetRetryCountAfterNormal"); + if (resetRetryCountAfterNormal == null) { + resetRetryCountAfterNormal = true; + } + node.setResetRetryCountAfterNormal(resetRetryCountAfterNormal); + + Long retryIntervalMs = data.getLong("retryIntervalMs"); + if (retryIntervalMs == null) { + retryIntervalMs = 1000L; + } + node.setRetryIntervalMs(retryIntervalMs); + + Integer maxRetryCount = data.getInteger("maxRetryCount"); + if (maxRetryCount == null) { + maxRetryCount = 3; + } + node.setMaxRetryCount(maxRetryCount); + } + // 错误重试 end ======= + } + } + + return node; + } + + protected abstract T doParse(JSONObject nodeJSONObject, JSONObject data, JSONObject chainJSONObject); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/ChainParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/ChainParser.java new file mode 100644 index 0000000..1ce9dd3 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/ChainParser.java @@ -0,0 +1,201 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.Edge; +import com.easyagents.flow.core.chain.JsCodeCondition; +import com.easyagents.flow.core.chain.Node; +import com.easyagents.flow.core.util.CollectionUtil; +import com.easyagents.flow.core.util.StringUtil; + +import java.util.HashMap; +import java.util.Map; + +public class ChainParser { + + private final Map> nodeParserMap; + + public ChainParser(Map> nodeParserMap) { + this.nodeParserMap = nodeParserMap; + } + + public Map> getNodeParserMap() { + return nodeParserMap; + } + + public void addNodeParser(String type, NodeParser nodeParser) { + this.nodeParserMap.put(type, nodeParser); + } + + public void removeNodeParser(String type) { + this.nodeParserMap.remove(type); + } + + + public ChainDefinition parse(String jsonString) { + if (StringUtil.noText(jsonString)) { + throw new IllegalStateException("jsonString is empty"); + } + + JSONObject root = JSON.parseObject(jsonString); + JSONArray nodes = root.getJSONArray("nodes"); + JSONArray edges = root.getJSONArray("edges"); + + return parse(root, nodes, edges); + } + + + public ChainDefinition parse(JSONObject chainJSONObject, JSONArray nodes, JSONArray edges) { + if (CollectionUtil.noItems(nodes) || CollectionUtil.noItems(edges)) { + return null; + } + + ChainDefinition definition = new ChainDefinition(); + for (int i = 0; i < nodes.size(); i++) { + JSONObject nodeObject = nodes.getJSONObject(i); +// if ((parentNode == null && StringUtil.noText(nodeObject.getString("parentId"))) +// || (parentNode != null && parentNode.getString("id").equals(nodeObject.getString("parentId")))) { + Node node = parseNode(chainJSONObject, nodeObject); + if (node != null) { + definition.addNode(node); + } +// } + } + + for (int i = 0; i < edges.size(); i++) { +// JSONObject edgeObject = edges.getJSONObject(i); +// JSONObject edgeData = edgeObject.getJSONObject("data"); +// if ((parentNode == null && (edgeData == null || StringUtil.noText(edgeData.getString("parentNodeId")))) +// || (parentNode != null && edgeData != null && edgeData.getString("parentNodeId").equals(parentNode.getString("id")) +// //不添加子流程里的第一条 edge(也就是父节点连接子节点的第一条线) +// && !parentNode.getString("id").equals(edgeObject.getString("source")))) { +// ChainEdge edge = parseEdge(edgeObject); +// if (edge != null) { +// chain.addEdge(edge); +// } +// } + + JSONObject edgeObject = edges.getJSONObject(i); + Edge edge = parseEdge(edgeObject); + if (edge == null) { + continue; + } +// if (parentNode == null || +// //不添加子流程里的第一条 edge(也就是父节点连接子节点的第一条线) +// (!parentNode.getString("id").equals(edgeObject.getString("source"))) +// ) { + definition.addEdge(edge); +// } + } + + return definition; + } + + private Node parseNode(JSONObject chainJSONObject, JSONObject nodeObject) { + String type = nodeObject.getString("type"); + if (StringUtil.noText(type)) { + return null; + } + + NodeParser nodeParser = nodeParserMap.get(type); + return nodeParser == null ? null : nodeParser.parse(nodeObject, chainJSONObject, this); + } + + + private Edge parseEdge(JSONObject edgeObject) { + if (edgeObject == null) return null; + Edge edge = new Edge(); + edge.setId(edgeObject.getString("id")); + edge.setSource(edgeObject.getString("source")); + edge.setTarget(edgeObject.getString("target")); + + JSONObject data = edgeObject.getJSONObject("data"); + if (data == null || data.isEmpty()) { + return edge; + } + + String conditionString = data.getString("condition"); + if (StringUtil.hasText(conditionString)) { + edge.setCondition(new JsCodeCondition(conditionString.trim())); + } + return edge; + } + + public void addAllParsers(Map> defaultNodeParsers) { + this.nodeParserMap.putAll(defaultNodeParsers); + } + + + public static Builder builder() { + return new Builder(); + } + + + public static final class Builder { + + private final Map> customParsers = new HashMap<>(); + private boolean includeDefaults = true; + + private Builder() { + } + + /** + * 是否包含默认节点解析器(默认为 true)。 + */ + public Builder withDefaultParsers(boolean include) { + this.includeDefaults = include; + return this; + } + + /** + * 添加自定义节点解析器(会覆盖同名的默认解析器)。 + */ + public Builder addParser(String type, NodeParser parser) { + if (type == null || parser == null) { + throw new IllegalArgumentException("type and parser must not be null"); + } + this.customParsers.put(type, parser); + return this; + } + + /** + * 批量添加自定义解析器。 + */ + public Builder addParsers(Map> parsers) { + if (parsers != null) { + this.customParsers.putAll(parsers); + } + return this; + } + + public ChainParser build() { + Map> finalMap = new HashMap<>(); + + if (includeDefaults) { + finalMap.putAll(DefaultNodeParsers.getDefaultNodeParsers()); + } + + // 自定义解析器覆盖默认 + finalMap.putAll(customParsers); + + return new ChainParser(finalMap); + } + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/DefaultNodeParsers.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/DefaultNodeParsers.java new file mode 100644 index 0000000..95d7c63 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/DefaultNodeParsers.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser; + +import com.easyagents.flow.core.parser.impl.*; + +import java.util.HashMap; +import java.util.Map; + +public class DefaultNodeParsers { + + private static final Map> defaultNodeParsers = new HashMap<>(); + + static { + defaultNodeParsers.put("startNode", new StartNodeParser()); + defaultNodeParsers.put("codeNode", new CodeNodeParser()); + defaultNodeParsers.put("confirmNode", new ConfirmNodeParser()); + + defaultNodeParsers.put("httpNode", new HttpNodeParser()); + defaultNodeParsers.put("knowledgeNode", new KnowledgeNodeParser()); + defaultNodeParsers.put("loopNode", new LoopNodeParser()); + defaultNodeParsers.put("searchEngineNode", new SearchEngineNodeParser()); + defaultNodeParsers.put("templateNode", new TemplateNodeParser()); + + defaultNodeParsers.put("endNode", new EndNodeParser()); + defaultNodeParsers.put("llmNode", new LlmNodeParser()); + } + + public static Map> getDefaultNodeParsers() { + return defaultNodeParsers; + } + + public static void registerDefaultNodeParser(String type, NodeParser nodeParser) { + defaultNodeParsers.put(type, nodeParser); + } + + public static void unregisterDefaultNodeParser(String type) { + defaultNodeParsers.remove(type); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/NodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/NodeParser.java new file mode 100644 index 0000000..bf3afde --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/NodeParser.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.chain.Node; + +public interface NodeParser { + + ChainParser getChainParser(); + + T parse(JSONObject nodeJSONObject, JSONObject chainJSONObject, ChainParser chainParser); + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/CodeNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/CodeNodeParser.java new file mode 100644 index 0000000..e2affb3 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/CodeNodeParser.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser.impl; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.node.CodeNode; +import com.easyagents.flow.core.parser.BaseNodeParser; + +public class CodeNodeParser extends BaseNodeParser { + + @Override + public CodeNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { + String engine = data.getString("engine"); + CodeNode codeNode = new CodeNode(); + codeNode.setEngine(engine); + codeNode.setCode(data.getString("code")); + return codeNode; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/ConfirmNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/ConfirmNodeParser.java new file mode 100644 index 0000000..05bb22b --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/ConfirmNodeParser.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser.impl; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.chain.Parameter; +import com.easyagents.flow.core.node.ConfirmNode; +import com.easyagents.flow.core.parser.BaseNodeParser; + +import java.util.List; + +public class ConfirmNodeParser extends BaseNodeParser { + + @Override + public ConfirmNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { + + ConfirmNode confirmNode = new ConfirmNode(); + confirmNode.setMessage(data.getString("message")); + + List confirms = getParameters(data, "confirms"); + if (confirms != null && !confirms.isEmpty()) { + confirmNode.setConfirms(confirms); + } + + return confirmNode; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/EndNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/EndNodeParser.java new file mode 100644 index 0000000..76a5c9c --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/EndNodeParser.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser.impl; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.node.EndNode; +import com.easyagents.flow.core.parser.BaseNodeParser; + +public class EndNodeParser extends BaseNodeParser { + + @Override + public EndNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { + EndNode endNode = new EndNode(); + endNode.setMessage(data.getString("message")); + return endNode; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/HttpNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/HttpNodeParser.java new file mode 100644 index 0000000..011a2ca --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/HttpNodeParser.java @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser.impl; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.chain.Parameter; +import com.easyagents.flow.core.node.HttpNode; +import com.easyagents.flow.core.parser.BaseNodeParser; + +import java.util.List; + +public class HttpNodeParser extends BaseNodeParser { + + @Override + public HttpNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { + HttpNode httpNode = new HttpNode(); + httpNode.setUrl(data.getString("url")); + httpNode.setMethod(data.getString("method")); + httpNode.setBodyJson(data.getString("bodyJson")); + httpNode.setRawBody(data.getString("rawBody")); + httpNode.setBodyType(data.getString("bodyType")); +// httpNode.setFileStorage(tinyflow.getFileStorage()); + + List headers = getParameters(data, "headers"); + httpNode.setHeaders(headers); + + List formData = getParameters(data, "formData"); + httpNode.setFormData(formData); + + List formUrlencoded = getParameters(data, "formUrlencoded"); + httpNode.setFormUrlencoded(formUrlencoded); + + return httpNode; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/KnowledgeNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/KnowledgeNodeParser.java new file mode 100644 index 0000000..60ee056 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/KnowledgeNodeParser.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser.impl; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.node.KnowledgeNode; +import com.easyagents.flow.core.parser.BaseNodeParser; + +public class KnowledgeNodeParser extends BaseNodeParser { + + @Override + public KnowledgeNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { + KnowledgeNode knowledgeNode = new KnowledgeNode(); + knowledgeNode.setKnowledgeId(data.get("knowledgeId")); + knowledgeNode.setLimit(data.getString("limit")); + knowledgeNode.setKeyword(data.getString("keyword")); + + return knowledgeNode; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/LlmNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/LlmNodeParser.java new file mode 100644 index 0000000..6868c05 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/LlmNodeParser.java @@ -0,0 +1,40 @@ +package com.easyagents.flow.core.parser.impl; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.chain.Parameter; +import com.easyagents.flow.core.llm.Llm; +import com.easyagents.flow.core.node.LlmNode; +import com.easyagents.flow.core.parser.BaseNodeParser; + +import java.util.List; + +public class LlmNodeParser extends BaseNodeParser { + + @Override + public LlmNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { + LlmNode llmNode = new LlmNode(); + llmNode.setLlmId(data.getString("llmId")); + llmNode.setUserPrompt(data.getString("userPrompt")); + llmNode.setSystemPrompt(data.getString("systemPrompt")); + llmNode.setOutType(data.getString("outType")); + + + Llm.ChatOptions chatOptions = new Llm.ChatOptions(); + chatOptions.setTopK(data.containsKey("topK") ? data.getInteger("topK") : 10); + chatOptions.setTopP(data.containsKey("topP") ? data.getFloat("topP") : 0.8F); + chatOptions.setTemperature(data.containsKey("temperature") ? data.getFloat("temperature") : 0.8F); + llmNode.setChatOptions(chatOptions); + +// LlmProvider llmProvider = tinyflow.getLlmProvider(); +// if (llmProvider != null) { +// Llm llm = llmProvider.getLlm(data.getString("llmId")); +// llmNode.setLlm(llm); +// } + + // 支持图片识别输入 + List images = getParameters(data, "images"); + llmNode.setImages(images); + + return llmNode; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/LoopNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/LoopNodeParser.java new file mode 100644 index 0000000..3a42639 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/LoopNodeParser.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser.impl; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.chain.Parameter; +import com.easyagents.flow.core.node.LoopNode; +import com.easyagents.flow.core.parser.BaseNodeParser; + +import java.util.List; + +public class LoopNodeParser extends BaseNodeParser { + + @Override + public LoopNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { + LoopNode loopNode = new LoopNode(); + + // 这里需要设置 id,先设置 id 后, loopNode.setLoopChain(chain); 才能取获取当前节点的 id +// loopNode.setId(root.getString("id")); + + List loopVars = getParameters(data, "loopVars"); + if (!loopVars.isEmpty()) { + loopNode.setLoopVar(loopVars.get(0)); + } + +// JSONArray nodes = chainJSONObject.getJSONArray("nodes"); +// JSONArray edges = chainJSONObject.getJSONArray("edges"); + +// ChainDefinition chain = getChainParser().parse(chainJSONObject, nodes, edges, root); +// loopNode.setLoopChain(chain); + + return loopNode; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/SearchEngineNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/SearchEngineNodeParser.java new file mode 100644 index 0000000..dd2693b --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/SearchEngineNodeParser.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser.impl; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.node.SearchEngineNode; +import com.easyagents.flow.core.parser.BaseNodeParser; + +public class SearchEngineNodeParser extends BaseNodeParser { + + @Override + public SearchEngineNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { + SearchEngineNode searchEngineNode = new SearchEngineNode(); + searchEngineNode.setKeyword(data.getString("keyword")); + searchEngineNode.setLimit(data.getString("limit")); + + String engine = data.getString("engine"); + searchEngineNode.setEngine(engine); + +// if (tinyflow.getSearchEngineProvider() != null) { +// SearchEngine searchEngine = tinyflow.getSearchEngineProvider().getSearchEngine(engine); +// searchEngineNode.setSearchEngine(searchEngine); +// } + + return searchEngineNode; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/StartNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/StartNodeParser.java new file mode 100644 index 0000000..0191214 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/StartNodeParser.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser.impl; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.node.StartNode; +import com.easyagents.flow.core.parser.BaseNodeParser; + +public class StartNodeParser extends BaseNodeParser { + + @Override + public StartNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { + return new StartNode(); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/TemplateNodeParser.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/TemplateNodeParser.java new file mode 100644 index 0000000..aecfdb0 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/parser/impl/TemplateNodeParser.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.parser.impl; + +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.node.TemplateNode; +import com.easyagents.flow.core.parser.BaseNodeParser; + +public class TemplateNodeParser extends BaseNodeParser { + + @Override + public TemplateNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { + TemplateNode templateNode = new TemplateNode(); + templateNode.setTemplate(data.getString("template")); + return templateNode; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/BaseSearchEngine.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/BaseSearchEngine.java new file mode 100644 index 0000000..83a5ac0 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/BaseSearchEngine.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.searchengine; + + +public abstract class BaseSearchEngine implements SearchEngine { + + protected String apiUrl; + protected String apiKey; + protected String keyword; + protected String searchCount; + protected String otherProperties; + + public String getApiUrl() { + return apiUrl; + } + + public void setApiUrl(String apiUrl) { + this.apiUrl = apiUrl; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getKeyword() { + return keyword; + } + + public void setKeyword(String keyword) { + this.keyword = keyword; + } + + public String getSearchCount() { + return searchCount; + } + + public void setSearchCount(String searchCount) { + this.searchCount = searchCount; + } + + public String getOtherProperties() { + return otherProperties; + } + + public void setOtherProperties(String otherProperties) { + this.otherProperties = otherProperties; + } + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngine.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngine.java new file mode 100644 index 0000000..f84f8ea --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngine.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.searchengine; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.node.SearchEngineNode; + +import java.util.List; +import java.util.Map; + +public interface SearchEngine { + + List> search(String keyword, int limit, SearchEngineNode searchEngineNode, Chain chain); + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngineManager.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngineManager.java new file mode 100644 index 0000000..3fe7f8c --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngineManager.java @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.searchengine; + +import com.easyagents.flow.core.searchengine.impl.BochaaiSearchEngineImpl; + +import java.util.ArrayList; +import java.util.List; + +public class SearchEngineManager { + + public List providers = new ArrayList<>(); + + private static class ManagerHolder { + private static final SearchEngineManager INSTANCE = new SearchEngineManager(); + } + + private SearchEngineManager() { + BochaaiSearchEngineImpl bochaaiSearchEngine = new BochaaiSearchEngineImpl(); + providers.add(id -> { + if ("bocha".equals(id) || "bochaai".equals(id)) { + return bochaaiSearchEngine; + } + return null; + }); + } + + public static SearchEngineManager getInstance() { + return ManagerHolder.INSTANCE; + } + + public void registerProvider(SearchEngineProvider provider) { + providers.add(provider); + } + + public void removeProvider(SearchEngineProvider provider) { + providers.remove(provider); + } + + public SearchEngine geSearchEngine(Object searchEngineId) { + for (SearchEngineProvider provider : providers) { + SearchEngine searchEngine = provider.getSearchEngine(searchEngineId); + if (searchEngine != null) { + return searchEngine; + } + } + return null; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngineProvider.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngineProvider.java new file mode 100644 index 0000000..2076b82 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/SearchEngineProvider.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.searchengine; + +public interface SearchEngineProvider { + SearchEngine getSearchEngine(Object id); +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/impl/BochaaiSearchEngineImpl.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/impl/BochaaiSearchEngineImpl.java new file mode 100644 index 0000000..20c7fa8 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/searchengine/impl/BochaaiSearchEngineImpl.java @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.searchengine.impl; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.node.SearchEngineNode; +import com.easyagents.flow.core.searchengine.BaseSearchEngine; +import com.easyagents.flow.core.util.Maps; +import com.easyagents.flow.core.util.OKHttpClientWrapper; + +import java.util.*; + +public class BochaaiSearchEngineImpl extends BaseSearchEngine { + + private static final String DEFAULT_API_URL = "https://api.bochaai.com/v1/ai-search"; + private OKHttpClientWrapper okHttpClientWrapper = new OKHttpClientWrapper(); + + public BochaaiSearchEngineImpl() { + setApiUrl(DEFAULT_API_URL); + } + + public OKHttpClientWrapper getOkHttpClientWrapper() { + return okHttpClientWrapper; + } + + public void setOkHttpClientWrapper(OKHttpClientWrapper okHttpClientWrapper) { + this.okHttpClientWrapper = okHttpClientWrapper; + } + + @Override + public List> search(String keyword, int limit, SearchEngineNode searchEngineNode, Chain chain) { + + Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer " + apiKey); + headers.put("Content-Type", "application/json"); + + String jsonString = Maps.of("query", keyword) + .set("summary", true). + set("freshness", "noLimit") + .set("count", limit) + .set("stream", false) + .toJSON(); + + + String responseString = okHttpClientWrapper.post(apiUrl, headers, jsonString); + JSONObject object = JSON.parseObject(responseString); + + if (200 == object.getIntValue("code")) { + JSONArray messages = object.getJSONArray("messages"); + List> result = new ArrayList<>(); + for (int i = 0; i < messages.size(); i++) { + result.add(messages.getJSONObject(i)); + } + return result; + } + + return Collections.emptyList(); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/CollectionUtil.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/CollectionUtil.java new file mode 100644 index 0000000..82e49e7 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/CollectionUtil.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util; + +import java.util.Collection; +import java.util.List; + +public class CollectionUtil { + + private CollectionUtil() { + } + + + public static boolean noItems(Collection collection) { + return collection == null || collection.isEmpty(); + } + + + public static boolean hasItems(Collection collection) { + return !noItems(collection); + } + + public static T firstItem(List list) { + if (list == null || list.isEmpty()) { + return null; + } + return list.get(0); + } + + + public static T lastItem(List list) { + if (list == null || list.isEmpty()) { + return null; + } + return list.get(list.size() - 1); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IOUtil.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IOUtil.java new file mode 100644 index 0000000..6bc5851 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IOUtil.java @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util; + +import okio.BufferedSink; + +import java.io.*; +import java.nio.charset.StandardCharsets; + +public class IOUtil { + + public static void writeBytes(byte[] bytes, File toFile) { + try (FileOutputStream stream = new FileOutputStream(toFile)) { + stream.write(bytes); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static byte[] readBytes(File file) { + try (FileInputStream inputStream = new FileInputStream(file)) { + return readBytes(inputStream); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static byte[] readBytes(InputStream inputStream) { + try { + ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + copy(inputStream, outStream); + return outStream.toByteArray(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static void copy(InputStream inputStream, BufferedSink sink) throws IOException { + byte[] buffer = new byte[2048]; + for (int len; (len = inputStream.read(buffer)) != -1; ) { + sink.write(buffer, 0, len); + } + } + + public static void copy(InputStream inputStream, OutputStream outStream) throws IOException { + byte[] buffer = new byte[2048]; + for (int len; (len = inputStream.read(buffer)) != -1; ) { + outStream.write(buffer, 0, len); + } + } + + public static String readUtf8(InputStream inputStream) throws IOException { + ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + copy(inputStream, outStream); + return new String(outStream.toByteArray(), StandardCharsets.UTF_8); + } + + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IterableUtil.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IterableUtil.java new file mode 100644 index 0000000..4759226 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/IterableUtil.java @@ -0,0 +1,64 @@ +package com.easyagents.flow.core.util; + +import java.util.Collection; +import java.util.Iterator; +import java.util.List; + +public class IterableUtil { + + public static boolean isEmpty(Iterable iterable) { + if (iterable == null) { + return true; + } + if (iterable instanceof Collection) { + return ((Collection) iterable).isEmpty(); + } + return !iterable.iterator().hasNext(); + } + + + public static boolean isNotEmpty(Iterable iterable) { + return !isEmpty(iterable); + } + + public static int size(Iterable iterable) { + if (iterable == null) { + return 0; + } + if (iterable instanceof Collection) { + return ((Collection) iterable).size(); + } + int size = 0; + for (Iterator it = iterable.iterator(); it.hasNext(); it.next()) { + size++; + } + return size; + } + + public static T get(Iterable iterable, int index) { + if (iterable == null) { + throw new IllegalArgumentException("iterable must not be null"); + } + if (index < 0) { + throw new IndexOutOfBoundsException("index < 0: " + index); + } + + if (iterable instanceof List) { + List list = (List) iterable; + if (index >= list.size()) { + throw new IndexOutOfBoundsException("index >= size: " + index); + } + return list.get(index); + } + + int i = 0; + for (T t : iterable) { + if (i == index) { + return t; + } + i++; + } + + throw new IndexOutOfBoundsException("index >= size: " + index); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/JsConditionUtil.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/JsConditionUtil.java new file mode 100644 index 0000000..45d1341 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/JsConditionUtil.java @@ -0,0 +1,190 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.util.graalvm.JsInteropUtils; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.Value; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class JsConditionUtil { + + // 使用 Context.Builder 构建上下文,线程安全 + private static final Context.Builder CONTEXT_BUILDER = Context.newBuilder("js") + .option("engine.WarnInterpreterOnly", "false") + .allowHostAccess(HostAccess.ALL) // 允许访问 Java 对象的方法和字段 + .allowHostClassLookup(className -> false) // 禁止动态加载任意 Java 类 + .option("js.ecmascript-version", "2021"); // 使用较新的 ECMAScript 版本 + + /** + * 执行 JavaScript 表达式并返回 boolean 结果 + * + * @param code JS 表达式(应返回布尔或可转换为布尔的值) + * @param chain Chain 上下文对象 + * @param initMap 初始变量映射 + * @return true 表示满足条件,继续执行;false 表示跳过 + */ + public static boolean eval(String code, Chain chain, Map initMap) { + try (Context context = CONTEXT_BUILDER.build()) { + Map _result = new HashMap<>(); + Value bindings = context.getBindings("js"); + + // 合并上下文变量 + Map contextVariables = collectContextVariables(chain, initMap); + contextVariables.forEach((key, value) -> { + bindings.putMember(key, JsInteropUtils.wrapJavaValueForJS(context, value)); + }); + + bindings.putMember("_result", _result); + code = "_result.value = " + code; + + context.eval("js", code); + Object value = _result.get("value"); + return toBoolean(value); + } catch (Exception e) { + throw new RuntimeException("JavaScript 执行失败: " + e.getMessage(), e); + } + } + + + public static long evalLong(String code, Chain chain, Map initMap) { + try (Context context = CONTEXT_BUILDER.build()) { + Map _result = new HashMap<>(); + Value bindings = context.getBindings("js"); + + // 合并上下文变量 + Map contextVariables = collectContextVariables(chain, initMap); + contextVariables.forEach((key, value) -> { + bindings.putMember(key, JsInteropUtils.wrapJavaValueForJS(context, value)); + }); + + bindings.putMember("_result", _result); + code = "_result.value = " + code; + + context.eval("js", code); + Object value = _result.get("value"); + return toLong(value); + } catch (Exception e) { + throw new RuntimeException("JavaScript 执行失败: " + e.getMessage(), e); + } + } + + + /** + * 将任意对象安全转换为 long 类型 + */ + private static long toLong(Object value) { + if (value == null) { + return 0L; + } + + if (value instanceof Number) { + return ((Number) value).longValue(); + } + + if (value instanceof String) { + String str = ((String) value).trim(); + if (str.isEmpty()) { + return 0L; + } + try { + // 支持整数和浮点字符串(如 "123", "45.67") + return Double.valueOf(str).longValue(); + } catch (NumberFormatException e) { + throw new RuntimeException("无法将字符串 \"" + str + "\" 转换为 long", e); + } + } + + if (value instanceof Value) { + Value v = (Value) value; + if (v.isNumber()) { + return v.asLong(); // GraalVM 的 asLong() 会自动处理 double/integer + } else if (v.isString()) { + return toLong(v.asString()); + } else if (v.isNull()) { + return 0L; + } else { + throw new RuntimeException("无法将 JS 值 " + v + " 转换为 long"); + } + } + + // 兜底:尝试 toString 后解析 + try { + String str = value.toString().trim(); + return str.isEmpty() ? 0L : Double.valueOf(str).longValue(); + } catch (Exception e) { + throw new RuntimeException("无法将对象 " + value + " 转换为 long", e); + } + } + + /** + * 收集上下文中的变量 + */ + private static Map collectContextVariables(Chain chain, Map initMap) { + Map variables = new ConcurrentHashMap<>(); + + // 添加 Chain Memory 中的变量(去掉前缀) + chain.getState().getMemory().forEach((key, value) -> { + int dotIndex = key.indexOf("."); + String varName = (dotIndex >= 0) ? key.substring(dotIndex + 1) : key; + variables.put(varName, value); + }); + + // 添加 _chain 和 initMap 变量 + variables.putAll(initMap); + + return variables; + } + + /** + * 将任意对象转换为布尔值 + */ + private static boolean toBoolean(Object value) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof Number) { + return ((Number) value).intValue() != 0; + } + if (value instanceof String) { + String str = ((String) value).trim().toLowerCase(); + return !str.isEmpty() && !"0".equals(str) && !"false".equals(str); + } + if (value instanceof Value) { + Value v = (Value) value; + if (v.isBoolean()) { + return v.asBoolean(); + } else if (v.isNumber()) { + return v.asDouble() != 0; + } else if (v.isString()) { + String str = v.asString().trim().toLowerCase(); + return !str.isEmpty() && !"0".equals(str) && !"false".equals(str); + } else { + return !v.isNull(); + } + } + return true; + } + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/MapUtil.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/MapUtil.java new file mode 100644 index 0000000..bc1835d --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/MapUtil.java @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util; + + +import com.alibaba.fastjson.JSONPath; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.function.Function; + +public class MapUtil { + private static final Logger log = LoggerFactory.getLogger(MapUtil.class); + + private static final boolean IS_JDK8 = (8 == getJvmVersion0()); + + private MapUtil() { + } + + private static String tryTrim(String string) { + return string != null ? string.trim() : ""; + } + + private static int getJvmVersion0() { + int jvmVersion = -1; + try { + String javaSpecVer = tryTrim(System.getProperty("java.specification.version")); + if (StringUtil.hasText(javaSpecVer)) { + if (javaSpecVer.startsWith("1.")) { + javaSpecVer = javaSpecVer.substring(2); + } + if (javaSpecVer.indexOf('.') == -1) { + jvmVersion = Integer.parseInt(javaSpecVer); + } + } + } catch (Throwable ignore) { + // ignore + } + // default is jdk8 + if (jvmVersion == -1) { + jvmVersion = 8; + } + return jvmVersion; + } + + /** + * A temporary workaround for Java 8 specific performance issue JDK-8161372 .
+ * This class should be removed once we drop Java 8 support. + * + * @see https://bugs.openjdk.java.net/browse/JDK-8161372 + */ + public static V computeIfAbsent(Map map, K key, Function mappingFunction) { + if (IS_JDK8) { + V value = map.get(key); + if (value != null) { + return value; + } + } + return map.computeIfAbsent(key, mappingFunction); + } + + public static Object getByPath(Map from, String keyOrPath) { + if (StringUtil.noText(keyOrPath) || from == null || from.isEmpty()) { + return null; + } + + Object result = from.get(keyOrPath); + if (result != null) { + return result; + } + + List parts = Arrays.asList(keyOrPath.split("\\.")); + if (parts.isEmpty()) { + return null; + } + + int matchedLevels = 0; + for (int i = parts.size(); i > 0; i--) { + String tryKey = String.join(".", parts.subList(0, i)); + Object tempResult = from.get(tryKey); + if (tempResult != null) { + result = tempResult; + matchedLevels = i; + break; + } + } + + if (result == null) { + return null; + } + + if (result instanceof Collection) { + List results = new ArrayList<>(); + for (Object item : ((Collection) result)) { + results.add(getResult(parts, matchedLevels, item)); + } + return results; + } + + return getResult(parts, matchedLevels, result); + } + + private static Object getResult(List parts, int matchedLevels, Object result) { + List remainingParts = parts.subList(matchedLevels, parts.size()); + String jsonPath = "$." + String.join(".", remainingParts); + try { + return JSONPath.eval(result, jsonPath); + } catch (Exception e) { + log.error(e.toString(), e); + } + + return null; + } + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/Maps.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/Maps.java new file mode 100644 index 0000000..04fc5eb --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/Maps.java @@ -0,0 +1,156 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.serializer.SerializerFeature; + +import java.lang.reflect.Array; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +public class Maps extends HashMap { + + public static Maps of() { + return new Maps(); + } + + public static Maps of(String key, Object value) { + Maps maps = Maps.of(); + maps.put(key, value); + return maps; + } + + public static Maps ofNotNull(String key, Object value) { + return new Maps().setIfNotNull(key, value); + } + + public static Maps ofNotEmpty(String key, Object value) { + return new Maps().setIfNotEmpty(key, value); + } + + public static Maps ofNotEmpty(String key, Maps value) { + return new Maps().setIfNotEmpty(key, value); + } + + + public Maps set(String key, Object value) { + super.put(key, value); + return this; + } + + public Maps setChild(String key, Object value) { + if (key.contains(".")) { + String[] keys = key.split("\\."); + Map currentMap = this; + for (int i = 0; i < keys.length; i++) { + String currentKey = keys[i].trim(); + if (currentKey.isEmpty()) { + continue; + } + if (i == keys.length - 1) { + currentMap.put(currentKey, value); + } else { + //noinspection unchecked + currentMap = (Map) currentMap.computeIfAbsent(currentKey, k -> Maps.of()); + } + } + } else { + super.put(key, value); + } + + return this; + } + + public Maps setOrDefault(String key, Object value, Object orDefault) { + if (isNullOrEmpty(value)) { + return this.set(key, orDefault); + } else { + return this.set(key, value); + } + } + + public Maps setIf(boolean condition, String key, Object value) { + if (condition) put(key, value); + return this; + } + + public Maps setIf(Function func, String key, Object value) { + if (func.apply(this)) put(key, value); + return this; + } + + public Maps setIfNotNull(String key, Object value) { + if (value != null) put(key, value); + return this; + } + + public Maps setIfNotEmpty(String key, Object value) { + if (!isNullOrEmpty(value)) { + put(key, value); + } + return this; + } + + public Maps setIfNotEmpty(Map source) { + if (!isNullOrEmpty(source)) { + this.putAll(source); + } + return this; + } + + + public Maps setIfContainsKey(String checkKey, String key, Object value) { + if (this.containsKey(checkKey)) { + this.put(key, value); + } + return this; + } + + public Maps setIfNotContainsKey(String checkKey, String key, Object value) { + if (!this.containsKey(checkKey)) { + this.put(key, value); + } + return this; + } + + public String toJSON() { + return JSON.toJSONString(this, SerializerFeature.DisableCircularReferenceDetect); + } + + + private static boolean isNullOrEmpty(Object value) { + if (value == null) { + return true; + } + + if (value instanceof Collection && ((Collection) value).isEmpty()) { + return true; + } + + if (value instanceof Map && ((Map) value).isEmpty()) { + return true; + } + + if (value.getClass().isArray() && Array.getLength(value) == 0) { + return true; + } + + return value instanceof String && ((String) value).trim().isEmpty(); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/NamedThreadFactory.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/NamedThreadFactory.java new file mode 100644 index 0000000..ad3af52 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/NamedThreadFactory.java @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * @author michael yang (fuhai999@gmail.com) + */ +public class NamedThreadFactory implements ThreadFactory { + + protected static final AtomicInteger POOL_COUNTER = new AtomicInteger(1); + protected final AtomicInteger mThreadCounter; + protected final String mPrefix; + protected final boolean mDaemon; + protected final ThreadGroup mGroup; + + public NamedThreadFactory() { + this("pool-" + POOL_COUNTER.getAndIncrement(), false); + } + + public NamedThreadFactory(String prefix) { + this(prefix, false); + } + + public NamedThreadFactory(String prefix, boolean daemon) { + this.mThreadCounter = new AtomicInteger(1); + this.mPrefix = prefix + "-thread-"; + this.mDaemon = daemon; + SecurityManager s = System.getSecurityManager(); + this.mGroup = s == null ? Thread.currentThread().getThreadGroup() : s.getThreadGroup(); + } + + @Override + public Thread newThread(Runnable runnable) { + String name = this.mPrefix + this.mThreadCounter.getAndIncrement(); + Thread ret = new Thread(this.mGroup, runnable, name, 0L); + ret.setDaemon(this.mDaemon); + return ret; + } + + public ThreadGroup getThreadGroup() { + return this.mGroup; + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/NamedThreadPools.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/NamedThreadPools.java new file mode 100644 index 0000000..eafe915 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/NamedThreadPools.java @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util; + +import java.util.concurrent.*; + +/** + * @author michael yang (fuhai999@gmail.com) + */ +public class NamedThreadPools { + + public static ExecutorService newFixedThreadPool(String prefix) { + int nThreads = Runtime.getRuntime().availableProcessors(); + return newFixedThreadPool(nThreads, prefix); + } + + + public static ExecutorService newFixedThreadPool(int nThreads, String name) { + return new ThreadPoolExecutor(nThreads, nThreads, + 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(nThreads * 2), + new NamedThreadFactory(name)); + } + + + public static ExecutorService newCachedThreadPool(String name) { + return newCachedThreadPool(new NamedThreadFactory(name)); + } + + + public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { + return new ThreadPoolExecutor(0, Integer.MAX_VALUE, + 60L, TimeUnit.SECONDS, + new SynchronousQueue(), + threadFactory); + } + + + public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize, String name) { + return newScheduledThreadPool(corePoolSize, new NamedThreadFactory(name)); + } + + + public static ScheduledExecutorService newScheduledThreadPool( + int corePoolSize, ThreadFactory threadFactory) { + return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OKHttpClientWrapper.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OKHttpClientWrapper.java new file mode 100644 index 0000000..06184ba --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OKHttpClientWrapper.java @@ -0,0 +1,199 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util; + +import okhttp3.*; +import okio.BufferedSink; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +public class OKHttpClientWrapper { + private static final Logger LOG = LoggerFactory.getLogger(OKHttpClientWrapper.class); + private static final MediaType JSON_TYPE = MediaType.parse("application/json; charset=utf-8"); + + private final OkHttpClient okHttpClient; + + public OKHttpClientWrapper() { + this(OkHttpClientUtil.buildDefaultClient()); + } + + public OKHttpClientWrapper(OkHttpClient okHttpClient) { + this.okHttpClient = okHttpClient; + } + + public String get(String url) { + return executeString(url, "GET", null, null); + } + + public byte[] getBytes(String url) { + return executeBytes(url, "GET", null, null); + } + + public String get(String url, Map headers) { + return executeString(url, "GET", headers, null); + } + + public String post(String url, Map headers, String payload) { + return executeString(url, "POST", headers, payload); + } + + public byte[] postBytes(String url, Map headers, String payload) { + return executeBytes(url, "POST", headers, payload); + } + + public String put(String url, Map headers, String payload) { + return executeString(url, "PUT", headers, payload); + } + + public String delete(String url, Map headers, String payload) { + return executeString(url, "DELETE", headers, payload); + } + + public String multipartString(String url, Map headers, Map payload) { + try (Response response = multipart(url, headers, payload); + ResponseBody body = response.body()) { + if (body != null) { + return body.string(); + } + } catch (Exception e) { + LOG.error(e.toString(), e); + } + return null; + } + + + public byte[] multipartBytes(String url, Map headers, Map payload) { + try (Response response = multipart(url, headers, payload); + ResponseBody body = response.body()) { + if (body != null) { + return body.bytes(); + } + } catch (Exception e) { + LOG.error(e.toString(), e); + } + return null; + } + + + public Response multipart(String url, Map headers, Map payload) throws IOException { + Request.Builder builder = new Request.Builder() + .url(url); + + if (headers != null && !headers.isEmpty()) { + headers.forEach(builder::addHeader); + } + + MultipartBody.Builder mbBuilder = new MultipartBody.Builder() + .setType(MultipartBody.FORM); + payload.forEach((s, o) -> { + if (o instanceof File) { + File f = (File) o; + RequestBody body = RequestBody.create(f, MediaType.parse("application/octet-stream")); + mbBuilder.addFormDataPart(s, f.getName(), body); + } else if (o instanceof InputStream) { + RequestBody body = new InputStreamRequestBody(MediaType.parse("application/octet-stream"), (InputStream) o); + mbBuilder.addFormDataPart(s, s, body); + } else if (o instanceof byte[]) { + mbBuilder.addFormDataPart(s, s, RequestBody.create((byte[]) o)); + } else { + mbBuilder.addFormDataPart(s, String.valueOf(o)); + } + }); + + MultipartBody multipartBody = mbBuilder.build(); + Request request = builder.post(multipartBody).build(); + return okHttpClient.newCall(request).execute(); + } + + + public String executeString(String url, String method, Map headers, String payload) { + try (Response response = execute0(url, method, headers, payload); + ResponseBody body = response.body()) { + if (body != null) { + return body.string(); + } + } catch (Exception e) { + LOG.error(e.toString(), e); + } + return null; + } + + + public byte[] executeBytes(String url, String method, Map headers, String payload) { + try (Response response = execute0(url, method, headers, payload); + ResponseBody body = response.body()) { + if (body != null) { + return body.bytes(); + } + } catch (Exception e) { + LOG.error(e.toString(), e); + } + return null; + } + + + private Response execute0(String url, String method, Map headers, String payload) throws IOException { + Request.Builder builder = new Request.Builder() + .url(url); + + if (headers != null && !headers.isEmpty()) { + headers.forEach(builder::addHeader); + } + + Request request; + if ("GET".equalsIgnoreCase(method)) { + request = builder.method(method, null).build(); + } else { + RequestBody body = RequestBody.create(payload, JSON_TYPE); + request = builder.method(method, body).build(); + } + + return okHttpClient.newCall(request).execute(); + } + + + public static class InputStreamRequestBody extends RequestBody { + private final InputStream inputStream; + private final MediaType contentType; + + public InputStreamRequestBody(MediaType contentType, InputStream inputStream) { + if (inputStream == null) throw new NullPointerException("inputStream == null"); + this.contentType = contentType; + this.inputStream = inputStream; + } + + @Override + public MediaType contentType() { + return contentType; + } + + @Override + public long contentLength() throws IOException { + return inputStream.available() == 0 ? -1 : inputStream.available(); + } + + @Override + public void writeTo(@NotNull BufferedSink sink) throws IOException { + IOUtil.copy(inputStream, sink); + } + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OkHttpClientUtil.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OkHttpClientUtil.java new file mode 100644 index 0000000..3182dbf --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/OkHttpClientUtil.java @@ -0,0 +1,165 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util; + +import okhttp3.OkHttpClient; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.security.SecureRandom; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Utility class for creating and configuring OkHttpClient instances. + *

+ * By default, it uses secure TLS settings. Insecure HTTPS (trust-all) can be enabled + * via system property {@code tinyflow.okhttp.insecure=true}, but it is strongly + * discouraged in production environments. + *

+ */ +public final class OkHttpClientUtil { + + private static final Logger LOGGER = Logger.getLogger(OkHttpClientUtil.class.getName()); + + private static volatile OkHttpClient.Builder customBuilder; + private static final Object LOCK = new Object(); + + // Prevent instantiation + private OkHttpClientUtil() { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Sets a custom OkHttpClient.Builder to be used by {@link #buildDefaultClient()}. + * This should be called during application initialization. + */ + public static void setCustomBuilder(OkHttpClient.Builder builder) { + if (builder == null) { + throw new IllegalArgumentException("Builder must not be null"); + } + customBuilder = builder; + } + + /** + * Returns a shared default OkHttpClient instance with reasonable timeouts and optional proxy. + * If a custom builder was set via {@link #setCustomBuilder}, it will be used. + *

+ * SSL is secure by default. Insecure mode (trust-all) can be enabled via system property: + * {@code -Dtinyflow.okhttp.insecure=true} + *

+ */ + public static OkHttpClient buildDefaultClient() { + OkHttpClient.Builder builder = customBuilder; + if (builder != null) { + return builder.build(); + } + + synchronized (LOCK) { + // Double-check in case another thread set it while waiting + builder = customBuilder; + if (builder != null) { + return builder.build(); + } + + builder = new OkHttpClient.Builder() + .connectTimeout(1, TimeUnit.MINUTES) + .readTimeout(5, TimeUnit.MINUTES); + + // Optional insecure mode (for development/testing only) + if (isInsecureModeEnabled()) { + LOGGER.warning("OkHttpClient is running in INSECURE mode (trust-all SSL). " + + "This is dangerous and should not be used in production."); + enableInsecureSsl(builder); + } + + configureProxy(builder); + return builder.build(); + } + } + + private static boolean isInsecureModeEnabled() { + return Boolean.parseBoolean(System.getProperty("tinyflow.okhttp.insecure", "false")); + } + + + private static void enableInsecureSsl(OkHttpClient.Builder builder) { + try { + X509TrustManager trustManager = new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[]{trustManager}, new SecureRandom()); + SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + + builder.sslSocketFactory(sslSocketFactory, trustManager) + .hostnameVerifier((hostname, session) -> true); + } catch (Exception e) { + throw new IllegalStateException("Failed to configure insecure SSL for OkHttpClient", e); + } + } + + private static void configureProxy(OkHttpClient.Builder builder) { + String proxyHost = getProxyHost(); + String proxyPort = getProxyPort(); + + if (StringUtil.hasText(proxyHost) && StringUtil.hasText(proxyPort)) { + try { + int port = Integer.parseInt(proxyPort.trim()); + InetSocketAddress addr = new InetSocketAddress(proxyHost.trim(), port); + builder.proxy(new Proxy(Proxy.Type.HTTP, addr)); + LOGGER.fine("Configured HTTP proxy: " + proxyHost + ":" + port); + } catch (NumberFormatException e) { + LOGGER.log(Level.WARNING, "Invalid proxy port: " + proxyPort, e); + } + } + } + + private static String getProxyHost() { + String host = System.getProperty("https.proxyHost"); + if (!StringUtil.hasText(host)) { + host = System.getProperty("http.proxyHost"); + } + return host; + } + + private static String getProxyPort() { + String port = System.getProperty("https.proxyPort"); + if (!StringUtil.hasText(port)) { + port = System.getProperty("http.proxyPort"); + } + return port; + } +} \ No newline at end of file diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/StringUtil.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/StringUtil.java new file mode 100644 index 0000000..cf74bbc --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/StringUtil.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util; + +public class StringUtil { + + public static boolean noText(String string) { + return !hasText(string); + } + + public static boolean hasText(String string) { + return string != null && !string.isEmpty() && containsText(string); + } + + public static boolean hasText(String... strings) { + for (String string : strings) { + if (!hasText(string)) { + return false; + } + } + return true; + } + + private static boolean containsText(CharSequence str) { + for (int i = 0; i < str.length(); i++) { + if (!Character.isWhitespace(str.charAt(i))) { + return true; + } + } + return false; + } + + public static String getFirstWithText(String... strings) { + if (strings == null) { + return null; + } + for (String str : strings) { + if (hasText(str)) { + return str; + } + } + return null; + } + + /** + * 判断字符串是否是数字 + * + * @param string 需要判断的字符串 + * @return boolean 是数字返回 true,否则返回 false + */ + public static boolean isNumeric(String string) { + if (string == null || string.isEmpty()) { + return false; + } + char[] chars = string.trim().toCharArray(); + for (char c : chars) { + if (!Character.isDigit(c)) { + return false; + } + } + return true; + } + + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/TextTemplate.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/TextTemplate.java new file mode 100644 index 0000000..8d33f56 --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/TextTemplate.java @@ -0,0 +1,356 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONPath; +import com.alibaba.fastjson.serializer.SerializerFeature; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 文本模板引擎,用于将包含 {{xxx}} 占位符的字符串模板,动态渲染为最终文本。 + * 支持 JSONPath 取值语法与 “??” 空值兜底逻辑。 + *

+ * 例如: + * 模板: "Hello {{ user.name ?? 'Unknown' }}!" + * 数据: { "user": { "name": "Alice" } } + * 输出: "Hello Alice!" + *

+ * 支持缓存模板与 JSONPath 编译结果,提升性能。 + */ +public class TextTemplate { + + /** + * 匹配 {{ expression }} 的正则表达式 + */ + private static final Pattern PLACEHOLDER_PATTERN = + Pattern.compile("\\{\\{\\s*([^{}]+?)\\s*}}"); + + /** + * 模板缓存(按原始模板字符串) + */ + private static final Map TEMPLATE_CACHE = new ConcurrentHashMap<>(); + + /** + * JSONPath 编译缓存,避免重复编译 + */ + private static final Map JSONPATH_CACHE = new ConcurrentHashMap<>(); + + /** + * 原始模板字符串 + */ + private final String originalTemplate; + + /** + * 模板中拆分出的静态与动态 token 列表 + */ + private final List tokens; + + public TextTemplate(String template) { + this.originalTemplate = template != null ? template : ""; + this.tokens = Collections.unmodifiableList(parseTemplate(this.originalTemplate)); + } + + /** + * 从缓存中获取或新建模板实例 + */ + public static TextTemplate of(String template) { + String finalTemplate = template != null ? template : ""; + return MapUtil.computeIfAbsent(TEMPLATE_CACHE, finalTemplate, k -> new TextTemplate(finalTemplate)); + } + + /** + * 清空模板与 JSONPath 缓存 + */ + public static void clearCache() { + TEMPLATE_CACHE.clear(); + JSONPATH_CACHE.clear(); + } + + + public String formatToString(List> rootMaps) { + Map rootMap = new HashMap<>(); + for (Map m : rootMaps) { + if (m != null) { + rootMap.putAll(m); + } + } + return formatToString(rootMap, false); + } + + /** + * 将模板格式化为字符串 + */ + public String formatToString(Map rootMap) { + return formatToString(rootMap, false); + } + + /** + * 将模板格式化为字符串,可选择是否对结果进行 JSON 转义 + * + * @param rootMap 数据上下文 + * @param escapeForJsonOutput 是否对结果进行 JSON 字符串转义 + */ + public String formatToString(Map rootMap, boolean escapeForJsonOutput) { + if (tokens.isEmpty()) return originalTemplate; + if (rootMap == null) rootMap = Collections.emptyMap(); + + StringBuilder sb = new StringBuilder(originalTemplate.length() + 64); + + for (TemplateToken token : tokens) { + if (token.isStatic) { + // 静态文本,直接拼接 + sb.append(token.content); + continue; + } + + // 动态表达式求值 + String value = evaluate(token.parseResult, rootMap, escapeForJsonOutput); + + // 没有兜底且值为空时抛出异常 + if (!token.explicitEmptyFallback && value.isEmpty()) { + throw new IllegalArgumentException(String.format( + "Missing value for expression: \"%s\"%nTemplate: %s%nProvided parameters:%n%s", + token.rawExpression, + originalTemplate, + JSON.toJSONString(rootMap, SerializerFeature.PrettyFormat) + )); + } + sb.append(value); + } + + return sb.toString(); + } + + /** + * 解析模板字符串,将其拆解为静态文本与动态占位符片段 + */ + private List parseTemplate(String template) { + List result = new ArrayList<>(template.length() / 8); + if (template == null || template.isEmpty()) return result; + + Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); + int lastEnd = 0; + + while (matcher.find()) { + int start = matcher.start(); + int end = matcher.end(); + + // 处理 {{ 前的静态文本 + if (start > lastEnd) { + result.add(TemplateToken.staticText(template.substring(lastEnd, start))); + } + + // 处理 {{ ... }} 动态部分 + String rawExpr = matcher.group(1); + TemplateParseResult parsed = parseTemplateExpression(rawExpr); + result.add(TemplateToken.dynamic(parsed.parseResult, rawExpr, parsed.explicitEmptyFallback)); + + lastEnd = end; + } + + // 末尾剩余静态文本 + if (lastEnd < template.length()) { + result.add(TemplateToken.staticText(template.substring(lastEnd))); + } + + return result; + } + + /** + * 解析单个表达式内容,处理 ?? 空值兜底逻辑。 + * 例如: user.name ?? user.nick ?? "未知" + */ + private TemplateParseResult parseTemplateExpression(String expr) { + // 无 ?? 表示该值必填 + if (!expr.contains("??")) { + return new TemplateParseResult(new ParseResult(expr.trim(), null), false); + } + + // 按 ?? 分割,支持链式兜底 + String[] parts = expr.split("\\s*\\?\\?\\s*", -1); + boolean explicitEmptyFallback = parts[parts.length - 1].trim().isEmpty(); + + // 从右往左构建兜底链 + ParseResult result = null; + for (int i = parts.length - 1; i >= 0; i--) { + String p = parts[i].trim(); + if (p.isEmpty()) p = "\"\""; // 空串转为 "" 字面量 + result = new ParseResult(p, result); + } + + return new TemplateParseResult(result, explicitEmptyFallback); + } + + /** + * 递归求值表达式(支持多级兜底) + */ + private String evaluate(ParseResult pr, Map root, boolean escapeForJsonOutput) { + if (pr == null) return ""; + + // 字面量直接返回 + if (pr.isLiteral) { + String literal = pr.getUnquotedLiteral(); + return escapeForJsonOutput ? escapeJsonString(literal) : literal; + } + + // 尝试从 JSONPath 取值 + Object value = getValueByJsonPath(root, pr.expression, escapeForJsonOutput); + if (value != null) { + return value.toString(); + } + + // 若未取到,则尝试 fallback + return evaluate(pr.defaultResult, root, escapeForJsonOutput); + } + + /** + * 根据 JSONPath 获取对象值 + */ + private Object getValueByJsonPath(Map root, String path, boolean escapeForJsonOutput) { + try { + String fullPath = path.startsWith("$") ? path : "$." + path; + JSONPath compiled = MapUtil.computeIfAbsent(JSONPATH_CACHE, fullPath, JSONPath::compile); + Object value = compiled.eval(root); + if (escapeForJsonOutput && value instanceof String) { + return escapeJsonString((String) value); + } + return value; + } catch (Exception ignored) { + return null; + } + } + + /** + * 将字符串进行 JSON 安全转义 + */ + private static String escapeJsonString(String input) { + if (input == null || input.isEmpty()) return input; + return input + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\b", "\\b") + .replace("\f", "\\f") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * 去掉字符串两端的引号 + */ + private static String unquote(String str) { + if (str == null || str.length() < 2) return str; + char first = str.charAt(0); + char last = str.charAt(str.length() - 1); + if ((first == '\'' && last == '\'') || (first == '"' && last == '"')) { + return str.substring(1, str.length() - 1); + } + return str; + } + + + /** + * 模板片段对象。 + * 每个模板字符串会被解析为若干个 TemplateToken: + * - 静态文本(isStatic = true) + * - 动态表达式(isStatic = false) + */ + private static class TemplateToken { + final boolean isStatic; // 是否为静态文本 + final String content; // 静态文本内容 + final ParseResult parseResult; // 动态解析结果(表达式树) + final String rawExpression; // 原始表达式字符串 + final boolean explicitEmptyFallback; // 是否显式声明空兜底(以 ?? 结尾) + + private TemplateToken(boolean isStatic, String content, + ParseResult parseResult, String rawExpression, + boolean explicitEmptyFallback) { + this.isStatic = isStatic; + this.content = content; + this.parseResult = parseResult; + this.rawExpression = rawExpression; + this.explicitEmptyFallback = explicitEmptyFallback; + } + + /** + * 创建静态文本 token + */ + static TemplateToken staticText(String text) { + return new TemplateToken(true, text, null, null, false); + } + + /** + * 创建动态表达式 token + */ + static TemplateToken dynamic(ParseResult parseResult, String rawExpression, boolean explicitEmptyFallback) { + return new TemplateToken(false, null, parseResult, rawExpression, explicitEmptyFallback); + } + } + + /** + * 表达式解析结果。 + * 支持嵌套的默认值链,如:user.name ?? user.nick ?? "匿名" + */ + private static class ParseResult { + final String expression; // 当前表达式内容(可能是 JSONPath 或字符串字面量) + final ParseResult defaultResult; // 默认值链的下一个节点 + final boolean isLiteral; // 是否为字面量字符串('xxx' 或 "xxx") + + ParseResult(String expression, ParseResult defaultResult) { + this.expression = expression; + this.defaultResult = defaultResult; + this.isLiteral = isLiteralExpression(expression); + } + + /** + * 判断是否是字符串字面量 + */ + private static boolean isLiteralExpression(String expr) { + if (expr == null || expr.length() < 2) return false; + char first = expr.charAt(0); + char last = expr.charAt(expr.length() - 1); + return (first == '\'' && last == '\'') || (first == '"' && last == '"'); + } + + /** + * 返回去除引号后的字符串字面量值 + */ + String getUnquotedLiteral() { + if (!isLiteral) throw new IllegalStateException("Not a literal: " + expression); + return unquote(expression); + } + } + + /** + * 模板解析的最终结果,包含: + * - 解析后的表达式树(ParseResult) + * - 是否显式声明空兜底 + */ + private static class TemplateParseResult { + final ParseResult parseResult; + final boolean explicitEmptyFallback; + + TemplateParseResult(ParseResult parseResult, boolean explicitEmptyFallback) { + this.parseResult = parseResult; + this.explicitEmptyFallback = explicitEmptyFallback; + } + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/JsInteropUtils.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/JsInteropUtils.java new file mode 100644 index 0000000..1028c5d --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/JsInteropUtils.java @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util.graalvm; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; + +import java.time.*; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class JsInteropUtils { + + public static Value wrapJavaValueForJS(Context context, Object value) { + if (value == null) { + return context.asValue(null); + } + + // 处理 LocalDateTime / LocalDate / ZonedDateTime -> JS Date + if (value instanceof LocalDateTime) { + return context.eval("js", "new Date('" + ((LocalDateTime) value).atZone(ZoneId.systemDefault()) + "')"); + } + if (value instanceof LocalDate) { + return context.eval("js", "new Date('" + ((LocalDate) value).atStartOfDay(ZoneId.systemDefault()) + "')"); + } + if (value instanceof ZonedDateTime) { + return context.eval("js", "new Date('" + (value) + "')"); + } + if (value instanceof Date) { + return context.eval("js", "new Date(" + ((Date) value).getTime() + ")"); + } + + // 处理 Map -> ProxyObject + if (value instanceof Map) { + return context.asValue(new ProxyMap((Map) value, context)); + } + + // 处理 List -> ProxyArray + if (value instanceof List) { + return context.asValue(new ProxyList((List) value, context)); + } + + // 处理 Set -> ProxyArray + if (value instanceof Set) { + return context.asValue(new ProxyList(new ArrayList<>((Set) value), context)); + } + + // 处理数组 -> ProxyArray + if (value.getClass().isArray()) { + int length = java.lang.reflect.Array.getLength(value); + List list = IntStream.range(0, length) + .mapToObj(i -> java.lang.reflect.Array.get(value, i)) + .collect(Collectors.toList()); + return context.asValue(new ProxyList(list, context)); + } + + // 默认处理:基本类型或 Java 对象直接返回 + return context.asValue(value); + } + + + // 双向转换:将 JS 值转为 Java 类型 + public static Object unwrapJsValue(Value value) { + if (value.isHostObject()) { + return value.asHostObject(); + } else if (value.hasMembers()) { + Map map = new HashMap<>(); + for (String key : value.getMemberKeys()) { + map.put(key, unwrapJsValue(value.getMember(key))); + } + return map; + } else if (value.hasArrayElements()) { + List list = new ArrayList<>(); + long size = value.getArraySize(); + for (long i = 0; i < size; i++) { + list.add(unwrapJsValue(value.getArrayElement(i))); + } + return list; + } else if (value.isDate()) { + Instant instant = Instant.from(value.asDate()); + return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); + } else { + return value.as(Object.class); + } + } + +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/ProxyList.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/ProxyList.java new file mode 100644 index 0000000..2a0039f --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/ProxyList.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util.graalvm; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.proxy.ProxyArray; + +import java.util.List; + +public class ProxyList implements ProxyArray { + private final List list; + private final Context context; + + public ProxyList(List list, Context context) { + this.list = list; + this.context = context; + } + + + @Override + public Object get(long index) { + return JsInteropUtils.wrapJavaValueForJS(context, list.get((int)index)).as(Object.class); + } + + @Override + public boolean remove(long index) { + if (index >= 0 && index < list.size()) { + list.remove(index); + return true; + } + return false; + } + + @Override + public void set(long index, Value value) { + if (index >= 0 && index < list.size()) { + list.set((int)index, JsInteropUtils.unwrapJsValue(value)); + } + } + + + @Override + public long getSize() { + return list.size(); + } +} diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/ProxyMap.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/ProxyMap.java new file mode 100644 index 0000000..730453a --- /dev/null +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/util/graalvm/ProxyMap.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). + *

+ * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.gnu.org/licenses/lgpl-3.0.txt + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.flow.core.util.graalvm; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.proxy.ProxyObject; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public class ProxyMap implements ProxyObject { + private final Map map; + private final Context context; + + public ProxyMap(Map map, Context context) { + this.map = map; + this.context = context; + } + + @Override + public Object getMember(String key) { + return JsInteropUtils.wrapJavaValueForJS(context, map.get(key)).as(Object.class); + } + + @Override + public boolean hasMember(String key) { + return map.containsKey(key); + } + + @Override + public Set getMemberKeys() { + return map.keySet().stream() + .map(Object::toString) + .collect(Collectors.toSet()); + } + + @Override + public void putMember(String key, Value value) { + map.put(key, JsInteropUtils.unwrapJsValue(value)); + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainAsyncStringTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainAsyncStringTest.java new file mode 100644 index 0000000..4bfda61 --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainAsyncStringTest.java @@ -0,0 +1,72 @@ +//package com.easyagents.flow.core.test; +// +//import com.easyagents.flow.core.chain.ChainEdge; +//import com.easyagents.flow.core.chain.JsCodeCondition; +//import org.junit.Test; +// +//import java.util.HashMap; +// +//public class ChainAsyncStringTest { +// +// @Test +// public void test() { +// +// System.out.println("start: "+ Thread.currentThread().getId()); +// +// Chain chain = new Chain(); +// +// JsExecNode a = new JsExecNode(); +// a.setId("a"); +// a.setCode("console.log('aaaa....')"); +// chain.addNode(a); +// +// /// //bbbbb +// JsExecNode b = new JsExecNode(); +// b.setId("b"); +// b.setCode("console.log('bbbb....')"); +// b.setAsync(true); +// chain.addNode(b); +// +// +// /// //////cccccc +// JsExecNode c = new JsExecNode(); +// c.setId("c"); +// c.setCode("console.log('cccc....')"); +// c.setAsync(true); +// chain.addNode(c); +// +// +// /// /////dddd +// JsExecNode d = new JsExecNode(); +// d.setCode("console.log('dddd....')"); +// d.setId("d"); +// d.setCondition(new JsCodeCondition("_context.isUpstreamFullyExecuted()")); +// chain.addNode(d); +// +// ChainEdge ab = new ChainEdge(); +// ab.setSource("a"); +// ab.setTarget("b"); +// chain.addEdge(ab); +// +// ChainEdge ac = new ChainEdge(); +// ac.setSource("a"); +// ac.setTarget("c"); +// chain.addEdge(ac); +// +// +// ChainEdge bd = new ChainEdge(); +// bd.setSource("b"); +// bd.setTarget("d"); +// chain.addEdge(bd); +// +// ChainEdge cd = new ChainEdge(); +// cd.setSource("c"); +// cd.setTarget("d"); +// chain.addEdge(cd); +// +// // A→B→D +// // ↘C↗ +// chain.executeForResult(new HashMap<>()); +// +// } +//} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainEvnTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainEvnTest.java new file mode 100644 index 0000000..9c3882d --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainEvnTest.java @@ -0,0 +1,20 @@ +package com.easyagents.flow.core.test; + +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.node.CodeNode; + +public class ChainEvnTest { + + public static void main(String[] args) { + + CodeNode codeNode = new CodeNode(); + codeNode.setCode("console.log('>>>JAVA_HOME: {{env.sys.JAVA_HOME}}<<<<')"); + codeNode.setEngine("js"); + + ChainDefinition definition = new ChainDefinition(); + definition.addNode(codeNode); + +// Chain chain = definition.createChain(); +// chain.execute(Maps.of()); + } +} diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/TinyflowTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/TinyflowTest.java new file mode 100644 index 0000000..dbbd957 --- /dev/null +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/TinyflowTest.java @@ -0,0 +1,71 @@ +package com.easyagents.flow.core.test; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.Event; +import com.easyagents.flow.core.chain.listener.ChainEventListener; +import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository; +import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository; +import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository; +import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import com.easyagents.flow.core.parser.ChainParser; + +import java.util.HashMap; +import java.util.Map; + +public class TinyflowTest { + + // static String data1 = ""; + static String data1 = "{\"nodes\":[{\"id\":\"2\",\"type\":\"llmNode\",\"data\":{\"title\":\"大模型\",\"description\":\"处理大模型相关问题\",\"expand\":true,\"outputDefs\":[{\"id\":\"pyiig8ntGWZhVdVz\",\"dataType\":\"Object\",\"name\":\"param\",\"children\":[{\"id\":\"1\",\"name\":\"newParam1\",\"dataType\":\"String\"},{\"id\":\"2\",\"name\":\"newParam2\",\"dataType\":\"String\"}]}]},\"position\":{\"x\":600,\"y\":50},\"measured\":{\"width\":334,\"height\":687},\"selected\":false},{\"id\":\"3\",\"type\":\"startNode\",\"data\":{\"title\":\"开始节点\",\"description\":\"开始定义输入参数\",\"expand\":true,\"parameters\":[{\"id\":\"Q37GZ5KKvPpCD7Cs\",\"name\":\"name\"}]},\"position\":{\"x\":150,\"y\":25},\"measured\":{\"width\":306,\"height\":209},\"selected\":false},{\"id\":\"4\",\"type\":\"endNode\",\"data\":{\"title\":\"结束节点\",\"description\":\"结束定义输出参数\",\"expand\":true,\"outputDefs\":[{\"id\":\"z7fOwoTjQ7AbUJdm\",\"ref\":\"3.name\",\"name\":\"test\"}]},\"position\":{\"x\":994,\"y\":218},\"measured\":{\"width\":334,\"height\":209},\"selected\":false,\"dragging\":false}],\"edges\":[{\"markerEnd\":{\"type\":\"arrowclosed\",\"width\":20,\"height\":20},\"source\":\"3\",\"target\":\"2\",\"id\":\"xy-edge__3-2\"},{\"markerEnd\":{\"type\":\"arrowclosed\",\"width\":20,\"height\":20},\"source\":\"2\",\"target\":\"4\",\"id\":\"xy-edge__2-4\"}],\"viewport\":{\"x\":250,\"y\":100,\"zoom\":1}}"; + + public static void main(String[] args) { + + Map variables = new HashMap<>(); + variables.put("name", "michael"); + + ChainParser chainParser = ChainParser.builder() + .withDefaultParsers(true) + .build(); + + + ChainExecutor executor = new ChainExecutor( + new ChainDefinitionRepository() { + @Override + public ChainDefinition getChainDefinitionById(String id) { + ChainDefinition definition = chainParser.parse(data1); + definition.setId(id); + return definition; + } + } + , new InMemoryChainStateRepository() + , new InMemoryNodeStateRepository() + ); + + + executor.addEventListener(new ChainEventListener() { + @Override + public void onEvent(Event event, Chain chain) { + System.out.println(event.toString()); + } + }); + + +// chain.addEventListener(new ChainEventListener() { +// @Override +// public void onEvent(Event event, Chain chain) { +// System.out.println(event.toString()); +// } +// }); +// +// chain.addOutputListener(new ChainOutputListener() { +// @Override +// public void onOutput(Chain chain, Node node, Object outputMessage) { +// System.out.println("outputMessage: " + outputMessage); +// } +// }); + + Map result = executor.execute("1", variables); +// + System.out.println(result); + } +} diff --git a/easy-agents-image/easy-agents-image-gitee/pom.xml b/easy-agents-image/easy-agents-image-gitee/pom.xml new file mode 100644 index 0000000..d196cf5 --- /dev/null +++ b/easy-agents-image/easy-agents-image-gitee/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + com.easyagents + easy-agents-image + ${revision} + + + easy-agents-image-gitee + easy-agents-image-gitee + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + compile + + + junit + junit + test + + + + diff --git a/easy-agents-image/easy-agents-image-gitee/src/main/java/com/easyagents/image/gitee/GiteeImageModel.java b/easy-agents-image/easy-agents-image-gitee/src/main/java/com/easyagents/image/gitee/GiteeImageModel.java new file mode 100644 index 0000000..a1dd58b --- /dev/null +++ b/easy-agents-image/easy-agents-image-gitee/src/main/java/com/easyagents/image/gitee/GiteeImageModel.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.gitee; + +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.model.image.*; +import com.easyagents.core.util.Maps; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; + +import java.util.HashMap; +import java.util.Map; + +public class GiteeImageModel implements ImageModel { + private GiteeImageModelConfig config; + private HttpClient httpClient = new HttpClient(); + + public GiteeImageModel(GiteeImageModelConfig config) { + this.config = config; + } + + @Override + public ImageResponse generate(GenerateImageRequest request) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer " + config.getApiKey()); + + String payload = Maps.of("model", config.getModel()) + .set("prompt", request.getPrompt()) + .setIfNotNull("n", request.getN()) + .set("size", request.getSize()) + .set("response_format", "url") + .toJSON(); + + String url = config.getEndpoint() + "/v1/images/generations"; + String responseJson = httpClient.post(url, headers, payload); + + if (StringUtil.noText(responseJson)) { + return ImageResponse.error("response is no text"); + } + + JSONObject root = JSON.parseObject(responseJson); + JSONArray images = root.getJSONArray("data"); + if (images == null || images.isEmpty()) { + return ImageResponse.error("image data is empty: " + responseJson); + } + ImageResponse response = new ImageResponse(); + for (int i = 0; i < images.size(); i++) { + JSONObject imageObj = images.getJSONObject(i); + response.addImage(imageObj.getString("url")); + } + + return response; + } + + @Override + public ImageResponse img2imggenerate(GenerateImageRequest request) { + return null; + } + + + @Override + public ImageResponse edit(EditImageRequest request) { + throw new UnsupportedOperationException("GiteeImageModel Can not support edit image."); + } + + @Override + public ImageResponse vary(VaryImageRequest request) { + throw new UnsupportedOperationException("GiteeImageModel Can not support vary image."); + } + +} diff --git a/easy-agents-image/easy-agents-image-gitee/src/main/java/com/easyagents/image/gitee/GiteeImageModelConfig.java b/easy-agents-image/easy-agents-image-gitee/src/main/java/com/easyagents/image/gitee/GiteeImageModelConfig.java new file mode 100644 index 0000000..ddd18e4 --- /dev/null +++ b/easy-agents-image/easy-agents-image-gitee/src/main/java/com/easyagents/image/gitee/GiteeImageModelConfig.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.gitee; + +import java.io.Serializable; + +public class GiteeImageModelConfig implements Serializable { + private String endpoint = "https://ai.gitee.com"; + private String model = "flux-1-schnell"; + private String apiKey; + + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } +} diff --git a/easy-agents-image/easy-agents-image-gitee/src/test/java/com/easyagents/image/test/GiteeImageModelTest.java b/easy-agents-image/easy-agents-image-gitee/src/test/java/com/easyagents/image/test/GiteeImageModelTest.java new file mode 100644 index 0000000..4d33cfb --- /dev/null +++ b/easy-agents-image/easy-agents-image-gitee/src/test/java/com/easyagents/image/test/GiteeImageModelTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.test; + +import com.easyagents.core.model.image.GenerateImageRequest; +import com.easyagents.core.model.image.Image; +import com.easyagents.core.model.image.ImageModel; +import com.easyagents.core.model.image.ImageResponse; +import com.easyagents.image.gitee.GiteeImageModel; +import com.easyagents.image.gitee.GiteeImageModelConfig; +import org.junit.Test; + +import java.io.File; + +public class GiteeImageModelTest { + + @Test + public void testGenImage(){ + GiteeImageModelConfig config = new GiteeImageModelConfig(); + config.setApiKey("****"); + + ImageModel imageModel = new GiteeImageModel(config); + + GenerateImageRequest request = new GenerateImageRequest(); + request.setPrompt("A cute little tiger standing in the high-speed train"); + request.setSize(1024,1024); + ImageResponse generate = imageModel.generate(request); + if (generate != null && generate.getImages() != null){ + int index = 0; + for (Image image : generate.getImages()) { + image.writeToFile(new File("/Users/michael/Desktop/test/image"+(index++)+".jpg")); + } + } + + System.out.println(generate); + } + +} diff --git a/easy-agents-image/easy-agents-image-openai/pom.xml b/easy-agents-image/easy-agents-image-openai/pom.xml new file mode 100644 index 0000000..d9513cc --- /dev/null +++ b/easy-agents-image/easy-agents-image-openai/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + com.easyagents + easy-agents-image + ${revision} + + + easy-agents-image-openai + easy-agents-image-openai + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + compile + + + junit + junit + test + + + + diff --git a/easy-agents-image/easy-agents-image-openai/src/main/java/com/easyagents/image/openai/OpenAIImageModel.java b/easy-agents-image/easy-agents-image-openai/src/main/java/com/easyagents/image/openai/OpenAIImageModel.java new file mode 100644 index 0000000..98f9bc1 --- /dev/null +++ b/easy-agents-image/easy-agents-image-openai/src/main/java/com/easyagents/image/openai/OpenAIImageModel.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.openai; + +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.model.image.*; +import com.easyagents.core.util.Maps; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; + +import java.util.HashMap; +import java.util.Map; + +public class OpenAIImageModel extends BaseImageModel { + + private OpenAIImageModelConfig config; + private HttpClient httpClient = new HttpClient(); + + public OpenAIImageModel(OpenAIImageModelConfig config) { + super(config); + this.config = config; + } + + @Override + public ImageResponse generate(GenerateImageRequest request) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer " + config.getApiKey()); + + String payload = Maps.of("model", config.getModel()) + .set("prompt", request.getPrompt()) + .setIfNotNull("n", request.getN()) + .set("size", request.getSize()) + .toJSON(); + + + String url = config.getEndpoint() + "/v1/images/generations"; + String responseJson = httpClient.post(url, headers, payload); + + if (StringUtil.noText(responseJson)) { + return ImageResponse.error("response is no text"); + } + + JSONObject root = JSON.parseObject(responseJson); + JSONArray images = root.getJSONArray("data"); + if (images == null || images.isEmpty()) { + return ImageResponse.error("image data is empty: " + responseJson); + } + ImageResponse response = new ImageResponse(); + for (int i = 0; i < images.size(); i++) { + JSONObject imageObj = images.getJSONObject(i); + response.addImage(imageObj.getString("url")); + } + + return response; + } + + @Override + public ImageResponse img2imggenerate(GenerateImageRequest request) { + return null; + } + + + @Override + public ImageResponse edit(EditImageRequest request) { + throw new UnsupportedOperationException("not support edit image"); + } + + @Override + public ImageResponse vary(VaryImageRequest request) { + throw new UnsupportedOperationException("not support vary image"); + } + +} diff --git a/easy-agents-image/easy-agents-image-openai/src/main/java/com/easyagents/image/openai/OpenAIImageModelConfig.java b/easy-agents-image/easy-agents-image-openai/src/main/java/com/easyagents/image/openai/OpenAIImageModelConfig.java new file mode 100644 index 0000000..0538ed3 --- /dev/null +++ b/easy-agents-image/easy-agents-image-openai/src/main/java/com/easyagents/image/openai/OpenAIImageModelConfig.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.openai; + +import com.easyagents.core.model.config.BaseModelConfig; + +public class OpenAIImageModelConfig extends BaseModelConfig { + private static final String endpoint = "https://api.openai.com"; + private static final String model = "dall-e-3"; + + public OpenAIImageModelConfig() { + setEndpoint(endpoint); + setModel(model); + } + +} diff --git a/easy-agents-image/easy-agents-image-openai/src/test/java/com/easyagents/image/test/OpenAIImageModelTest.java b/easy-agents-image/easy-agents-image-openai/src/test/java/com/easyagents/image/test/OpenAIImageModelTest.java new file mode 100644 index 0000000..e6bec56 --- /dev/null +++ b/easy-agents-image/easy-agents-image-openai/src/test/java/com/easyagents/image/test/OpenAIImageModelTest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.test; + +import com.easyagents.core.model.image.GenerateImageRequest; +import com.easyagents.core.model.image.ImageResponse; +import com.easyagents.image.openai.OpenAIImageModel; +import com.easyagents.image.openai.OpenAIImageModelConfig; +import org.junit.Test; + +public class OpenAIImageModelTest { + + @Test + public void testGenImage(){ + OpenAIImageModelConfig config = new OpenAIImageModelConfig(); + config.setApiKey("sk-5gqOclb****"); + + OpenAIImageModel imageModel = new OpenAIImageModel(config); + + GenerateImageRequest request = new GenerateImageRequest(); + request.setPrompt("A cute little tiger standing in the high-speed train"); + ImageResponse generate = imageModel.generate(request); + System.out.println(generate); + } +} diff --git a/easy-agents-image/easy-agents-image-qianfan/pom.xml b/easy-agents-image/easy-agents-image-qianfan/pom.xml new file mode 100644 index 0000000..9a9f55f --- /dev/null +++ b/easy-agents-image/easy-agents-image-qianfan/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + com.easyagents + easy-agents-image + ${revision} + + + easy-agents-image-qianfan + easy-agents-image-qianfan + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + junit + junit + test + + + + com.google.guava + guava + 33.5.0-jre + test + + + + diff --git a/easy-agents-image/easy-agents-image-qianfan/src/main/java/com/easyagents/image/qianfan/QianfanImageModel.java b/easy-agents-image/easy-agents-image-qianfan/src/main/java/com/easyagents/image/qianfan/QianfanImageModel.java new file mode 100644 index 0000000..c645c6b --- /dev/null +++ b/easy-agents-image/easy-agents-image-qianfan/src/main/java/com/easyagents/image/qianfan/QianfanImageModel.java @@ -0,0 +1,85 @@ +package com.easyagents.image.qianfan; + +import com.easyagents.core.model.image.*; +import com.easyagents.core.util.Maps; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import okhttp3.*; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + + +public class QianfanImageModel implements ImageModel { + static final OkHttpClient HTTP_CLIENT = new OkHttpClient().newBuilder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build(); + + private QianfanImageModelConfig config; + + public QianfanImageModel(QianfanImageModelConfig config) { + this.config = config; + } + + @Override + public ImageResponse generate(GenerateImageRequest request) { + ImageResponse responseImage = new ImageResponse(); + try { + request.setModel(config.getModels()); + String payload = promptToPayload(request); + + RequestBody body = RequestBody.create(MediaType.parse("application/json"), payload); + Request requestQianfan = new Request.Builder() + .url(config.getEndpoint() + config.getEndpointGenerations()) + .post(body) + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "Bearer " + config.getApiKey()) + .build(); + + Response response = HTTP_CLIENT.newCall(requestQianfan).execute(); + if (!response.isSuccessful()) { + throw new IOException("Unexpected code " + response); + } + + JSONObject jsonObject = JSON.parseObject(response.body().string()); + JSONArray dataArray = jsonObject.getJSONArray("data"); + for (int i = 0; i < dataArray.size(); i++) { + responseImage.addImage(dataArray.getJSONObject(i).getString("url")); + } + + return responseImage; + } catch (IOException e) { + ImageResponse.error(e.getMessage()); + e.printStackTrace(); + return responseImage; + } catch (Exception e) { + ImageResponse.error(e.getMessage()); + e.printStackTrace(); + return responseImage; + } + } + + public static String promptToPayload(GenerateImageRequest request) { + return Maps.of("Prompt", request.getPrompt()) + .setIfNotEmpty("model", request.getModel()) + .toJSON(); + } + + @Override + public ImageResponse img2imggenerate(GenerateImageRequest request) { + return null; + } + + @Override + public ImageResponse edit(EditImageRequest request) { + return null; + } + + @Override + public ImageResponse vary(VaryImageRequest request) { + return null; + } +} diff --git a/easy-agents-image/easy-agents-image-qianfan/src/main/java/com/easyagents/image/qianfan/QianfanImageModelConfig.java b/easy-agents-image/easy-agents-image-qianfan/src/main/java/com/easyagents/image/qianfan/QianfanImageModelConfig.java new file mode 100644 index 0000000..b0ae945 --- /dev/null +++ b/easy-agents-image/easy-agents-image-qianfan/src/main/java/com/easyagents/image/qianfan/QianfanImageModelConfig.java @@ -0,0 +1,64 @@ +package com.easyagents.image.qianfan; + +import java.util.Map; +import java.util.function.Consumer; + +public class QianfanImageModelConfig { + + private String endpoint = "https://qianfan.baidubce.com/v2"; + private String endpointGenerations = "/images/generations"; + private String models="irag-1.0"; + private String apiKey; + private Consumer> headersConfig; + + public Consumer> getHeadersConfig() { + return headersConfig; + } + + public void setHeadersConfig(Consumer> headersConfig) { + this.headersConfig = headersConfig; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getEndpointGenerations() { + return endpointGenerations; + } + + public void setEndpointGenerations(String endpointGenerations) { + this.endpointGenerations = endpointGenerations; + } + + public String getModels() { + return models; + } + + public void setModels(String models) { + this.models = models; + } + + @Override + public String toString() { + return "QianfanImageModelConfig{" + + "endpoint='" + endpoint + '\'' + + ", endpointGenerations='" + endpointGenerations + '\'' + + ", models='" + models + '\'' + + ", apiKey='" + apiKey + '\'' + + ", headersConfig=" + headersConfig + + '}'; + } +} diff --git a/easy-agents-image/easy-agents-image-qianfan/src/test/java/com/easyagents/image/test/QianfanImageModelTest.java b/easy-agents-image/easy-agents-image-qianfan/src/test/java/com/easyagents/image/test/QianfanImageModelTest.java new file mode 100644 index 0000000..e07d284 --- /dev/null +++ b/easy-agents-image/easy-agents-image-qianfan/src/test/java/com/easyagents/image/test/QianfanImageModelTest.java @@ -0,0 +1,22 @@ +package com.easyagents.image.test; + +import com.easyagents.core.model.image.GenerateImageRequest; +import com.easyagents.core.model.image.ImageResponse; +import com.easyagents.image.qianfan.QianfanImageModel; +import com.easyagents.image.qianfan.QianfanImageModelConfig; +import org.junit.Test; + +public class QianfanImageModelTest { + + @Test + public void testGenerate() throws InterruptedException { + QianfanImageModelConfig config = new QianfanImageModelConfig(); + config.setApiKey("*************"); + QianfanImageModel imageModel = new QianfanImageModel(config); + + GenerateImageRequest request = new GenerateImageRequest(); + request.setPrompt("画一个职场性感女生图片"); + ImageResponse generate = imageModel.generate(request); + System.out.println(generate); + } +} diff --git a/easy-agents-image/easy-agents-image-qwen/pom.xml b/easy-agents-image/easy-agents-image-qwen/pom.xml new file mode 100644 index 0000000..b743de2 --- /dev/null +++ b/easy-agents-image/easy-agents-image-qwen/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + com.easyagents + easy-agents-image + ${revision} + + + easy-agents-image-qwen + easy-agents-image-qwen + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + + + com.alibaba + dashscope-sdk-java + + 2.18.2 + + + junit + junit + test + + + + diff --git a/easy-agents-image/easy-agents-image-qwen/src/main/java/com/easyagents/image/qwen/QwenImageModel.java b/easy-agents-image/easy-agents-image-qwen/src/main/java/com/easyagents/image/qwen/QwenImageModel.java new file mode 100644 index 0000000..1a70a57 --- /dev/null +++ b/easy-agents-image/easy-agents-image-qwen/src/main/java/com/easyagents/image/qwen/QwenImageModel.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.qwen; + +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.model.image.*; +import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesis; +import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisParam; +import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Objects; + +public class QwenImageModel implements ImageModel { + private static final Logger LOG = LoggerFactory.getLogger(QwenImageModel.class); + private final QwenImageModelConfig config; + private final HttpClient httpClient = new HttpClient(); + + public QwenImageModel(QwenImageModelConfig config) { + this.config = config; + } + + @Override + public ImageResponse generate(GenerateImageRequest request) { + try { + ImageSynthesis is = new ImageSynthesis(); + ImageSynthesisParam param = + ImageSynthesisParam.builder() + .apiKey(config.getApiKey()) + .model(null != request.getModel() ? request.getModel() : config.getModel()) + .size(request.getSize()) + .prompt(request.getPrompt()) + .seed(Integer.valueOf(String.valueOf(request.getOptionOrDefault("seed",1)))) + .build(); + ImageSynthesisResult result = is.call(param); + if (Objects.isNull(result.getOutput().getResults())){ + return ImageResponse.error(result.getOutput().getMessage()); + } + ImageResponse imageResponse = new ImageResponse(); + for(Map item :result.getOutput().getResults()) { + imageResponse.addImage(item.get("url")); + } + return imageResponse; + } catch (Exception e) { + return ImageResponse.error(e.getMessage()); + } + } + + @Override + public ImageResponse img2imggenerate(GenerateImageRequest request) { + throw new IllegalStateException("QwenImageModel Can not support img2imggenerate."); + } + + @Override + public ImageResponse edit(EditImageRequest request) { + throw new IllegalStateException("QwenImageModel Can not support edit image."); + } + + @Override + public ImageResponse vary(VaryImageRequest request) { + throw new IllegalStateException("QwenImageModel Can not support vary image."); + } + + +} diff --git a/easy-agents-image/easy-agents-image-qwen/src/main/java/com/easyagents/image/qwen/QwenImageModelConfig.java b/easy-agents-image/easy-agents-image-qwen/src/main/java/com/easyagents/image/qwen/QwenImageModelConfig.java new file mode 100644 index 0000000..31f71c8 --- /dev/null +++ b/easy-agents-image/easy-agents-image-qwen/src/main/java/com/easyagents/image/qwen/QwenImageModelConfig.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.qwen; + +public class QwenImageModelConfig { + private String endpoint = "https://dashscope.aliyuncs.com"; + + private String model = "flux-schnell"; + + private String apiKey; + + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } +} diff --git a/easy-agents-image/easy-agents-image-qwen/src/test/java/com/easyagents/image/test/QwenImageModelTest.java b/easy-agents-image/easy-agents-image-qwen/src/test/java/com/easyagents/image/test/QwenImageModelTest.java new file mode 100644 index 0000000..55e2ad9 --- /dev/null +++ b/easy-agents-image/easy-agents-image-qwen/src/test/java/com/easyagents/image/test/QwenImageModelTest.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.test; + +import com.easyagents.core.model.image.GenerateImageRequest; +import com.easyagents.core.model.image.ImageModel; +import com.easyagents.core.model.image.ImageResponse; +import com.easyagents.image.qwen.QwenImageModel; +import com.easyagents.image.qwen.QwenImageModelConfig; +import org.junit.Test; + +public class QwenImageModelTest { + + @Test + public void testGenImage() throws InterruptedException { + Thread thread = new Thread(() -> { + QwenImageModelConfig config = new QwenImageModelConfig(); + config.setApiKey("******************"); + ImageModel imageModel = new QwenImageModel(config); + GenerateImageRequest request = new GenerateImageRequest(); + request.setPrompt("雨中, 竹林, 小路"); + request.setModel("flux-schnell"); + ImageResponse generate = imageModel.generate(request); + System.out.println(generate); + }); + +// Thread thread2 = new Thread(() -> { +// QwenImageModelConfig config = new QwenImageModelConfig(); +// config.setApiKey("******************"); +// ImageModel imageModel = new QwenImageModel(config); +// GenerateImageRequest request = new GenerateImageRequest(); +// request.setPrompt("雨中, 竹林, 小路"); +// request.setModel("flux-schnell"); +// ImageResponse generate = imageModel.generate(request); +// }); + thread.start(); +// thread2.start(); + thread.join(); +// thread2.join(); + } +} diff --git a/easy-agents-image/easy-agents-image-siliconflow/pom.xml b/easy-agents-image/easy-agents-image-siliconflow/pom.xml new file mode 100644 index 0000000..30a2fec --- /dev/null +++ b/easy-agents-image/easy-agents-image-siliconflow/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + com.easyagents + easy-agents-image + ${revision} + + + easy-agents-image-siliconflow + easy-agents-image-siliconflow + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + compile + + + + junit + junit + test + + + + + diff --git a/easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconImageModel.java b/easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconImageModel.java new file mode 100644 index 0000000..9d27b8b --- /dev/null +++ b/easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconImageModel.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.siliconflow; + +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.model.image.*; +import com.easyagents.core.util.Maps; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; + +import java.util.HashMap; +import java.util.Map; + +public class SiliconImageModel implements ImageModel { + private SiliconflowImageModelConfig config; + private HttpClient httpClient = new HttpClient(); + + public SiliconImageModel(SiliconflowImageModelConfig config) { + this.config = config; + } + + @Override + public ImageResponse generate(GenerateImageRequest request) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer " + config.getApiKey()); + + String payload = Maps.of("prompt", request.getPrompt()) + .setIfNotEmpty("negative_prompt", request.getNegativePrompt()) + .setOrDefault("image_size", request.getSize(), config.getImageSize()) + .setOrDefault("batch_size", request.getN(), 1) + .setOrDefault("num_inference_steps", request.getOption("num_inference_steps"), config.getNumInferenceSteps()) + .setOrDefault("guidance_scale", request.getOption("guidance_scale"), config.getGuidanceScale()) + .toJSON(); + + String url = config.getEndpoint() + SiliconflowImageModels.getPath(config.getModel()); + String response = httpClient.post(url, headers, payload); + if (StringUtil.noText(response)) { + return ImageResponse.error("response is no text"); + } + + if (StringUtil.notJsonObject(response)) { + return ImageResponse.error(response); + } + + JSONObject jsonObject = JSON.parseObject(response); + JSONArray imagesArray = jsonObject.getJSONArray("images"); + if (imagesArray == null || imagesArray.isEmpty()) { + return null; + } + + ImageResponse imageResponse = new ImageResponse(); + for (int i = 0; i < imagesArray.size(); i++) { + JSONObject imageObject = imagesArray.getJSONObject(i); + imageResponse.addImage(imageObject.getString("url")); + } + + return imageResponse; + } + + @Override + public ImageResponse img2imggenerate(GenerateImageRequest request) { + return null; + } + + + @Override + public ImageResponse edit(EditImageRequest request) { + throw new IllegalStateException("SiliconImageModel Can not support edit image."); + } + + @Override + public ImageResponse vary(VaryImageRequest request) { + throw new IllegalStateException("SiliconImageModel Can not support vary image."); + } + +} diff --git a/easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconflowImageModelConfig.java b/easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconflowImageModelConfig.java new file mode 100644 index 0000000..3229ebb --- /dev/null +++ b/easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconflowImageModelConfig.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.siliconflow; + + +import java.io.Serializable; + +public class SiliconflowImageModelConfig implements Serializable { + private String endpoint = "https://api.siliconflow.cn"; + private String model = SiliconflowImageModels.flux_1_schnell; + private String apiKey; + private Integer numInferenceSteps = 20; + private Integer guidanceScale = 7; + private String imageSize = "1024x1024"; + + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public Integer getNumInferenceSteps() { + return numInferenceSteps; + } + + public void setNumInferenceSteps(Integer numInferenceSteps) { + this.numInferenceSteps = numInferenceSteps; + } + + public Integer getGuidanceScale() { + return guidanceScale; + } + + public void setGuidanceScale(Integer guidanceScale) { + this.guidanceScale = guidanceScale; + } + + public String getImageSize() { + return imageSize; + } + + public void setImageSize(String imageSize) { + this.imageSize = imageSize; + } +} diff --git a/easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconflowImageModels.java b/easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconflowImageModels.java new file mode 100644 index 0000000..62fde08 --- /dev/null +++ b/easy-agents-image/easy-agents-image-siliconflow/src/main/java/com/easyagents/image/siliconflow/SiliconflowImageModels.java @@ -0,0 +1,39 @@ +package com.easyagents.image.siliconflow; + +import com.easyagents.core.util.Maps; + +import java.util.Map; + +public class SiliconflowImageModels { + + + /** + * 由 Black Forest Labs 开发的 120 亿参数文生图模型,采用潜在对抗扩散蒸馏技术,能够在 1 到 4 步内生成高质量图像。该模型性能媲美闭源替代品,并在 Apache-2.0 许可证下发布,适用于个人、科研和商业用途。 + */ + public static final String flux_1_schnell = "FLUX.1-schnell"; + + /** + * 由 Stability AI 开发并开源的文生图大模型,其创意图像生成能力位居行业前列。具备出色的指令理解能力,能够支持反向 Prompt 定义来精确生成内容。 + */ + public static final String Stable_Diffusion_3 = "Stable Diffusion 3"; + public static final String Stable_Diffusion_XL = "Stable Diffusion XL"; + public static final String Stable_Diffusion_2_1 = "Stable Diffusion 2.1"; + public static final String Stable_Diffusion_Turbo = "Stable Diffusion Turbo"; + public static final String Stable_Diffusion_XL_Turbo = "Stable Diffusion XL Turbo"; + public static final String Stable_Diffusion_XL_Lighting = "Stable Diffusion XL Lighting"; + + + private static Map modelsPathMapping = Maps + .of(flux_1_schnell, "/v1/black-forest-labs/FLUX.1-schnell/text-to-image") + .set(Stable_Diffusion_3, "/v1/stabilityai/stable-diffusion-3-medium/text-to-image") + .set(Stable_Diffusion_XL, "/v1/stabilityai/stable-diffusion-xl-base-1.0/text-to-image") + .set(Stable_Diffusion_2_1, "/v1/stabilityai/stable-diffusion-2-1/text-to-image") + .set(Stable_Diffusion_Turbo, "/v1/stabilityai/sd-turbo/text-to-image") + .set(Stable_Diffusion_XL_Turbo, "/v1/stabilityai/sdxl-turbo/text-to-image") + .set(Stable_Diffusion_XL_Lighting, "/v1/ByteDance/SDXL-Lightning/text-to-image") + ; + + public static String getPath(String model) { + return (String) modelsPathMapping.get(model); + } +} diff --git a/easy-agents-image/easy-agents-image-siliconflow/src/test/java/com/easyagents/image/siliconflow/test/SiliconflowImageModelTest.java b/easy-agents-image/easy-agents-image-siliconflow/src/test/java/com/easyagents/image/siliconflow/test/SiliconflowImageModelTest.java new file mode 100644 index 0000000..eedee2d --- /dev/null +++ b/easy-agents-image/easy-agents-image-siliconflow/src/test/java/com/easyagents/image/siliconflow/test/SiliconflowImageModelTest.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.siliconflow.test; + +import com.easyagents.core.model.image.GenerateImageRequest; +import com.easyagents.core.model.image.Image; +import com.easyagents.core.model.image.ImageResponse; +import com.easyagents.image.siliconflow.SiliconImageModel; +import com.easyagents.image.siliconflow.SiliconflowImageModelConfig; +import com.easyagents.image.siliconflow.SiliconflowImageModels; +import org.junit.Test; + +import java.io.File; + +public class SiliconflowImageModelTest { + + @Test + public void testGenImage(){ + SiliconflowImageModelConfig config = new SiliconflowImageModelConfig(); + config.setModel(SiliconflowImageModels.Stable_Diffusion_XL); + config.setApiKey("sk-****"); + + SiliconImageModel imageModel = new SiliconImageModel(config); + + GenerateImageRequest request = new GenerateImageRequest(); + request.setPrompt("A cute little tiger standing in the high-speed train"); + request.setSize(1024,1024); + request.setN(4); + ImageResponse generate = imageModel.generate(request); + if (generate != null && generate.getImages() != null){ + int index = 0; + for (Image image : generate.getImages()) { + image.writeToFile(new File("/Users/michael/Desktop/test/image"+(index++)+".jpg")); + } + } + + System.out.println(generate); + } + +} diff --git a/easy-agents-image/easy-agents-image-stability/pom.xml b/easy-agents-image/easy-agents-image-stability/pom.xml new file mode 100644 index 0000000..3d1f344 --- /dev/null +++ b/easy-agents-image/easy-agents-image-stability/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + com.easyagents + easy-agents-image + ${revision} + + + easy-agents-image-stability + easy-agents-image-stability + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + + + junit + junit + test + + + + diff --git a/easy-agents-image/easy-agents-image-stability/src/main/java/com/easyagents/image/stability/StabilityImageModel.java b/easy-agents-image/easy-agents-image-stability/src/main/java/com/easyagents/image/stability/StabilityImageModel.java new file mode 100644 index 0000000..bbf401b --- /dev/null +++ b/easy-agents-image/easy-agents-image-stability/src/main/java/com/easyagents/image/stability/StabilityImageModel.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.stability; + +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.model.image.*; +import com.easyagents.core.util.Maps; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class StabilityImageModel implements ImageModel { + private static final Logger LOG = LoggerFactory.getLogger(StabilityImageModel.class); + private StabilityImageModelConfig config; + private HttpClient httpClient = new HttpClient(); + + public StabilityImageModel(StabilityImageModelConfig config) { + this.config = config; + } + + @Override + public ImageResponse generate(GenerateImageRequest request) { + Map headers = new HashMap<>(); + headers.put("accept", "image/*"); + headers.put("Authorization", "Bearer " + config.getApiKey()); + + Map payload = Maps.of("prompt", request.getPrompt()) + .setIfNotNull("output_format", "jpeg"); + + String url = config.getEndpoint() + "/v2beta/stable-image/generate/sd3"; + + try (Response response = httpClient.multipart(url, headers, payload); + ResponseBody body = response.body()) { + if (response.isSuccessful() && body != null) { + ImageResponse imageResponse = new ImageResponse(); + imageResponse.addImage(body.bytes()); + return imageResponse; + } + } catch (IOException e) { + LOG.error(e.toString(), e); + } + + return null; + } + + @Override + public ImageResponse img2imggenerate(GenerateImageRequest request) { + return null; + } + + @Override + public ImageResponse edit(EditImageRequest request) { + return null; + } + + @Override + public ImageResponse vary(VaryImageRequest request) { + return null; + } +} diff --git a/easy-agents-image/easy-agents-image-stability/src/main/java/com/easyagents/image/stability/StabilityImageModelConfig.java b/easy-agents-image/easy-agents-image-stability/src/main/java/com/easyagents/image/stability/StabilityImageModelConfig.java new file mode 100644 index 0000000..d1217dd --- /dev/null +++ b/easy-agents-image/easy-agents-image-stability/src/main/java/com/easyagents/image/stability/StabilityImageModelConfig.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.stability; + +public class StabilityImageModelConfig { + private String endpoint = "https://api.stability.ai/"; + private String apiKey; + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } +} diff --git a/easy-agents-image/easy-agents-image-stability/src/test/java/com/easyagents/image/test/StabilityImageModelTest.java b/easy-agents-image/easy-agents-image-stability/src/test/java/com/easyagents/image/test/StabilityImageModelTest.java new file mode 100644 index 0000000..5c5e8cd --- /dev/null +++ b/easy-agents-image/easy-agents-image-stability/src/test/java/com/easyagents/image/test/StabilityImageModelTest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.test; + +import com.easyagents.core.model.image.GenerateImageRequest; +import com.easyagents.core.model.image.ImageResponse; +import com.easyagents.image.stability.StabilityImageModel; +import com.easyagents.image.stability.StabilityImageModelConfig; +import org.junit.Test; + +public class StabilityImageModelTest { + + @Test + public void testGenImage(){ + StabilityImageModelConfig config = new StabilityImageModelConfig(); + config.setApiKey("sk-5gqOcl*****"); + + StabilityImageModel imageModel = new StabilityImageModel(config); + + GenerateImageRequest request = new GenerateImageRequest(); + request.setPrompt("A cute little tiger standing in the high-speed train"); + ImageResponse generate = imageModel.generate(request); + System.out.println(generate); + } +} diff --git a/easy-agents-image/easy-agents-image-tencent/pom.xml b/easy-agents-image/easy-agents-image-tencent/pom.xml new file mode 100644 index 0000000..fa3877c --- /dev/null +++ b/easy-agents-image/easy-agents-image-tencent/pom.xml @@ -0,0 +1,37 @@ + + + 4.0.0 + + com.easyagents + easy-agents-image + ${revision} + + + easy-agents-image-tencent + easy-agents-image-tencent + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + + + com.tencentcloudapi + tencentcloud-sdk-java-common + 3.1.1261 + + + junit + junit + test + + + + diff --git a/easy-agents-image/easy-agents-image-tencent/src/main/java/com/easyagents/image/tencent/TencentImageModel.java b/easy-agents-image/easy-agents-image-tencent/src/main/java/com/easyagents/image/tencent/TencentImageModel.java new file mode 100644 index 0000000..95b4d28 --- /dev/null +++ b/easy-agents-image/easy-agents-image-tencent/src/main/java/com/easyagents/image/tencent/TencentImageModel.java @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.tencent; + +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.model.image.*; +import com.easyagents.core.util.Maps; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.tencentcloudapi.common.DatatypeConverter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.text.SimpleDateFormat; +import java.util.*; + +public class TencentImageModel implements ImageModel { + private static final Logger LOG = LoggerFactory.getLogger(TencentImageModel.class); + private final TencentImageModelConfig config; + private final HttpClient httpClient = new HttpClient(); + + public TencentImageModel(TencentImageModelConfig config) { + this.config = config; + } + + @Override + public ImageResponse generate(GenerateImageRequest request) { + try { + String payload = promptToPayload(request); + Map headers = createAuthorizationToken("SubmitHunyuanImageJob", payload); + String response = httpClient.post(config.getEndpoint(), headers, payload); + JSONObject jsonObject = JSON.parseObject(response); + JSONObject error = jsonObject.getJSONObject("Response").getJSONObject("Error"); + if (error != null && !error.isEmpty()) { + return ImageResponse.error(error.getString("Message")); + } + Object jobId = jsonObject.getJSONObject("Response").get("JobId"); + if (Objects.isNull(jobId)) { + return ImageResponse.error("response is no jobId"); + } + String id = (String) jobId; + return getImage(id); + } catch (Exception e) { + return ImageResponse.error(e.getMessage()); + } + } + + @Override + public ImageResponse img2imggenerate(GenerateImageRequest request) { + return null; + } + + @Override + public ImageResponse edit(EditImageRequest request) { + throw new IllegalStateException("TencentImageModel Can not support edit image."); + } + + @Override + public ImageResponse vary(VaryImageRequest request) { + throw new IllegalStateException("TencentImageModel Can not support vary image."); + } + + + private static final Object LOCK = new Object(); + + private ImageResponse getImage(String jobId) { + ImageResponse imageResponse = null; + while (true) { + synchronized (LOCK) { + imageResponse = callService(jobId); + if (!Objects.isNull(imageResponse)) { + break; + } + // 等待一段时间再重试 + try { + LOCK.wait(1000); + } catch (InterruptedException e) { + // 线程在等待时被中断 + Thread.currentThread().interrupt(); + imageResponse = ImageResponse.error(e.toString()); + break; + } + } + } + return imageResponse; + } + + + public ImageResponse callService(String jobId) { + try { + String payload = Maps.of("JobId", jobId).toJSON(); + Map headers = createAuthorizationToken("QueryHunyuanImageJob", payload); + String resp = httpClient.post(config.getEndpoint(), headers, payload); + JSONObject resultJson = JSONObject.parseObject(resp).getJSONObject("Response"); + JSONObject error = resultJson.getJSONObject("Error"); + if (error != null && !error.isEmpty()) { + return ImageResponse.error(error.getString("Message")); + } + if (Objects.isNull(resultJson.get("JobStatusCode"))) { + return ImageResponse.error("response is no JobStatusCode"); + } + Integer jobStatusCode = resultJson.getInteger("JobStatusCode"); + if (Objects.equals(5, jobStatusCode)) { + //处理完成 + if (Objects.isNull(resultJson.get("ResultImage"))) { + return ImageResponse.error("response is no ResultImage"); + } + JSONArray imagesArray = resultJson.getJSONArray("ResultImage"); + ImageResponse response = new ImageResponse(); + for (int i = 0; i < imagesArray.size(); i++) { + String imageObj = imagesArray.getString(i); + response.addImage(imageObj); + } + return response; + } + if (Objects.equals(4, jobStatusCode)) { + //处理错误 + return ImageResponse.error(resultJson.getString("JobErrorMsg")); + } + } catch (Exception e) { + return ImageResponse.error(e.getMessage()); + } + return null; + } + + + public static String promptToPayload(GenerateImageRequest request) { + return Maps.of("Prompt", request.getPrompt()) + .setIfNotEmpty("NegativePrompt", request.getNegativePrompt()) + .setIfNotEmpty("Style", request.getSize()) + .setIfNotEmpty("Resolution", request.getQuality()) + .setIfNotEmpty("Num", request.getN()) + .setIfNotEmpty(request.getOptions()) + .toJSON(); + } + + + private final static Charset UTF8 = StandardCharsets.UTF_8; + private final static String CT_JSON = "application/json; charset=utf-8"; + + public static byte[] hmac256(byte[] key, String msg) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec(key, mac.getAlgorithm()); + mac.init(secretKeySpec); + return mac.doFinal(msg.getBytes(UTF8)); + } + + public static String sha256Hex(String s) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] d = md.digest(s.getBytes(UTF8)); + return DatatypeConverter.printHexBinary(d).toLowerCase(); + } + + /** + * @return java.util.Map + * @Author sunch + * @Description 封装参数 + * @Date 17:34 2025/3/5 + * @Param [action, payload] + */ + public Map createAuthorizationToken(String action, String payload) { + try { + String service = config.getService(); + String host = config.getHost(); + String version = "2023-09-01"; + String algorithm = "TC3-HMAC-SHA256"; + String timestamp = String.valueOf(System.currentTimeMillis() / 1000); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + // 注意时区,否则容易出错 + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + String date = sdf.format(new Date(Long.parseLong(timestamp + "000"))); + + // ************* 步骤 1:拼接规范请求串 ************* + String httpRequestMethod = "POST"; + String canonicalUri = "/"; + String canonicalQueryString = ""; + String canonicalHeaders = "content-type:application/json; charset=utf-8\n" + + "host:" + host + "\n" + "x-tc-action:" + action.toLowerCase() + "\n"; + String signedHeaders = "content-type;host;x-tc-action"; + + String hashedRequestPayload = sha256Hex(payload); + String canonicalRequest = httpRequestMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" + + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestPayload; +// System.out.println(canonicalRequest); + + // ************* 步骤 2:拼接待签名字符串 ************* + String credentialScope = date + "/" + service + "/" + "tc3_request"; + String hashedCanonicalRequest = sha256Hex(canonicalRequest); + String stringToSign = algorithm + "\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest; +// System.out.println(stringToSign); + + // ************* 步骤 3:计算签名 ************* + byte[] secretDate = hmac256(("TC3" + config.getApiKey()).getBytes(UTF8), date); + byte[] secretService = hmac256(secretDate, service); + byte[] secretSigning = hmac256(secretService, "tc3_request"); + String signature = DatatypeConverter.printHexBinary(hmac256(secretSigning, stringToSign)).toLowerCase(); +// System.out.println(signature); + + // ************* 步骤 4:拼接 Authorization ************* + String authorization = algorithm + " " + "Credential=" + config.getApiSecret() + "/" + credentialScope + ", " + + "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature; +// System.out.println(authorization); + + Map headers = new HashMap<>(); + headers.put("Authorization", authorization); + headers.put("Content-Type", CT_JSON); + headers.put("Host", host); + headers.put("X-TC-Action", action); + headers.put("X-TC-Timestamp", timestamp); + headers.put("X-TC-Version", version); + headers.put("X-TC-Region", config.getRegion()); + return headers; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + +} diff --git a/easy-agents-image/easy-agents-image-tencent/src/main/java/com/easyagents/image/tencent/TencentImageModelConfig.java b/easy-agents-image/easy-agents-image-tencent/src/main/java/com/easyagents/image/tencent/TencentImageModelConfig.java new file mode 100644 index 0000000..fe7af45 --- /dev/null +++ b/easy-agents-image/easy-agents-image-tencent/src/main/java/com/easyagents/image/tencent/TencentImageModelConfig.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.tencent; + +public class TencentImageModelConfig { + private String endpoint = "https://hunyuan.tencentcloudapi.com"; + private String apiKey; + + private String apiSecret; + + private String service = "hunyuan"; + + private String region = "ap-guangzhou"; + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getService() { + return service; + } + + public void setService(String service) { + this.service = service; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getApiSecret() { + return apiSecret; + } + + public void setApiSecret(String apiSecret) { + this.apiSecret = apiSecret; + } + + public String getHost() { + String endpoint = getEndpoint(); + if (endpoint.toLowerCase().startsWith("https://")) { + endpoint = endpoint.substring(8); + } else if (endpoint.toLowerCase().startsWith("http://")) { + endpoint = endpoint.substring(7); + } + return endpoint; + } +} diff --git a/easy-agents-image/easy-agents-image-tencent/src/test/java/com/easyagents/image/test/TencentImageModelTest.java b/easy-agents-image/easy-agents-image-tencent/src/test/java/com/easyagents/image/test/TencentImageModelTest.java new file mode 100644 index 0000000..9fbddc8 --- /dev/null +++ b/easy-agents-image/easy-agents-image-tencent/src/test/java/com/easyagents/image/test/TencentImageModelTest.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.test; + +import com.easyagents.core.model.image.GenerateImageRequest; +import com.easyagents.core.model.image.ImageModel; +import com.easyagents.core.model.image.ImageResponse; +import com.easyagents.image.tencent.TencentImageModel; +import com.easyagents.image.tencent.TencentImageModelConfig; +import org.junit.Test; + +public class TencentImageModelTest { + + @Test + public void testGenImage() throws InterruptedException { + Thread thread = new Thread(() -> { + TencentImageModelConfig config = new TencentImageModelConfig(); + config.setApiSecret("*****************"); + config.setApiKey("*****************"); + ImageModel imageModel = new TencentImageModel(config); + GenerateImageRequest request = new GenerateImageRequest(); + request.setPrompt("雨中, 竹林, 小路"); + request.setN(1); + ImageResponse generate = imageModel.generate(request); + System.out.println(generate); + System.out.flush(); + }); + + Thread thread2 = new Thread(() -> { + TencentImageModelConfig config = new TencentImageModelConfig(); + config.setApiSecret("*****************"); + config.setApiKey("*****************"); + ImageModel imageModel = new TencentImageModel(config); + GenerateImageRequest request = new GenerateImageRequest(); + request.setPrompt("雨中, 竹林, 小路, 人生"); + request.setN(1); + ImageResponse generate = imageModel.generate(request); + System.out.println(generate); + System.out.flush(); + }); + thread.start(); + thread2.start(); + thread.join(); + thread2.join(); + } +} diff --git a/easy-agents-image/easy-agents-image-volcengine/pom.xml b/easy-agents-image/easy-agents-image-volcengine/pom.xml new file mode 100644 index 0000000..a4f102a --- /dev/null +++ b/easy-agents-image/easy-agents-image-volcengine/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + com.easyagents + easy-agents-image + ${revision} + + + easy-agents-image-volcengine + easy-agents-image-volcengine + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + + + + + com.volcengine + volcengine-java-sdk-ark-runtime + 0.2.9 + + + + com.volcengine + volc-sdk-java + 1.0.221 + + + + junit + junit + test + + + + com.google.guava + guava + 33.5.0-jre + test + + + + diff --git a/easy-agents-image/easy-agents-image-volcengine/src/main/java/com/easyagents/image/volcengine/VolcengineImageModel.java b/easy-agents-image/easy-agents-image-volcengine/src/main/java/com/easyagents/image/volcengine/VolcengineImageModel.java new file mode 100644 index 0000000..e113583 --- /dev/null +++ b/easy-agents-image/easy-agents-image-volcengine/src/main/java/com/easyagents/image/volcengine/VolcengineImageModel.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.volcengine; + +import com.easyagents.core.model.image.*; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.volcengine.service.visual.IVisualService; +import com.volcengine.service.visual.impl.VisualServiceImpl; + +public class VolcengineImageModel implements ImageModel { + + private VolcengineImageModelConfig config; + private IVisualService visualService = VisualServiceImpl.getInstance(); + + public VolcengineImageModel(VolcengineImageModelConfig config) { + this.config = config; + visualService.setAccessKey(config.getAccessKey()); + visualService.setSecretKey(config.getSecretKey()); + } + + private ImageResponse processImageRequest(GenerateImageRequest request) { + JSONObject req = new JSONObject(request.getOptions()); + ImageResponse responseimage = new ImageResponse(); + try { + Object response = visualService.cvProcess(req); + if (response instanceof JSONObject) { + JSONObject jsonResponse = (JSONObject) response; + JSONObject dataObject = jsonResponse.getJSONObject("data"); + JSONArray imageUrlsArray = dataObject.getJSONArray("image_urls"); + for (int i = 0; i < imageUrlsArray.size(); i++) { + responseimage.addImage(imageUrlsArray.getString(i)); + } + } else { + throw new RuntimeException("Unexpected response type: " + response.getClass().getName()); + } + return responseimage; + } catch (Exception e) { + ImageResponse.error(e.getMessage()); + e.printStackTrace(); // 记录堆栈跟踪方便调试 + return responseimage; + } + } + + + @Override + public ImageResponse generate(GenerateImageRequest request) { + return processImageRequest(request); + } + + @Override + public ImageResponse img2imggenerate(GenerateImageRequest request) { + return processImageRequest(request); + } + + + @Override + public ImageResponse edit(EditImageRequest request) { + throw new UnsupportedOperationException("not support edit image"); + } + + @Override + public ImageResponse vary(VaryImageRequest request) { + throw new UnsupportedOperationException("not support vary image"); + } + +} diff --git a/easy-agents-image/easy-agents-image-volcengine/src/main/java/com/easyagents/image/volcengine/VolcengineImageModelConfig.java b/easy-agents-image/easy-agents-image-volcengine/src/main/java/com/easyagents/image/volcengine/VolcengineImageModelConfig.java new file mode 100644 index 0000000..49ba6a6 --- /dev/null +++ b/easy-agents-image/easy-agents-image-volcengine/src/main/java/com/easyagents/image/volcengine/VolcengineImageModelConfig.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.image.volcengine; + +import java.io.Serializable; + +public class VolcengineImageModelConfig implements Serializable { + + private String accessKey; + private String secretKey; + + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } +} diff --git a/easy-agents-image/easy-agents-image-volcengine/src/test/java/VolcengineImageTest.java b/easy-agents-image/easy-agents-image-volcengine/src/test/java/VolcengineImageTest.java new file mode 100644 index 0000000..b100a2f --- /dev/null +++ b/easy-agents-image/easy-agents-image-volcengine/src/test/java/VolcengineImageTest.java @@ -0,0 +1,99 @@ +import com.easyagents.core.model.image.GenerateImageRequest; +import com.easyagents.core.model.image.ImageResponse; +import com.easyagents.image.volcengine.VolcengineImageModel; +import com.easyagents.image.volcengine.VolcengineImageModelConfig; +import com.alibaba.fastjson2.JSONObject; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +public class VolcengineImageTest { + @Test + public void testGenImage(){ + VolcengineImageModelConfig config = new VolcengineImageModelConfig(); + config.setAccessKey("*********************"); + config.setSecretKey("*********************"); + + VolcengineImageModel imageModel = new VolcengineImageModel(config); + + GenerateImageRequest request = new GenerateImageRequest(); + + JSONObject req=new JSONObject(); + //请求Body(查看接口文档请求参数-请求示例,将请求参数内容复制到此)参考通用2.1-文生图 + req.put("req_key","high_aes_general_v21_L"); + req.put("prompt","千军万马"); + req.put("model_version","general_v2.1_L"); + req.put("req_schedule_conf","general_v20_9B_pe"); + req.put("llm_seed",-1); + req.put("seed",-1); + req.put("scale",3.5); + req.put("ddim_steps",25); + req.put("width",512); + req.put("height",512); + req.put("use_pre_llm",true); + req.put("use_sr",true); + req.put("sr_seed",-1); + req.put("sr_strength",0.4); + req.put("sr_scale",3.5); + req.put("sr_steps",20); + req.put("is_only_sr",false); + req.put("return_url",true); + // 创建子级JSONObject + JSONObject subData = new JSONObject(); + subData.put("add_logo", true); + subData.put("position", 2); + subData.put("language", 0); + subData.put("opacity", 0.3); + subData.put("logo_text_content", "wangyangyang"); + req.put("logo_info",subData); + + request.setOptions(req); + + ImageResponse generate = imageModel.generate(request); + System.out.println(generate); + } + + + + + @Test( ) + public void testImg2ImgXLSft() throws IOException { + VolcengineImageModelConfig config = new VolcengineImageModelConfig(); + config.setAccessKey("*********************"); + config.setSecretKey("*********************"); + + VolcengineImageModel imageModel = new VolcengineImageModel(config); + + GenerateImageRequest request = new GenerateImageRequest(); + + JSONObject req=new JSONObject(); + req.put("req_key","i2i_xl_sft"); + List images=new ArrayList<>(); + + File file = new File(System.getProperty("user.dir"), "../../testresource/ark_demo_img_1.png"); + // 将图片读取为字节数组 + byte[] imageBytes = Files.readAllBytes(Paths.get(file.toURI())); + + // 将字节数组编码为Base64 + String base64String = Base64.getEncoder().encodeToString(imageBytes); + + images.add(base64String); +// images.add("https://ark-project.tos-cn-beijing.volces.com/doc_image/ark_demo_img_1.png"); + req.put("binary_data_base64",images); + req.put("prompt","根据图片内容生成风格、服装及发型一样的亚洲美女图片"); + req.put("return_url",true); + request.setOptions(req); + + ImageResponse generate = imageModel.img2imggenerate(request); + System.out.println(generate); + + + + } +} diff --git a/easy-agents-image/easy-agents-image-volcengine/src/test/java/VolcengineTest.java b/easy-agents-image/easy-agents-image-volcengine/src/test/java/VolcengineTest.java new file mode 100644 index 0000000..b0347c9 --- /dev/null +++ b/easy-agents-image/easy-agents-image-volcengine/src/test/java/VolcengineTest.java @@ -0,0 +1,52 @@ +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.volcengine.service.visual.IVisualService; +import com.volcengine.service.visual.impl.VisualServiceImpl; + +public class VolcengineTest { + //以下是同步调用直接返回结果的方法,按照实际需求可调用sdk同步及异步相关方法; + public static void main(String[] args) { + IVisualService visualService = VisualServiceImpl.getInstance(); + // call below method if you dont set ak and sk in ~/.vcloud/config + visualService.setAccessKey("AKLTNmU0M2RkNWZkMmZmNDQwYWI2NTZiMjA1ODYxY2M3MjE"); + visualService.setSecretKey("TkdObFpXSTFZMlJtWldReE5EVTRPRGt4TkRsaE1EVTRaalpsTnpnMllURQ=="); + + + JSONObject req=new JSONObject(); + //请求Body(查看接口文档请求参数-请求示例,将请求参数内容复制到此) + req.put("req_key","high_aes_general_v21_L"); + req.put("prompt","千军万马"); + req.put("model_version","general_v2.1_L"); + req.put("req_schedule_conf","general_v20_9B_pe"); + req.put("llm_seed",-1); + req.put("seed",-1); + req.put("scale",3.5); + req.put("ddim_steps",25); + req.put("width",512); + req.put("height",512); + req.put("use_pre_llm",true); + req.put("use_sr",true); + req.put("sr_seed",-1); + req.put("sr_strength",0.4); + req.put("sr_scale",3.5); + req.put("sr_steps",20); + req.put("is_only_sr",false); + req.put("return_url",true); + //创建子级JSONObject + JSONObject subData = new JSONObject(); + subData.put("add_logo", true); + subData.put("position", 2); + subData.put("language", 0); + subData.put("opacity", 0.3); + subData.put("logo_text_content", "wangyangyang"); + req.put("logo_info",subData); + + + try { + Object response = visualService.cvProcess(req); + System.out.println(JSON.toJSONString(response)); + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/easy-agents-image/pom.xml b/easy-agents-image/pom.xml new file mode 100644 index 0000000..7216101 --- /dev/null +++ b/easy-agents-image/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-image + easy-agents-image + pom + + easy-agents-image-openai + easy-agents-image-stability + easy-agents-image-gitee + easy-agents-image-siliconflow + easy-agents-image-tencent + easy-agents-image-volcengine + easy-agents-image-qwen + easy-agents-image-qianfan + + + + 8 + 8 + UTF-8 + + + diff --git a/easy-agents-mcp/pom.xml b/easy-agents-mcp/pom.xml new file mode 100644 index 0000000..2c84336 --- /dev/null +++ b/easy-agents-mcp/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-mcp + easy-agents-mcp + + + 17 + 17 + UTF-8 + 0.17.0 + + + + + + com.easyagents + easy-agents-core + + + io.modelcontextprotocol.sdk + mcp-core + ${mcp.version} + + + io.modelcontextprotocol.sdk + mcp + ${mcp.version} + + + io.modelcontextprotocol.sdk + mcp-json + ${mcp.version} + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + ${mcp.version} + + + io.modelcontextprotocol.sdk + mcp-test + ${mcp.version} + test + + + + + + + + + + + + + + diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/CloseableTransport.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/CloseableTransport.java new file mode 100644 index 0000000..aeff79d --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/CloseableTransport.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.mcp.client; + +import io.modelcontextprotocol.spec.McpClientTransport; + +import java.io.Closeable; + +public interface CloseableTransport extends Closeable { + McpClientTransport getTransport(); +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/HttpSseTransportFactory.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/HttpSseTransportFactory.java new file mode 100644 index 0000000..851f7c5 --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/HttpSseTransportFactory.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.mcp.client; + +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.spec.McpClientTransport; + +import java.util.Map; + +public class HttpSseTransportFactory implements McpTransportFactory { + + @Override + public CloseableTransport create(McpConfig.ServerSpec spec, Map resolvedEnv) { + String url = spec.getUrl(); + if (url == null || url.isEmpty()) { + throw new IllegalArgumentException("URL is required for HTTP SSE transport"); + } + + + HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder(url).jsonMapper(McpJsonMapper.getDefault()) + .build(); + + return new CloseableTransport() { + @Override + public McpClientTransport getTransport() { + return transport; + } + + @Override + public void close() { + try { + transport.close(); + } catch (Exception e) { + // ignore + } + } + }; + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/HttpStreamTransportFactory.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/HttpStreamTransportFactory.java new file mode 100644 index 0000000..90d65ed --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/HttpStreamTransportFactory.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.mcp.client; + +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpClientTransport; + +import java.util.Map; + +public class HttpStreamTransportFactory implements McpTransportFactory { + + @Override + public CloseableTransport create(McpConfig.ServerSpec spec, Map resolvedEnv) { + String url = spec.getUrl(); + if (url == null || url.isEmpty()) { + throw new IllegalArgumentException("URL is required for HTTP Stream transport"); + } + + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(url) + .build(); + + return new CloseableTransport() { + @Override + public McpClientTransport getTransport() { + return transport; + } + + @Override + public void close() { + try { + transport.close(); + } catch (Exception e) { + // ignore + } + } + }; + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCallException.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCallException.java new file mode 100644 index 0000000..4101402 --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCallException.java @@ -0,0 +1,78 @@ +package com.easyagents.mcp.client; + +public class McpCallException extends RuntimeException { + + /** + * Constructs a new runtime exception with the specified detail + * message, cause, suppression enabled or disabled, and writable + * stack trace enabled or disabled. + * + * @param message the detail message. + * @param cause the cause. (A {@code null} value is permitted, + * and indicates that the cause is nonexistent or unknown.) + * @param enableSuppression whether or not suppression is enabled + * or disabled + * @param writableStackTrace whether or not the stack trace should + * be writable + * @since 1.7 + */ + protected McpCallException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } + + /** + * Constructs a new runtime exception with the specified cause and a + * detail message of {@code (cause==null ? null : cause.toString())} + * (which typically contains the class and detail message of + * {@code cause}). This constructor is useful for runtime exceptions + * that are little more than wrappers for other throwables. + * + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A {@code null} value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public McpCallException(Throwable cause) { + super(cause); + } + + /** + * Constructs a new runtime exception with the specified detail message and + * cause.

Note that the detail message associated with + * {@code cause} is not automatically incorporated in + * this runtime exception's detail message. + * + * @param message the detail message (which is saved for later retrieval + * by the {@link #getMessage()} method). + * @param cause the cause (which is saved for later retrieval by the + * {@link #getCause()} method). (A {@code null} value is + * permitted, and indicates that the cause is nonexistent or + * unknown.) + * @since 1.4 + */ + public McpCallException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new runtime exception with the specified detail message. + * The cause is not initialized, and may subsequently be initialized by a + * call to {@link #initCause}. + * + * @param message the detail message. The detail message is saved for + * later retrieval by the {@link #getMessage()} method. + */ + public McpCallException(String message) { + super(message); + } + + /** + * Constructs a new runtime exception with {@code null} as its + * detail message. The cause is not initialized, and may subsequently be + * initialized by a call to {@link #initCause}. + */ + public McpCallException() { + super(); + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpClientDescriptor.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpClientDescriptor.java new file mode 100644 index 0000000..e59f285 --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpClientDescriptor.java @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.mcp.client; + +import com.easyagents.core.model.chat.tool.Tool; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +public class McpClientDescriptor { + private static final Logger log = LoggerFactory.getLogger(McpClientDescriptor.class); + + private final String name; + private final McpConfig.ServerSpec spec; + private final Map resolvedEnv; + + private volatile McpSyncClient client; + private volatile CloseableTransport managedTransport; + private volatile boolean closed = false; + private final AtomicBoolean initializing = new AtomicBoolean(false); + + private volatile boolean alive = false; + private volatile Instant lastPingTime = Instant.EPOCH; + private static final long MIN_PING_INTERVAL_MS = 5_000; + + public McpClientDescriptor(String name, McpConfig.ServerSpec spec, Map resolvedEnv) { + this.name = name; + this.spec = spec; + this.resolvedEnv = new HashMap<>(resolvedEnv); + } + + synchronized McpSyncClient getClient() { + if (closed) { + throw new IllegalStateException("MCP client closed: " + name); + } + if (client == null) { + initialize(); + } + return client; + } + + + public Tool getMcpTool(String toolName) { + McpSyncClient client = getClient(); + McpSchema.ListToolsResult listToolsResult = client.listTools(); + for (McpSchema.Tool tool : listToolsResult.tools()) { + if (tool.name().equals(toolName)) { + return new McpTool(getClient(), tool); + } + } + + return null; + } + + private synchronized void initialize() { + if (client != null || closed) return; + if (!initializing.compareAndSet(false, true)) { + while (client == null && !closed) { + try { + wait(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Initialization interrupted", e); + } + } + return; + } + + try { + McpTransportFactory factory = getTransportFactory(spec.getTransport()); + CloseableTransport transport = factory.create(spec, resolvedEnv); + this.managedTransport = transport; + + McpSyncClient c = McpClient.sync(transport.getTransport()) + .requestTimeout(java.time.Duration.ofSeconds(10)) + .build(); + + c.initialize(); + this.client = c; + this.alive = true; + log.info("MCP client initialized: {}", name); + + } catch (Exception e) { + String errorMsg = "Failed to initialize MCP client: " + name + ", error: " + e.getMessage(); + log.error(errorMsg, e); + if (managedTransport != null) { + try { + managedTransport.close(); + } catch (Exception closeEx) { + log.warn("Error closing transport during init failure", closeEx); + } + } + throw new RuntimeException(errorMsg, e); + } finally { + initializing.set(false); + notifyAll(); + } + } + + boolean pingIfNeeded() { + if (closed || client == null) { + alive = false; + return false; + } + + long now = System.currentTimeMillis(); + if ((now - lastPingTime.toEpochMilli()) < MIN_PING_INTERVAL_MS) { + return alive; + } + + try { + client.ping(); + alive = true; + } catch (Exception e) { + alive = false; + String msg = String.format("Ping failed for MCP client '%s': %s", name, e.getMessage()); + log.debug(msg); + } finally { + lastPingTime = Instant.now(); + } + return alive; + } + + boolean isAlive() { + return alive && !closed && client != null; + } + + boolean isClosed() { + return closed; + } + + synchronized void close() { + if (closed) return; + closed = true; + + if (client != null) { + try { + client.close(); + } catch (Exception ignored) { + } + client = null; + } + + if (managedTransport != null) { + try { + managedTransport.close(); + } catch (Exception e) { + log.warn("Error closing transport for '{}'", name, e); + } + managedTransport = null; + } + + alive = false; + log.info("MCP client closed: {}", name); + } + + private McpTransportFactory getTransportFactory(String transportType) { + switch (transportType.toLowerCase()) { + case "stdio": + return new StdioTransportFactory(); + case "http-sse": + return new HttpSseTransportFactory(); + case "http-stream": + return new HttpStreamTransportFactory(); + default: + throw new IllegalArgumentException("Unsupported transport: " + transportType); + } + } + + public String getName() { + return name; + } + + public McpConfig.ServerSpec getSpec() { + return spec; + } + + public Map getResolvedEnv() { + return resolvedEnv; + } + + public void setClient(McpSyncClient client) { + this.client = client; + } + + public CloseableTransport getManagedTransport() { + return managedTransport; + } + + public void setManagedTransport(CloseableTransport managedTransport) { + this.managedTransport = managedTransport; + } + + public void setClosed(boolean closed) { + this.closed = closed; + } + + public AtomicBoolean getInitializing() { + return initializing; + } + + public void setAlive(boolean alive) { + this.alive = alive; + } + + public Instant getLastPingTime() { + return lastPingTime; + } + + public void setLastPingTime(Instant lastPingTime) { + this.lastPingTime = lastPingTime; + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpClientManager.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpClientManager.java new file mode 100644 index 0000000..5954546 --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpClientManager.java @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.mcp.client; + +import com.easyagents.core.model.chat.tool.Tool; +import com.alibaba.fastjson2.JSON; +import io.modelcontextprotocol.client.McpSyncClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class McpClientManager implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(McpClientManager.class); + private static volatile McpClientManager INSTANCE; + + private final Map descriptorRegistry = new ConcurrentHashMap<>(); + private final ScheduledExecutorService healthChecker; + + + private static final String CONFIG_RESOURCE_PROPERTY = "mcp.config.servers-resource"; + private static final String DEFAULT_CONFIG_RESOURCE = "mcp-servers.json"; + + private McpClientManager() { + this.healthChecker = Executors.newSingleThreadScheduledExecutor(r -> + new Thread(r, "mcp-health-checker") + ); + long healthCheckIntervalMs = 10_000; + this.healthChecker.scheduleAtFixedRate( + this::performHealthCheck, + healthCheckIntervalMs, + healthCheckIntervalMs, + TimeUnit.MILLISECONDS + ); + Runtime.getRuntime().addShutdownHook(new Thread(this::close, "mcp-shutdown-hook")); + + autoLoadConfigFromResource(); + } + + private void autoLoadConfigFromResource() { + String resourcePath = System.getProperty(CONFIG_RESOURCE_PROPERTY, DEFAULT_CONFIG_RESOURCE); + try (InputStream is = McpClientManager.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (is != null) { + String json = new String(is.readAllBytes(), StandardCharsets.UTF_8); + registerFromJson(json); + log.info("Auto-loaded MCP configuration from: {}", resourcePath); + } else { + log.debug("MCP config resource not found (skipping auto-load): {}", resourcePath); + } + } catch (Exception e) { + log.warn("Failed to auto-load MCP config from resource: " + resourcePath, e); + } + } + + public static void reloadConfig() { + McpClientManager manager = getInstance(); + // 先关闭所有现有 client + manager.descriptorRegistry.values().forEach(McpClientDescriptor::close); + manager.descriptorRegistry.clear(); + // 重新加载 + manager.autoLoadConfigFromResource(); + } + + public static McpClientManager getInstance() { + if (INSTANCE == null) { + synchronized (McpClientManager.class) { + if (INSTANCE == null) { + INSTANCE = new McpClientManager(); + } + } + } + return INSTANCE; + } + + + public void registerFromJson(String json) { + McpConfig mcpConfig = JSON.parseObject(json, McpConfig.class); + registerFromConfig(mcpConfig); + } + + public void registerFromFile(Path filePath) throws IOException { + String json = Files.readString(filePath, StandardCharsets.UTF_8); + registerFromJson(json); + } + + public void registerFromResource(String resourcePath) { + try (InputStream is = McpClientManager.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (is == null) { + throw new IllegalArgumentException("Resource not found: " + resourcePath); + } + String json = new String(is.readAllBytes(), StandardCharsets.UTF_8); + registerFromJson(json); + } catch (IOException e) { + throw new RuntimeException("Failed to load MCP config from resource: " + resourcePath, e); + } + } + + private void registerFromConfig(McpConfig config) { + if (config == null || config.getMcpServers() == null) { + log.warn("MCP config is empty, skipping."); + return; + } + + for (Map.Entry entry : config.getMcpServers().entrySet()) { + String name = entry.getKey(); + McpConfig.ServerSpec spec = entry.getValue(); + + if (descriptorRegistry.containsKey(name)) { + try { + McpClientDescriptor desc = descriptorRegistry.get(name); + desc.close(); + } finally { + descriptorRegistry.remove(name); + } + } + + Map resolvedEnv = new HashMap<>(); + for (Map.Entry envEntry : spec.getEnv().entrySet()) { + String key = envEntry.getKey(); + String value = envEntry.getValue(); + if (value != null && value.startsWith("${input:") && value.endsWith("}")) { + String inputId = value.substring("${input:".length(), value.length() - 1); + value = System.getProperty("mcp.input." + inputId, ""); + } + resolvedEnv.put(key, value); + } + + McpClientDescriptor descriptor = new McpClientDescriptor(name, spec, resolvedEnv); + descriptorRegistry.put(name, descriptor); + log.info("Registered MCP client: {} (transport: {})", name, spec.getTransport()); + } + } + + public McpSyncClient getMcpClient(String name) { + McpClientDescriptor desc = descriptorRegistry.get(name); + if (desc == null) { + throw new IllegalArgumentException("MCP client not found: " + name); + } + return desc.getClient(); + } + + public Tool getMcpTool(String name, String toolName) { + McpClientDescriptor desc = descriptorRegistry.get(name); + if (desc == null) { + throw new IllegalArgumentException("MCP client not found: " + name); + } + return desc.getMcpTool(toolName); + } + + public boolean isClientOnline(String name) { + McpClientDescriptor desc = descriptorRegistry.get(name); + return desc != null && desc.isAlive(); + } + + public void reconnect(String name) { + McpClientDescriptor oldDesc = descriptorRegistry.get(name); + if (oldDesc == null) return; + + oldDesc.close(); + McpClientDescriptor newDesc = new McpClientDescriptor( + oldDesc.getName(), oldDesc.getSpec(), oldDesc.getResolvedEnv() + ); + descriptorRegistry.put(name, newDesc); + log.info("Reconnected MCP client: {}", name); + } + + private void performHealthCheck() { + for (McpClientDescriptor desc : descriptorRegistry.values()) { + if (desc.isClosed()) continue; + try { + desc.pingIfNeeded(); + } catch (Exception e) { + log.error("Health check error for client: " + desc.getName(), e); + } + } + } + + @Override + public void close() { + healthChecker.shutdown(); + try { + if (!healthChecker.awaitTermination(5, TimeUnit.SECONDS)) { + healthChecker.shutdownNow(); + } + } catch (InterruptedException e) { + healthChecker.shutdownNow(); + Thread.currentThread().interrupt(); + } + + for (McpClientDescriptor desc : descriptorRegistry.values()) { + try { + desc.close(); + } catch (Exception e) { + log.warn("Error closing MCP client descriptor", e); + } + } + descriptorRegistry.clear(); + log.info("McpClientManager closed."); + } + + public McpClientDescriptor getMcpClientDescriptor(String name) { + return descriptorRegistry.get(name); + } + + public Map getDescriptorRegistry() { + return descriptorRegistry; + } + + public ScheduledExecutorService getHealthChecker() { + return healthChecker; + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpConfig.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpConfig.java new file mode 100644 index 0000000..2d77533 --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpConfig.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.mcp.client; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class McpConfig { + + private List inputs = Collections.emptyList(); + private Map mcpServers = Collections.emptyMap(); + + public List getInputs() { + return inputs; + } + + public void setInputs(List inputs) { + this.inputs = inputs; + } + + public Map getMcpServers() { + return mcpServers; + } + + public void setMcpServers(Map mcpServers) { + this.mcpServers = mcpServers; + } + + public static class InputSpec { + private String type; + private String id; + private String description; + + // getters + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } + + public static class ServerSpec { + private String transport = "stdio"; // 新增 + private String command; + private List args; + private Map env = Collections.emptyMap(); + private String url; // 新增 + + public String getTransport() { + return transport; + } + + public void setTransport(String transport) { + this.transport = transport; + } + + public String getCommand() { + return command; + } + + public void setCommand(String command) { + this.command = command; + } + + public List getArgs() { + return args; + } + + public void setArgs(List args) { + this.args = args; + } + + public Map getEnv() { + return env; + } + + public void setEnv(Map env) { + this.env = env; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpTool.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpTool.java new file mode 100644 index 0000000..07f1acc --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpTool.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.mcp.client; + +import com.easyagents.core.model.chat.tool.Parameter; +import com.easyagents.core.model.chat.tool.Tool; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class McpTool implements Tool { + + final McpSyncClient mcpClient; + final McpSchema.Tool mcpOriginalTool; + + public McpTool(McpSyncClient mcpClient, McpSchema.Tool mcpOriginalTool) { + this.mcpClient = mcpClient; + this.mcpOriginalTool = mcpOriginalTool; + } + + @Override + public String getName() { + return mcpOriginalTool.name(); + } + + @Override + public String getDescription() { + return mcpOriginalTool.description(); + } + + @Override + public Parameter[] getParameters() { + McpSchema.JsonSchema inputSchema = mcpOriginalTool.inputSchema(); + if (inputSchema == null) { + return new Parameter[0]; + } + + Map properties = inputSchema.properties(); + if (properties == null || properties.isEmpty()) { + return new Parameter[0]; + } + + List required = inputSchema.required(); + if (required == null) required = Collections.emptyList(); + + Parameter[] parameters = new Parameter[properties.size()]; + int i = 0; + for (Map.Entry entry : properties.entrySet()) { + Parameter parameter = new Parameter(); + parameter.setName(entry.getKey()); + + //"type" -> "number" + //"minimum" -> {Integer@3634} 1 + //"maximum" -> {Integer@3636} 10 + //"default" -> {Integer@3638} 3 + //"description" -> "Number of resource links to return (1-10)" + //"enum" -> {ArrayList@3858} size = 3 + // key = "enum" + // value = {ArrayList@3858} size = 3 + // 0 = "error" + // 1 = "success" + // 2 = "debug" + //"additionalProperties" -> {LinkedHashMap@3759} size = 3 + // key = "additionalProperties" + // value = {LinkedHashMap@3759} size = 3 + // "type" -> "string" + // "format" -> "uri" + // "description" -> "URL of the file to include in the zip" + @SuppressWarnings("unchecked") Map entryValue = (Map) entry.getValue(); + + parameter.setType((String) entryValue.get("type")); + parameter.setDescription((String) entryValue.get("description")); + parameter.setDefaultValue(entryValue.get("default")); + + if (required.contains(entry.getKey())) { + parameter.setRequired(true); + } + + Object anEnum = entryValue.get("enum"); + if (anEnum instanceof Collection) { + parameter.setEnums(((Collection) anEnum).toArray(new String[0])); + } + + parameters[i++] = parameter; + } + + return parameters; + } + + @Override + public Object invoke(Map argsMap) { + McpSchema.CallToolResult callToolResult; + try { + callToolResult = mcpClient.callTool(new McpSchema.CallToolRequest(mcpOriginalTool.name(), argsMap)); + } catch (Exception e) { + throw new McpCallException("MCP Tool call exception, tool name: " + mcpOriginalTool.name(), e); + } + + if (callToolResult.isError() != null && callToolResult.isError()) { + throw new McpCallException("MCP Tool call exception, tool name: " + mcpOriginalTool.name() + ", info: " + callToolResult.structuredContent()); + } + + List content = callToolResult.content(); + if (content == null || content.isEmpty()) { + return null; + } + + if (content.size() == 1 && content.get(0) instanceof McpSchema.TextContent) { + return ((McpSchema.TextContent) content.get(0)).text(); + } + + return content; + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpTransportFactory.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpTransportFactory.java new file mode 100644 index 0000000..7f0e785 --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpTransportFactory.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.mcp.client; + +import java.util.Map; + +@FunctionalInterface +public interface McpTransportFactory { + CloseableTransport create(McpConfig.ServerSpec spec, Map resolvedEnv); +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/StdioTransportFactory.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/StdioTransportFactory.java new file mode 100644 index 0000000..cbefa08 --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/StdioTransportFactory.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.mcp.client; + +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.spec.McpClientTransport; + +import java.util.Map; + +public class StdioTransportFactory implements McpTransportFactory { + + @Override + public CloseableTransport create(McpConfig.ServerSpec spec, Map resolvedEnv) { +// ProcessBuilder pb = new ProcessBuilder(); +// List args = spec.getArgs(); +// if (args != null && !args.isEmpty()) { +// pb.command(spec.getCommand(), args.toArray(new String[0])); +// } else { +// pb.command(spec.getCommand()); +// } +// if (!resolvedEnv.isEmpty()) { +// pb.environment().putAll(resolvedEnv); +// } +// pb.redirectErrorStream(true); + + try { +// Process process = pb.start(); +// OutputStream stdin = process.getOutputStream(); +// InputStream stdout = process.getInputStream(); + +// StdioClientTransport transport = new StdioClientTransport( +// stdin, stdout, McpJsonMapper.getDefault(), () -> {} +// ); + + +// ServerParameters params = ServerParameters.builder("npx") +// .args("-y", "@modelcontextprotocol/server-everything") +// .build(); + + + ServerParameters parameters = ServerParameters.builder(spec.getCommand()) + .args(spec.getArgs()) + .build(); + + StdioClientTransport transport = new StdioClientTransport(parameters, McpJsonMapper.getDefault()); + + + return new CloseableTransport() { + @Override + public McpClientTransport getTransport() { + return transport; + } + + @Override + public void close() { + try { + transport.close(); + } catch (Exception e) { + // ignore + } +// if (process.isAlive()) { +// process.destroy(); +// try { +// if (!process.waitFor(3, TimeUnit.SECONDS)) { +// process.destroyForcibly(); +// } +// } catch (InterruptedException ex) { +// Thread.currentThread().interrupt(); +// process.destroyForcibly(); +// } +// } + } + }; + } catch (Exception e) { + throw new RuntimeException("Failed to start stdio process", e); + } + } +} diff --git a/easy-agents-mcp/src/main/resources/mcp-servers.json b/easy-agents-mcp/src/main/resources/mcp-servers.json new file mode 100644 index 0000000..d00587c --- /dev/null +++ b/easy-agents-mcp/src/main/resources/mcp-servers.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "everything": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-everything" + ] + } + } +} diff --git a/easy-agents-mcp/src/test/java/com/easyagents/mcp/client/McpClientManagerTest.java b/easy-agents-mcp/src/test/java/com/easyagents/mcp/client/McpClientManagerTest.java new file mode 100644 index 0000000..1c1abc9 --- /dev/null +++ b/easy-agents-mcp/src/test/java/com/easyagents/mcp/client/McpClientManagerTest.java @@ -0,0 +1,397 @@ +package com.easyagents.mcp.client; + +import com.easyagents.core.message.ToolCall; +import com.easyagents.core.model.chat.tool.Tool; +import com.easyagents.core.model.chat.tool.ToolExecutor; +import io.modelcontextprotocol.client.McpSyncClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * McpClientManager unit test class + * McpClientManager单元测试类 + */ +class McpClientManagerTest { + + @Mock + private McpClientDescriptor mockDescriptor; + + @Mock + private McpSyncClient mockClient; + + @Mock + private Tool mockTool; + + private McpClientManager mcpClientManager; + private AutoCloseable mockitoMocks; + + // Test JSON configuration + // 测试用JSON配置 + private static final String TEST_JSON_CONFIG = """ + { + "mcpServers": { + "test-server": { + "transport": "stdio", + "env": { + "TEST_VAR": "test_value", + "INPUT_VAR": "${input:test_input}" + } + } + } + } + """; + + @BeforeEach + void setUp() { + // 初始化Mockito mocks + mockitoMocks = MockitoAnnotations.openMocks(this); + + // Reset singleton instance before each test + // 在每个测试前重置单例实例 + try { + java.lang.reflect.Field instance = McpClientManager.class.getDeclaredField("INSTANCE"); + instance.setAccessible(true); + instance.set(null, null); + } catch (Exception e) { + throw new RuntimeException(e); + } + + mcpClientManager = McpClientManager.getInstance(); + } + + @AfterEach + void tearDown() throws Exception { + if (mcpClientManager != null) { + mcpClientManager.close(); + } + // 关闭Mockito mocks + if (mockitoMocks != null) { + mockitoMocks.close(); + } + } + + @Test + @DisplayName("Test singleton pattern - 单例模式测试") + void testSingletonPattern() { + McpClientManager instance1 = McpClientManager.getInstance(); + McpClientManager instance2 = McpClientManager.getInstance(); + + assertSame(instance1, instance2, "Should return same instance for singleton pattern"); + assertNotNull(instance1, "Instance should not be null"); + } + + @Test + @DisplayName("Test registerFromJson - JSON配置注册测试") + void testRegisterFromJson() { + // Mock descriptor behavior + // 模拟描述符行为 + when(mockDescriptor.getName()).thenReturn("test-server"); + when(mockDescriptor.isAlive()).thenReturn(true); + when(mockDescriptor.getClient()).thenReturn(mockClient); + when(mockDescriptor.getMcpTool("test-tool")).thenReturn(mockTool); + + // Use reflection to replace descriptor in registry + // 使用反射替换注册表中的描述符 + try { + java.lang.reflect.Field registryField = McpClientManager.class.getDeclaredField("descriptorRegistry"); + registryField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map registry = + (java.util.Map) registryField.get(mcpClientManager); + + registry.put("test-server", mockDescriptor); + } catch (Exception e) { + throw new RuntimeException(e); + } + + // Test client retrieval + // 测试客户端获取 + McpSyncClient client = mcpClientManager.getMcpClient("test-server"); + assertNotNull(client, "Client should not be null"); + assertSame(mockClient, client, "Should return the mocked client"); + + // Test tool retrieval + // 测试工具获取 + Tool tool = mcpClientManager.getMcpTool("test-server", "test-tool"); + assertNotNull(tool, "Tool should not be null"); + assertSame(mockTool, tool, "Should return the mocked tool"); + + // Test online status + // 测试在线状态 + assertTrue(mcpClientManager.isClientOnline("test-server"), "Client should be online"); + } + + @Test + @DisplayName("Test getClient with non-existent client - 获取不存在客户端测试") + void testGetMcpClientNonExistent() { + assertThrows(IllegalArgumentException.class, () -> { + mcpClientManager.getMcpClient("non-existent-server"); + }, "Should throw IllegalArgumentException for non-existent client"); + } + + @Test + @DisplayName("Test getMcpTool with non-existent client - 获取不存在客户端工具测试") + void testGetMcpToolNonExistent() { + assertThrows(IllegalArgumentException.class, () -> { + mcpClientManager.getMcpTool("non-existent-server", "test-tool"); + }, "Should throw IllegalArgumentException for non-existent client"); + } + + @Test + @DisplayName("Test isClientOnline with non-existent client - 检查不存在客户端在线状态测试") + void testIsClientOnlineNonExistent() { + assertFalse(mcpClientManager.isClientOnline("non-existent-server"), + "Should return false for non-existent client"); + } + + @Test + @DisplayName("Test registerFromFile - 文件配置注册测试") + void testRegisterFromFile() throws IOException { + // Create a temporary file with test config + // 创建包含测试配置的临时文件 + Path tempFile = Files.createTempFile("test-config", ".json"); + Files.write(tempFile, TEST_JSON_CONFIG.getBytes(StandardCharsets.UTF_8)); + + assertDoesNotThrow(() -> { + mcpClientManager.registerFromFile(tempFile); + }, "Should not throw exception when registering from file"); + + // Clean up + // 清理 + Files.deleteIfExists(tempFile); + } + + @Test + @DisplayName("Test registerFromFile IOException - 文件配置注册IO异常测试") + void testRegisterFromFileIOException() { + Path nonExistentPath = Path.of("/non/existent/path.json"); + + assertThrows(IOException.class, () -> { + mcpClientManager.registerFromFile(nonExistentPath); + }, "Should throw IOException for non-existent file"); + } + + @Test + @DisplayName("Test registerFromResource - 资源配置注册测试") + void testRegisterFromResource() { + String resourcePath = "mcp-servers.json"; + assertDoesNotThrow(() -> { + mcpClientManager.registerFromResource(resourcePath); + }, "Should not throw exception when registering from resource"); + } + + @Test + @DisplayName("Test registerFromResource with non-existent resource - 注册不存在资源测试") + void testRegisterFromResourceNonExistent() { + String resourcePath = "non-existent-config.json"; + + assertThrows(IllegalArgumentException.class, () -> { + mcpClientManager.registerFromResource(resourcePath); + }, "Should throw IllegalArgumentException for non-existent resource"); + } + + @Test + @DisplayName("Test reconnect functionality - 重新连接功能测试") + void testReconnect() { + // First register a descriptor + // 首先注册一个描述符 + try { + java.lang.reflect.Field registryField = McpClientManager.class.getDeclaredField("descriptorRegistry"); + registryField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map registry = + (java.util.Map) registryField.get(mcpClientManager); + + registry.put("test-server", mockDescriptor); + } catch (Exception e) { + throw new RuntimeException(e); + } + + // Mock close behavior + // 模拟关闭行为 + doNothing().when(mockDescriptor).close(); + when(mockDescriptor.getName()).thenReturn("test-server"); + when(mockDescriptor.getSpec()).thenReturn(new McpConfig().getMcpServers().get("test-server")); + when(mockDescriptor.getResolvedEnv()).thenReturn(new HashMap<>()); + + assertDoesNotThrow(() -> { + mcpClientManager.reconnect("test-server"); + }, "Reconnect should not throw exception"); + + // Verify close was called + // 验证关闭被调用 + verify(mockDescriptor, times(1)).close(); + } + + @Test + @DisplayName("Test reconnect with non-existent client - 重新连接不存在客户端测试") + void testReconnectNonExistent() { + assertDoesNotThrow(() -> { + mcpClientManager.reconnect("non-existent-server"); + }, "Reconnect should not throw exception for non-existent client"); + } + + @Test + @DisplayName("Test reloadConfig - 重新加载配置测试") + void testReloadConfig() throws IllegalAccessException, NoSuchFieldException { + // Mock the autoLoadConfigFromResource method + // 模拟autoLoadConfigFromResource方法 + try (MockedStatic managerMock = Mockito.mockStatic(McpClientManager.class, + Mockito.CALLS_REAL_METHODS)) { + + // Add a descriptor first + // 首先添加一个描述符 + java.lang.reflect.Field registryField = McpClientManager.class.getDeclaredField("descriptorRegistry"); + registryField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map registry = + (java.util.Map) registryField.get(mcpClientManager); + + registry.put("test-server", mockDescriptor); + + // Mock close behavior + // 模拟关闭行为 + doNothing().when(mockDescriptor).close(); + + assertDoesNotThrow(() -> { + McpClientManager.reloadConfig(); + }, "Reload config should not throw exception"); + + // Verify that existing descriptors were closed + // 验证现有描述符被关闭 + verify(mockDescriptor, times(1)).close(); + } + } + + @Test + @DisplayName("Test close functionality - 关闭功能测试") + void testClose() { + assertDoesNotThrow(() -> { + mcpClientManager.close(); + }, "Close should not throw exception"); + + // Verify that the manager can be closed multiple times safely + // 验证管理器可以安全地多次关闭 + assertDoesNotThrow(() -> { + mcpClientManager.close(); + }, "Second close should not throw exception"); + } + + @Test + @DisplayName("Test environment variable resolution - 环境变量解析测试") + void testEnvironmentVariableResolution() { + // Test the registerFromConfig method with environment variable resolution + // 测试带有环境变量解析的registerFromConfig方法 + String jsonWithInputVar = """ + { + "mcpServers": { + "test-server": { + "transport": "stdio", + "env": { + "INPUT_VAR": "${input:test_input}", + "NORMAL_VAR": "normal_value" + } + } + } + } + """; + + // Set system property for input resolution + // 设置输入解析的系统属性 + System.setProperty("mcp.input.test_input", "resolved_value"); + + try { + mcpClientManager.registerFromJson(jsonWithInputVar); + } finally { + // Clean up system property + // 清理系统属性 + System.clearProperty("mcp.input.test_input"); + } + + // The registration should succeed without throwing exception + // 注册应该成功而不抛出异常 + assertDoesNotThrow(() -> { + mcpClientManager.isClientOnline("test-server"); + }); + } + + @Test + @DisplayName("Test JSON parsing error - JSON解析错误测试") + void testJsonParsingError() { + String invalidJson = "{ invalid json }"; + + assertThrows(Exception.class, () -> { + mcpClientManager.registerFromJson(invalidJson); + }, "Should throw exception for invalid JSON"); + } + + @Test + @DisplayName("Test duplicate registration - 重复注册测试") + void testDuplicateRegistration() { + // Register the same server twice + // 重复注册同一个服务器 + try { + java.lang.reflect.Field registryField = McpClientManager.class.getDeclaredField("descriptorRegistry"); + registryField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map registry = + (java.util.Map) registryField.get(mcpClientManager); + + registry.put("duplicate-server", mockDescriptor); + when(mockDescriptor.getName()).thenReturn("duplicate-server"); + } catch (Exception e) { + throw new RuntimeException(e); + } + + // The second registration should be skipped (no exception thrown) + // 第二次注册应该被跳过(不抛出异常) + assertDoesNotThrow(() -> { + mcpClientManager.registerFromJson(TEST_JSON_CONFIG); + }); + } + + + @Test + @DisplayName("Test call tool - 测试工具调用") + void testCallTool() { + + // The second registration should be skipped (no exception thrown) + // 第二次注册应该被跳过(不抛出异常) + assertDoesNotThrow(() -> { + mcpClientManager.registerFromResource("mcp-servers.json"); + }); + + + Tool mcpTool = mcpClientManager.getMcpTool("everything", "add"); + + if (mcpTool == null) { + return; + } + + System.out.println(mcpTool); + + ToolExecutor toolExecutor = new ToolExecutor(mcpTool + , new ToolCall("add", "add", "{\"a\":1,\"b\":2}")); + + Object result = toolExecutor.execute(); + + assertEquals("The sum of 1 and 2 is 3.", result); + + System.out.println(result); + } +} diff --git a/easy-agents-rerank/easy-agents-rerank-default/pom.xml b/easy-agents-rerank/easy-agents-rerank-default/pom.xml new file mode 100644 index 0000000..a727a71 --- /dev/null +++ b/easy-agents-rerank/easy-agents-rerank-default/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + com.easyagents + easy-agents-rerank + ${revision} + + + easy-agents-rerank-default + easy-agents-rerank-default + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + + + junit + junit + test + + + + diff --git a/easy-agents-rerank/easy-agents-rerank-default/src/main/java/com/easyagents/rerank/DefaultRerankModel.java b/easy-agents-rerank/easy-agents-rerank-default/src/main/java/com/easyagents/rerank/DefaultRerankModel.java new file mode 100644 index 0000000..805a8d0 --- /dev/null +++ b/easy-agents-rerank/easy-agents-rerank-default/src/main/java/com/easyagents/rerank/DefaultRerankModel.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.rerank; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.model.rerank.BaseRerankModel; +import com.easyagents.core.model.rerank.RerankException; +import com.easyagents.core.model.rerank.RerankOptions; +import com.easyagents.core.util.Maps; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.alibaba.fastjson2.JSONPath; + +import java.util.*; + +public class DefaultRerankModel extends BaseRerankModel { + + private HttpClient httpClient = new HttpClient(); + + public DefaultRerankModel(DefaultRerankModelConfig config) { + super(config); + } + + public HttpClient getHttpClient() { + return httpClient; + } + + public void setHttpClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + public List rerank(String query, List documents, RerankOptions options) { + + DefaultRerankModelConfig config = getConfig(); + String url = config.getEndpoint() + config.getRequestPath(); + + Map headers = new HashMap<>(2); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer " + config.getApiKey()); + + List payloadDocuments = new ArrayList<>(documents.size()); + for (Document document : documents) { + payloadDocuments.add(document.getContent()); + } + + String payload = Maps.of("model", options.getModelOrDefault(config.getModel())) + .set("query", query) + .set("documents", payloadDocuments) + .toJSON(); + + String response = httpClient.post(url, headers, payload); + if (StringUtil.noText(response)) { + throw new RerankException("empty response"); + } + + //{ + // "model": "Qwen3-Reranker-4B", + // "usage": { + // "totalTokens": 0, + // "promptTokens": 0 + // }, + // "results": [ + // { + // "index": 0, + // "document": { + // "text": "Use pandas: `import pandas as pd; df = pd.read_csv('data.csv')`" + // }, + // "relevance_score": 0.95654296875 + // }, + // { + // "index": 3, + // "document": { + // "text": "CSV means Comma Separated Values. Python files can be opened using read() method." + // }, + // "relevance_score": 0.822265625 + // }, + // { + // "index": 1, + // "document": { + // "text": "You can read CSV files with numpy.loadtxt()" + // }, + // "relevance_score": 0.310791015625 + // }, + // { + // "index": 2, + // "document": { + // "text": "To write JSON files, use json.dump() in Python" + // }, + // "relevance_score": 0.00009608268737792969 + // } + // ] + //} + JSONObject jsonObject = JSON.parseObject(response); + JSONArray results = (JSONArray) JSONPath.eval(jsonObject, config.getResultsJsonPath()); + + if (results == null || results.isEmpty()) { + throw new RerankException("empty results"); + } + + + for (int i = 0; i < results.size(); i++) { + JSONObject result = results.getJSONObject(i); + int index = result.getIntValue(config.getIndexJsonKey()); + Document document = documents.get(index); + document.setScore(result.getDoubleValue(config.getScoreJsonKey())); + } + + // 对 documents 排序, score 越大的越靠前 + documents.sort(Comparator.comparingDouble(Document::getScore).reversed()); + + return documents; + } +} diff --git a/easy-agents-rerank/easy-agents-rerank-default/src/main/java/com/easyagents/rerank/DefaultRerankModelConfig.java b/easy-agents-rerank/easy-agents-rerank-default/src/main/java/com/easyagents/rerank/DefaultRerankModelConfig.java new file mode 100644 index 0000000..1f8dda1 --- /dev/null +++ b/easy-agents-rerank/easy-agents-rerank-default/src/main/java/com/easyagents/rerank/DefaultRerankModelConfig.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.rerank; + + +import com.easyagents.core.model.config.BaseModelConfig; + +public class DefaultRerankModelConfig extends BaseModelConfig { + + private String resultsJsonPath = "$.results"; + private String indexJsonKey = "index"; + private String scoreJsonKey = "relevance_score"; + + + public String getResultsJsonPath() { + return resultsJsonPath; + } + + public void setResultsJsonPath(String resultsJsonPath) { + this.resultsJsonPath = resultsJsonPath; + } + + public String getIndexJsonKey() { + return indexJsonKey; + } + + public void setIndexJsonKey(String indexJsonKey) { + this.indexJsonKey = indexJsonKey; + } + + public String getScoreJsonKey() { + return scoreJsonKey; + } + + public void setScoreJsonKey(String scoreJsonKey) { + this.scoreJsonKey = scoreJsonKey; + } +} diff --git a/easy-agents-rerank/easy-agents-rerank-default/src/test/java/com/easyagents/rereank/gitee/DefaultRerankModelTest.java b/easy-agents-rerank/easy-agents-rerank-default/src/test/java/com/easyagents/rereank/gitee/DefaultRerankModelTest.java new file mode 100644 index 0000000..f984d14 --- /dev/null +++ b/easy-agents-rerank/easy-agents-rerank-default/src/test/java/com/easyagents/rereank/gitee/DefaultRerankModelTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.rereank.gitee; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.rerank.RerankException; +import com.easyagents.rerank.DefaultRerankModel; +import com.easyagents.rerank.DefaultRerankModelConfig; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +public class DefaultRerankModelTest { + + @Test(expected = RerankException.class) + public void testRerank() { + + DefaultRerankModelConfig config = new DefaultRerankModelConfig(); + config.setEndpoint("https://ai.gitee.com"); + config.setRequestPath("/v1/rerank"); + config.setModel("Qwen3-Reranker-8B"); + config.setApiKey("*****"); + + DefaultRerankModel model = new DefaultRerankModel(config); + List documents = new ArrayList<>(); + documents.add(Document.of("Paris is the capital of France.")); + documents.add(Document.of("London is the capital of England.")); + documents.add(Document.of("Tokyo is the capital of Japan.")); + documents.add(Document.of("Beijing is the capital of China.")); + documents.add(Document.of("Washington, D.C. is the capital of the United States.")); + documents.add(Document.of("Moscow is the capital of Russia.")); + + List rerank = model.rerank("What is the capital of France?", documents); + System.out.println(rerank); + + } +} diff --git a/easy-agents-rerank/easy-agents-rerank-gitee/pom.xml b/easy-agents-rerank/easy-agents-rerank-gitee/pom.xml new file mode 100644 index 0000000..a7ba3f0 --- /dev/null +++ b/easy-agents-rerank/easy-agents-rerank-gitee/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + com.easyagents + easy-agents-rerank + ${revision} + + + easy-agents-rerank-gitee + easy-agents-rerank-gitee + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + + + + com.easyagents + easy-agents-rerank-default + + + + junit + junit + test + + + + diff --git a/easy-agents-rerank/easy-agents-rerank-gitee/src/main/java/com/easyagents/rerank/gitee/GiteeRerankModel.java b/easy-agents-rerank/easy-agents-rerank-gitee/src/main/java/com/easyagents/rerank/gitee/GiteeRerankModel.java new file mode 100644 index 0000000..ec1e75b --- /dev/null +++ b/easy-agents-rerank/easy-agents-rerank-gitee/src/main/java/com/easyagents/rerank/gitee/GiteeRerankModel.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.rerank.gitee; + +import com.easyagents.rerank.DefaultRerankModel; +import com.easyagents.rerank.DefaultRerankModelConfig; + +public class GiteeRerankModel extends DefaultRerankModel { + + public GiteeRerankModel(DefaultRerankModelConfig config) { + super(config); + } +} diff --git a/easy-agents-rerank/easy-agents-rerank-gitee/src/main/java/com/easyagents/rerank/gitee/GiteeRerankModelConfig.java b/easy-agents-rerank/easy-agents-rerank-gitee/src/main/java/com/easyagents/rerank/gitee/GiteeRerankModelConfig.java new file mode 100644 index 0000000..a8d80b4 --- /dev/null +++ b/easy-agents-rerank/easy-agents-rerank-gitee/src/main/java/com/easyagents/rerank/gitee/GiteeRerankModelConfig.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.rerank.gitee; + +import com.easyagents.rerank.DefaultRerankModelConfig; + +public class GiteeRerankModelConfig extends DefaultRerankModelConfig { + + private static final String DEFAULT_ENDPOINT = "https://ai.gitee.com"; + private static final String DEFAULT_BASE_PATH = "/v1/rerank"; + private static final String DEFAULT_MODEL = "Qwen3-Reranker-8B"; + + public GiteeRerankModelConfig() { + super(); + setEndpoint(DEFAULT_ENDPOINT); + setRequestPath(DEFAULT_BASE_PATH); + setModel(DEFAULT_MODEL); + } +} diff --git a/easy-agents-rerank/easy-agents-rerank-gitee/src/test/java/com/easyagents/rereank/gitee/GiteeRerankModelTest.java b/easy-agents-rerank/easy-agents-rerank-gitee/src/test/java/com/easyagents/rereank/gitee/GiteeRerankModelTest.java new file mode 100644 index 0000000..acbb5ab --- /dev/null +++ b/easy-agents-rerank/easy-agents-rerank-gitee/src/test/java/com/easyagents/rereank/gitee/GiteeRerankModelTest.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.rereank.gitee; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.rerank.RerankException; +import com.easyagents.rerank.gitee.GiteeRerankModel; +import com.easyagents.rerank.gitee.GiteeRerankModelConfig; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +public class GiteeRerankModelTest { + + @Test(expected = RerankException.class) + public void testRerank() { + + GiteeRerankModelConfig config = new GiteeRerankModelConfig(); + config.setApiKey("***"); + + GiteeRerankModel model = new GiteeRerankModel(config); + List documents = new ArrayList<>(); + documents.add(Document.of("Paris is the capital of France.")); + documents.add(Document.of("London is the capital of England.")); + documents.add(Document.of("Tokyo is the capital of Japan.")); + documents.add(Document.of("Beijing is the capital of China.")); + documents.add(Document.of("Washington, D.C. is the capital of the United States.")); + documents.add(Document.of("Moscow is the capital of Russia.")); + + List rerank = model.rerank("What is the capital of France?", documents); + System.out.println(rerank); + + } +} diff --git a/easy-agents-rerank/pom.xml b/easy-agents-rerank/pom.xml new file mode 100644 index 0000000..f6599d2 --- /dev/null +++ b/easy-agents-rerank/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-rerank + easy-agents-rerank + + pom + + easy-agents-rerank-default + easy-agents-rerank-gitee + + + + 8 + 8 + UTF-8 + + + diff --git a/easy-agents-samples/easy-agents-helloworld/.gitignore b/easy-agents-samples/easy-agents-helloworld/.gitignore new file mode 100644 index 0000000..480bdf5 --- /dev/null +++ b/easy-agents-samples/easy-agents-helloworld/.gitignore @@ -0,0 +1,39 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.kotlin + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/easy-agents-samples/easy-agents-helloworld/pom.xml b/easy-agents-samples/easy-agents-helloworld/pom.xml new file mode 100644 index 0000000..a3d6120 --- /dev/null +++ b/easy-agents-samples/easy-agents-helloworld/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + com.easyagents + easy-agents-helloworld + 1.0-SNAPSHOT + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-bom + 0.0.1 + + + + diff --git a/easy-agents-samples/easy-agents-helloworld/src/main/java/com/easyagents/Main.java b/easy-agents-samples/easy-agents-helloworld/src/main/java/com/easyagents/Main.java new file mode 100644 index 0000000..694c0cc --- /dev/null +++ b/easy-agents-samples/easy-agents-helloworld/src/main/java/com/easyagents/Main.java @@ -0,0 +1,20 @@ +package com.easyagents; + +import com.easyagents.llm.openai.OpenAIChatConfig; +import com.easyagents.llm.openai.OpenAIChatModel; + +public class Main { + public static void main(String[] args) { + + OpenAIChatModel chatModel = OpenAIChatConfig.builder() + .provider("GiteeAI") + .endpoint("https://ai.gitee.com") + .requestPath("/v1/chat/completions") + .apiKey("P****QL7D12") + .model("Qwen3-32B") + .buildModel(); + + String output = chatModel.chat("如何才能更幽默?"); + System.out.println(output); + } +} diff --git a/easy-agents-samples/readme.md b/easy-agents-samples/readme.md new file mode 100644 index 0000000..28a39f1 --- /dev/null +++ b/easy-agents-samples/readme.md @@ -0,0 +1,3 @@ +# Easy-Agents 示例代码 + +建议从 hello world 开始 diff --git a/easy-agents-search-engine/easy-agents-search-engine-es/pom.xml b/easy-agents-search-engine/easy-agents-search-engine-es/pom.xml new file mode 100644 index 0000000..6f18ae3 --- /dev/null +++ b/easy-agents-search-engine/easy-agents-search-engine-es/pom.xml @@ -0,0 +1,42 @@ + + 4.0.0 + + com.easyagents + easy-agents-search-engine + ${revision} + + + easy-agents-search-engine-es + easy-agents-search-engine-es + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-search-engine-service + + + + com.easyagents + easy-agents-core + + + + co.elastic.clients + elasticsearch-java + 8.15.0 + + + com.fasterxml.jackson.core + jackson-databind + 2.15.2 + + + + diff --git a/easy-agents-search-engine/easy-agents-search-engine-es/src/main/java/com/easyagents/engine/es/ESConfig.java b/easy-agents-search-engine/easy-agents-search-engine-es/src/main/java/com/easyagents/engine/es/ESConfig.java new file mode 100644 index 0000000..3a432de --- /dev/null +++ b/easy-agents-search-engine/easy-agents-search-engine-es/src/main/java/com/easyagents/engine/es/ESConfig.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.engine.es; + +public class ESConfig { + private String host; + private String userName; + private String password; + private String indexName; + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getIndexName() { + return indexName; + } + + public void setIndexName(String indexName) { + this.indexName = indexName; + } + +} diff --git a/easy-agents-search-engine/easy-agents-search-engine-es/src/main/java/com/easyagents/engine/es/ElasticSearcher.java b/easy-agents-search-engine/easy-agents-search-engine-es/src/main/java/com/easyagents/engine/es/ElasticSearcher.java new file mode 100644 index 0000000..ab9b0c6 --- /dev/null +++ b/easy-agents-search-engine/easy-agents-search-engine-es/src/main/java/com/easyagents/engine/es/ElasticSearcher.java @@ -0,0 +1,223 @@ +package com.easyagents.engine.es; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.*; +import co.elastic.clients.elasticsearch.core.bulk.BulkOperation; +import co.elastic.clients.elasticsearch.core.bulk.IndexOperation; +import co.elastic.clients.json.JsonData; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.ElasticsearchTransport; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import com.easyagents.core.document.Document; +import com.easyagents.search.engine.service.DocumentSearcher; +import org.apache.http.HttpHost; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.elasticsearch.client.RestClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.util.*; + +public class ElasticSearcher implements DocumentSearcher { + + private static final Logger LOG = LoggerFactory.getLogger(ElasticSearcher.class); + + private final ESConfig esConfig; + + public ElasticSearcher(ESConfig esConfig) { + this.esConfig = esConfig; + } + + // 忽略 SSL 的 client 构建逻辑 + private RestClient buildRestClient() throws NoSuchAlgorithmException, KeyManagementException { + TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return null; + } + + public void checkClientTrusted(X509Certificate[] certs, String authType) { + } + + public void checkServerTrusted(X509Certificate[] certs, String authType) { + } + } + }; + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); + + CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + AuthScope.ANY, + new UsernamePasswordCredentials(esConfig.getUserName(), esConfig.getPassword())); + + return RestClient.builder(HttpHost.create(esConfig.getHost())) + .setHttpClientConfigCallback(httpClientBuilder -> { + httpClientBuilder.setSSLContext(sslContext); + httpClientBuilder.setSSLHostnameVerifier((hostname, session) -> true); + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + return httpClientBuilder; + }) + .build(); + } + + + /** + * 添加文档到Elasticsearch + */ + @Override + public boolean addDocument(Document document) { + if (document == null || document.getContent() == null) { + return false; + } + + RestClient restClient = null; + ElasticsearchTransport transport = null; + try { + restClient = buildRestClient(); + transport = new RestClientTransport(restClient, new JacksonJsonpMapper()); + ElasticsearchClient client = new ElasticsearchClient(transport); + + Map source = new HashMap<>(); + source.put("id", document.getId()); + source.put("content", document.getContent()); + if (document.getTitle() != null) { + source.put("title", document.getTitle()); + } + + String documentId = document.getId().toString(); + IndexOperation indexOp = IndexOperation.of(i -> i + .index(esConfig.getIndexName()) + .id(documentId) + .document(JsonData.of(source)) + ); + + BulkOperation bulkOp = BulkOperation.of(b -> b.index(indexOp)); + BulkRequest request = BulkRequest.of(b -> b.operations(Collections.singletonList(bulkOp))); + BulkResponse response = client.bulk(request); + return !response.errors(); + + } catch (Exception e) { + LOG.error(e.getMessage(), e); + return false; + } finally { + closeResources(transport, restClient); + } + } + + @Override + public List searchDocuments(String keyword, int count) { + RestClient restClient = null; + ElasticsearchTransport transport = null; + + try { + restClient = buildRestClient(); + transport = new RestClientTransport(restClient, new JacksonJsonpMapper()); + ElasticsearchClient client = new ElasticsearchClient(transport); + + SearchRequest request = SearchRequest.of(s -> s + .index(esConfig.getIndexName()) + .size(count) + .query(q -> q + .match(m -> m + .field("title") + .field("content") + .query(keyword) + ) + ) + ); + + SearchResponse response = client.search(request, Document.class); + List results = new ArrayList<>(); + response.hits().hits().forEach(hit -> results.add(hit.source())); + return results; + + } catch (Exception e) { + LOG.error(e.getMessage(), e); + return Collections.emptyList(); + } finally { + closeResources(transport, restClient); + } + } + + @Override + public boolean deleteDocument(Object id) { + if (id == null) { + return false; + } + + RestClient restClient = null; + ElasticsearchTransport transport = null; + try { + restClient = buildRestClient(); + transport = new RestClientTransport(restClient, new JacksonJsonpMapper()); + ElasticsearchClient client = new ElasticsearchClient(transport); + + DeleteRequest request = DeleteRequest.of(d -> d + .index(esConfig.getIndexName()) + .id(id.toString()) + ); + + DeleteResponse response = client.delete(request); + return response.result() == co.elastic.clients.elasticsearch._types.Result.Deleted; + + } catch (Exception e) { + LOG.error("Error deleting document with id: " + id, e); + return false; + } finally { + closeResources(transport, restClient); + } + } + + @Override + public boolean updateDocument(Document document) { + if (document == null || document.getId() == null) { + return false; + } + + RestClient restClient = null; + ElasticsearchTransport transport = null; + + try { + restClient = buildRestClient(); + transport = new RestClientTransport(restClient, new JacksonJsonpMapper()); + ElasticsearchClient client = new ElasticsearchClient(transport); + + UpdateRequest request = UpdateRequest.of(u -> u + .index(esConfig.getIndexName()) + .id(document.getId().toString()) + .doc(document) + ); + + UpdateResponse response = client.update(request, Object.class); + return response.result() == co.elastic.clients.elasticsearch._types.Result.Updated; + } catch (Exception e) { + LOG.error("Error updating document with id: " + document.getId(), e); + return false; + } finally { + closeResources(transport, restClient); + } + } + + + private void closeResources(AutoCloseable... closeables) { + for (AutoCloseable closeable : closeables) { + try { + if (closeable != null) + closeable.close(); + } catch (Exception e) { + LOG.error("Error closing resource", e); + } + } + } +} diff --git a/easy-agents-search-engine/easy-agents-search-engine-es/src/test/java/com/easyagents/search/engines/es/ElasticSearcherTest.java b/easy-agents-search-engine/easy-agents-search-engine-es/src/test/java/com/easyagents/search/engines/es/ElasticSearcherTest.java new file mode 100644 index 0000000..6d16f8d --- /dev/null +++ b/easy-agents-search-engine/easy-agents-search-engine-es/src/test/java/com/easyagents/search/engines/es/ElasticSearcherTest.java @@ -0,0 +1,42 @@ +package com.easyagents.search.engines.es; + +import com.easyagents.core.document.Document; +import com.easyagents.engine.es.ESConfig; +import com.easyagents.engine.es.ElasticSearcher; + +import java.math.BigInteger; +import java.util.List; + +public class ElasticSearcherTest { + public static void main(String[] args) throws Exception { + // 创建工具类实例 (忽略SSL证书,如果有认证则提供用户名密码) + ESConfig searcherConfig = new ESConfig(); + searcherConfig.setHost("https://127.0.0.1:9200"); + searcherConfig.setUserName("elastic"); + searcherConfig.setPassword("elastic"); + searcherConfig.setIndexName("aiknowledge"); + ElasticSearcher esUtil = new ElasticSearcher(searcherConfig); + Document document1 = new Document(); + document1.setContent("平台客服工具:是指拼多多平台开发并向商家提供的功能或工具,商家通过其专属账号登录平台客服工具后,可以与平台消费者取得\\n\" +\n" + + " \"联系并为消费者提供客户服务"); + document1.setId(BigInteger.valueOf(1)); + esUtil.addDocument(document1); + + Document document2 = new Document(); + document2.setId(2); + document2.setContent("document 2 的内容"); + document2.setTitle("document 2"); + esUtil.addDocument(document2); + + System.out.println("查询开始--------"); + List res = esUtil.searchDocuments("客服"); + res.forEach(System.out::println); + System.out.println("查询结束--------"); + + document1.setTitle("document 3"); + esUtil.updateDocument(document1); + +// esUtil.deleteDocument(1); + + } +} diff --git a/easy-agents-search-engine/easy-agents-search-engine-lucene/pom.xml b/easy-agents-search-engine/easy-agents-search-engine-lucene/pom.xml new file mode 100644 index 0000000..62623ce --- /dev/null +++ b/easy-agents-search-engine/easy-agents-search-engine-lucene/pom.xml @@ -0,0 +1,55 @@ + + 4.0.0 + + com.easyagents + easy-agents-search-engine + ${revision} + + + easy-agents-search-engine-lucene + easy-agents-search-engine-lucene + + + 2.6.3 + 8.11.1 + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + + + org.apache.lucene + lucene-core + ${lucene.version} + + + org.apache.lucene + lucene-analyzers-common + ${lucene.version} + + + org.apache.lucene + lucene-queryparser + ${lucene.version} + + + org.lionsoul + jcseg-core + 2.6.3 + + + org.lionsoul + jcseg-analyzer + 2.6.3 + + + com.easyagents + easy-agents-search-engine-service + + + diff --git a/easy-agents-search-engine/easy-agents-search-engine-lucene/src/main/java/com/easyagents/search/engine/lucene/LuceneConfig.java b/easy-agents-search-engine/easy-agents-search-engine-lucene/src/main/java/com/easyagents/search/engine/lucene/LuceneConfig.java new file mode 100644 index 0000000..4ab626f --- /dev/null +++ b/easy-agents-search-engine/easy-agents-search-engine-lucene/src/main/java/com/easyagents/search/engine/lucene/LuceneConfig.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.search.engine.lucene; + +public class LuceneConfig { + // lucene 目录 + private String indexDirPath; + + public String getIndexDirPath() { + return indexDirPath; + } + + public void setIndexDirPath(String indexDirPath) { + this.indexDirPath = indexDirPath; + } +} diff --git a/easy-agents-search-engine/easy-agents-search-engine-lucene/src/main/java/com/easyagents/search/engine/lucene/LuceneSearcher.java b/easy-agents-search-engine/easy-agents-search-engine-lucene/src/main/java/com/easyagents/search/engine/lucene/LuceneSearcher.java new file mode 100644 index 0000000..ab246ab --- /dev/null +++ b/easy-agents-search-engine/easy-agents-search-engine-lucene/src/main/java/com/easyagents/search/engine/lucene/LuceneSearcher.java @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.search.engine.lucene; + +import com.easyagents.core.document.Document; +import com.easyagents.search.engine.service.DocumentSearcher; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.document.Field; +import org.apache.lucene.document.StringField; +import org.apache.lucene.document.TextField; +import org.apache.lucene.index.*; +import org.apache.lucene.queryparser.classic.ParseException; +import org.apache.lucene.queryparser.classic.QueryParser; +import org.apache.lucene.search.*; +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.FSDirectory; +import org.jetbrains.annotations.NotNull; +import org.lionsoul.jcseg.ISegment; +import org.lionsoul.jcseg.analyzer.JcsegAnalyzer; +import org.lionsoul.jcseg.dic.DictionaryFactory; +import org.lionsoul.jcseg.segmenter.SegmenterConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class LuceneSearcher implements DocumentSearcher { + + private static final Logger LOG = LoggerFactory.getLogger(LuceneSearcher.class); + + private Directory directory; + + public LuceneSearcher(LuceneConfig config) { + Objects.requireNonNull(config, "LuceneConfig 不能为 null"); + try { + String indexDirPath = config.getIndexDirPath(); // 索引目录路径 + File indexDir = new File(indexDirPath); + if (!indexDir.exists() && !indexDir.mkdirs()) { + throw new IllegalStateException("can not mkdirs for path: " + indexDirPath); + } + + this.directory = FSDirectory.open(indexDir.toPath()); + } catch (IOException e) { + LOG.error("初始化 Lucene 索引失败", e); + throw new RuntimeException(e); + } + } + + @Override + public boolean addDocument(Document document) { + if (document == null || document.getContent() == null) return false; + + IndexWriter indexWriter = null; + try { + indexWriter = createIndexWriter(); + + org.apache.lucene.document.Document luceneDoc = new org.apache.lucene.document.Document(); + luceneDoc.add(new StringField("id", document.getId().toString(), Field.Store.YES)); + luceneDoc.add(new TextField("content", document.getContent(), Field.Store.YES)); + + if (document.getTitle() != null) { + luceneDoc.add(new TextField("title", document.getTitle(), Field.Store.YES)); + } + + + indexWriter.addDocument(luceneDoc); + indexWriter.commit(); + return true; + } catch (Exception e) { + LOG.error("添加文档失败", e); + return false; + } finally { + close(indexWriter); + } + } + + + @Override + public boolean deleteDocument(Object id) { + if (id == null) return false; + + IndexWriter indexWriter = null; + try { + indexWriter = createIndexWriter(); + Term term = new Term("id", id.toString()); + indexWriter.deleteDocuments(term); + indexWriter.commit(); + return true; + } catch (IOException e) { + LOG.error("删除文档失败", e); + return false; + } finally { + close(indexWriter); + } + } + + @Override + public boolean updateDocument(Document document) { + if (document == null || document.getId() == null) return false; + + IndexWriter indexWriter = null; + try { + indexWriter = createIndexWriter(); + Term term = new Term("id", document.getId().toString()); + + org.apache.lucene.document.Document luceneDoc = new org.apache.lucene.document.Document(); + luceneDoc.add(new StringField("id", document.getId().toString(), Field.Store.YES)); + luceneDoc.add(new TextField("content", document.getContent(), Field.Store.YES)); + + if (document.getTitle() != null) { + luceneDoc.add(new TextField("title", document.getTitle(), Field.Store.YES)); + } + + indexWriter.updateDocument(term, luceneDoc); + indexWriter.commit(); + return true; + } catch (IOException e) { + LOG.error("更新文档失败", e); + return false; + } finally { + close(indexWriter); + } + } + + @Override + public List searchDocuments(String keyword, int count) { + List results = new ArrayList<>(); + try (IndexReader reader = DirectoryReader.open(directory)) { + IndexSearcher searcher = new IndexSearcher(reader); + Query query = buildQuery(keyword); + TopDocs topDocs = searcher.search(query, count); + for (ScoreDoc scoreDoc : topDocs.scoreDocs) { + org.apache.lucene.document.Document doc = searcher.doc(scoreDoc.doc); + Document resultDoc = new Document(); + resultDoc.setId(doc.get("id")); + resultDoc.setContent(doc.get("content")); + resultDoc.setTitle(doc.get("title")); + + resultDoc.setScore((double) scoreDoc.score); + + results.add(resultDoc); + } + } catch (Exception e) { + LOG.error("搜索文档失败", e); + } + + return results; + } + + private static Query buildQuery(String keyword) { + try { + Analyzer analyzer = createAnalyzer(); + + QueryParser titleQueryParser = new QueryParser("title", analyzer); + Query titleQuery = titleQueryParser.parse(keyword); + BooleanClause titleBooleanClause = new BooleanClause(titleQuery, BooleanClause.Occur.SHOULD); + + QueryParser contentQueryParser = new QueryParser("content", analyzer); + Query contentQuery = contentQueryParser.parse(keyword); + BooleanClause contentBooleanClause = new BooleanClause(contentQuery, BooleanClause.Occur.SHOULD); + + BooleanQuery.Builder builder = new BooleanQuery.Builder(); + builder.add(titleBooleanClause) + .add(contentBooleanClause); + return builder.build(); + } catch (ParseException e) { + LOG.error(e.toString(), e); + } + return null; + } + + + @NotNull + private IndexWriter createIndexWriter() throws IOException { + Analyzer analyzer = createAnalyzer(); + IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer); + return new IndexWriter(directory, indexWriterConfig); + } + + + private static Analyzer createAnalyzer() { + SegmenterConfig config = new SegmenterConfig(true); + return new JcsegAnalyzer(ISegment.Type.NLP, config, DictionaryFactory.createSingletonDictionary(config)); + } + + public void close(IndexWriter indexWriter) { + try { + if (indexWriter != null) { + indexWriter.close(); + } + } catch (IOException e) { + LOG.error("关闭 Lucene 失败", e); + } + } +} diff --git a/easy-agents-search-engine/easy-agents-search-engine-lucene/src/test/java/com/easyagents/engines/test/TestLuceneCRUD.java b/easy-agents-search-engine/easy-agents-search-engine-lucene/src/test/java/com/easyagents/engines/test/TestLuceneCRUD.java new file mode 100644 index 0000000..41e33c7 --- /dev/null +++ b/easy-agents-search-engine/easy-agents-search-engine-lucene/src/test/java/com/easyagents/engines/test/TestLuceneCRUD.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.engines.test; + +import com.easyagents.core.document.Document; +import com.easyagents.search.engine.lucene.LuceneConfig; +import com.easyagents.search.engine.lucene.LuceneSearcher; + +import java.util.List; + +public class TestLuceneCRUD { + public static void main(String[] args) { + // 1. 配置 Lucene 索引路径 + LuceneConfig config = new LuceneConfig(); + config.setIndexDirPath("./2lucene_index"); // 设置索引目录路径 + // 2. 创建 LuceneSearcher 实例 + + LuceneSearcher luceneSearcher = new LuceneSearcher(config); + + // 文档ID(用于更新和删除) + // ---- Step 1: 添加文档 ---- + System.out.println("【添加文档】"); + Document doc1 = new Document(); + doc1.setId(1); + doc1.setTitle("利润最大化的原则"); + doc1.setContent("平台客服工具:是指拼多多平台开发并向企业提供的功能或工具,商家通过其专属账号登录平台客服工具后,可以与平台消费者取得\n" + + "联系并为消费者提供客户服务"); + + boolean addSuccess = luceneSearcher.addDocument(doc1); + System.out.println("添加文档1结果:" + (addSuccess ? "成功" : "失败")); + + + Document doc2 = new Document(); + doc2.setId(2); + doc2.setTitle("企业获取报酬的活动"); + doc2.setContent("研究如何最合理地分配稀缺资源及不同的用途"); + + boolean addSuccess1 = luceneSearcher.addDocument(doc2); + System.out.println("添加文档2结果:" + (addSuccess1 ? "成功" : "失败")); + + // 查询添加后的结果 + testSearch(luceneSearcher, "企业"); + testSearch(luceneSearcher, "报酬"); + + // ---- Step 2: 更新文档 ---- + System.out.println("\n【更新文档】"); + Document updatedDoc = new Document(); + updatedDoc.setId(1); + updatedDoc.setContent("平台客服工具:是指拼多多平台开发并向商家提供的功能或工具,商家通过其专属账号登录平台客服工具后,可以与平台消费者取得\n" + + "联系并为消费者提供客户服务2"); + + boolean updateSuccess = luceneSearcher.updateDocument(updatedDoc); + System.out.println("更新文档结果:" + (updateSuccess ? "成功" : "失败")); + + // 查询更新后的结果 + testSearch(luceneSearcher, "消费者"); + + // ---- Step 3: 删除文档 ---- + System.out.println("\n【删除文档】"); + boolean deleteSuccess = luceneSearcher.deleteDocument(2); + System.out.println("删除文档结果:" + (deleteSuccess ? "成功" : "失败")); + + // 查询删除后的结果 + testSearch(luceneSearcher, "报酬"); + + } + + // 封装一个搜索方法,打印搜索结果 + private static void testSearch(LuceneSearcher searcher, String keyword) { + List results = searcher.searchDocuments(keyword); + if (results.isEmpty()) { + System.out.println("没有找到匹配的文档。"); + } else { + System.out.println("找到 " + results.size() + " 个匹配文档:"); + for (com.easyagents.core.document.Document doc : results) { + System.out.println("ID: " + doc.getId()); + System.out.println("标题: " + doc.getTitle()); + System.out.println("内容: " + doc.getContent()); + System.out.println("-----------------------------"); + } + } + } +} diff --git a/easy-agents-search-engine/easy-agents-search-engine-service/pom.xml b/easy-agents-search-engine/easy-agents-search-engine-service/pom.xml new file mode 100644 index 0000000..66ff78a --- /dev/null +++ b/easy-agents-search-engine/easy-agents-search-engine-service/pom.xml @@ -0,0 +1,26 @@ + + 4.0.0 + + + com.easyagents + easy-agents-search-engine + ${revision} + + + easy-agents-search-engine-service + easy-agents-search-engine-service + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + + + diff --git a/easy-agents-search-engine/easy-agents-search-engine-service/src/main/java/com/easyagents/search/engine/service/DocumentSearcher.java b/easy-agents-search-engine/easy-agents-search-engine-service/src/main/java/com/easyagents/search/engine/service/DocumentSearcher.java new file mode 100644 index 0000000..99b4cdc --- /dev/null +++ b/easy-agents-search-engine/easy-agents-search-engine-service/src/main/java/com/easyagents/search/engine/service/DocumentSearcher.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.search.engine.service; + +import com.easyagents.core.document.Document; + +import java.util.List; + +public interface DocumentSearcher { + + boolean addDocument(Document document); + + boolean deleteDocument(Object id); + + boolean updateDocument(Document document); + + default List searchDocuments(String keyword) { + return searchDocuments(keyword, 10); + } + + List searchDocuments(String keyword, int count); +} diff --git a/easy-agents-search-engine/pom.xml b/easy-agents-search-engine/pom.xml new file mode 100644 index 0000000..aad4e08 --- /dev/null +++ b/easy-agents-search-engine/pom.xml @@ -0,0 +1,26 @@ + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-search-engine + easy-agents-search-engine + + pom + + + 8 + 8 + UTF-8 + + + + easy-agents-search-engine-service + easy-agents-search-engine-es + easy-agents-search-engine-lucene + + diff --git a/easy-agents-spring-boot-starter/pom.xml b/easy-agents-spring-boot-starter/pom.xml new file mode 100644 index 0000000..2bf7f0f --- /dev/null +++ b/easy-agents-spring-boot-starter/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-spring-boot-starter + easy-agents-spring-boot-starter + + + 8 + 8 + UTF-8 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + com.easyagents + easy-agents-bom + + + + + diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/deepseek/DeepSeekAutoConfiguration.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/deepseek/DeepSeekAutoConfiguration.java new file mode 100644 index 0000000..d8e8078 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/deepseek/DeepSeekAutoConfiguration.java @@ -0,0 +1,30 @@ +package com.easyagents.spring.boot.llm.deepseek; + +import com.easyagents.llm.deepseek.DeepseekConfig; +import com.easyagents.llm.deepseek.DeepseekChatModel; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Easy-Agents 大语言模型自动配置。 + * DeepSeek + */ +@ConditionalOnClass(DeepseekChatModel.class) +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(DeepSeekProperties.class) +public class DeepSeekAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public DeepseekChatModel deepseekLlm(DeepSeekProperties properties) { + DeepseekConfig config = new DeepseekConfig(); + config.setModel(properties.getModel()); + config.setEndpoint(properties.getEndpoint()); + config.setApiKey(properties.getApiKey()); + return new DeepseekChatModel(config); + } + +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/deepseek/DeepSeekProperties.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/deepseek/DeepSeekProperties.java new file mode 100644 index 0000000..451ecab --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/deepseek/DeepSeekProperties.java @@ -0,0 +1,36 @@ +package com.easyagents.spring.boot.llm.deepseek; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "easy-agents.llm.deepseek") +public class DeepSeekProperties { + + private String model = "deepseek-chat"; + private String endpoint = "https://api.deepseek.com"; + private String apiKey; + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/ollama/OllamaAutoConfiguration.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/ollama/OllamaAutoConfiguration.java new file mode 100644 index 0000000..c8f1fd6 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/ollama/OllamaAutoConfiguration.java @@ -0,0 +1,33 @@ +package com.easyagents.spring.boot.llm.ollama; + +import com.easyagents.llm.ollama.OllamaChatModel; +import com.easyagents.llm.ollama.OllamaChatConfig; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Easy-Agents Ollama自动配置。 + * + * @author hustlelr + * @since 2025-02-11 + */ +@ConditionalOnClass(OllamaChatModel.class) +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(OllamaProperties.class) +public class OllamaAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public OllamaChatModel ollamaLlm(OllamaProperties properties) { + OllamaChatConfig config = new OllamaChatConfig(); + config.setApiKey(properties.getApiKey()); + config.setEndpoint(properties.getEndpoint()); + config.setModel(properties.getModel()); + config.setThinkingEnabled(properties.getThink()); + return new OllamaChatModel(config); + } + +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/ollama/OllamaProperties.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/ollama/OllamaProperties.java new file mode 100644 index 0000000..d0aad5a --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/ollama/OllamaProperties.java @@ -0,0 +1,49 @@ +package com.easyagents.spring.boot.llm.ollama; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author hustlelr + * @since 2025-02-11 + */ +@ConfigurationProperties(prefix = "easy-agents.llm.ollama") +public class OllamaProperties { + + private String model; + private String endpoint = "http://localhost:11434"; + private String apiKey; + private Boolean think; + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public Boolean getThink() { + return think; + } + + public void setThink(Boolean think) { + this.think = think; + } + +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/openai/OpenAIAutoConfiguration.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/openai/OpenAIAutoConfiguration.java new file mode 100644 index 0000000..a023d01 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/openai/OpenAIAutoConfiguration.java @@ -0,0 +1,33 @@ +package com.easyagents.spring.boot.llm.openai; + +import com.easyagents.llm.openai.OpenAIChatModel; +import com.easyagents.llm.openai.OpenAIChatConfig; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Easy-Agents 大语言模型自动配置。 + * + * @author 王帅 + * @since 2024-04-10 + */ +@ConditionalOnClass(OpenAIChatModel.class) +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(OpenAIProperties.class) +public class OpenAIAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public OpenAIChatModel openAILlm(OpenAIProperties properties) { + OpenAIChatConfig config = new OpenAIChatConfig(); + config.setApiKey(properties.getApiKey()); + config.setEndpoint(properties.getEndpoint()); + config.setModel(properties.getModel()); + config.setRequestPath(properties.getRequestPath()); + return new OpenAIChatModel(config); + } + +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/openai/OpenAIProperties.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/openai/OpenAIProperties.java new file mode 100644 index 0000000..7d37f88 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/openai/OpenAIProperties.java @@ -0,0 +1,48 @@ +package com.easyagents.spring.boot.llm.openai; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author 王帅 + * @since 2024-04-10 + */ +@ConfigurationProperties(prefix = "easy-agents.llm.openai") +public class OpenAIProperties { + + private String model = "gpt-3.5-turbo"; + private String endpoint = "https://api.openai.com"; + private String apiKey; + private String requestPath = "/v1/chat/completions"; + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getRequestPath() { + return requestPath; + } + + public void setRequestPath(String requestPath) { + this.requestPath = requestPath; + } +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/qwen/QwenAutoConfiguration.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/qwen/QwenAutoConfiguration.java new file mode 100644 index 0000000..6e65606 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/qwen/QwenAutoConfiguration.java @@ -0,0 +1,32 @@ +package com.easyagents.spring.boot.llm.qwen; + +import com.easyagents.llm.qwen.QwenChatModel; +import com.easyagents.llm.qwen.QwenChatConfig; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Easy-Agents 大语言模型自动配置。 + * + * @author 王帅 + * @since 2024-04-10 + */ +@ConditionalOnClass(QwenChatModel.class) +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(QwenProperties.class) +public class QwenAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public QwenChatModel qwenLlm(QwenProperties properties) { + QwenChatConfig config = new QwenChatConfig(); + config.setApiKey(properties.getApiKey()); + config.setEndpoint(properties.getEndpoint()); + config.setModel(properties.getModel()); + return new QwenChatModel(config); + } + +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/qwen/QwenProperties.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/qwen/QwenProperties.java new file mode 100644 index 0000000..6d5fac3 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/llm/qwen/QwenProperties.java @@ -0,0 +1,40 @@ +package com.easyagents.spring.boot.llm.qwen; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author 王帅 + * @since 2024-04-10 + */ +@ConfigurationProperties(prefix = "easy-agents.llm.qwen") +public class QwenProperties { + + private String model = "qwen-turbo"; + private String endpoint = "https://dashscope.aliyuncs.com"; + private String apiKey; + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/aliyun/AliyunAutoConfiguration.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/aliyun/AliyunAutoConfiguration.java new file mode 100644 index 0000000..09e8016 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/aliyun/AliyunAutoConfiguration.java @@ -0,0 +1,31 @@ +package com.easyagents.spring.boot.store.aliyun; + +import com.easyagents.store.aliyun.AliyunVectorStore; +import com.easyagents.store.aliyun.AliyunVectorStoreConfig; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author 王帅 + * @since 2024-04-10 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(AliyunVectorStore.class) +@EnableConfigurationProperties(AliyunProperties.class) +public class AliyunAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public AliyunVectorStore aliyunVectorStore(AliyunProperties properties) { + AliyunVectorStoreConfig config = new AliyunVectorStoreConfig(); + config.setApiKey(properties.getApiKey()); + config.setEndpoint(properties.getEndpoint()); + config.setDatabase(properties.getDatabase()); + config.setDefaultCollectionName(properties.getDefaultCollectionName()); + return new AliyunVectorStore(config); + } + +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/aliyun/AliyunProperties.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/aliyun/AliyunProperties.java new file mode 100644 index 0000000..69cbd34 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/aliyun/AliyunProperties.java @@ -0,0 +1,49 @@ +package com.easyagents.spring.boot.store.aliyun; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author 王帅 + * @since 2024-04-10 + */ +@ConfigurationProperties(prefix = "easy-agents.store.aliyun") +public class AliyunProperties { + + private String endpoint; + private String apiKey; + private String database; + private String defaultCollectionName; + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } + + public String getDefaultCollectionName() { + return defaultCollectionName; + } + + public void setDefaultCollectionName(String defaultCollectionName) { + this.defaultCollectionName = defaultCollectionName; + } + +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/chroma/ChromaAutoConfiguration.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/chroma/ChromaAutoConfiguration.java new file mode 100644 index 0000000..4775cc0 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/chroma/ChromaAutoConfiguration.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.spring.boot.store.chroma; + +import com.easyagents.store.chroma.ChromaVectorStore; +import com.easyagents.store.chroma.ChromaVectorStoreConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Chroma自动配置类 + * + * @author easy-agents + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(ChromaVectorStore.class) +@EnableConfigurationProperties(ChromaProperties.class) +public class ChromaAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public ChromaVectorStore chromaVectorStore(ChromaProperties properties) { + ChromaVectorStoreConfig config = new ChromaVectorStoreConfig(); + config.setHost(properties.getHost()); + config.setPort(properties.getPort()); + config.setCollectionName(properties.getCollectionName()); + config.setAutoCreateCollection(properties.isAutoCreateCollection()); + config.setApiKey(properties.getApiKey()); + config.setTenant(properties.getTenant()); + config.setDatabase(properties.getDatabase()); + + return new ChromaVectorStore(config); + } +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/chroma/ChromaProperties.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/chroma/ChromaProperties.java new file mode 100644 index 0000000..eab190c --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/chroma/ChromaProperties.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.spring.boot.store.chroma; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Chroma配置属性类 + * + * @author easy-agents + */ +@ConfigurationProperties(prefix = "easy-agents.store.chroma") +public class ChromaProperties { + + private String host = "localhost"; + + private int port = 8000; + + private String collectionName = "default_collection"; + + private boolean autoCreateCollection = true; + + private String apiKey; + + private String tenant = "default_tenant"; + + private String database = "default_database"; + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public String getCollectionName() { + return collectionName; + } + + public void setCollectionName(String collectionName) { + this.collectionName = collectionName; + } + + public boolean isAutoCreateCollection() { + return autoCreateCollection; + } + + public void setAutoCreateCollection(boolean autoCreateCollection) { + this.autoCreateCollection = autoCreateCollection; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getTenant() { + return tenant; + } + + public void setTenant(String tenant) { + this.tenant = tenant; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/elasticsearch/ElasticSearchAutoConfiguration.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/elasticsearch/ElasticSearchAutoConfiguration.java new file mode 100644 index 0000000..5ee3dc3 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/elasticsearch/ElasticSearchAutoConfiguration.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.spring.boot.store.elasticsearch; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import com.easyagents.store.elasticsearch.ElasticSearchVectorStore; +import com.easyagents.store.elasticsearch.ElasticSearchVectorStoreConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author songyinyin + * @since 2024/8/13 上午11:26 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(ElasticSearchVectorStore.class) +@EnableConfigurationProperties(ElasticSearchProperties.class) +public class ElasticSearchAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public ElasticSearchVectorStore elasticSearchVectorStore(ElasticSearchProperties properties, + @Autowired(required = false) ElasticsearchClient client) { + ElasticSearchVectorStoreConfig config = new ElasticSearchVectorStoreConfig(); + config.setServerUrl(properties.getServerUrl()); + config.setApiKey(properties.getApiKey()); + config.setUsername(properties.getUsername()); + config.setPassword(properties.getPassword()); + config.setDefaultIndexName(properties.getDefaultIndexName()); + if (client != null) { + return new ElasticSearchVectorStore(config, client); + } + return new ElasticSearchVectorStore(config); + } +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/elasticsearch/ElasticSearchProperties.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/elasticsearch/ElasticSearchProperties.java new file mode 100644 index 0000000..2cfac46 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/elasticsearch/ElasticSearchProperties.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.spring.boot.store.elasticsearch; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author songyinyin + * @since 2024/8/13 上午11:25 + */ +@ConfigurationProperties(prefix = "easy-agents.store.elasticsearch") +public class ElasticSearchProperties { + + private String serverUrl = "https://localhost:9200"; + + private String apiKey; + + private String username; + + private String password; + + private String defaultIndexName = "easy-agents-default"; + + public String getServerUrl() { + return serverUrl; + } + + public void setServerUrl(String serverUrl) { + this.serverUrl = serverUrl; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getDefaultIndexName() { + return defaultIndexName; + } + + public void setDefaultIndexName(String defaultIndexName) { + this.defaultIndexName = defaultIndexName; + } +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/opensearch/OpenSearchAutoConfiguration.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/opensearch/OpenSearchAutoConfiguration.java new file mode 100644 index 0000000..4167d2c --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/opensearch/OpenSearchAutoConfiguration.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.spring.boot.store.opensearch; + +import com.easyagents.store.opensearch.OpenSearchVectorStore; +import com.easyagents.store.opensearch.OpenSearchVectorStoreConfig; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author songyinyin + * @since 2024/8/13 上午11:26 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(OpenSearchVectorStore.class) +@EnableConfigurationProperties(OpenSearchProperties.class) +public class OpenSearchAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public OpenSearchVectorStore openSearchVectorStore(OpenSearchProperties properties, + @Autowired(required = false) OpenSearchClient client) { + OpenSearchVectorStoreConfig config = new OpenSearchVectorStoreConfig(); + config.setServerUrl(properties.getServerUrl()); + config.setApiKey(properties.getApiKey()); + config.setUsername(properties.getUsername()); + config.setPassword(properties.getPassword()); + config.setDefaultIndexName(properties.getDefaultIndexName()); + if (client != null) { + return new OpenSearchVectorStore(config, client); + } + return new OpenSearchVectorStore(config); + } +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/opensearch/OpenSearchProperties.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/opensearch/OpenSearchProperties.java new file mode 100644 index 0000000..ee6e7af --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/opensearch/OpenSearchProperties.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.spring.boot.store.opensearch; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author songyinyin + * @since 2024/8/13 上午11:25 + */ +@ConfigurationProperties(prefix = "easy-agents.store.opensearch") +public class OpenSearchProperties { + + private String serverUrl = "https://localhost:9200"; + + private String apiKey; + + private String username; + + private String password; + + private String defaultIndexName = "easy-agents-default"; + + public String getServerUrl() { + return serverUrl; + } + + public void setServerUrl(String serverUrl) { + this.serverUrl = serverUrl; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getDefaultIndexName() { + return defaultIndexName; + } + + public void setDefaultIndexName(String defaultIndexName) { + this.defaultIndexName = defaultIndexName; + } +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/qcloud/QCloudProperties.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/qcloud/QCloudProperties.java new file mode 100644 index 0000000..286455a --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/qcloud/QCloudProperties.java @@ -0,0 +1,58 @@ +package com.easyagents.spring.boot.store.qcloud; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author 王帅 + * @since 2024-04-10 + */ +@ConfigurationProperties(prefix = "easy-agents.store.qcloud") +public class QCloudProperties { + + private String host; + private String apiKey; + private String account; + private String database; + private String defaultCollectionName; + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } + + public String getDefaultCollectionName() { + return defaultCollectionName; + } + + public void setDefaultCollectionName(String defaultCollectionName) { + this.defaultCollectionName = defaultCollectionName; + } + +} diff --git a/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/qcloud/QCloudStoreAutoConfiguration.java b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/qcloud/QCloudStoreAutoConfiguration.java new file mode 100644 index 0000000..3d85790 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/java/com/easyagents/spring/boot/store/qcloud/QCloudStoreAutoConfiguration.java @@ -0,0 +1,32 @@ +package com.easyagents.spring.boot.store.qcloud; + +import com.easyagents.store.qcloud.QCloudVectorStore; +import com.easyagents.store.qcloud.QCloudVectorStoreConfig; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author 王帅 + * @since 2024-04-10 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(QCloudVectorStore.class) +@EnableConfigurationProperties(QCloudProperties.class) +public class QCloudStoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public QCloudVectorStore qCloudVectorStore(QCloudProperties properties) { + QCloudVectorStoreConfig config = new QCloudVectorStoreConfig(); + config.setHost(properties.getHost()); + config.setApiKey(properties.getApiKey()); + config.setAccount(properties.getAccount()); + config.setDatabase(properties.getDatabase()); + config.setDefaultCollectionName(properties.getDefaultCollectionName()); + return new QCloudVectorStore(config); + } + +} diff --git a/easy-agents-spring-boot-starter/src/main/resources/META-INF/spring.factories b/easy-agents-spring-boot-starter/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..663545c --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/resources/META-INF/spring.factories @@ -0,0 +1,10 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + com.easyagents.spring.boot.chatModel.chatglm.ChatglmAutoConfiguration,\ + com.easyagents.spring.boot.chatModel.openai.OpenAIAutoConfiguration,\ + com.easyagents.spring.boot.chatModel.qwen.QwenAutoConfiguration,\ + com.easyagents.spring.boot.chatModel.spark.SparkAutoConfiguration,\ + com.easyagents.spring.boot.store.aliyun.AliyunAutoConfiguration,\ + com.easyagents.spring.boot.store.qcloud.QCloudStoreAutoConfiguration,\ + com.easyagents.spring.boot.chatModel.ollama.OllamaAutoConfiguration,\ + com.easyagents.spring.boot.chatModel.deepseek.DeepSeekAutoConfiguration,\ + com.easyagents.spring.boot.store.chroma.ChromaAutoConfiguration diff --git a/easy-agents-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/easy-agents-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..102f3d6 --- /dev/null +++ b/easy-agents-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,8 @@ +com.easyagents.spring.boot.chatModel.chatglm.ChatglmAutoConfiguration +com.easyagents.spring.boot.chatModel.openai.OpenAIAutoConfiguration +com.easyagents.spring.boot.chatModel.qwen.QwenAutoConfiguration +com.easyagents.spring.boot.chatModel.spark.SparkAutoConfiguration +com.easyagents.spring.boot.store.aliyun.AliyunAutoConfiguration +com.easyagents.spring.boot.store.qcloud.QCloudStoreAutoConfiguration +com.easyagents.spring.boot.chatModel.ollama.OllamaAutoConfiguration +com.easyagents.spring.boot.store.chroma.ChromaAutoConfiguration diff --git a/easy-agents-store/easy-agents-store-aliyun/pom.xml b/easy-agents-store/easy-agents-store-aliyun/pom.xml new file mode 100644 index 0000000..41bef1b --- /dev/null +++ b/easy-agents-store/easy-agents-store-aliyun/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + com.easyagents + easy-agents-store + ${revision} + + + easy-agents-store-aliyun + easy-agents-store-aliyun + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + + + + org.slf4j + slf4j-api + + + + + + diff --git a/easy-agents-store/easy-agents-store-aliyun/src/main/java/com/easyagents/store/aliyun/AliyunVectorStore.java b/easy-agents-store/easy-agents-store-aliyun/src/main/java/com/easyagents/store/aliyun/AliyunVectorStore.java new file mode 100644 index 0000000..22be39b --- /dev/null +++ b/easy-agents-store/easy-agents-store-aliyun/src/main/java/com/easyagents/store/aliyun/AliyunVectorStore.java @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.aliyun; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.StoreResult; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +/** + * 文档 https://help.aliyun.com/document_detail/2510317.html + */ +public class AliyunVectorStore extends DocumentStore { + private static final Logger LOG = LoggerFactory.getLogger(AliyunVectorStore.class); + + private AliyunVectorStoreConfig config; + + private final HttpClient httpUtil = new HttpClient(); + + public AliyunVectorStore(AliyunVectorStoreConfig config) { + this.config = config; + } + + @Override + public StoreResult doStore(List documents, StoreOptions options) { + if (documents == null || documents.isEmpty()) { + return StoreResult.success(); + } + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("dashvector-auth-token", config.getApiKey()); + + Map payloadMap = new HashMap<>(); + + List> payloadDocs = new ArrayList<>(); + for (Document vectorDocument : documents) { + Map document = new HashMap<>(); + if (vectorDocument.getMetadataMap() != null) { + document.put("fields", vectorDocument.getMetadataMap()); + } + document.put("vector", vectorDocument.getVector()); + document.put("id", vectorDocument.getId()); + payloadDocs.add(document); + } + + payloadMap.put("docs", payloadDocs); + + String payload = JSON.toJSONString(payloadMap); + String url = "https://" + config.getEndpoint() + "/v1/collections/" + + options.getCollectionNameOrDefault(config.getDefaultCollectionName()) + "/docs"; + String response = httpUtil.post(url, headers, payload); + + if (StringUtil.noText(response)) { + return StoreResult.fail(); + } + + JSONObject jsonObject = JSON.parseObject(response); + Integer code = jsonObject.getInteger("code"); + String message = jsonObject.getString("message"); + + if (code != null && code == 0 && "Success".equals(message)) { + + return StoreResult.successWithIds(documents); + } else { + LOG.error("delete vector fail: " + response); + return StoreResult.fail(message); + } + } + + + @Override + public StoreResult doDelete(Collection ids, StoreOptions options) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("dashvector-auth-token", config.getApiKey()); + + Map payloadMap = new HashMap<>(); + payloadMap.put("ids", ids); + String payload = JSON.toJSONString(payloadMap); + + String url = "https://" + config.getEndpoint() + "/v1/collections/" + + options.getCollectionNameOrDefault(config.getDefaultCollectionName()) + "/docs"; + String response = httpUtil.delete(url, headers, payload); + if (StringUtil.noText(response)) { + return StoreResult.fail(); + } + + JSONObject jsonObject = JSON.parseObject(response); + Integer code = jsonObject.getInteger("code"); + if (code != null && code == 0) { + return StoreResult.success(); + } else { + LOG.error("delete vector fail: " + response); + return StoreResult.fail(); + } + } + + + @Override + public StoreResult doUpdate(List documents, StoreOptions options) { + if (documents == null || documents.isEmpty()) { + return StoreResult.success(); + } + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("dashvector-auth-token", config.getApiKey()); + + Map payloadMap = new HashMap<>(); + + List> payloadDocs = new ArrayList<>(); + for (Document vectorDocument : documents) { + Map document = new HashMap<>(); + if (vectorDocument.getMetadataMap() != null) { + document.put("fields", vectorDocument.getMetadataMap()); + } + document.put("vector", vectorDocument.getVector()); + document.put("id", vectorDocument.getId()); + payloadDocs.add(document); + } + + payloadMap.put("docs", payloadDocs); + + String payload = JSON.toJSONString(payloadMap); + String url = "https://" + config.getEndpoint() + "/v1/collections/" + + options.getCollectionNameOrDefault(config.getDefaultCollectionName()) + "/docs"; + String response = httpUtil.put(url, headers, payload); + + if (StringUtil.noText(response)) { + return StoreResult.fail(); + } + + JSONObject jsonObject = JSON.parseObject(response); + Integer code = jsonObject.getInteger("code"); + if (code != null && code == 0) { + return StoreResult.successWithIds(documents); + } else { + LOG.error("delete vector fail: " + response); + return StoreResult.fail(); + } + + } + + + @Override + public List doSearch(SearchWrapper wrapper, StoreOptions options) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("dashvector-auth-token", config.getApiKey()); + + Map payloadMap = new HashMap<>(); + payloadMap.put("vector", wrapper.getVector()); + payloadMap.put("topk", wrapper.getMaxResults()); + payloadMap.put("include_vector", wrapper.isWithVector()); + payloadMap.put("filter", wrapper.toFilterExpression()); + + String payload = JSON.toJSONString(payloadMap); + String url = "https://" + config.getEndpoint() + "/v1/collections/" + + options.getCollectionNameOrDefault(config.getDefaultCollectionName()) + "/query"; + String result = httpUtil.post(url, headers, payload); + + if (StringUtil.noText(result)) { + return null; + } + + //https://help.aliyun.com/document_detail/2510319.html + JSONObject rootObject = JSON.parseObject(result); + int code = rootObject.getIntValue("code"); + if (code != 0) { + //error + LoggerFactory.getLogger(AliyunVectorStore.class).error("can not search data AliyunVectorStore(code: " + code + "), message: " + rootObject.getString("message")); + return null; + } + + JSONArray output = rootObject.getJSONArray("output"); + List documents = new ArrayList<>(output.size()); + for (int i = 0; i < output.size(); i++) { + JSONObject jsonObject = output.getJSONObject(i); + Document document = new Document(); + document.setId(jsonObject.getString("id")); + document.setVector(jsonObject.getObject("vector", float[].class)); + // 阿里云数据采用余弦相似度计算 jsonObject.getDoubleValue("score") 表示余弦距离, + // 原始余弦距离范围是[0, 2],0表示最相似,2表示最不相似 + Double distance = jsonObject.getDouble("score"); + if (distance != null) { + double score = distance / 2.0; + document.setScore(1.0d - score); + } + + + JSONObject fields = jsonObject.getJSONObject("fields"); + document.addMetadata(fields); + + documents.add(document); + } + + return documents; + } +} diff --git a/easy-agents-store/easy-agents-store-aliyun/src/main/java/com/easyagents/store/aliyun/AliyunVectorStoreConfig.java b/easy-agents-store/easy-agents-store-aliyun/src/main/java/com/easyagents/store/aliyun/AliyunVectorStoreConfig.java new file mode 100644 index 0000000..ff2fe85 --- /dev/null +++ b/easy-agents-store/easy-agents-store-aliyun/src/main/java/com/easyagents/store/aliyun/AliyunVectorStoreConfig.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.aliyun; + +import com.easyagents.core.store.DocumentStoreConfig; +import com.easyagents.core.util.StringUtil; + +/** + * https://help.aliyun.com/document_detail/2510317.html + */ +public class AliyunVectorStoreConfig implements DocumentStoreConfig { + private String endpoint; + private String apiKey; + private String database; + private String defaultCollectionName; + + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } + + public String getDefaultCollectionName() { + return defaultCollectionName; + } + + public void setDefaultCollectionName(String defaultCollectionName) { + this.defaultCollectionName = defaultCollectionName; + } + + @Override + public boolean checkAvailable() { + return StringUtil.hasText(this.endpoint, this.apiKey, this.database, this.defaultCollectionName); + } +} diff --git a/easy-agents-store/easy-agents-store-chroma/pom.xml b/easy-agents-store/easy-agents-store-chroma/pom.xml new file mode 100644 index 0000000..ed36ed0 --- /dev/null +++ b/easy-agents-store/easy-agents-store-chroma/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + com.easyagents + easy-agents-store + ${revision} + + + easy-agents-store-chroma + easy-agents-store-chroma + + + 8 + 8 + UTF-8 + + + + + + com.easyagents + easy-agents-core + + + + + + + junit + junit + 4.13.2 + test + + + \ No newline at end of file diff --git a/easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaExpressionAdaptor.java b/easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaExpressionAdaptor.java new file mode 100644 index 0000000..4c8cede --- /dev/null +++ b/easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaExpressionAdaptor.java @@ -0,0 +1,101 @@ +/* +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.chroma; + +import com.easyagents.core.store.condition.Condition; +import com.easyagents.core.store.condition.ConditionType; +import com.easyagents.core.store.condition.ExpressionAdaptor; +import com.easyagents.core.store.condition.Value; + +import java.util.StringJoiner; + +public class ChromaExpressionAdaptor implements ExpressionAdaptor { + + public static final ChromaExpressionAdaptor DEFAULT = new ChromaExpressionAdaptor(); + + @Override + public String toOperationSymbol(ConditionType type) { + if (type == ConditionType.EQ) { + return " == "; + } else if (type == ConditionType.NE) { + return " != "; + } else if (type == ConditionType.GT) { + return " > "; + } else if (type == ConditionType.GE) { + return " >= "; + } else if (type == ConditionType.LT) { + return " < "; + } else if (type == ConditionType.LE) { + return " <= "; + } else if (type == ConditionType.IN) { + return " IN "; + } + return type.getDefaultSymbol(); + } + + @Override + public String toCondition(Condition condition) { + if (condition.getType() == ConditionType.BETWEEN) { + Object[] values = (Object[]) ((Value) condition.getRight()).getValue(); + return "(" + toLeft(condition.getLeft()) + + toOperationSymbol(ConditionType.GE) + + values[0] + " && " + + toLeft(condition.getLeft()) + + toOperationSymbol(ConditionType.LE) + + values[1] + ")"; + } + + return ExpressionAdaptor.super.toCondition(condition); + } + + @Override + public String toValue(Condition condition, Object value) { + if (value == null) { + return "null"; + } + + if (condition.getType() == ConditionType.IN) { + Object[] values = (Object[]) value; + StringJoiner stringJoiner = new StringJoiner(",", "[", "]"); + for (Object v : values) { + if (v != null) { + stringJoiner.add("\"" + v + "\""); + } + } + return stringJoiner.toString(); + } else if (value instanceof String) { + return "\"" + value + "\""; + } else if (value instanceof Boolean) { + return ((Boolean) value).toString(); + } else if (value instanceof Number) { + return value.toString(); + } + + return ExpressionAdaptor.super.toValue(condition, value); + } + + public String toLeft(Object left) { + if (left instanceof String) { + String field = (String) left; + if (field.contains(".")) { + return field; + } + return field; + } + return left.toString(); + } +} diff --git a/easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaVectorStore.java b/easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaVectorStore.java new file mode 100644 index 0000000..2efb838 --- /dev/null +++ b/easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaVectorStore.java @@ -0,0 +1,794 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.chroma; + +import com.easyagents.core.document.Document; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.StoreResult; +import com.easyagents.core.store.condition.ExpressionAdaptor; +import com.easyagents.core.model.client.HttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; + +/** + * ChromaVectorStore class provides an interface to interact with Chroma Vector Database + * using direct HTTP calls to the Chroma REST API. + */ +public class ChromaVectorStore extends DocumentStore { + + private static final Logger logger = LoggerFactory.getLogger(ChromaVectorStore.class); + private final String baseUrl; + private final String collectionName; + private final String tenant; + private final String database; + private final ChromaVectorStoreConfig config; + private final ExpressionAdaptor expressionAdaptor; + private final HttpClient httpClient; + private final int MAX_RETRIES = 3; + private final long RETRY_INTERVAL_MS = 1000; + + private static final String BASE_API = "/api/v2"; + + public ChromaVectorStore(ChromaVectorStoreConfig config) { + Objects.requireNonNull(config, "ChromaVectorStoreConfig cannot be null"); + this.baseUrl = config.getBaseUrl(); + this.tenant = config.getTenant(); + this.database = config.getDatabase(); + this.collectionName = config.getCollectionName(); + this.config = config; + this.expressionAdaptor = ChromaExpressionAdaptor.DEFAULT; + + // 创建并配置HttpClient实例 + this.httpClient = createHttpClient(); + + // 验证配置的有效性 + validateConfig(); + + // 如果配置了自动创建集合,检查并创建集合 + if (config.isAutoCreateCollection()) { + try { + // 确保租户和数据库存在 + ensureTenantAndDatabaseExists(); + // 确保集合存在 + ensureCollectionExists(); + } catch (Exception e) { + logger.warn("Failed to ensure collection exists: {}. Will retry on first operation.", e.getMessage()); + } + } + } + + private HttpClient createHttpClient() { + HttpClient client = new HttpClient(); + return client; + } + + private void validateConfig() { + if (baseUrl == null || baseUrl.isEmpty()) { + throw new IllegalArgumentException("Base URL cannot be empty"); + } + + if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) { + throw new IllegalArgumentException("Base URL must start with http:// or https://"); + } + } + + /** + * 确保租户和数据库存在,如果不存在则创建 + */ + private void ensureTenantAndDatabaseExists() { + try { + // 检查并创建租户 + if (tenant != null && !tenant.isEmpty()) { + ensureTenantExists(); + + // 检查并创建数据库(如果租户已设置) + if (database != null && !database.isEmpty()) { + ensureDatabaseExists(); + } + } + } catch (Exception e) { + logger.error("Error ensuring tenant and database exist", e); + } + } + + /** + * 确保租户存在,如果不存在则创建 + */ + private void ensureTenantExists() throws IOException { + String tenantUrl = baseUrl + BASE_API + "/tenants/" + tenant; + Map headers = createHeaders(); + + try { + // 尝试获取租户信息 + String responseBody = executeWithRetry(() -> httpClient.get(tenantUrl, headers)); + logger.debug("Successfully verified tenant '{}' exists", tenant); + } catch (IOException e) { + // 如果获取失败,尝试创建租户 + logger.info("Creating tenant '{}' as it does not exist", tenant); + + Map requestBody = new HashMap<>(); + requestBody.put("name", tenant); + + String createTenantUrl = baseUrl + BASE_API + "/tenants"; + String jsonRequestBody = safeJsonSerialize(requestBody); + + String responseBody = executeWithRetry(() -> httpClient.post(createTenantUrl, headers, jsonRequestBody)); + logger.info("Successfully created tenant '{}'", tenant); + } + } + + /** + * 确保数据库存在,如果不存在则创建 + */ + private void ensureDatabaseExists() throws IOException { + if (tenant == null || tenant.isEmpty()) { + throw new IllegalStateException("Cannot create database without tenant"); + } + + String databaseUrl = baseUrl + BASE_API + "/tenants/" + tenant + "/databases/" + database; + Map headers = createHeaders(); + + try { + // 尝试获取数据库信息 + String responseBody = executeWithRetry(() -> httpClient.get(databaseUrl, headers)); + logger.debug("Successfully verified database '{}' exists in tenant '{}'", + database, tenant); + } catch (IOException e) { + // 如果获取失败,尝试创建数据库 + logger.info("Creating database '{}' in tenant '{}' as it does not exist", + database, tenant); + + Map requestBody = new HashMap<>(); + requestBody.put("name", database); + + String createDatabaseUrl = baseUrl + BASE_API + "/tenants/" + tenant + "/databases"; + String jsonRequestBody = safeJsonSerialize(requestBody); + + String responseBody = executeWithRetry(() -> httpClient.post(createDatabaseUrl, headers, jsonRequestBody)); + logger.info("Successfully created database '{}' in tenant '{}'", + database, tenant); + } + } + + /** + * 根据collectionName查询Collection ID + */ + private String getCollectionId(String collectionName) throws IOException { + String collectionsUrl = buildCollectionsUrl(); + Map headers = createHeaders(); + + String responseBody = executeWithRetry(() -> httpClient.get(collectionsUrl, headers)); + if (responseBody == null) { + throw new IOException("Failed to get collections, no response"); + } + + Object responseObj = parseJsonResponse(responseBody); + List> collections = new ArrayList<>(); + + // 处理不同格式的响应 + if (responseObj instanceof Map) { + Map responseMap = (Map) responseObj; + if (responseMap.containsKey("collections") && responseMap.get("collections") instanceof List) { + collections = (List>) responseMap.get("collections"); + } + } else if (responseObj instanceof List) { + List rawCollections = (List) responseObj; + for (Object item : rawCollections) { + if (item instanceof Map) { + collections.add((Map) item); + } + } + } + + // 查找指定名称的集合 + for (Map collection : collections) { + if (collection.containsKey("name") && collectionName.equals(collection.get("name"))) { + return collection.get("id").toString(); + } + } + + throw new IOException("Collection not found: " + collectionName); + } + + private void createCollection() throws IOException { + // 构建创建集合的API URL,包含tenant和database + String createCollectionUrl = buildCollectionsUrl(); + Map headers = createHeaders(); + + Map requestBody = new HashMap<>(); + requestBody.put("name", collectionName); + + String jsonRequestBody = safeJsonSerialize(requestBody); + + String responseBody = executeWithRetry(() -> httpClient.post(createCollectionUrl, headers, jsonRequestBody)); + if (responseBody == null) { + throw new IOException("Failed to create collection: no response"); + } + + try { + Object responseObj = parseJsonResponse(responseBody); + + Map responseMap = null; + if (responseObj instanceof Map) { + responseMap = (Map) responseObj; + } + if (responseMap.containsKey("error")) { + throw new IOException("Failed to create collection: " + responseMap.get("error")); + } + + logger.info("Collection '{}' created successfully", collectionName); + } catch (Exception e) { + throw new IOException("Failed to process collection creation response: " + e.getMessage(), e); + } + } + + @Override + public StoreResult doStore(List documents, StoreOptions options) { + Objects.requireNonNull(documents, "Documents cannot be null"); + + if (documents.isEmpty()) { + logger.debug("No documents to store"); + return StoreResult.success(); + } + + try { + // 确保集合存在 + ensureCollectionExists(); + + String collectionName = getCollectionName(options); + + List ids = new ArrayList<>(); + List> embeddings = new ArrayList<>(); + List> metadatas = new ArrayList<>(); + List documentsContent = new ArrayList<>(); + + for (Document doc : documents) { + ids.add(String.valueOf(doc.getId())); + + if (doc.getVector() != null) { + List embedding = doc.getVectorAsDoubleList(); + embeddings.add(embedding); + } else { + embeddings.add(null); + } + + Map metadata = doc.getMetadataMap() != null ? + new HashMap<>(doc.getMetadataMap()) : new HashMap<>(); + metadatas.add(metadata); + + documentsContent.add(doc.getContent()); + } + + Map requestBody = new HashMap<>(); + requestBody.put("ids", ids); + requestBody.put("embeddings", embeddings); + requestBody.put("metadatas", metadatas); + requestBody.put("documents", documentsContent); + + String collectionId = getCollectionId(collectionName); + + // 构建包含tenant和database的完整URL + String collectionUrl = buildCollectionUrl(collectionId, "add"); + + Map headers = createHeaders(); + + String jsonRequestBody = safeJsonSerialize(requestBody); + + logger.debug("Storing {} documents to collection '{}'", documents.size(), collectionName); + + String responseBody = executeWithRetry(() -> httpClient.post(collectionUrl, headers, jsonRequestBody)); + if (responseBody == null) { + logger.error("Error storing documents: no response"); + return StoreResult.fail(); + } + + Object responseObj = parseJsonResponse(responseBody); + + Map responseMap = null; + if (responseObj instanceof Map) { + responseMap = (Map) responseObj; + } + if (responseMap.containsKey("error")) { + String errorMsg = "Error storing documents: " + responseMap.get("error"); + logger.error(errorMsg); + return StoreResult.fail(); + } + + logger.debug("Successfully stored {} documents", documents.size()); + return StoreResult.successWithIds(documents); + } catch (Exception e) { + logger.error("Error storing documents to Chroma", e); + return StoreResult.fail(); + } + } + + @Override + public StoreResult doDelete(Collection ids, StoreOptions options) { + Objects.requireNonNull(ids, "IDs cannot be null"); + + if (ids.isEmpty()) { + logger.debug("No IDs to delete"); + return StoreResult.success(); + } + + try { + // 确保集合存在 + ensureCollectionExists(); + + String collectionName = getCollectionName(options); + + List stringIds = ids.stream() + .map(Object::toString) + .collect(Collectors.toList()); + Map requestBody = new HashMap<>(); + requestBody.put("ids", stringIds); + + String collectionId = getCollectionId(collectionName); + + // 构建包含tenant和database的完整URL + String collectionUrl = buildCollectionUrl(collectionId, "delete"); + + Map headers = createHeaders(); + + String jsonRequestBody = safeJsonSerialize(requestBody); + + logger.debug("Deleting {} documents from collection '{}'", ids.size(), collectionName); + + String responseBody = executeWithRetry(() -> httpClient.post(collectionUrl, headers, jsonRequestBody)); + if (responseBody == null) { + logger.error("Error deleting documents: no response"); + return StoreResult.fail(); + } + + Object responseObj = parseJsonResponse(responseBody); + + Map responseMap = null; + if (responseObj instanceof Map) { + responseMap = (Map) responseObj; + } + if (responseMap.containsKey("error")) { + String errorMsg = "Error deleting documents: " + responseMap.get("error"); + logger.error(errorMsg); + return StoreResult.fail(); + } + + logger.debug("Successfully deleted {} documents", ids.size()); + return StoreResult.success(); + } catch (Exception e) { + logger.error("Error deleting documents from Chroma", e); + return StoreResult.fail(); + } + } + + @Override + public StoreResult doUpdate(List documents, StoreOptions options) { + Objects.requireNonNull(documents, "Documents cannot be null"); + + if (documents.isEmpty()) { + logger.debug("No documents to update"); + return StoreResult.success(); + } + + try { + // Chroma doesn't support direct update, so we delete and re-add + List ids = documents.stream().map(Document::getId).collect(Collectors.toList()); + StoreResult deleteResult = doDelete(ids, options); + + if (!deleteResult.isSuccess()) { + logger.warn("Delete failed during update operation: {}", deleteResult.toString()); + // 尝试继续添加,因为可能有些文档是新的 + } + + StoreResult storeResult = doStore(documents, options); + + if (storeResult.isSuccess()) { + logger.debug("Successfully updated {} documents", documents.size()); + } + + return storeResult; + } catch (Exception e) { + logger.error("Error updating documents in Chroma", e); + return StoreResult.fail(); + } + } + + @Override + public List doSearch(SearchWrapper wrapper, StoreOptions options) { + Objects.requireNonNull(wrapper, "SearchWrapper cannot be null"); + + try { + // 确保集合存在 + ensureCollectionExists(); + + String collectionName = getCollectionName(options); + + int limit = wrapper.getMaxResults() > 0 ? wrapper.getMaxResults() : 10; + + Map requestBody = new HashMap<>(); + // 检查查询条件是否有效 + if (wrapper.getVector() == null && wrapper.getText() == null) { + throw new IllegalArgumentException("Either vector or text must be provided for search"); + } + + // 设置查询向量 + if (wrapper.getVector() != null) { + List queryEmbedding = wrapper.getVectorAsDoubleList(); + requestBody.put("query_embeddings", Collections.singletonList(queryEmbedding)); + logger.debug("Performing vector search with dimension: {}", queryEmbedding.size()); + } else if (wrapper.getText() != null) { + requestBody.put("query_texts", Collections.singletonList(wrapper.getText())); + logger.debug("Performing text search: {}", sanitizeLogString(wrapper.getText(), 100)); + } + + // 设置返回数量 + requestBody.put("n_results", limit); + + // 设置过滤条件 + if (wrapper.getCondition() != null) { + try { + String whereClause = expressionAdaptor.toCondition(wrapper.getCondition()); + // Chroma的where条件是JSON对象,需要解析 + Object whereObj = parseJsonResponse(whereClause); + + Map whereMap = null; + if (whereObj instanceof Map) { + whereMap = (Map) whereObj; + } + requestBody.put("where", whereMap); + logger.debug("Search with filter condition: {}", whereClause); + } catch (Exception e) { + logger.warn("Failed to parse filter condition: {}, ignoring condition", e.getMessage()); + } + } + + String collectionId = getCollectionId(collectionName); + + // 构建包含tenant和database的完整URL + String collectionUrl = buildCollectionUrl(collectionId, "query"); + + Map headers = createHeaders(); + + String jsonRequestBody = safeJsonSerialize(requestBody); + + String responseBody = executeWithRetry(() -> httpClient.post(collectionUrl, headers, jsonRequestBody)); + if (responseBody == null) { + logger.error("Error searching documents: no response"); + return Collections.emptyList(); + } + + + Object responseObj = parseJsonResponse(responseBody); + + Map responseMap = null; + if (responseObj instanceof Map) { + responseMap = (Map) responseObj; + } + + // 检查响应是否包含error字段 + if (responseMap.containsKey("error")) { + logger.error("Error searching documents: {}", responseMap.get("error")); + return Collections.emptyList(); + } + + // 解析结果 + return parseSearchResults(responseMap); + } catch (Exception e) { + logger.error("Error searching documents in Chroma", e); + return Collections.emptyList(); + } + } + + /** + * 支持直接使用向量数组和topK参数的搜索方法 + */ + public List searchInternal(double[] vector, int topK, StoreOptions options) { + Objects.requireNonNull(vector, "Vector cannot be null"); + + if (topK <= 0) { + topK = 10; + } + + try { + // 确保集合存在 + ensureCollectionExists(); + + String collectionName = getCollectionName(options); + + Map requestBody = new HashMap<>(); + + // 设置查询向量 + List queryEmbedding = Arrays.stream(vector) + .boxed() + .collect(Collectors.toList()); + requestBody.put("query_embeddings", Collections.singletonList(queryEmbedding)); + + // 设置返回数量 + requestBody.put("n_results", topK); + + String collectionId = getCollectionId(collectionName); + + // 构建包含tenant和database的完整URL + String collectionUrl = buildCollectionUrl(collectionId, "query"); + + Map headers = createHeaders(); + + String jsonRequestBody = safeJsonSerialize(requestBody); + + logger.debug("Performing direct vector search with dimension: {}", vector.length); + + String responseBody = executeWithRetry(() -> httpClient.post(collectionUrl, headers, jsonRequestBody)); + if (responseBody == null) { + logger.error("Error searching documents: no response"); + return Collections.emptyList(); + } + + Object responseObj = parseJsonResponse(responseBody); + + Map responseMap = null; + if (responseObj instanceof Map) { + responseMap = (Map) responseObj; + } + + // 检查响应是否包含error字段 + if (responseMap.containsKey("error")) { + logger.error("Error searching documents: {}", responseMap.get("error")); + return Collections.emptyList(); + } + + // 解析结果 + return parseSearchResults(responseMap); + } catch (Exception e) { + logger.error("Error searching documents in Chroma", e); + return Collections.emptyList(); + } + } + + private List parseSearchResults(Map responseMap) { + try { + List ids = extractResultsFromNestedList(responseMap, "ids"); + List documents = extractResultsFromNestedList(responseMap, "documents"); + List> metadatas = extractResultsFromNestedList(responseMap, "metadatas"); + List> embeddings = extractResultsFromNestedList(responseMap, "embeddings"); + List distances = extractResultsFromNestedList(responseMap, "distances"); + + if (ids == null || ids.isEmpty()) { + logger.debug("No documents found in search results"); + return Collections.emptyList(); + } + + // 转换为Easy-Agents的Document格式 + List resultDocs = new ArrayList<>(); + for (int i = 0; i < ids.size(); i++) { + Document doc = new Document(); + doc.setId(ids.get(i)); + + if (documents != null && i < documents.size()) { + doc.setContent(documents.get(i)); + } + + if (metadatas != null && i < metadatas.size()) { + doc.setMetadataMap(metadatas.get(i)); + } + + if (embeddings != null && i < embeddings.size() && embeddings.get(i) != null) { + doc.setVector(embeddings.get(i)); + } + + // 设置相似度分数(距离越小越相似) + if (distances != null && i < distances.size()) { + double score = 1.0 - distances.get(i); + // 确保分数在合理范围内 + score = Math.max(0, Math.min(1, score)); + doc.setScore(score); + } + + resultDocs.add(doc); + } + + logger.debug("Found {} documents in search results", resultDocs.size()); + return resultDocs; + } catch (Exception e) { + logger.error("Failed to parse search results", e); + return Collections.emptyList(); + } + } + + @SuppressWarnings("unchecked") + private List extractResultsFromNestedList(Map responseMap, String key) { + try { + if (!responseMap.containsKey(key)) { + return null; + } + + List outerList = (List) responseMap.get(key); + if (outerList == null || outerList.isEmpty()) { + return null; + } + + // Chroma返回的结果是嵌套列表,第一个元素是当前查询的结果 + return (List) outerList.get(0); + } catch (Exception e) { + logger.warn("Failed to extract '{}' from response: {}", key, e.getMessage()); + return null; + } + } + + private Map createHeaders() { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + + if (config.getApiKey() != null && !config.getApiKey().isEmpty()) { + headers.put("X-Chroma-Token", config.getApiKey()); + } + + // 添加租户和数据库信息(如果配置了) + if (tenant != null && !tenant.isEmpty()) { + headers.put("X-Chroma-Tenant", tenant); + } + + if (database != null && !database.isEmpty()) { + headers.put("X-Chroma-Database", database); + } + + return headers; + } + + private T executeWithRetry(HttpOperation operation) throws IOException { + int attempts = 0; + IOException lastException = null; + + while (attempts < MAX_RETRIES) { + try { + attempts++; + return operation.execute(); + } catch (IOException e) { + lastException = e; + + // 如果是最后一次尝试,则抛出异常 + if (attempts >= MAX_RETRIES) { + throw new IOException("Operation failed after " + MAX_RETRIES + " attempts: " + e.getMessage(), e); + } + + // 记录重试信息 + logger.warn("Operation failed (attempt {} of {}), retrying in {}ms: {}", + attempts, MAX_RETRIES, RETRY_INTERVAL_MS, e.getMessage()); + + // 等待一段时间后重试 + try { + Thread.sleep(RETRY_INTERVAL_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IOException("Retry interrupted", ie); + } + } + } + + // 这一行理论上不会执行到,但为了编译器满意 + throw lastException != null ? lastException : new IOException("Operation failed without exception"); + } + + private String safeJsonSerialize(Map map) { + // 使用标准的JSON序列化,但在实际应用中可以添加更多的安全检查 + try { + return new com.google.gson.Gson().toJson(map); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize request body to JSON", e); + } + } + + private Object parseJsonResponse(String json) { + try { + if (json == null || json.trim().isEmpty()) { + return null; + } + // Check if JSON starts with [ indicating an array + if (json.trim().startsWith("[")) { + return new com.google.gson.Gson().fromJson(json, List.class); + } else { + // Otherwise assume it's an object + return new com.google.gson.Gson().fromJson(json, Map.class); + } + } catch (Exception e) { + throw new RuntimeException("Failed to parse JSON response: " + json, e); + } + } + + private String sanitizeLogString(String input, int maxLength) { + if (input == null) { + return null; + } + + String sanitized = input.replaceAll("[\n\r]", " "); + return sanitized.length() > maxLength ? sanitized.substring(0, maxLength) + "..." : sanitized; + } + + private String getCollectionName(StoreOptions options) { + return options != null ? options.getCollectionNameOrDefault(collectionName) : collectionName; + } + + /** + * 构建特定集合操作的URL,包含tenant和database + */ + private String buildCollectionUrl(String collectionId, String operation) { + StringBuilder urlBuilder = new StringBuilder(baseUrl).append(BASE_API); + + if (tenant != null && !tenant.isEmpty()) { + urlBuilder.append("/tenants/").append(tenant); + + if (database != null && !database.isEmpty()) { + urlBuilder.append("/databases/").append(database); + } + } + + urlBuilder.append("/collections/").append(collectionId).append("/").append(operation); + return urlBuilder.toString(); + } + + /** + * Close the connection to Chroma database + */ + public void close() { + // HttpClient类使用连接池管理,这里可以添加额外的资源清理逻辑 + logger.info("Chroma client closed"); + } + + /** + * 确保集合存在,如果不存在则创建 + */ + private void ensureCollectionExists() throws IOException { + try { + // 尝试获取默认集合ID,如果能获取到则说明集合存在 + getCollectionId(collectionName); + logger.debug("Collection '{}' exists", collectionName); + } catch (IOException e) { + // 如果获取集合ID失败,说明集合不存在,需要创建 + logger.info("Collection '{}' does not exist, creating...", collectionName); + createCollection(); + logger.info("Collection '{}' created successfully", collectionName); + } + } + + /** + * 构建集合列表URL,包含tenant和database + */ + private String buildCollectionsUrl() { + StringBuilder urlBuilder = new StringBuilder(baseUrl).append(BASE_API); + + if (tenant != null && !tenant.isEmpty()) { + urlBuilder.append("/tenants/").append(tenant); + + if (database != null && !database.isEmpty()) { + urlBuilder.append("/databases/").append(database); + } + } + + urlBuilder.append("/collections"); + return urlBuilder.toString(); + } + + /** + * 函数式接口,用于封装HTTP操作以支持重试 + */ + private interface HttpOperation { + T execute() throws IOException; + } +} diff --git a/easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaVectorStoreConfig.java b/easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaVectorStoreConfig.java new file mode 100644 index 0000000..7d2667a --- /dev/null +++ b/easy-agents-store/easy-agents-store-chroma/src/main/java/com/easyagents/store/chroma/ChromaVectorStoreConfig.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.chroma; + +import com.easyagents.core.store.DocumentStoreConfig; +import com.easyagents.core.util.StringUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +/** + * ChromaVectorStoreConfig class provides configuration for ChromaVectorStore. + */ +public class ChromaVectorStoreConfig implements DocumentStoreConfig { + private static final Logger logger = LoggerFactory.getLogger(ChromaVectorStoreConfig.class); + + private String host = "localhost"; + private int port = 8000; + private String collectionName; + private boolean autoCreateCollection = true; + private String apiKey; + private String tenant; + private String database; + + public ChromaVectorStoreConfig() { + } + + /** + * Get the host of Chroma database + * + * @return the host of Chroma database + */ + public String getHost() { + return host; + } + + /** + * Set the host of Chroma database + * + * @param host the host of Chroma database + */ + public void setHost(String host) { + this.host = host; + } + + /** + * Get the port of Chroma database + * + * @return the port of Chroma database + */ + public int getPort() { + return port; + } + + /** + * Set the port of Chroma database + * + * @param port the port of Chroma database + */ + public void setPort(int port) { + this.port = port; + } + + /** + * Get the collection name of Chroma database + * + * @return the collection name of Chroma database + */ + public String getCollectionName() { + return collectionName; + } + + /** + * Set the collection name of Chroma database + * + * @param collectionName the collection name of Chroma database + */ + public void setCollectionName(String collectionName) { + this.collectionName = collectionName; + } + + /** + * Get whether to automatically create the collection if it doesn't exist + * + * @return true if the collection should be created automatically, false otherwise + */ + public boolean isAutoCreateCollection() { + return autoCreateCollection; + } + + /** + * Set whether to automatically create the collection if it doesn't exist + * + * @param autoCreateCollection true if the collection should be created automatically, false otherwise + */ + public void setAutoCreateCollection(boolean autoCreateCollection) { + this.autoCreateCollection = autoCreateCollection; + } + + /** + * Get the API key of Chroma database + * + * @return the API key of Chroma database + */ + public String getApiKey() { + return apiKey; + } + + /** + * Set the API key of Chroma database + * + * @param apiKey the API key of Chroma database + */ + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + /** + * Get the tenant of Chroma database + * + * @return the tenant of Chroma database + */ + public String getTenant() { + return tenant; + } + + /** + * Set the tenant of Chroma database + * + * @param tenant the tenant of Chroma database + */ + public void setTenant(String tenant) { + this.tenant = tenant; + } + + /** + * Get the database of Chroma database + * + * @return the database of Chroma database + */ + public String getDatabase() { + return database; + } + + /** + * Set the database of Chroma database + * + * @param database the database of Chroma database + */ + public void setDatabase(String database) { + this.database = database; + } + + @Override + public boolean checkAvailable() { + try { + URL url = new URL(getBaseUrl() + "/api/v2/heartbeat"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + + if (apiKey != null && !apiKey.isEmpty()) { + connection.setRequestProperty("X-Chroma-Token", apiKey); + } + + int responseCode = connection.getResponseCode(); + connection.disconnect(); + + return responseCode == 200; + } catch (IOException e) { + logger.warn("Chroma database is not available: {}", e.getMessage()); + return false; + } + } + + /** + * Get the base URL of Chroma database + * + * @return the base URL of Chroma database + */ + public String getBaseUrl() { + return "http://" + host + ":" + port; + } +} diff --git a/easy-agents-store/easy-agents-store-chroma/src/test/java/com/easyagents/store/chroma/ChromaVectorStoreTest.java b/easy-agents-store/easy-agents-store-chroma/src/test/java/com/easyagents/store/chroma/ChromaVectorStoreTest.java new file mode 100644 index 0000000..e643e98 --- /dev/null +++ b/easy-agents-store/easy-agents-store-chroma/src/test/java/com/easyagents/store/chroma/ChromaVectorStoreTest.java @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.chroma; + +import com.easyagents.core.document.Document; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.StoreResult; +import com.easyagents.core.model.client.HttpClient; +import org.junit.AfterClass; +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.*; + +/** + * ChromaVectorStore的测试类,测试文档的存储、搜索、更新和删除功能 + * 包含连接检查和错误处理机制,支持在无真实Chroma服务器时跳过测试 + */ +public class ChromaVectorStoreTest { + + private static ChromaVectorStore store; + private static String testTenant = "default_tenant"; + private static String testDatabase = "default_database"; + private static String testCollectionName = "test_collection"; + private static boolean isChromaAvailable = false; + private static boolean useMock = false; // 设置为true可以在没有真实Chroma服务器时使用模拟模式 + + /** + * 在测试开始前初始化ChromaVectorStore实例 + */ + @BeforeClass + public static void setUp() { + // 创建配置对象 + ChromaVectorStoreConfig config = new ChromaVectorStoreConfig(); + config.setHost("localhost"); + config.setPort(8000); + config.setCollectionName(testCollectionName); + config.setTenant(testTenant); + config.setDatabase(testDatabase); + config.setAutoCreateCollection(true); + + // 初始化存储实例 + try { + store = new ChromaVectorStore(config); + System.out.println("ChromaVectorStore initialized successfully."); + + // 检查连接是否可用 + isChromaAvailable = checkChromaConnection(config); + + if (!isChromaAvailable && !useMock) { + System.out.println("Chroma server is not available. Tests will be skipped unless useMock is set to true."); + } + } catch (Exception e) { + System.err.println("Failed to initialize ChromaVectorStore: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * 检查Chroma服务器连接是否可用 + */ + private static boolean checkChromaConnection(ChromaVectorStoreConfig config) { + try { + String baseUrl = "http://" + config.getHost() + ":" + config.getPort(); + String healthCheckUrl = baseUrl + "/api/v2/heartbeat"; + + HttpClient httpClient = new HttpClient(); + System.out.println("Checking Chroma server connection at: " + healthCheckUrl); + + // 使用较短的超时时间进行健康检查 + String response = httpClient.get(healthCheckUrl); + if (response != null) { + System.out.println("Chroma server connection successful! Response: " + response); + return true; + } else { + System.out.println("Chroma server connection failed: Empty response"); + return false; + } + } catch (Exception e) { + System.out.println("Chroma server connection failed: " + e.getMessage()); + System.out.println("Please ensure Chroma server is running on http://" + config.getHost() + ":" + config.getPort()); + System.out.println("To run tests without a real Chroma server, set 'useMock = true'"); + return false; + } + } + + /** + * 检查是否应该运行测试 + */ + private void assumeChromaAvailable() { + Assume.assumeTrue("Chroma server is not available and mock mode is disabled", + isChromaAvailable || useMock); + } + + /** + * 在所有测试完成后清理资源 + */ + @AfterClass + public static void tearDown() { + if (store != null) { + try { + store.close(); + System.out.println("ChromaVectorStore closed successfully."); + } catch (Exception e) { + System.err.println("Error closing ChromaVectorStore: " + e.getMessage()); + } + } + } + + /** + * 测试存储文档功能 + */ + @Test + public void testStoreDocuments() { + assumeChromaAvailable(); + + System.out.println("Starting testStoreDocuments..."); + + // 创建测试文档 + List documents = createTestDocuments(); + + // 如果使用模拟模式,直接返回成功结果 + if (useMock) { + System.out.println("Running in mock mode. Simulating store operation."); + StoreResult mockResult = StoreResult.successWithIds(documents); + assertTrue("Mock store operation should be successful", mockResult.isSuccess()); + assertEquals("All document IDs should be returned in mock mode", + documents.size(), mockResult.ids().size()); + System.out.println("testStoreDocuments completed successfully in mock mode."); + return; + } + + // 存储文档 + try { + StoreResult result = store.doStore(documents, StoreOptions.DEFAULT); + System.out.println("Store result: " + result); + + // 验证存储是否成功 + assertTrue("Store operation should be successful", result.isSuccess()); + assertEquals("All document IDs should be returned", documents.size(), result.ids().size()); + + System.out.println("testStoreDocuments completed successfully."); + } catch (Exception e) { + System.err.println("Failed to store documents: " + e.getMessage()); + e.printStackTrace(); + fail("Store operation failed with exception: " + e.getMessage()); + } + } + + /** + * 测试搜索文档功能 + */ + @Test + public void testSearchDocuments() { + assumeChromaAvailable(); + + System.out.println("Starting testSearchDocuments..."); + + // 创建测试文档 + List documents = createTestDocuments(); + + // 如果使用模拟模式 + if (useMock) { + System.out.println("Running in mock mode. Simulating search operation."); + // 模拟搜索结果,返回前3个文档 + List mockResults = new ArrayList<>(documents.subList(0, Math.min(3, documents.size()))); + for (int i = 0; i < mockResults.size(); i++) { + mockResults.get(i).setScore(1.0 - i * 0.1); // 模拟相似度分数 + } + + // 验证模拟结果 + assertNotNull("Mock search results should not be null", mockResults); + assertFalse("Mock search results should not be empty", mockResults.isEmpty()); + assertTrue("Mock search results should have the correct maximum size", mockResults.size() <= 3); + + System.out.println("testSearchDocuments completed successfully in mock mode."); + return; + } + + try { + // 首先存储一些测试文档 + store.doStore(documents, StoreOptions.DEFAULT); + + // 创建搜索包装器 + SearchWrapper searchWrapper = new SearchWrapper(); + // 使用第一个文档的向量进行搜索 + searchWrapper.setVector(documents.get(0).getVector()); + searchWrapper.setMaxResults(3); + + // 执行搜索 + List searchResults = store.doSearch(searchWrapper, StoreOptions.DEFAULT); + + // 验证搜索结果 + assertNotNull("Search results should not be null", searchResults); + assertFalse("Search results should not be empty", searchResults.isEmpty()); + assertTrue("Search results should have the correct maximum size", + searchResults.size() <= searchWrapper.getMaxResults()); + + // 打印搜索结果 + System.out.println("Search results:"); + for (Document doc : searchResults) { + System.out.printf("id=%s, content=%s, vector=%s, score=%s\n", + doc.getId(), doc.getContent(), Arrays.toString(doc.getVector()), doc.getScore()); + } + + System.out.println("testSearchDocuments completed successfully."); + } catch (Exception e) { + System.err.println("Failed to search documents: " + e.getMessage()); + e.printStackTrace(); + fail("Search operation failed with exception: " + e.getMessage()); + } + } + + /** + * 测试更新文档功能 + */ + @Test + public void testUpdateDocuments() { + assumeChromaAvailable(); + + System.out.println("Starting testUpdateDocuments..."); + + // 创建测试文档 + List documents = createTestDocuments(); + + // 如果使用模拟模式 + if (useMock) { + System.out.println("Running in mock mode. Simulating update operation."); + + // 修改文档内容 + Document updatedDoc = documents.get(0); + String originalContent = updatedDoc.getContent(); + updatedDoc.setContent(originalContent + " [UPDATED]"); + + // 模拟更新结果 + StoreResult mockResult = StoreResult.successWithIds(Arrays.asList(updatedDoc)); + assertTrue("Mock update operation should be successful", mockResult.isSuccess()); + + System.out.println("testUpdateDocuments completed successfully in mock mode."); + return; + } + + try { + // 首先存储一些测试文档 + store.doStore(documents, StoreOptions.DEFAULT); + + // 修改文档内容 + Document updatedDoc = documents.get(0); + String originalContent = updatedDoc.getContent(); + updatedDoc.setContent(originalContent + " [UPDATED]"); + + // 执行更新 + StoreResult result = store.doUpdate(Arrays.asList(updatedDoc), StoreOptions.DEFAULT); + + // 验证更新是否成功 + assertTrue("Update operation should be successful", result.isSuccess()); + + // 搜索更新后的文档以验证更改 + SearchWrapper searchWrapper = new SearchWrapper(); + searchWrapper.setVector(updatedDoc.getVector()); + searchWrapper.setMaxResults(1); + + List searchResults = store.doSearch(searchWrapper, StoreOptions.DEFAULT); + assertTrue("Should find the updated document", !searchResults.isEmpty()); + assertEquals("Document content should be updated", + updatedDoc.getContent(), searchResults.get(0).getContent()); + + System.out.println("testUpdateDocuments completed successfully."); + } catch (Exception e) { + System.err.println("Failed to update documents: " + e.getMessage()); + e.printStackTrace(); + fail("Update operation failed with exception: " + e.getMessage()); + } + } + + /** + * 测试删除文档功能 + */ + @Test + public void testDeleteDocuments() { + assumeChromaAvailable(); + + System.out.println("Starting testDeleteDocuments..."); + + // 创建测试文档 + List documents = createTestDocuments(); + + // 如果使用模拟模式 + if (useMock) { + System.out.println("Running in mock mode. Simulating delete operation."); + + // 获取要删除的文档ID + List idsToDelete = new ArrayList<>(); + idsToDelete.add(documents.get(0).getId()); + + // 模拟删除结果 + StoreResult mockResult = StoreResult.success(); + assertTrue("Mock delete operation should be successful", mockResult.isSuccess()); + + System.out.println("testDeleteDocuments completed successfully in mock mode."); + return; + } + + try { + // 首先存储一些测试文档 + store.doStore(documents, StoreOptions.DEFAULT); + + // 获取要删除的文档ID + List idsToDelete = new ArrayList<>(); + idsToDelete.add(documents.get(0).getId()); + + // 执行删除 + StoreResult result = store.doDelete(idsToDelete, StoreOptions.DEFAULT); + + // 验证删除是否成功 + assertTrue("Delete operation should be successful", result.isSuccess()); + + // 尝试搜索已删除的文档 + SearchWrapper searchWrapper = new SearchWrapper(); + searchWrapper.setVector(documents.get(0).getVector()); + searchWrapper.setMaxResults(10); + + List searchResults = store.doSearch(searchWrapper, StoreOptions.DEFAULT); + + // 检查结果中是否包含已删除的文档 + boolean deletedDocFound = searchResults.stream() + .anyMatch(doc -> doc.getId().equals(documents.get(0).getId())); + + assertFalse("Deleted document should not be found", deletedDocFound); + + System.out.println("testDeleteDocuments completed successfully."); + } catch (Exception e) { + System.err.println("Failed to delete documents: " + e.getMessage()); + e.printStackTrace(); + fail("Delete operation failed with exception: " + e.getMessage()); + } + } + + /** + * 创建测试文档 + */ + private List createTestDocuments() { + List documents = new ArrayList<>(); + + // 创建5个测试文档,每个文档都有不同的内容和向量 + for (int i = 0; i < 5; i++) { + Document doc = new Document(); + doc.setId("doc_" + i); + doc.setContent("This is test document content " + i); + doc.setTitle("Test Document " + i); + + // 创建一个简单的向量,向量维度为10 + float[] vector = new float[10]; + for (int j = 0; j < vector.length; j++) { + vector[j] = i + j * 0.1f; + } + doc.setVector(vector); + + documents.add(doc); + } + + return documents; + } +} diff --git a/easy-agents-store/easy-agents-store-elasticsearch/pom.xml b/easy-agents-store/easy-agents-store-elasticsearch/pom.xml new file mode 100644 index 0000000..165b231 --- /dev/null +++ b/easy-agents-store/easy-agents-store-elasticsearch/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + com.easyagents + easy-agents-store + ${revision} + + + easy-agents-store-elasticsearch + easy-agents-store-elasticsearch + + + 8.15.0 + 2.17.0 + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + + + + co.elastic.clients + elasticsearch-java + ${elasticsearch.version} + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + junit + junit + test + + + + diff --git a/easy-agents-store/easy-agents-store-elasticsearch/src/main/java/com/easyagents/store/elasticsearch/ElasticSearchVectorStore.java b/easy-agents-store/easy-agents-store-elasticsearch/src/main/java/com/easyagents/store/elasticsearch/ElasticSearchVectorStore.java new file mode 100644 index 0000000..99a5861 --- /dev/null +++ b/easy-agents-store/easy-agents-store-elasticsearch/src/main/java/com/easyagents/store/elasticsearch/ElasticSearchVectorStore.java @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.elasticsearch; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.ErrorCause; +import co.elastic.clients.elasticsearch._types.mapping.DenseVectorProperty; +import co.elastic.clients.elasticsearch._types.mapping.Property; +import co.elastic.clients.elasticsearch._types.mapping.TextProperty; +import co.elastic.clients.elasticsearch._types.mapping.TypeMapping; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import co.elastic.clients.elasticsearch._types.query_dsl.ScriptScoreQuery; +import co.elastic.clients.elasticsearch.core.BulkRequest; +import co.elastic.clients.elasticsearch.core.BulkResponse; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem; +import co.elastic.clients.json.JsonData; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.ElasticsearchTransport; +import co.elastic.clients.transport.endpoints.BooleanResponse; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import com.easyagents.core.document.Document; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.StoreResult; +import com.easyagents.core.store.exception.StoreException; +import com.easyagents.core.util.StringUtil; +import org.apache.http.Header; +import org.apache.http.HttpHost; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.message.BasicHeader; +import org.apache.http.ssl.SSLContextBuilder; +import org.elasticsearch.client.RestClient; +import org.elasticsearch.client.RestClientBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLContext; +import java.io.IOException; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * es 向量存储:elasticsearch-java + * + * @author songyinyin + * @since 2024/8/12 下午4:17 + */ +public class ElasticSearchVectorStore extends DocumentStore { + + private static final Logger log = LoggerFactory.getLogger(ElasticSearchVectorStore.class); + + private final ElasticsearchClient client; + + private final ElasticSearchVectorStoreConfig config; + + public ElasticSearchVectorStore(ElasticSearchVectorStoreConfig config) { + this.config = config; + RestClientBuilder restClientBuilder = RestClient.builder(HttpHost.create(config.getServerUrl())); + + try { + SSLContext sslContext = SSLContextBuilder.create().loadTrustMaterial(null, (chains, authType) -> true).build(); + + if (StringUtil.hasText(config.getUsername())) { + CredentialsProvider provider = new BasicCredentialsProvider(); + provider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(config.getUsername(), config.getPassword())); + restClientBuilder.setHttpClientConfigCallback(httpClientBuilder -> { + httpClientBuilder.setSSLContext(sslContext); + httpClientBuilder.setDefaultCredentialsProvider(provider); + return httpClientBuilder; + }); + } + + if (StringUtil.hasText(config.getApiKey())) { + restClientBuilder.setDefaultHeaders(new Header[]{ + new BasicHeader("Authorization", "Apikey " + config.getApiKey()) + }); + } + + ElasticsearchTransport transport = new RestClientTransport(restClientBuilder.build(), new JacksonJsonpMapper()); + + this.client = new ElasticsearchClient(transport); + } catch (NoSuchAlgorithmException | KeyManagementException | KeyStoreException e) { + throw new StoreException("Elasticsearch init error", e); + } + try { + client.ping(); + } catch (IOException e) { + log.error("[I/O Elasticsearch Exception]", e); + throw new StoreException(e.getMessage()); + } + } + + public ElasticSearchVectorStore(ElasticSearchVectorStoreConfig config, ElasticsearchClient client) { + this.config = config; + this.client = client; + } + + private static void throwIfError(BulkResponse bulkResponse) { + if (bulkResponse.errors()) { + for (BulkResponseItem item : bulkResponse.items()) { + if (item.error() == null) { + continue; + } + ErrorCause errorCause = item.error(); + throw new StoreException("type: " + errorCause.type() + "," + "reason: " + errorCause.reason()); + } + } + } + + @Override + public StoreResult doStore(List documents, StoreOptions options) { + String indexName; + if (StringUtil.hasText(options.getCollectionName())){ + indexName = options.getCollectionName(); + } else { + indexName = options.getIndexNameOrDefault(config.getDefaultIndexName()); + } + createIndexIfNotExist(indexName); + return saveOrUpdate(documents, indexName); + } + + @Override + public StoreResult doDelete(Collection ids, StoreOptions options) { + String indexName = options.getIndexNameOrDefault(config.getDefaultIndexName()); + BulkRequest.Builder bulkBuilder = new BulkRequest.Builder(); + for (Object id : ids) { + bulkBuilder.operations(op -> op.delete(d -> d.index(indexName).id(id.toString()))); + } + bulk(bulkBuilder.build()); + return StoreResult.success(); + } + + @Override + public StoreResult doUpdate(List documents, StoreOptions options) { + String indexName = options.getIndexNameOrDefault(config.getDefaultIndexName()); + return saveOrUpdate(documents, indexName); + } + + public List doSearch(SearchWrapper wrapper, StoreOptions options) { + // 最小匹配分数,无值则默认0 + Double minScore = wrapper.getMinScore(); + // 获取索引名,无指定则使用配置的默认索引 + String indexName = options.getIndexNameOrDefault(config.getDefaultIndexName()); + + // 公式:(cosineSimilarity + 1.0) / 2 将相似度映射到 0~1 区间 + ScriptScoreQuery scriptScoreQuery = ScriptScoreQuery.of(fn -> fn + .minScore(minScore == null ? 0 : minScore.floatValue()) + .query(Query.of(q -> q.matchAll(m -> m))) + .script(s -> s + .source("(cosineSimilarity(params.query_vector, 'vector') + 1.0) / 2") + .params("query_vector", JsonData.of(wrapper.getVector())) + ) + ); + + try { + SearchResponse response = client.search( + SearchRequest.of(s -> s.index(indexName) + .query(n -> n.scriptScore(scriptScoreQuery)) + .size(wrapper.getMaxResults())), + JsonData.class + ); + + return response.hits().hits().stream() + .filter(hit -> hit.source() != null) // 过滤_source为空的无效结果 + .map(hit -> parseFromJsonData(hit.source(), hit.score())) + .collect(Collectors.toList()); + } catch (IOException e) { + log.error("[es/search] Elasticsearch I/O exception occurred", e); + throw new StoreException(e.getMessage()); + } + } + + private StoreResult saveOrUpdate(List documents, String indexName) { + BulkRequest.Builder bulkBuilder = new BulkRequest.Builder(); + for (Document document : documents) { + bulkBuilder.operations(op -> op.index( + idx -> idx.index(indexName).id(document.getId().toString()).document(document)) + ); + } + bulk(bulkBuilder.build()); + return StoreResult.successWithIds(documents); + } + + private void bulk(BulkRequest bulkRequest) { + try { + BulkResponse bulkResponse = client.bulk(bulkRequest); + throwIfError(bulkResponse); + } catch (IOException e) { + log.error("[I/O Elasticsearch Exception]", e); + throw new StoreException(e.getMessage()); + } + } + + private void createIndexIfNotExist(String indexName) { + try { + BooleanResponse response = client.indices().exists(c -> c.index(indexName)); + if (!response.value()) { + log.info("[ElasticSearch] Index {} not exists, creating...", indexName); + client.indices().create(c -> c.index(indexName) + .mappings(getDefaultMappings(this.getEmbeddingModel().dimensions()))); + } + } catch (IOException e) { + log.error("[I/O ElasticSearch Exception]", e); + throw new StoreException(e.getMessage()); + } + } + + private TypeMapping getDefaultMappings(int dimension) { + Map properties = new HashMap<>(4); + properties.put("content", Property.of(p -> p.text(TextProperty.of(t -> t)))); + properties.put("vector", Property.of(p -> p.denseVector(DenseVectorProperty.of(d -> d.dims(dimension))))); + return TypeMapping.of(c -> c.properties(properties)); + } + + private Document parseFromJsonData(JsonData source, Double score) { + Document document = new Document(); + Map dataMap = source.to(Map.class); + + document.setId(dataMap.get("id")); + document.setTitle((String) dataMap.get("title")); + document.setContent((String) dataMap.get("content")); + document.setScore(score); + + Object vectorObj = dataMap.get("vector"); + if (vectorObj instanceof List) { + List vectorList = (List) vectorObj; + float[] vector = new float[vectorList.size()]; + for (int i = 0; i < vectorList.size(); i++) { + Object val = vectorList.get(i); + if (val instanceof Number) { + vector[i] = ((Number) val).floatValue(); + } + } + document.setVector(vector); + } + + @SuppressWarnings("unchecked") + Map metadataMap = (Map) dataMap.get("metadataMap"); + if (metadataMap != null && !metadataMap.isEmpty()) { + document.setMetadataMap(metadataMap); + } else { + Map otherMetadata = new HashMap<>(); + for (Map.Entry entry : dataMap.entrySet()) { + String key = entry.getKey(); + if (!"id".equals(key) && !"title".equals(key) + && !"content".equals(key) && !"vector".equals(key)) { + otherMetadata.put(key, entry.getValue()); + } + } + if (!otherMetadata.isEmpty()) { + document.setMetadataMap(otherMetadata); + } + } + + return document; + } +} diff --git a/easy-agents-store/easy-agents-store-elasticsearch/src/main/java/com/easyagents/store/elasticsearch/ElasticSearchVectorStoreConfig.java b/easy-agents-store/easy-agents-store-elasticsearch/src/main/java/com/easyagents/store/elasticsearch/ElasticSearchVectorStoreConfig.java new file mode 100644 index 0000000..90ca5ae --- /dev/null +++ b/easy-agents-store/easy-agents-store-elasticsearch/src/main/java/com/easyagents/store/elasticsearch/ElasticSearchVectorStoreConfig.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.elasticsearch; + +import com.easyagents.core.store.DocumentStoreConfig; +import com.easyagents.core.util.StringUtil; + +/** + * 连接 elasticsearch 配置:elasticsearch-java + * + * @author songyinyin + */ +public class ElasticSearchVectorStoreConfig implements DocumentStoreConfig { + + private String serverUrl = "https://localhost:9200"; + + private String apiKey; + + private String username; + + private String password; + + private String defaultIndexName = "easy-agents-default"; + + public String getServerUrl() { + return serverUrl; + } + + public void setServerUrl(String serverUrl) { + this.serverUrl = serverUrl; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getDefaultIndexName() { + return defaultIndexName; + } + + public void setDefaultIndexName(String defaultIndexName) { + this.defaultIndexName = defaultIndexName; + } + + @Override + public boolean checkAvailable() { + return StringUtil.hasText(this.serverUrl, this.apiKey, this.defaultIndexName); + } +} diff --git a/easy-agents-store/easy-agents-store-elasticsearch/src/test/java/com/easyagents/store/opensearch/ElasticSearchVectorStoreTest.java b/easy-agents-store/easy-agents-store-elasticsearch/src/test/java/com/easyagents/store/opensearch/ElasticSearchVectorStoreTest.java new file mode 100644 index 0000000..5ce6746 --- /dev/null +++ b/easy-agents-store/easy-agents-store-elasticsearch/src/test/java/com/easyagents/store/opensearch/ElasticSearchVectorStoreTest.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.opensearch; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.embedding.EmbeddingModel; +import com.easyagents.core.model.embedding.EmbeddingOptions; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.VectorData; +import com.easyagents.core.store.exception.StoreException; +import com.easyagents.store.elasticsearch.ElasticSearchVectorStore; +import com.easyagents.store.elasticsearch.ElasticSearchVectorStoreConfig; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * @author songyinyin + */ +public class ElasticSearchVectorStoreTest { + + private static ElasticSearchVectorStore getVectorStore() { + ElasticSearchVectorStoreConfig config = new ElasticSearchVectorStoreConfig(); + // config.setApiKey("bmtXRVNaRUJNMEZXZzMzcnNvSXk6MlNMVmFnT0hRVVNUSmN3UXpoNWp4Zw=="); + config.setUsername("elastic"); + config.setPassword("Dd2024a10"); + ElasticSearchVectorStore store = new ElasticSearchVectorStore(config); + store.setEmbeddingModel(new EmbeddingModel() { + @Override + public VectorData embed(Document document, EmbeddingOptions options) { + VectorData vectorData = new VectorData(); + vectorData.setVector(new float[]{0, 0}); + return vectorData; + } + }); + return store; + } + + @Test(expected = StoreException.class) + public void test01() { + ElasticSearchVectorStore store = getVectorStore(); + + // https://opensearch.org/docs/latest/search-plugins/vector-search/#example + List list = new ArrayList<>(); + Document doc1 = new Document(); + doc1.setId(1); + doc1.setContent("test1"); + doc1.setVector(new float[]{5.2f, 4.4f}); + list.add(doc1); + Document doc2 = new Document(); + doc2.setId(2); + doc2.setContent("test2"); + doc2.setVector(new float[]{5.2f, 3.9f}); + list.add(doc2); + Document doc3 = new Document(); + doc3.setId(3); + doc3.setContent("test3"); + doc3.setVector(new float[]{4.9f, 3.4f}); + list.add(doc3); + Document doc4 = new Document(); + doc4.setId(4); + doc4.setContent("test4"); + doc4.setVector(new float[]{4.2f, 4.6f}); + list.add(doc4); + Document doc5 = new Document(); + doc5.setId(5); + doc5.setContent("test5"); + doc5.setVector(new float[]{3.3f, 4.5f}); + list.add(doc5); + store.doStore(list, StoreOptions.DEFAULT); + + // 可能要等一会 才能查出结果 + SearchWrapper searchWrapper = new SearchWrapper(); + searchWrapper.setVector(new float[]{5, 4}); + searchWrapper.setMaxResults(3); + List documents = store.doSearch(searchWrapper, StoreOptions.DEFAULT); + for (Document document : documents) { + System.out.printf("id=%s, content=%s, vector=%s, metadata=%s\n", + document.getId(), document.getContent(), Arrays.toString(document.getVector()), document.getMetadataMap()); + } + + } +} diff --git a/easy-agents-store/easy-agents-store-opensearch/pom.xml b/easy-agents-store/easy-agents-store-opensearch/pom.xml new file mode 100644 index 0000000..36011d0 --- /dev/null +++ b/easy-agents-store/easy-agents-store-opensearch/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + com.easyagents + easy-agents-store + ${revision} + + + easy-agents-store-opensearch + easy-agents-store-opensearch + + + 2.13.0 + 5.1.4 + 5.1.4 + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + + + + org.opensearch.client + opensearch-java + ${opensearch.version} + + + + org.apache.httpcomponents.client5 + httpclient5 + ${httpclient5.version} + + + + junit + junit + test + + + + org.apache.httpcomponents.core5 + httpcore5-h2 + ${httpcore5-h2} + + + + diff --git a/easy-agents-store/easy-agents-store-opensearch/src/main/java/com/easyagents/store/opensearch/OpenSearchVectorStore.java b/easy-agents-store/easy-agents-store-opensearch/src/main/java/com/easyagents/store/opensearch/OpenSearchVectorStore.java new file mode 100644 index 0000000..5e03380 --- /dev/null +++ b/easy-agents-store/easy-agents-store-opensearch/src/main/java/com/easyagents/store/opensearch/OpenSearchVectorStore.java @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.opensearch; + +import com.easyagents.core.document.Document; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.StoreResult; +import com.easyagents.core.store.exception.StoreException; +import com.easyagents.core.util.StringUtil; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.ssl.SSLContextBuilder; +import org.opensearch.client.json.JsonData; +import org.opensearch.client.json.jackson.JacksonJsonpMapper; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch._types.ErrorCause; +import org.opensearch.client.opensearch._types.InlineScript; +import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.mapping.TextProperty; +import org.opensearch.client.opensearch._types.mapping.TypeMapping; +import org.opensearch.client.opensearch._types.query_dsl.Query; +import org.opensearch.client.opensearch._types.query_dsl.ScriptScoreQuery; +import org.opensearch.client.opensearch.core.BulkRequest; +import org.opensearch.client.opensearch.core.BulkResponse; +import org.opensearch.client.opensearch.core.SearchRequest; +import org.opensearch.client.opensearch.core.SearchResponse; +import org.opensearch.client.opensearch.core.bulk.BulkResponseItem; +import org.opensearch.client.transport.OpenSearchTransport; +import org.opensearch.client.transport.endpoints.BooleanResponse; +import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLContext; +import java.io.IOException; +import java.net.URISyntaxException; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static java.util.Collections.singletonList; +import static java.util.stream.Collectors.toList; + +/** + * OpenSearch 向量存储 + * + * @author songyinyin + * @since 2024/8/10 下午8:31 + */ +public class OpenSearchVectorStore extends DocumentStore { + + private static final Logger log = LoggerFactory.getLogger(OpenSearchVectorStore.class); + + private final OpenSearchClient client; + + private final OpenSearchVectorStoreConfig config; + + public OpenSearchVectorStore(OpenSearchVectorStoreConfig config) { + this.config = config; + HttpHost openSearchHost; + try { + openSearchHost = HttpHost.create(config.getServerUrl()); + } catch (URISyntaxException se) { + log.error("[OpenSearch Exception]", se); + throw new StoreException(se.getMessage()); + } + + try { + SSLContext sslContext = SSLContextBuilder.create().loadTrustMaterial(null, (chains, authType) -> true).build(); + TlsStrategy tlsStrategy = ClientTlsStrategyBuilder.create() + .setSslContext(sslContext) + .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) + .build(); + + OpenSearchTransport transport = ApacheHttpClient5TransportBuilder + .builder(openSearchHost) + .setMapper(new JacksonJsonpMapper()) + .setHttpClientConfigCallback(httpClientBuilder -> { + + if (StringUtil.hasText(config.getApiKey())) { + httpClientBuilder.setDefaultHeaders(singletonList( + new BasicHeader("Authorization", "ApiKey " + config.getApiKey()) + )); + } + + if (StringUtil.hasText(config.getUsername()) && StringUtil.hasText(config.getPassword())) { + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials(new AuthScope(openSearchHost), + new UsernamePasswordCredentials(config.getUsername(), config.getPassword().toCharArray())); + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + } + + httpClientBuilder.setConnectionManager(PoolingAsyncClientConnectionManagerBuilder + .create().setTlsStrategy(tlsStrategy).build()); + + return httpClientBuilder; + }) + .build(); + + this.client = new OpenSearchClient(transport); + try { + client.ping(); + } catch (IOException e) { + log.error("[I/O OpenSearch Exception]", e); + throw new StoreException(e.getMessage()); + } + } catch (NoSuchAlgorithmException | KeyManagementException | KeyStoreException e) { + throw new StoreException("OpenSearchClient init error", e); + } + } + + public OpenSearchVectorStore(OpenSearchVectorStoreConfig config, OpenSearchClient client) { + this.config = config; + this.client = client; + } + + private void createIndexIfNotExist(String indexName) { + try { + BooleanResponse response = client.indices().exists(c -> c.index(indexName)); + if (!response.value()) { + log.info("[OpenSearch] Index {} not exists, creating...", indexName); + client.indices().create(c -> c.index(indexName) + .settings(s -> s.knn(true)) + .mappings(getDefaultMappings(this.getEmbeddingModel().dimensions()))); + } + } catch (IOException e) { + log.error("[I/O OpenSearch Exception]", e); + throw new StoreException(e.getMessage()); + } + } + + private TypeMapping getDefaultMappings(int dimension) { + Map properties = new HashMap<>(4); + properties.put("content", Property.of(p -> p.text(TextProperty.of(t -> t)))); + properties.put("vector", Property.of(p -> p.knnVector( + k -> k.dimension(dimension) + ))); + return TypeMapping.of(c -> c.properties(properties)); + } + + @Override + public StoreResult doStore(List documents, StoreOptions options) { + BulkRequest.Builder bulkBuilder = new BulkRequest.Builder(); + String indexName = options.getIndexNameOrDefault(config.getDefaultIndexName()); + createIndexIfNotExist(indexName); + for (Document document : documents) { + bulkBuilder.operations(op -> op.index( + idx -> idx.index(indexName).id(document.getId().toString()).document(document)) + ); + } + bulk(bulkBuilder.build()); + return StoreResult.successWithIds(documents); + } + + private void bulk(BulkRequest bulkRequest) { + try { + BulkResponse bulkResponse = client.bulk(bulkRequest); + throwIfError(bulkResponse); + } catch (IOException e) { + log.error("[I/O OpenSearch Exception]", e); + throw new StoreException(e.getMessage()); + } + } + + private static void throwIfError(BulkResponse bulkResponse) { + if (bulkResponse.errors()) { + for (BulkResponseItem item : bulkResponse.items()) { + if (item.error() == null) { + continue; + } + ErrorCause errorCause = item.error(); + throw new StoreException("type: " + errorCause.type() + "," + "reason: " + errorCause.reason()); + } + } + } + + @Override + public StoreResult doDelete(Collection ids, StoreOptions options) { + String indexName = options.getIndexNameOrDefault(config.getDefaultIndexName()); + BulkRequest.Builder bulkBuilder = new BulkRequest.Builder(); + for (Object id : ids) { + bulkBuilder.operations(op -> op.delete(d -> d.index(indexName).id(id.toString()))); + } + bulk(bulkBuilder.build()); + return StoreResult.success(); + } + + @Override + public StoreResult doUpdate(List documents, StoreOptions options) { + BulkRequest.Builder bulkBuilder = new BulkRequest.Builder(); + String indexName = options.getIndexNameOrDefault(config.getDefaultIndexName()); + for (Document document : documents) { + bulkBuilder.operations(op -> op.update( + idx -> idx.index(indexName).id(document.getId().toString()).document(document)) + ); + } + bulk(bulkBuilder.build()); + return StoreResult.successWithIds(documents); + } + + @Override + public List doSearch(SearchWrapper wrapper, StoreOptions options) { + Double minScore = wrapper.getMinScore(); + String indexName = options.getIndexNameOrDefault(config.getDefaultIndexName()); + + // https://aws.amazon.com/cn/blogs/china/use-aws-opensearch-knn-plug-in-to-implement-vector-retrieval/ + // boost 默认是 1,小于 1 会降低相关性: https://opensearch.org/docs/latest/query-dsl/specialized/script-score/#parameters + ScriptScoreQuery scriptScoreQuery = ScriptScoreQuery.of(q -> q.minScore(minScore == null ? 0 : minScore.floatValue()) + .query(Query.of(qu -> qu.matchAll(m -> m))) + .script(s -> s.inline(InlineScript.of(i -> i + .source("knn_score") + .lang("knn") + .params("field", JsonData.of("vector")) + .params("query_value", JsonData.of(wrapper.getVector())) + .params("space_type", JsonData.of("cosinesimil")) + )))); + + try { + SearchResponse response = client.search( + SearchRequest.of(s -> s.index(indexName) + .query(n -> n.scriptScore(scriptScoreQuery)) + .size(wrapper.getMaxResults())), + Document.class + ); + return response.hits().hits().stream() + .filter(s -> s.source() != null) + .map(s -> { + Document source = s.source(); + source.setScore(s.score()); + return source; + }) + .collect(toList()); + } catch (IOException e) { + log.error("[I/O OpenSearch Exception]", e); + throw new StoreException(e.getMessage()); + } + } +} diff --git a/easy-agents-store/easy-agents-store-opensearch/src/main/java/com/easyagents/store/opensearch/OpenSearchVectorStoreConfig.java b/easy-agents-store/easy-agents-store-opensearch/src/main/java/com/easyagents/store/opensearch/OpenSearchVectorStoreConfig.java new file mode 100644 index 0000000..755b718 --- /dev/null +++ b/easy-agents-store/easy-agents-store-opensearch/src/main/java/com/easyagents/store/opensearch/OpenSearchVectorStoreConfig.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.opensearch; + +import com.easyagents.core.store.DocumentStoreConfig; +import com.easyagents.core.util.StringUtil; + +/** + * 连接 open search 配置:opensearch-java + * + * @author songyinyin + * @since 2024/8/10 下午8:39 + */ +public class OpenSearchVectorStoreConfig implements DocumentStoreConfig { + + private String serverUrl = "https://localhost:9200"; + + private String apiKey; + + private String username; + + private String password; + + private String defaultIndexName = "easy-agents-default"; + + public String getServerUrl() { + return serverUrl; + } + + public void setServerUrl(String serverUrl) { + this.serverUrl = serverUrl; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getDefaultIndexName() { + return defaultIndexName; + } + + public void setDefaultIndexName(String defaultIndexName) { + this.defaultIndexName = defaultIndexName; + } + + + @Override + public boolean checkAvailable() { + return StringUtil.hasText(this.serverUrl, this.apiKey) + || StringUtil.hasText(this.serverUrl, this.username, this.password); + } +} diff --git a/easy-agents-store/easy-agents-store-opensearch/src/test/java/com/easyagents/store/opensearch/OpenSearchVectorStoreTest.java b/easy-agents-store/easy-agents-store-opensearch/src/test/java/com/easyagents/store/opensearch/OpenSearchVectorStoreTest.java new file mode 100644 index 0000000..cc06043 --- /dev/null +++ b/easy-agents-store/easy-agents-store-opensearch/src/test/java/com/easyagents/store/opensearch/OpenSearchVectorStoreTest.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.opensearch; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.embedding.EmbeddingModel; +import com.easyagents.core.model.embedding.EmbeddingOptions; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.VectorData; +import com.easyagents.core.store.exception.StoreException; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * @author songyinyin + * @since 2024/8/11 下午3:21 + */ +public class OpenSearchVectorStoreTest { + + @Test(expected = StoreException.class) + public void test01() { + OpenSearchVectorStore store = getOpenSearchVectorStore(); + + // https://opensearch.org/docs/latest/search-plugins/vector-search/#example + List list = new ArrayList<>(); + Document doc1 = new Document(); + doc1.setId(1); + doc1.setContent("test1"); + doc1.setVector(new float[]{5.2f, 4.4f}); + list.add(doc1); + Document doc2 = new Document(); + doc2.setId(2); + doc2.setContent("test2"); + doc2.setVector(new float[]{5.2f, 3.9f}); + list.add(doc2); + Document doc3 = new Document(); + doc3.setId(3); + doc3.setContent("test3"); + doc3.setVector(new float[]{4.9f, 3.4f}); + list.add(doc3); + Document doc4 = new Document(); + doc4.setId(4); + doc4.setContent("test4"); + doc4.setVector(new float[]{4.2f, 4.6f}); + list.add(doc4); + Document doc5 = new Document(); + doc5.setId(5); + doc5.setContent("test5"); + doc5.setVector(new float[]{3.3f, 4.5f}); + list.add(doc5); + store.doStore(list, StoreOptions.DEFAULT); + + // 可能要等一会 才能查出结果 + SearchWrapper searchWrapper = new SearchWrapper(); + searchWrapper.setVector(new float[]{5, 4}); + searchWrapper.setMaxResults(3); + List documents = store.doSearch(searchWrapper, StoreOptions.DEFAULT); + for (Document document : documents) { + System.out.printf("id=%s, content=%s, vector=%s, metadata=%s\n", + document.getId(), document.getContent(), Arrays.toString(document.getVector()), document.getMetadataMap()); + } + + } + + private static OpenSearchVectorStore getOpenSearchVectorStore() { + OpenSearchVectorStoreConfig config = new OpenSearchVectorStoreConfig(); + config.setUsername("admin"); + config.setPassword("4_Pa46WQczS?"); + OpenSearchVectorStore store = new OpenSearchVectorStore(config); + store.setEmbeddingModel(new EmbeddingModel() { + @Override + public VectorData embed(Document document, EmbeddingOptions options) { + VectorData vectorData = new VectorData(); + vectorData.setVector(new float[]{0, 0}); + return vectorData; + } + }); + return store; + } +} diff --git a/easy-agents-store/easy-agents-store-pgvector/pom.xml b/easy-agents-store/easy-agents-store-pgvector/pom.xml new file mode 100644 index 0000000..5a8a224 --- /dev/null +++ b/easy-agents-store/easy-agents-store-pgvector/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + com.easyagents + easy-agents-store + ${revision} + + + easy-agents-store-pgvector + easy-agents-store-pgvector + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + compile + + + + org.postgresql + postgresql + 42.7.5 + + + + junit + junit + test + + + + diff --git a/easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorUtil.java b/easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorUtil.java new file mode 100644 index 0000000..8f76def --- /dev/null +++ b/easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorUtil.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.pgvector; + +import org.postgresql.util.PGobject; +import java.sql.SQLException; + +public class PgvectorUtil { + /** + * 转化为vector. + * 如果需要half vector或者sparse vector 对应实现即可 + * @param src 向量 + * @return + * @throws SQLException + */ + public static PGobject toPgVector(double[] src) throws SQLException { + PGobject vector = new PGobject(); + vector.setType("vector"); + if (src.length == 0) { + vector.setValue("[]"); + return vector; + } + + StringBuilder sb = new StringBuilder("["); + for (double v : src) { + sb.append(v); + sb.append(","); + } + vector.setValue(sb.substring(0, sb.length() - 1) + "]"); + + return vector; + } + + public static double[] fromPgVector(String src) { + if (src.equals("[]")) { + return new double[0]; + } + + String[] strs = src.substring(1, src.length() - 1).split(","); + double[] output = new double[strs.length]; + for (int i = 0; i < strs.length; i++) { + try { + output[i] = Double.parseDouble(strs[i]); + } catch (Exception ignore) { + output[i] = 0; + } + } + return output; + } +} diff --git a/easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorVectorStore.java b/easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorVectorStore.java new file mode 100644 index 0000000..287b5e2 --- /dev/null +++ b/easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorVectorStore.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.pgvector; + +import com.easyagents.core.document.Document; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.StoreResult; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import org.postgresql.ds.PGSimpleDataSource; +import org.postgresql.util.PGobject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.*; +import java.util.*; + +public class PgvectorVectorStore extends DocumentStore { + private static final Logger logger = LoggerFactory.getLogger(PgvectorVectorStore.class); + public static final double DEFAULT_SIMILARITY_THRESHOLD = 0.3; + private final PGSimpleDataSource dataSource; + private final String defaultCollectionName; + private final PgvectorVectorStoreConfig config; + + + public PgvectorVectorStore(PgvectorVectorStoreConfig config) { + dataSource = new PGSimpleDataSource(); + dataSource.setServerNames(new String[]{config.getHost() + ":" + config.getPort()}); + dataSource.setUser(config.getUsername()); + dataSource.setPassword(config.getPassword()); + dataSource.setDatabaseName(config.getDatabaseName()); + if (!config.getProperties().isEmpty()) { + config.getProperties().forEach((k, v) -> { + try { + dataSource.setProperty(k, v); + } catch (SQLException e) { + logger.error("set pg property error", e); + } + }); + } + + this.defaultCollectionName = config.getDefaultCollectionName(); + this.config = config; + + // 异步初始化数据库 + new Thread(this::initDb).start(); + } + + public void initDb() { + // 启动的时候初始化向量表, 需要数据库支持pgvector插件 + // pg管理员需要在对应的库上执行 CREATE EXTENSION IF NOT EXISTS vector; + if (config.isAutoCreateCollection()) { + createCollection(defaultCollectionName); + } + } + + private Connection getConnection() throws SQLException { + Connection connection = dataSource.getConnection(); + connection.setAutoCommit(false); + return connection; + } + + @Override + public StoreResult doStore(List documents, StoreOptions options) { + + // 表名 + String collectionName = options.getCollectionNameOrDefault(defaultCollectionName); + + try (Connection connection = getConnection()) { + PreparedStatement pstmt = connection.prepareStatement("insert into " + collectionName + " (id, content, vector, metadata) values (?, ?, ?, ?::jsonb)"); + for (Document doc : documents) { + Map metadatas = doc.getMetadataMap(); + JSONObject jsonObject = JSON.parseObject(JSON.toJSONBytes(metadatas == null ? Collections.EMPTY_MAP : metadatas)); + pstmt.setString(1, String.valueOf(doc.getId())); + pstmt.setString(2, doc.getContent()); + pstmt.setObject(3, PgvectorUtil.toPgVector(doc.getVectorAsDoubleArray())); + pstmt.setString(4, jsonObject.toString()); + pstmt.addBatch(); + } + + pstmt.executeBatch(); + connection.commit(); + } catch (SQLException e) { + logger.error("store vector error", e); + return StoreResult.fail(); + } + return StoreResult.successWithIds(documents); + } + + private Boolean createCollection(String collectionName) { + try (Connection connection = getConnection()) { + try (CallableStatement statement = connection.prepareCall("CREATE TABLE IF NOT EXISTS " + collectionName + + " (id varchar(100) PRIMARY KEY, content text, vector vector(" + config.getVectorDimension() + "), metadata jsonb)")) { + statement.execute(); + } + + // 默认情况下,pgvector 执行精确的最近邻搜索,从而提供完美的召回率. 可以通过索引来修改 pgvector 的搜索方式,以获得更好的性能。 + // By default, pgvector performs exact nearest neighbor search, which provides perfect recall. + if (config.isUseHnswIndex()) { + try (Statement stmt = connection.createStatement()) { + stmt.execute("CREATE INDEX IF NOT EXISTS " + collectionName + "_vector_idx ON " + collectionName + + " USING hnsw (vector vector_cosine_ops)"); + } + } + + } catch (SQLException e) { + logger.error("create collection error", e); + return false; + } + + return true; + } + + @Override + public StoreResult doDelete(Collection ids, StoreOptions options) { + StringBuilder sql = new StringBuilder("DELETE FROM " + options.getCollectionNameOrDefault(defaultCollectionName) + " WHERE id IN ("); + for (int i = 0; i < ids.size(); i++) { + sql.append("?"); + if (i < ids.size() - 1) { + sql.append(","); + } + } + sql.append(")"); + + try (Connection connection = getConnection()) { + PreparedStatement pstmt = connection.prepareStatement(sql.toString()); + ArrayList list = new ArrayList<>(ids); + for (int i = 0; i < list.size(); i++) { + pstmt.setString(i + 1, (String) list.get(i)); + } + + pstmt.executeUpdate(); + connection.commit(); + } catch (Exception e) { + logger.error("delete document error: " + e, e); + return StoreResult.fail(); + } + + return StoreResult.success(); + + } + + @Override + public List doSearch(SearchWrapper searchWrapper, StoreOptions options) { + StringBuilder sql = new StringBuilder("select "); + if (searchWrapper.isOutputVector()) { + sql.append("id, vector, content, metadata"); + } else { + sql.append("id, content, metadata"); + } + + sql.append(" from ").append(options.getCollectionNameOrDefault(defaultCollectionName)); + sql.append(" where vector <=> ? < ? order by vector <=> ? LIMIT ?"); + + try (Connection connection = getConnection()){ + // 使用余弦距离计算最相似的文档 + PreparedStatement stmt = connection.prepareStatement(sql.toString()); + + PGobject vector = PgvectorUtil.toPgVector(searchWrapper.getVectorAsDoubleArray()); + stmt.setObject(1, vector); + stmt.setObject(2, Optional.ofNullable(searchWrapper.getMinScore()).orElse(DEFAULT_SIMILARITY_THRESHOLD)); + stmt.setObject(3, vector); + stmt.setObject(4, searchWrapper.getMaxResults()); + + ResultSet resultSet = stmt.executeQuery(); + List documents = new ArrayList<>(); + while (resultSet.next()) { + Document doc = new Document(); + doc.setId(resultSet.getString("id")); + doc.setContent(resultSet.getString("content")); + doc.addMetadata(JSON.parseObject(resultSet.getString("metadata"))); + + if (searchWrapper.isOutputVector()) { + String vectorStr = resultSet.getString("vector"); + doc.setVector(PgvectorUtil.fromPgVector(vectorStr)); + } + + documents.add(doc); + } + + return documents; + } catch (Exception e) { + logger.error("Error searching in pgvector", e); + return Collections.emptyList(); + } + } + + @Override + public StoreResult doUpdate(List documents, StoreOptions options) { + if (documents == null || documents.isEmpty()) { + return StoreResult.success(); + } + + StringBuilder sql = new StringBuilder("UPDATE " + options.getCollectionNameOrDefault(defaultCollectionName) + " SET "); + sql.append("content = ?, vector = ?, metadata = ?::jsonb WHERE id = ?"); + try (Connection connection = getConnection()) { + PreparedStatement pstmt = connection.prepareStatement(sql.toString()); + for (Document doc : documents) { + Map metadatas = doc.getMetadataMap(); + JSONObject metadataJson = JSON.parseObject(JSON.toJSONBytes(metadatas == null ? Collections.EMPTY_MAP : metadatas)); + pstmt.setString(1, doc.getContent()); + pstmt.setObject(2, PgvectorUtil.toPgVector(doc.getVectorAsDoubleArray())); + pstmt.setString(3, metadataJson.toString()); + pstmt.setString(4, String.valueOf(doc.getId())); + pstmt.addBatch(); + } + + pstmt.executeUpdate(); + connection.commit(); + } catch (Exception e) { + logger.error("Error update in pgvector", e); + return StoreResult.fail(); + } + return StoreResult.successWithIds(documents); + } +} diff --git a/easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorVectorStoreConfig.java b/easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorVectorStoreConfig.java new file mode 100644 index 0000000..75c5f50 --- /dev/null +++ b/easy-agents-store/easy-agents-store-pgvector/src/main/java/com/easyagents/store/pgvector/PgvectorVectorStoreConfig.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.pgvector; + +import com.easyagents.core.store.DocumentStoreConfig; +import com.easyagents.core.util.StringUtil; + +import java.util.HashMap; +import java.util.Map; + +/** + * postgreSQL访问配置 + * https://github.com/pgvector/pgvector + */ +public class PgvectorVectorStoreConfig implements DocumentStoreConfig { + private String host; + private int port = 5432; + private String databaseName = "agent_vector"; + private String username; + private String password; + private Map properties = new HashMap<>(); + private String defaultCollectionName; + private boolean autoCreateCollection = true; + private boolean useHnswIndex = false; + private int vectorDimension = 1024; + + public PgvectorVectorStoreConfig() { + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public String getDatabaseName() { + return databaseName; + } + + public void setDatabaseName(String databaseName) { + this.databaseName = databaseName; + } + + public String getDefaultCollectionName() { + return defaultCollectionName; + } + + public void setDefaultCollectionName(String defaultCollectionName) { + this.defaultCollectionName = defaultCollectionName; + } + + public boolean isAutoCreateCollection() { + return autoCreateCollection; + } + + public void setAutoCreateCollection(boolean autoCreateCollection) { + this.autoCreateCollection = autoCreateCollection; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public Map getProperties() { + return properties; + } + + public void setProperties(Map properties) { + this.properties = properties; + } + + @Override + public boolean checkAvailable() { + return StringUtil.hasText(this.host, this.username, this.password, this.databaseName); + } + + public int getVectorDimension() { + return vectorDimension; + } + + public void setVectorDimension(int vectorDimension) { + this.vectorDimension = vectorDimension; + } + + public boolean isUseHnswIndex() { + return useHnswIndex; + } + + public void setUseHnswIndex(boolean useHnswIndex) { + this.useHnswIndex = useHnswIndex; + } +} diff --git a/easy-agents-store/easy-agents-store-pgvector/src/test/java/com/easyagents/store/pgvector/PgvectorDbTest.java b/easy-agents-store/easy-agents-store-pgvector/src/test/java/com/easyagents/store/pgvector/PgvectorDbTest.java new file mode 100644 index 0000000..396b6a2 --- /dev/null +++ b/easy-agents-store/easy-agents-store-pgvector/src/test/java/com/easyagents/store/pgvector/PgvectorDbTest.java @@ -0,0 +1,134 @@ +package com.easyagents.store.pgvector; + +import com.easyagents.core.document.Document; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreResult; +import com.easyagents.core.util.Maps; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class PgvectorDbTest { + + @Test + public void testInsert() { + PgvectorVectorStoreConfig config = new PgvectorVectorStoreConfig(); + config.setHost("127.0.0.1"); + config.setPort(5432); + config.setDatabaseName("pgvector_test"); + config.setUsername("test"); + config.setPassword("123456"); + config.setVectorDimension(1024); + config.setUseHnswIndex(true); + config.setAutoCreateCollection(true); + config.setDefaultCollectionName("test"); + + PgvectorVectorStore store = new PgvectorVectorStore(config); + Document doc = new Document("测试数据"); + // 初始化 vector 为长度为 1024 的全是 1 的数组 + float[] vector = new float[1024]; + Arrays.fill(vector, 1.0f); + + doc.setVector(vector); + doc.setMetadataMap(Maps.of("test", "test")); + store.store(doc); + } + + @Test + public void testInsertMany() { + PgvectorVectorStoreConfig config = new PgvectorVectorStoreConfig(); + config.setHost("127.0.0.1"); + config.setPort(5432); + config.setDatabaseName("pgvector_test"); + config.setUsername("test"); + config.setPassword("123456"); + config.setVectorDimension(1024); + config.setUseHnswIndex(true); + config.setAutoCreateCollection(true); + config.setDefaultCollectionName("test"); + + PgvectorVectorStore store = new PgvectorVectorStore(config); + List docs = new ArrayList<>(100); + for (int i = 0; i < 100; i++) { + Document doc = new Document("测试数据" + i); + // 初始化 vector 为长度为 1024 的全是 1 的数组 + float[] vector = new float[1024]; + Arrays.fill(vector, (float) Math.random()); + + doc.setVector(vector); + doc.setMetadataMap(Maps.of("test", "test" + i)); + docs.add(doc); + } + + store.store(docs); + } + + @Test + public void testSearch() { + PgvectorVectorStoreConfig config = new PgvectorVectorStoreConfig(); + config.setHost("127.0.0.1"); + config.setPort(5432); + config.setDatabaseName("pgvector_test"); + config.setUsername("test"); + config.setPassword("123456"); + config.setVectorDimension(1024); + config.setUseHnswIndex(true); + config.setAutoCreateCollection(true); + config.setDefaultCollectionName("test"); + PgvectorVectorStore store = new PgvectorVectorStore(config); + + float[] vector = new float[1024]; + Arrays.fill(vector, 1.0f); + + SearchWrapper searchWrapper = new SearchWrapper().text("测试数据"); + searchWrapper.setVector(vector); + searchWrapper.setMinScore(0.0); + searchWrapper.setOutputVector(true); + List docs = store.search(searchWrapper); + System.out.println(docs); + } + + @Test + public void testUpdate() { + PgvectorVectorStoreConfig config = new PgvectorVectorStoreConfig(); + config.setHost("127.0.0.1"); + config.setPort(5432); + config.setDatabaseName("pgvector_test"); + config.setUsername("test"); + config.setPassword("123456"); + config.setVectorDimension(1024); + config.setUseHnswIndex(true); + config.setAutoCreateCollection(true); + config.setDefaultCollectionName("test"); + PgvectorVectorStore store = new PgvectorVectorStore(config); + + Document document = new Document("测试数据"); + document.setId("145314895749100ae8306079519b3393"); + document.setMetadataMap(Maps.of("test", "test0")); + float[] vector = new float[1024]; + Arrays.fill(vector, 1.1f); + document.setVector(vector); + StoreResult update = store.update(document); + System.out.println(update); + } + + @Test + public void testDelete() { + PgvectorVectorStoreConfig config = new PgvectorVectorStoreConfig(); + config.setHost("127.0.0.1"); + config.setPort(5432); + config.setDatabaseName("pgvector_test"); + config.setUsername("test"); + config.setPassword("123456"); + config.setVectorDimension(1024); + config.setUseHnswIndex(true); + config.setAutoCreateCollection(true); + config.setDefaultCollectionName("test"); + PgvectorVectorStore store = new PgvectorVectorStore(config); + + StoreResult update = store.delete("145314895749100ae8306079519b3393","e83518d36b6d5de8199b40e3ef4e4ce1"); + System.out.println(update); + } +} diff --git a/easy-agents-store/easy-agents-store-qcloud/pom.xml b/easy-agents-store/easy-agents-store-qcloud/pom.xml new file mode 100644 index 0000000..ed61ca8 --- /dev/null +++ b/easy-agents-store/easy-agents-store-qcloud/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + com.easyagents + easy-agents-store + ${revision} + + + easy-agents-store-qcloud + easy-agents-store-qcloud + + + 8 + 8 + UTF-8 + + + + com.easyagents + easy-agents-core + compile + + + + diff --git a/easy-agents-store/easy-agents-store-qcloud/src/main/java/com/easyagents/store/qcloud/QCloudVectorStore.java b/easy-agents-store/easy-agents-store-qcloud/src/main/java/com/easyagents/store/qcloud/QCloudVectorStore.java new file mode 100644 index 0000000..a1bc935 --- /dev/null +++ b/easy-agents-store/easy-agents-store-qcloud/src/main/java/com/easyagents/store/qcloud/QCloudVectorStore.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.qcloud; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.client.HttpClient; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.StoreResult; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.slf4j.LoggerFactory; + +import java.util.*; + +/** + * doc https://cloud.tencent.com/document/product/1709/95121 + */ +public class QCloudVectorStore extends DocumentStore { + + private QCloudVectorStoreConfig config; + + private final HttpClient httpUtil = new HttpClient(); + + + public QCloudVectorStore(QCloudVectorStoreConfig config) { + this.config = config; + } + + + @Override + public StoreResult doStore(List documents, StoreOptions options) { + if (documents == null || documents.isEmpty()) { + return StoreResult.success(); + } + + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer account=" + config.getAccount() + "&api_key=" + config.getApiKey()); + + Map payloadMap = new HashMap<>(); + payloadMap.put("database", config.getDatabase()); + payloadMap.put("collection", options.getCollectionNameOrDefault(config.getDefaultCollectionName())); + + + List> payloadDocs = new ArrayList<>(); + for (Document vectorDocument : documents) { + Map document = new HashMap<>(); + if (vectorDocument.getMetadataMap() != null) { + document.putAll(vectorDocument.getMetadataMap()); + } + document.put("vector", vectorDocument.getVector()); + document.put("id", vectorDocument.getId()); + payloadDocs.add(document); + } + payloadMap.put("documents", payloadDocs); + + String payload = JSON.toJSONString(payloadMap); + httpUtil.post(config.getHost() + "/document/upsert", headers, payload); + return StoreResult.successWithIds(documents); + } + + + @Override + public StoreResult doDelete(Collection ids, StoreOptions options) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer account=" + config.getAccount() + "&api_key=" + config.getApiKey()); + + Map payloadMap = new HashMap<>(); + payloadMap.put("database", config.getDatabase()); + payloadMap.put("collection", options.getCollectionNameOrDefault(config.getDefaultCollectionName())); + + Map documentIdsObj = new HashMap<>(); + documentIdsObj.put("documentIds", ids); + payloadMap.put("query", documentIdsObj); + + String payload = JSON.toJSONString(payloadMap); + + httpUtil.post(config.getHost() + "/document/delete", headers, payload); + + return StoreResult.success(); + } + + + @Override + public StoreResult doUpdate(List documents, StoreOptions options) { + if (documents == null || documents.isEmpty()) { + return StoreResult.success(); + } + + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer account=" + config.getAccount() + "&api_key=" + config.getApiKey()); + + Map payloadMap = new HashMap<>(); + payloadMap.put("database", config.getDatabase()); + payloadMap.put("collection", options.getCollectionNameOrDefault(config.getDefaultCollectionName())); + + for (Document document : documents) { + Map documentIdsObj = new HashMap<>(); + documentIdsObj.put("documentIds", Collections.singletonList(document.getId())); + payloadMap.put("query", documentIdsObj); + payloadMap.put("update", document.getMetadataMap()); + String payload = JSON.toJSONString(payloadMap); + httpUtil.post(config.getHost() + "/document/update", headers, payload); + } + + return StoreResult.successWithIds(documents); + } + + @Override + public List doSearch(SearchWrapper searchWrapper, StoreOptions options) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer account=" + config.getAccount() + "&api_key=" + config.getApiKey()); + Map payloadMap = new HashMap<>(); + payloadMap.put("database", config.getDatabase()); + payloadMap.put("collection", options.getCollectionNameOrDefault(config.getDefaultCollectionName())); + + Map searchMap = new HashMap<>(); + searchMap.put("vectors", Collections.singletonList(searchWrapper.getVector())); + + if (searchWrapper.getMaxResults() != null) { + searchMap.put("limit", searchWrapper.getMaxResults()); + } + + payloadMap.put("search", searchMap); + + + String payload = JSON.toJSONString(payloadMap); + + // https://cloud.tencent.com/document/product/1709/95123 + String response = httpUtil.post(config.getHost() + "/document/search", headers, payload); + if (StringUtil.noText(response)) { + return null; + } + + List result = new ArrayList<>(); + JSONObject rootObject = JSON.parseObject(response); + int code = rootObject.getIntValue("code"); + if (code != 0) { + LoggerFactory.getLogger(QCloudVectorStore.class).error("can not search in QCloudVectorStore, code:" + code + ", message: " + rootObject.getString("msg")); + return null; + } + + JSONArray rootDocs = rootObject.getJSONArray("documents"); + for (int i = 0; i < rootDocs.size(); i++) { + JSONArray docs = rootDocs.getJSONArray(i); + for (int j = 0; j < docs.size(); j++) { + JSONObject doc = docs.getJSONObject(j); + Document vd = new Document(); + vd.setId(doc.getString("id")); + doc.remove("id"); + vd.addMetadata(doc); + result.add(vd); + } + } + return result; + } +} diff --git a/easy-agents-store/easy-agents-store-qcloud/src/main/java/com/easyagents/store/qcloud/QCloudVectorStoreConfig.java b/easy-agents-store/easy-agents-store-qcloud/src/main/java/com/easyagents/store/qcloud/QCloudVectorStoreConfig.java new file mode 100644 index 0000000..4665393 --- /dev/null +++ b/easy-agents-store/easy-agents-store-qcloud/src/main/java/com/easyagents/store/qcloud/QCloudVectorStoreConfig.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.qcloud; + +import com.easyagents.core.store.DocumentStoreConfig; +import com.easyagents.core.util.StringUtil; + +public class QCloudVectorStoreConfig implements DocumentStoreConfig { + + private String host; + private String apiKey; + private String account; + private String database; + + private String defaultCollectionName; + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } + + public String getDefaultCollectionName() { + return defaultCollectionName; + } + + public void setDefaultCollectionName(String defaultCollectionName) { + this.defaultCollectionName = defaultCollectionName; + } + + @Override + public boolean checkAvailable() { + return StringUtil.hasText(this.host, this.apiKey, this.account, this.database, this.defaultCollectionName); + } +} + diff --git a/easy-agents-store/easy-agents-store-qdrant/pom.xml b/easy-agents-store/easy-agents-store-qdrant/pom.xml new file mode 100644 index 0000000..7190f4c --- /dev/null +++ b/easy-agents-store/easy-agents-store-qdrant/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + com.easyagents + easy-agents-store + ${revision} + + + easy-agents-store-qdrant + easy-agents-store-qdrant + + + 8 + 8 + UTF-8 + + + + + junit + junit + test + + + com.easyagents + easy-agents-core + + + org.slf4j + slf4j-api + + + io.grpc + grpc-stub + 1.65.1 + compile + + + io.grpc + grpc-protobuf + 1.65.1 + compile + + + io.grpc + grpc-netty-shaded + 1.65.1 + compile + + + io.qdrant + client + 1.14.0 + + + + + diff --git a/easy-agents-store/easy-agents-store-qdrant/src/main/java/com/easyagents/store/qdrant/QdrantVectorStore.java b/easy-agents-store/easy-agents-store-qdrant/src/main/java/com/easyagents/store/qdrant/QdrantVectorStore.java new file mode 100644 index 0000000..0a5a773 --- /dev/null +++ b/easy-agents-store/easy-agents-store-qdrant/src/main/java/com/easyagents/store/qdrant/QdrantVectorStore.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.qdrant; + +import com.easyagents.core.document.Document; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.StoreResult; +import com.easyagents.core.util.CollectionUtil; +import com.easyagents.core.util.StringUtil; +import io.grpc.Grpc; +import io.grpc.ManagedChannel; +import io.grpc.TlsChannelCredentials; +import io.qdrant.client.QdrantClient; +import io.qdrant.client.QdrantGrpcClient; +import io.qdrant.client.grpc.Collections; +import io.qdrant.client.grpc.JsonWithInt; +import io.qdrant.client.grpc.Points; +import io.qdrant.client.grpc.Points.*; + +import java.io.File; +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; + +import static io.qdrant.client.ConditionFactory.matchKeyword; +import static io.qdrant.client.PointIdFactory.id; +import static io.qdrant.client.QueryFactory.nearest; +import static io.qdrant.client.ValueFactory.value; +import static io.qdrant.client.VectorsFactory.vectors; +import static io.qdrant.client.WithPayloadSelectorFactory.enable; + +public class QdrantVectorStore extends DocumentStore { + + private final QdrantVectorStoreConfig config; + private final QdrantClient client; + private final String defaultCollectionName; + private boolean isCreateCollection = false; + + public QdrantVectorStore(QdrantVectorStoreConfig config) throws IOException { + this.config = config; + this.defaultCollectionName = config.getDefaultCollectionName(); + String uri = config.getUri(); + int port = 6334; + QdrantGrpcClient.Builder builder; + if (StringUtil.hasText(config.getCaPath())) { + ManagedChannel channel = Grpc.newChannelBuilder( + uri, + TlsChannelCredentials.newBuilder().trustManager(new File(config.getCaPath())).build() + ).build(); + builder = QdrantGrpcClient.newBuilder(channel, true); + } else { + if (uri.contains(":")) { + uri = uri.split(":")[0]; + port = Integer.parseInt(uri.split(":")[1]); + } + builder = QdrantGrpcClient.newBuilder(uri, port, false); + } + if (StringUtil.hasText(config.getApiKey())) { + builder.withApiKey(config.getApiKey()); + } + this.client = new QdrantClient(builder.build()); + } + + @Override + public StoreResult doStore(List documents, StoreOptions options) { + List points = new ArrayList<>(); + int size = 1024; + for (Document doc : documents) { + size = doc.getVector().length; + Map payload = new HashMap<>(); + payload.put("content", value(doc.getContent())); + points.add(PointStruct.newBuilder() + .setId(id(Long.parseLong(doc.getId().toString()))) + .setVectors(vectors(doc.getVector())) + .putAllPayload(payload) + .build()); + } + try { + String collectionName = options.getCollectionNameOrDefault(defaultCollectionName); + if (config.isAutoCreateCollection() && !isCreateCollection) { + Boolean exists = client.collectionExistsAsync(collectionName).get(); + if (!exists) { + client.createCollectionAsync(collectionName, Collections.VectorParams.newBuilder() + .setDistance(Collections.Distance.Cosine) + .setSize(size) + .build()) + .get(); + } + } else { + isCreateCollection = true; + } + if (CollectionUtil.hasItems(points)) { + client.upsertAsync(collectionName, points).get(); + } + return StoreResult.successWithIds(documents); + } catch (Exception e) { + return StoreResult.fail(); + } + } + + @Override + public StoreResult doDelete(Collection ids, StoreOptions options) { + try { + String collectionName = options.getCollectionNameOrDefault(defaultCollectionName); + List pointIds = ids.stream() + .map(id -> id((Long) id)) + .collect(Collectors.toList()); + client.deleteAsync(collectionName, pointIds).get(); + return StoreResult.success(); + } catch (Exception e) { + return StoreResult.fail(); + } + } + + @Override + public StoreResult doUpdate(List documents, StoreOptions options) { + try { + List points = new ArrayList<>(); + for (Document doc : documents) { + Map payload = new HashMap<>(); + payload.put("content", value(doc.getContent())); + points.add(PointStruct.newBuilder() + .setId(id(Long.parseLong(doc.getId().toString()))) + .setVectors(vectors(doc.getVector())) + .putAllPayload(payload) + .build()); + } + String collectionName = options.getCollectionNameOrDefault(defaultCollectionName); + if (CollectionUtil.hasItems(points)) { + client.upsertAsync(collectionName, points).get(); + } + return StoreResult.successWithIds(documents); + } catch (Exception e) { + return StoreResult.fail(); + } + } + + @Override + public List doSearch(SearchWrapper wrapper, StoreOptions options) { + List documents = new ArrayList<>(); + try { + String collectionName = options.getCollectionNameOrDefault(defaultCollectionName); + QueryPoints.Builder query = QueryPoints.newBuilder() + .setCollectionName(collectionName) + .setLimit(wrapper.getMaxResults()) + .setWithVectors(Points.WithVectorsSelector.newBuilder().setEnable(true).build()) + .setWithPayload(enable(true)); + if (wrapper.getVector() != null) { + query.setQuery(nearest(wrapper.getVector())); + } + if (StringUtil.hasText(wrapper.getText())) { + query.setFilter(Filter.newBuilder().addMust(matchKeyword("content", wrapper.getText()))); + } + List data = client.queryAsync(query.build()).get(); + for (ScoredPoint point : data) { + Document doc = new Document(); + doc.setId(point.getId().getNum()); + doc.setVector(point.getVectors().getVector().getDataList()); + doc.setContent(point.getPayloadMap().get("content").getStringValue()); + documents.add(doc); + } + return documents; + } catch (Exception e) { + return documents; + } + } + + public QdrantClient getClient() { + return client; + } +} diff --git a/easy-agents-store/easy-agents-store-qdrant/src/main/java/com/easyagents/store/qdrant/QdrantVectorStoreConfig.java b/easy-agents-store/easy-agents-store-qdrant/src/main/java/com/easyagents/store/qdrant/QdrantVectorStoreConfig.java new file mode 100644 index 0000000..ae628ce --- /dev/null +++ b/easy-agents-store/easy-agents-store-qdrant/src/main/java/com/easyagents/store/qdrant/QdrantVectorStoreConfig.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.qdrant; + +import com.easyagents.core.store.DocumentStoreConfig; +import com.easyagents.core.util.StringUtil; + +public class QdrantVectorStoreConfig implements DocumentStoreConfig { + + private String uri; + private String caPath; + private String defaultCollectionName; + private String apiKey; + private boolean autoCreateCollection = true; + + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + + public String getCaPath() { + return caPath; + } + + public void setCaPath(String caPath) { + this.caPath = caPath; + } + + public String getDefaultCollectionName() { + return defaultCollectionName; + } + + public void setDefaultCollectionName(String defaultCollectionName) { + this.defaultCollectionName = defaultCollectionName; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public boolean isAutoCreateCollection() { + return autoCreateCollection; + } + + public void setAutoCreateCollection(boolean autoCreateCollection) { + this.autoCreateCollection = autoCreateCollection; + } + + @Override + public boolean checkAvailable() { + return StringUtil.hasText(this.uri); + } +} diff --git a/easy-agents-store/easy-agents-store-qdrant/src/test/java/com/easyagents/store/qdrant/QdrantVectorStoreTest.java b/easy-agents-store/easy-agents-store-qdrant/src/test/java/com/easyagents/store/qdrant/QdrantVectorStoreTest.java new file mode 100644 index 0000000..5dee42d --- /dev/null +++ b/easy-agents-store/easy-agents-store-qdrant/src/test/java/com/easyagents/store/qdrant/QdrantVectorStoreTest.java @@ -0,0 +1,65 @@ +package com.easyagents.store.qdrant; + +import com.easyagents.core.document.Document; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class QdrantVectorStoreTest { + + @Test + public void testSaveVectors() throws Exception { + QdrantVectorStore db = getDb(); + StoreOptions options = new StoreOptions(); + options.setCollectionName("test_collection1"); + List list = new ArrayList<>(); + Document doc1 = new Document(); + doc1.setId(1L); + doc1.setContent("test1"); + doc1.setVector(new float[]{5.2f, 4.4f}); + list.add(doc1); + Document doc2 = new Document(); + doc2.setId(2L); + doc2.setContent("test2"); + doc2.setVector(new float[]{5.2f, 3.9f}); + list.add(doc2); + Document doc3 = new Document(); + doc3.setId(3); + doc3.setContent("test3"); + doc3.setVector(new float[]{4.9f, 3.4f}); + list.add(doc3); + db.store(list, options); + } + + @Test + public void testQuery() throws Exception { + QdrantVectorStore db = getDb();; + StoreOptions options = new StoreOptions(); + options.setCollectionName("test_collection1"); + SearchWrapper search = new SearchWrapper(); + search.setVector(new float[]{5.2f, 3.9f}); + //search.setText("test1"); + search.setMaxResults(1); + List record = db.search(search, options); + System.out.println(record); + } + + @Test + public void testDelete() throws Exception { + QdrantVectorStore db = getDb(); + StoreOptions options = new StoreOptions(); + options.setCollectionName("test_collection1"); + db.delete(Collections.singletonList(3L), options); + } + + private QdrantVectorStore getDb() throws Exception { + QdrantVectorStoreConfig config = new QdrantVectorStoreConfig(); + config.setUri("localhost"); + config.setDefaultCollectionName("test_collection1"); + return new QdrantVectorStore(config); + } +} diff --git a/easy-agents-store/easy-agents-store-redis/pom.xml b/easy-agents-store/easy-agents-store-redis/pom.xml new file mode 100644 index 0000000..8403274 --- /dev/null +++ b/easy-agents-store/easy-agents-store-redis/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + com.easyagents + easy-agents-store + ${revision} + + + easy-agents-store-redis + easy-agents-store-redis + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + compile + + + + redis.clients + jedis + 5.2.0 + + + + + diff --git a/easy-agents-store/easy-agents-store-redis/src/main/java/com/easyagents/store/redis/RedisVectorStore.java b/easy-agents-store/easy-agents-store-redis/src/main/java/com/easyagents/store/redis/RedisVectorStore.java new file mode 100644 index 0000000..73b765b --- /dev/null +++ b/easy-agents-store/easy-agents-store-redis/src/main/java/com/easyagents/store/redis/RedisVectorStore.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.redis; + +import com.easyagents.core.document.Document; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.StoreResult; +import com.easyagents.core.util.StringUtil; +import com.alibaba.fastjson2.JSON; +import kotlin.collections.ArrayDeque; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import redis.clients.jedis.JedisPooled; +import redis.clients.jedis.Pipeline; +import redis.clients.jedis.json.Path2; +import redis.clients.jedis.search.FTCreateParams; +import redis.clients.jedis.search.IndexDataType; +import redis.clients.jedis.search.Query; +import redis.clients.jedis.search.SearchResult; +import redis.clients.jedis.search.schemafields.SchemaField; +import redis.clients.jedis.search.schemafields.TextField; +import redis.clients.jedis.search.schemafields.VectorField; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.util.*; + +public class RedisVectorStore extends DocumentStore { + + protected final RedisVectorStoreConfig config; + protected final JedisPooled jedis; + protected final Set redisIndexesCache = new HashSet<>(); + protected static final Logger logger = LoggerFactory.getLogger(RedisVectorStore.class); + + + public RedisVectorStore(RedisVectorStoreConfig config) { + this.config = config; + this.jedis = new JedisPooled( + URI.create(config.getUri()) + ); + } + + + protected void createSchemaIfNecessary(String indexName) { + if (redisIndexesCache.contains(indexName)) { + return; + } + + // 检查 indexName 是否存在 + Set existIndexes = this.jedis.ftList(); + if (existIndexes != null && existIndexes.contains(indexName)) { + redisIndexesCache.add(indexName); + return; + } + + FTCreateParams ftCreateParams = FTCreateParams.createParams() + .on(IndexDataType.JSON) + .addPrefix(getPrefix(indexName)); + + jedis.ftCreate(indexName, ftCreateParams, schemaFields()); + redisIndexesCache.add(indexName); + } + + + protected Iterable schemaFields() { + Map vectorAttrs = new HashMap<>(); + //支持 COSINE: 余弦距离 , IP: 内积距离, L2: 欧几里得距离 + vectorAttrs.put("DISTANCE_METRIC", "COSINE"); + vectorAttrs.put("TYPE", "FLOAT32"); + vectorAttrs.put("DIM", this.getEmbeddingModel().dimensions()); + + List fields = new ArrayList<>(); + fields.add(TextField.of(jsonPath("text")).as("text").weight(1.0)); + + fields.add(VectorField.builder() + .fieldName(jsonPath("vector")) + .algorithm(VectorField.VectorAlgorithm.HNSW) + .attributes(vectorAttrs) + .as("vector") + .build()); + + return fields; + } + + protected String jsonPath(String field) { + return "$." + field; + } + + + @Override + public StoreResult doStore(List documents, StoreOptions options) { + String indexName = createIndexName(options); + + if (StringUtil.noText(indexName)) { + throw new IllegalStateException("IndexName is null or blank. please config the \"defaultCollectionName\" or store with designative collectionName."); + } + + createSchemaIfNecessary(indexName); + + try (Pipeline pipeline = jedis.pipelined();) { + for (Document document : documents) { + java.util.Map fields = new HashMap<>(); + fields.put("text", document.getContent()); + fields.put("vector", document.getVector()); + + //put all metadata + Map metadataMap = document.getMetadataMap(); + if (metadataMap != null) { + fields.putAll(metadataMap); + } + + String key = getPrefix(indexName) + document.getId(); + pipeline.jsonSetWithEscape(key, Path2.of("$"), fields); + } + + List objects = pipeline.syncAndReturnAll(); + for (Object object : objects) { + if (!object.equals("OK")) { + logger.error("Could not store document: {}", object); + return StoreResult.fail(); + } + } + } + + return StoreResult.successWithIds(documents); + } + + + @Override + public StoreResult doDelete(Collection ids, StoreOptions options) { + String indexName = createIndexName(options); + try (Pipeline pipeline = this.jedis.pipelined()) { + for (Object id : ids) { + String key = getPrefix(indexName) + id; + pipeline.jsonDel(key); + } + + List objects = pipeline.syncAndReturnAll(); + for (Object object : objects) { + if (!object.equals(1L)) { + logger.error("Could not delete document: {}", object); + return StoreResult.fail(); + } + } + } + + return StoreResult.success(); + } + + + @Override + public StoreResult doUpdate(List documents, StoreOptions options) { + return doStore(documents, options); + } + + + @Override + public List doSearch(SearchWrapper wrapper, StoreOptions options) { + String indexName = createIndexName(options); + + if (StringUtil.noText(indexName)) { + throw new IllegalStateException("IndexName is null or blank. please config the \"defaultCollectionName\" or store with designative collectionName."); + } + + createSchemaIfNecessary(indexName); + + // 创建查询向量 + byte[] vectorBytes = new byte[wrapper.getVector().length * 4]; + FloatBuffer floatBuffer = ByteBuffer.wrap(vectorBytes).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer(); + for (Float v : wrapper.getVector()) { + floatBuffer.put(v); + } + + + List returnFields = new ArrayList<>(); + returnFields.add("text"); + returnFields.add("vector"); + returnFields.add("score"); + + if (wrapper.getOutputFields() != null) { + returnFields.addAll(wrapper.getOutputFields()); + } + + // 使用 KNN 算法进行向量相似度搜索 + Query query = new Query("*=>[KNN " + wrapper.getMaxResults() + " @vector $BLOB AS score]") + .addParam("BLOB", vectorBytes) + .returnFields(returnFields.toArray(new String[0])) + .setSortBy("score", true) + .limit(0, wrapper.getMaxResults()) + .dialect(2); + + int keyPrefixLen = this.getPrefix(indexName).length(); + + // 执行搜索 + SearchResult searchResult = jedis.ftSearch(indexName, query); + List searchDocuments = searchResult.getDocuments(); + List documents = new ArrayDeque<>(searchDocuments.size()); + for (redis.clients.jedis.search.Document document : searchDocuments) { + String id = document.getId().substring(keyPrefixLen); + Document doc = new Document(); + doc.setId(id); + doc.setContent(document.getString("text")); + Object vector = document.get("vector"); + if (vector != null) { + float[] doubles = JSON.parseObject(vector.toString(), float[].class); + doc.setVector(doubles); + } + + if (wrapper.getOutputFields() != null) { + for (String field : wrapper.getOutputFields()) { + doc.addMetadata(field, document.getString(field)); + } + } + + double distance = 1.0d - similarityScore(document); + // 相似度得分设置为0-1 , 0表示最不相似, 1表示最相似 + doc.setScore(1.0d - distance); + documents.add(doc); + } + return documents; + } + + protected float similarityScore(redis.clients.jedis.search.Document doc) { + return (2 - Float.parseFloat(doc.getString("score"))) / 2; + } + + + protected String createIndexName(StoreOptions options) { + return options.getCollectionNameOrDefault(config.getDefaultCollectionName()); + } + + @NotNull + protected String getPrefix(String indexName) { + return this.config.getStorePrefix() + indexName + ":"; + } + + +} diff --git a/easy-agents-store/easy-agents-store-redis/src/main/java/com/easyagents/store/redis/RedisVectorStoreConfig.java b/easy-agents-store/easy-agents-store-redis/src/main/java/com/easyagents/store/redis/RedisVectorStoreConfig.java new file mode 100644 index 0000000..db4f971 --- /dev/null +++ b/easy-agents-store/easy-agents-store-redis/src/main/java/com/easyagents/store/redis/RedisVectorStoreConfig.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.redis; + +import com.easyagents.core.store.DocumentStoreConfig; +import com.easyagents.core.util.StringUtil; + + +public class RedisVectorStoreConfig implements DocumentStoreConfig { + + private String uri; + + private String storePrefix = "docs:"; + private String defaultCollectionName; + + + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + + public String getStorePrefix() { + return storePrefix; + } + + public void setStorePrefix(String storePrefix) { + this.storePrefix = storePrefix; + } + + public String getDefaultCollectionName() { + return defaultCollectionName; + } + + public void setDefaultCollectionName(String defaultCollectionName) { + this.defaultCollectionName = defaultCollectionName; + } + + @Override + public boolean checkAvailable() { + return StringUtil.hasText(this.uri); + } +} diff --git a/easy-agents-store/easy-agents-store-vectorex/pom.xml b/easy-agents-store/easy-agents-store-vectorex/pom.xml new file mode 100644 index 0000000..2886cae --- /dev/null +++ b/easy-agents-store/easy-agents-store-vectorex/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + com.easyagents + easy-agents-store + ${revision} + + + easy-agents-store-vectorex + easy-agents-store-vectorex + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + compile + + + + io.github.javpower + vectorex-client + 1.5.3 + + + + + diff --git a/easy-agents-store/easy-agents-store-vectorex/src/main/java/com/easyagents/store/vectorex/VectoRexStore.java b/easy-agents-store/easy-agents-store-vectorex/src/main/java/com/easyagents/store/vectorex/VectoRexStore.java new file mode 100644 index 0000000..9ef3572 --- /dev/null +++ b/easy-agents-store/easy-agents-store-vectorex/src/main/java/com/easyagents/store/vectorex/VectoRexStore.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.vectorex; + +import com.easyagents.core.document.Document; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.StoreResult; +import io.github.javpower.vectorexclient.VectorRexClient; +import io.github.javpower.vectorexclient.builder.QueryBuilder; +import io.github.javpower.vectorexclient.entity.MetricType; +import io.github.javpower.vectorexclient.entity.ScalarField; +import io.github.javpower.vectorexclient.entity.VectoRexEntity; +import io.github.javpower.vectorexclient.entity.VectorFiled; +import io.github.javpower.vectorexclient.req.CollectionDataAddReq; +import io.github.javpower.vectorexclient.req.CollectionDataDelReq; +import io.github.javpower.vectorexclient.req.VectoRexCollectionReq; +import io.github.javpower.vectorexclient.res.DbData; +import io.github.javpower.vectorexclient.res.ServerResponse; +import io.github.javpower.vectorexclient.res.VectorSearchResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +public class VectoRexStore extends DocumentStore { + + private final VectoRexStoreConfig config; + private final VectorRexClient client; + private final String defaultCollectionName; + private boolean isCreateCollection = false; + private static final Logger logger = LoggerFactory.getLogger(VectoRexStore.class); + + public VectoRexStore(VectoRexStoreConfig config) { + this.config = config; + this.defaultCollectionName = config.getDefaultCollectionName(); + this.client = new VectorRexClient(config.getUri(), config.getUsername(), config.getPassword()); + } + + @Override + public StoreResult doStore(List documents, StoreOptions options) { + List> data = new ArrayList<>(); + for (Document doc : documents) { + Map dict = new HashMap<>(); + dict.put("id", String.valueOf(doc.getId())); + dict.put("content", doc.getContent()); + dict.put("vector", doc.getVectorAsList()); + data.add(dict); + } + String collectionName = options.getCollectionNameOrDefault(defaultCollectionName); + if (config.isAutoCreateCollection() && !isCreateCollection) { + ServerResponse> collections = client.getCollections(); + if (collections.getData() == null || collections.getData().stream().noneMatch(e -> e.getCollectionName().equals(collectionName))) { + createCollection(collectionName); + } else { + isCreateCollection = true; + } + } + for (Map map : data) { + CollectionDataAddReq req = CollectionDataAddReq.builder().collectionName(collectionName).metadata(map).build(); + client.addCollectionData(req); + } + return StoreResult.successWithIds(documents); + } + + + private Boolean createCollection(String collectionName) { + List scalarFields = new ArrayList(); + ScalarField id = ScalarField.builder().name("id").isPrimaryKey(true).build(); + ScalarField content = ScalarField.builder().name("content").isPrimaryKey(false).build(); + scalarFields.add(id); + scalarFields.add(content); + List vectorFiles = new ArrayList(); + VectorFiled vector = VectorFiled.builder().name("vector").metricType(MetricType.FLOAT_COSINE_DISTANCE).dimensions(this.getEmbeddingModel().dimensions()).build(); + vectorFiles.add(vector); + ServerResponse response = client.createCollection(VectoRexCollectionReq.builder().collectionName(collectionName).scalarFields(scalarFields).vectorFileds(vectorFiles).build()); + return response.isSuccess(); + } + + @Override + public StoreResult doDelete(Collection ids, StoreOptions options) { + for (Object id : ids) { + CollectionDataDelReq req = CollectionDataDelReq.builder().collectionName(options.getCollectionNameOrDefault(defaultCollectionName)).id((String) id).build(); + ServerResponse response = client.deleteCollectionData(req); + if (!response.isSuccess()) { + return StoreResult.fail(); + } + } + return StoreResult.success(); + + } + + @Override + public List doSearch(SearchWrapper searchWrapper, StoreOptions options) { + ServerResponse> response = client.queryCollectionData(QueryBuilder.lambda(options.getCollectionNameOrDefault(defaultCollectionName)) + .vector("vector", Collections.singletonList(searchWrapper.getVectorAsList())).topK(searchWrapper.getMaxResults())); + if (!response.isSuccess()) { + logger.error("Error searching in VectoRex", response.getMsg()); + return Collections.emptyList(); + } + List data = response.getData(); + List documents = new ArrayList<>(); + for (VectorSearchResult result : data) { + DbData dd = result.getData(); + Map metadata = dd.getMetadata(); + Document doc = new Document(); + doc.setId(result.getId()); + doc.setContent((String) metadata.get("content")); + Object vectorObj = metadata.get("vector"); + if (vectorObj instanceof List) { + //noinspection unchecked + doc.setVector((List) vectorObj); + } + documents.add(doc); + } + return documents; + } + + @Override + public StoreResult doUpdate(List documents, StoreOptions options) { + if (documents == null || documents.isEmpty()) { + return StoreResult.success(); + } + List> data = new ArrayList<>(); + for (Document doc : documents) { + Map dict = new HashMap<>(); + dict.put("id", String.valueOf(doc.getId())); + dict.put("content", doc.getContent()); + dict.put("vector", doc.getVectorAsList()); + data.add(dict); + } + String collectionName = options.getCollectionNameOrDefault(defaultCollectionName); + for (Map map : data) { + CollectionDataAddReq req = CollectionDataAddReq.builder().collectionName(collectionName).metadata(map).build(); + client.updateCollectionData(req); + } + return StoreResult.successWithIds(documents); + } + + public VectorRexClient getClient() { + return client; + } + +} diff --git a/easy-agents-store/easy-agents-store-vectorex/src/main/java/com/easyagents/store/vectorex/VectoRexStoreConfig.java b/easy-agents-store/easy-agents-store-vectorex/src/main/java/com/easyagents/store/vectorex/VectoRexStoreConfig.java new file mode 100644 index 0000000..99fae92 --- /dev/null +++ b/easy-agents-store/easy-agents-store-vectorex/src/main/java/com/easyagents/store/vectorex/VectoRexStoreConfig.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.vectorex; + +import com.easyagents.core.store.DocumentStoreConfig; +import com.easyagents.core.util.StringUtil; + + +public class VectoRexStoreConfig implements DocumentStoreConfig { + + private String uri; + private String username; + private String password; + + private String defaultCollectionName; + private boolean autoCreateCollection = true; + + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + + + public String getDefaultCollectionName() { + return defaultCollectionName; + } + + public void setDefaultCollectionName(String defaultCollectionName) { + this.defaultCollectionName = defaultCollectionName; + } + + public boolean isAutoCreateCollection() { + return autoCreateCollection; + } + + public void setAutoCreateCollection(boolean autoCreateCollection) { + this.autoCreateCollection = autoCreateCollection; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + @Override + public boolean checkAvailable() { + return StringUtil.hasText(this.uri); + } +} diff --git a/easy-agents-store/easy-agents-store-vectorexdb/pom.xml b/easy-agents-store/easy-agents-store-vectorexdb/pom.xml new file mode 100644 index 0000000..5ade0dd --- /dev/null +++ b/easy-agents-store/easy-agents-store-vectorexdb/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + com.easyagents + easy-agents-store + ${revision} + + + easy-agents-store-vectorexdb + easy-agents-store-vectorexdb + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-core + compile + + + + io.github.javpower + vectorex-core + 1.5.3 + + + + com.easyagents + easy-agents-chat-openai + test + + + + diff --git a/easy-agents-store/easy-agents-store-vectorexdb/src/main/java/com/easyagents/store/vectorex/VectoRexStore.java b/easy-agents-store/easy-agents-store-vectorexdb/src/main/java/com/easyagents/store/vectorex/VectoRexStore.java new file mode 100644 index 0000000..80a64e9 --- /dev/null +++ b/easy-agents-store/easy-agents-store-vectorexdb/src/main/java/com/easyagents/store/vectorex/VectoRexStore.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.vectorex; + +import com.easyagents.core.document.Document; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; +import com.easyagents.core.store.StoreResult; +import com.easyagents.core.util.CollectionUtil; +import com.google.common.collect.Lists; +import io.github.javpower.vectorex.keynote.core.DbData; +import io.github.javpower.vectorex.keynote.core.VectorData; +import io.github.javpower.vectorex.keynote.core.VectorSearchResult; +import io.github.javpower.vectorex.keynote.model.MetricType; +import io.github.javpower.vectorex.keynote.model.VectorFiled; +import io.github.javpower.vectorexcore.VectoRexClient; +import io.github.javpower.vectorexcore.entity.KeyValue; +import io.github.javpower.vectorexcore.entity.ScalarField; +import io.github.javpower.vectorexcore.entity.VectoRexEntity; + +import java.util.*; + +public class VectoRexStore extends DocumentStore { + + private final VectoRexStoreConfig config; + private final VectoRexClient client; + private final String defaultCollectionName; + private boolean isCreateCollection=false; + + public VectoRexStore(VectoRexStoreConfig config) { + this.config = config; + this.defaultCollectionName=config.getDefaultCollectionName(); + this.client = new VectoRexClient(config.getUri()); + } + @Override + public StoreResult doStore(List documents, StoreOptions options) { + List data=new ArrayList<>(); + for (Document doc : documents) { + Map dict=new HashMap<>(); + dict.put("id",String.valueOf(doc.getId())); + dict.put("content", doc.getContent()); + dict.put("vector", doc.getVector()); + DbData dbData=new DbData(); + dbData.setId(String.valueOf(doc.getId())); + dbData.setMetadata(dict); + VectorData vd=new VectorData(dbData.getId(),doc.getVector()); + vd.setName("vector"); + dbData.setVectorFiled(Lists.newArrayList(vd)); + data.add(dbData); + } + String collectionName = options.getCollectionNameOrDefault(defaultCollectionName); + if(config.isAutoCreateCollection()&&!isCreateCollection){ + List collections = client.getCollections(); + if(CollectionUtil.noItems(collections)||collections.stream().noneMatch(e -> e.getCollectionName().equals(collectionName))){ + createCollection(collectionName); + }else { + isCreateCollection=true; + } + } + if(CollectionUtil.hasItems(data)){ + client.getStore(collectionName).saveAll(data); + } + return StoreResult.successWithIds(documents); + } + + + private void createCollection(String collectionName) { + VectorFiled vectorFiled = new VectorFiled(); + vectorFiled.setDimensions(this.getEmbeddingModel().dimensions()); + vectorFiled.setName("vector"); + vectorFiled.setMetricType(MetricType.FLOAT_COSINE_DISTANCE); + VectoRexEntity entity=new VectoRexEntity(); + entity.setCollectionName(collectionName); + List> vectorFiles=new ArrayList<>(); + vectorFiles.add(new KeyValue<>("vector",vectorFiled)); + List> scalarFields=new ArrayList<>(); + ScalarField id = new ScalarField(); + id.setName("id"); + id.setIsPrimaryKey(true); + scalarFields.add(new KeyValue<>("id",id)); + ScalarField content = new ScalarField(); + content.setName("content"); + content.setIsPrimaryKey(false); + scalarFields.add(new KeyValue<>("content",content)); + entity.setVectorFileds(vectorFiles); + entity.setScalarFields(scalarFields); + client.createCollection(entity); + } + + @Override + public StoreResult doDelete(Collection ids, StoreOptions options) { + client.getStore(options.getCollectionNameOrDefault(defaultCollectionName)).deleteAll((List) ids); + return StoreResult.success(); + + } + + @Override + public List doSearch(SearchWrapper searchWrapper, StoreOptions options) { + List data = client.getStore(options.getCollectionNameOrDefault(defaultCollectionName)).search("vector", searchWrapper.getVectorAsList(), searchWrapper.getMaxResults(), null); + List documents = new ArrayList<>(); + for (VectorSearchResult result : data) { + DbData dd = result.getData(); + Map metadata = dd.getMetadata(); + Document doc=new Document(); + doc.setId(result.getId()); + doc.setContent((String) metadata.get("content")); + Object vectorObj = metadata.get("vector"); + if (vectorObj instanceof List) { + //noinspection unchecked + doc.setVector((List) vectorObj); + } + documents.add(doc); + } + return documents; + } + + @Override + public StoreResult doUpdate(List documents, StoreOptions options) { + if (documents == null || documents.isEmpty()) { + return StoreResult.success(); + } + for (Document doc : documents) { + Map dict=new HashMap<>(); + dict.put("id",String.valueOf(doc.getId())); + dict.put("content", doc.getContent()); + dict.put("vector", doc.getVector()); + DbData dbData=new DbData(); + dbData.setId(String.valueOf(doc.getId())); + dbData.setMetadata(dict); + VectorData vd=new VectorData(dbData.getId(),doc.getVector()); + vd.setName("vector"); + dbData.setVectorFiled(Lists.newArrayList(vd)); + client.getStore(options.getCollectionNameOrDefault(defaultCollectionName)).update(dbData); + } + return StoreResult.successWithIds(documents); + } + + public VectoRexClient getClient() { + return client; + } + +} diff --git a/easy-agents-store/easy-agents-store-vectorexdb/src/main/java/com/easyagents/store/vectorex/VectoRexStoreConfig.java b/easy-agents-store/easy-agents-store-vectorexdb/src/main/java/com/easyagents/store/vectorex/VectoRexStoreConfig.java new file mode 100644 index 0000000..1bbd104 --- /dev/null +++ b/easy-agents-store/easy-agents-store-vectorexdb/src/main/java/com/easyagents/store/vectorex/VectoRexStoreConfig.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.vectorex; + +import com.easyagents.core.store.DocumentStoreConfig; +import com.easyagents.core.util.StringUtil; + + +public class VectoRexStoreConfig implements DocumentStoreConfig { + + private String uri; + private String defaultCollectionName; + private boolean autoCreateCollection = true; + + public String getUri() { + return uri; + } + + public void setUri(String uri) { + this.uri = uri; + } + + + public String getDefaultCollectionName() { + return defaultCollectionName; + } + + public void setDefaultCollectionName(String defaultCollectionName) { + this.defaultCollectionName = defaultCollectionName; + } + + public boolean isAutoCreateCollection() { + return autoCreateCollection; + } + + public void setAutoCreateCollection(boolean autoCreateCollection) { + this.autoCreateCollection = autoCreateCollection; + } + @Override + public boolean checkAvailable() { + return StringUtil.hasText(this.uri); + } +} diff --git a/easy-agents-store/easy-agents-store-vectorexdb/src/test/java/com/easyagents/store/vectorex/test/Test.java b/easy-agents-store/easy-agents-store-vectorexdb/src/test/java/com/easyagents/store/vectorex/test/Test.java new file mode 100644 index 0000000..43de4ba --- /dev/null +++ b/easy-agents-store/easy-agents-store-vectorexdb/src/test/java/com/easyagents/store/vectorex/test/Test.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.store.vectorex.test; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreResult; + +import com.easyagents.llm.openai.OpenAIChatModel; +import com.easyagents.llm.openai.OpenAIChatConfig; +import com.easyagents.store.vectorex.VectoRexStore; +import com.easyagents.store.vectorex.VectoRexStoreConfig; + +import java.util.List; + +public class Test { + public static void main(String[] args) { + + OpenAIChatConfig openAILlmConfig= new OpenAIChatConfig(); + openAILlmConfig.setApiKey(""); + openAILlmConfig.setEndpoint(""); + openAILlmConfig.setModel(""); + + ChatModel chatModel = new OpenAIChatModel(openAILlmConfig); + + + VectoRexStoreConfig config = new VectoRexStoreConfig(); + config.setDefaultCollectionName("test05"); + VectoRexStore store = new VectoRexStore(config); +// store.setEmbeddingModel(chatModel); + + Document document = new Document(); + document.setContent("你好"); + document.setId(1); + store.store(document); + + SearchWrapper sw = new SearchWrapper(); + sw.setText("你好"); + + List search = store.search(sw); + System.out.println(search); + + + StoreResult result = store.delete("1"); + System.out.println("-------delete-----" + result); + search = store.search(sw); + System.out.println(search); + } +} diff --git a/easy-agents-store/pom.xml b/easy-agents-store/pom.xml new file mode 100644 index 0000000..ed8aba4 --- /dev/null +++ b/easy-agents-store/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-store + easy-agents-store + + pom + + easy-agents-store-qcloud + easy-agents-store-aliyun + easy-agents-store-chroma + easy-agents-store-redis + easy-agents-store-opensearch + easy-agents-store-elasticsearch + easy-agents-store-vectorex + easy-agents-store-vectorexdb + easy-agents-store-pgvector + easy-agents-store-qdrant + + + + 8 + 8 + UTF-8 + + + diff --git a/easy-agents-support/pom.xml b/easy-agents-support/pom.xml new file mode 100644 index 0000000..daaa489 --- /dev/null +++ b/easy-agents-support/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-support + easy-agents-support + + + 8 + 8 + UTF-8 + + + + + com.easyagents + easy-agents-flow + + + + com.easyagents + easy-agents-core + + + + + + diff --git a/easy-agents-support/src/main/java/com/easyagents/flow/support/provider/EasyAgentsLlm.java b/easy-agents-support/src/main/java/com/easyagents/flow/support/provider/EasyAgentsLlm.java new file mode 100644 index 0000000..881ab7c --- /dev/null +++ b/easy-agents-support/src/main/java/com/easyagents/flow/support/provider/EasyAgentsLlm.java @@ -0,0 +1,69 @@ +package com.easyagents.flow.support.provider; + +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.SystemMessage; +import com.easyagents.core.model.chat.ChatModel; +import com.easyagents.core.model.chat.response.AiMessageResponse; +import com.easyagents.core.prompt.SimplePrompt; +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.llm.Llm; +import com.easyagents.flow.core.node.LlmNode; +import com.easyagents.flow.core.util.StringUtil; + +import java.util.List; + +public class EasyAgentsLlm implements Llm { + + private ChatModel chatModel; + + public ChatModel getChatModel() { + return chatModel; + } + + public void setChatModel(ChatModel chatModel) { + this.chatModel = chatModel; + } + + @Override + public String chat(MessageInfo messageInfo, ChatOptions options, LlmNode llmNode, Chain chain) { + + SimplePrompt prompt = new SimplePrompt(messageInfo.getMessage()); + + // 系统提示词 + if (StringUtil.hasText(messageInfo.getSystemMessage())) { + prompt.setSystemMessage(SystemMessage.of(messageInfo.getSystemMessage())); + } + + // 图片 + List images = messageInfo.getImages(); + if (images != null && !images.isEmpty()) { + for (String image : images) { + prompt.addImageUrl(image); + } + } + + com.easyagents.core.model.chat.ChatOptions chatOptions = new com.easyagents.core.model.chat.ChatOptions(); + chatOptions.setSeed(options.getSeed()); + chatOptions.setTemperature(options.getTemperature()); + chatOptions.setTopP(options.getTopP()); + chatOptions.setTopK(options.getTopK()); + chatOptions.setMaxTokens(options.getMaxTokens()); + chatOptions.setStop(options.getStop()); + + AiMessageResponse response = chatModel.chat(prompt, chatOptions); + if (response == null) { + throw new RuntimeException("EasyAgentsLlm can not get response!"); + } + + if (response.isError()) { + throw new RuntimeException("EasyAgentsLlm error: " + response.getErrorMessage()); + } + + AiMessage aiMessage = response.getMessage(); + if (aiMessage != null) { + return aiMessage.getContent(); + } + + throw new RuntimeException("EasyAgentsLlm can not get aiMessage!"); + } +} diff --git a/easy-agents-tool/easy-agents-tool-javascript/pom.xml b/easy-agents-tool/easy-agents-tool-javascript/pom.xml new file mode 100644 index 0000000..e8dc66c --- /dev/null +++ b/easy-agents-tool/easy-agents-tool-javascript/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + com.easyagents + easy-agents-tool + ${revision} + + + easy-agents-tool-javascript + easy-agents-tool-javascript + + + 8 + 8 + UTF-8 + + + diff --git a/easy-agents-tool/easy-agents-tool-python/pom.xml b/easy-agents-tool/easy-agents-tool-python/pom.xml new file mode 100644 index 0000000..4c85c6a --- /dev/null +++ b/easy-agents-tool/easy-agents-tool-python/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + com.easyagents + easy-agents-tool + ${revision} + + + easy-agents-tool-python + easy-agents-tool-python + + + 8 + 8 + UTF-8 + + + diff --git a/easy-agents-tool/easy-agents-tool-shell/pom.xml b/easy-agents-tool/easy-agents-tool-shell/pom.xml new file mode 100644 index 0000000..8d0a342 --- /dev/null +++ b/easy-agents-tool/easy-agents-tool-shell/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + com.easyagents + easy-agents-tool + ${revision} + + + easy-agents-tool-shell + easy-agents-tool-shell + + + 8 + 8 + UTF-8 + + + diff --git a/easy-agents-tool/pom.xml b/easy-agents-tool/pom.xml new file mode 100644 index 0000000..a477aef --- /dev/null +++ b/easy-agents-tool/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + + + easy-agents-tool + easy-agents-tool + pom + + easy-agents-tool-python + easy-agents-tool-javascript + easy-agents-tool-shell + + + + 8 + 8 + UTF-8 + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..afc8cf9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,545 @@ + + + 4.0.0 + + com.easyagents + easy-agents-parent + ${revision} + pom + + easy-agents + https://agentsflex.com + Easy-Agents is an elegant LLM Application Framework like LangChain with Java. + + + Github Issue + https://github.com/easy-agents/easy-agents/issues + + + + The Apache Software License, Version 2.0 + https://apache.org/licenses/LICENSE-2.0.txt + + + + + Michael Yang + fuhai999@gmail.com + + developer + + +8 + + + + https://github.com/easy-agents/easy-agents + scm:git:https://github.com/easy-agents/easy-agents.git + scm:git:https://github.com/easy-agents/easy-agents.git + + + + easy-agents-bom + easy-agents-core + easy-agents-chat + easy-agents-store + easy-agents-spring-boot-starter + easy-agents-image + easy-agents-rerank + easy-agents-search-engine + easy-agents-embedding + easy-agents-tool + easy-agents-mcp + easy-agents-flow + easy-agents-support + + + + + 0.0.1 + 8 + 8 + 1.3.0 + UTF-8 + 1.7.29 + 4.13.2 + 4.9.3 + 2.0.58 + 2.7.18 + 1.8.22 + 1.51.0 + + + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib-common + ${kotlin.version} + + + com.squareup.okhttp3 + okhttp + ${okhttp.version} + + + + com.squareup.okhttp3 + okhttp-sse + ${okhttp.version} + + + + com.alibaba.fastjson2 + fastjson2 + ${fastjson2.version} + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + org.slf4j + slf4j-log4j12 + ${slf4j.version} + + + + org.slf4j + slf4j-simple + ${slf4j.version} + + + + org.slf4j + jcl-over-slf4j + ${slf4j.version} + + + + junit + junit + ${junit.version} + + + + + com.easyagents + easy-agents-core + ${revision} + + + + com.easyagents + easy-agents-bom + ${revision} + + + + com.easyagents + easy-agents-flow + ${revision} + + + + com.easyagents + easy-agents-support + ${revision} + + + + + + com.easyagents + easy-agents-image-gitee + ${revision} + + + com.easyagents + easy-agents-image-openai + ${revision} + + + com.easyagents + easy-agents-image-qianfan + ${revision} + + + com.easyagents + easy-agents-image-qwen + ${revision} + + + com.easyagents + easy-agents-image-siliconflow + ${revision} + + + com.easyagents + easy-agents-image-stability + ${revision} + + + com.easyagents + easy-agents-image-tencent + ${revision} + + + com.easyagents + easy-agents-image-volcengine + ${revision} + + + + + + + com.easyagents + easy-agents-chat-chatglm + ${revision} + + + com.easyagents + easy-agents-chat-coze + ${revision} + + + com.easyagents + easy-agents-chat-deepseek + ${revision} + + + com.easyagents + easy-agents-chat-ollama + ${revision} + + + com.easyagents + easy-agents-chat-openai + ${revision} + + + com.easyagents + easy-agents-chat-qwen + ${revision} + + + + + + com.easyagents + easy-agents-rerank + ${revision} + + + + com.easyagents + easy-agents-rerank-default + ${revision} + + + + com.easyagents + easy-agents-rerank-gitee + ${revision} + + + + + + com.easyagents + easy-agents-embedding-openai + ${revision} + + + + com.easyagents + easy-agents-embedding-qwen + ${revision} + + + + com.easyagents + easy-agents-embedding-ollama + ${revision} + + + + + + com.easyagents + easy-agents-store-aliyun + ${revision} + + + com.easyagents + easy-agents-store-chroma + ${revision} + + + com.easyagents + easy-agents-store-elasticsearch + ${revision} + + + com.easyagents + easy-agents-store-opensearch + ${revision} + + + com.easyagents + easy-agents-store-pgvector + ${revision} + + + com.easyagents + easy-agents-store-qcloud + ${revision} + + + com.easyagents + easy-agents-store-qdrant + ${revision} + + + com.easyagents + easy-agents-store-redis + ${revision} + + + com.easyagents + easy-agents-store-vectorex + ${revision} + + + com.easyagents + easy-agents-store-vectorexdb + ${revision} + + + + + + com.easyagents + easy-agents-spring-boot-starter + ${revision} + + + + + + com.easyagents + easy-agents-search-engine + ${revision} + + + com.easyagents + easy-agents-search-engine-service + ${revision} + + + com.easyagents + easy-agents-search-engine-es + ${revision} + + + com.easyagents + easy-agents-search-engine-lucene + ${revision} + + + + + + + io.opentelemetry + opentelemetry-api + ${opentelemetry.version} + + + + + io.opentelemetry + opentelemetry-sdk + ${opentelemetry.version} + + + + + io.opentelemetry + opentelemetry-exporter-logging + ${opentelemetry.version} + + + + + io.opentelemetry + opentelemetry-exporter-otlp + ${opentelemetry.version} + + + + + com.easyagents + easy-agents-mcp + ${revision} + + + + + + + + + + src/main/resources + + **/* + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${maven.compiler.source} + ${maven.compiler.target} + UTF-8 + + + + + + org.codehaus.mojo + flatten-maven-plugin + ${maven-flatten.version} + + true + oss + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + + + + org.apache.maven.plugins + maven-source-plugin + 2.2.1 + + + package + + jar-no-fork + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.10.4 + + EasyAgents + EasyAgents + private + false + true + true + -Xdoclint:none + true + 8 + + + + attach-javadocs + + jar + + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + verify + + sign + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.7.0 + true + + central + true + + + + + + + + + + + + + + + + + + + + central + https://central.sonatype.com/ + + + + diff --git a/testresource/a.doc b/testresource/a.doc new file mode 100644 index 0000000000000000000000000000000000000000..38dcab8fbcdc0161581d7d4de0afe1ebde17ab41 GIT binary patch literal 141824 zcmeHw4SW^Fx&E9aKmv#GAtDXla+HXOhzF#ow8ijIK!g*L6A3hsKp>o00*FA6j~fDw zH8eqDi^9e47A;p>i^Z#AtsgDLN?WzJMX_G5RK%ixTdCzDQuX|wXZO5hvh3#U2@#X9 zli!n>-JO}8edl@Kd1vWAZa**P>?;Qk4pQ8GWp- z311Wb6*o@`DXBfxWuf}rzwcI|=PM+g%l(Yp#nSkH@FUvU=?iB2-cH{=sHXt=Gp!|8ARgvW{HTq;|M{Zg)HP4^dbM;5zjOY** zRD*#_RBAM6q|>6ERcCc=cv5z1bjZ@3C27%&Xu~t{Xh2QM4ya&$KxO4;MDxQx4kZ;9 zm8~fl9zHj;XnKBixbB6rp=BeM4-YS#`$E|V>v<0TU8z0_o3-yP+fr~KR9gLHC?jfZ zXXD#!-_*Q=Gg70|LT39K&u@RUv%0+aKsc+oe|`{GV4tV4y`5D?-A`+R`C0jU!VlN= zT-}s98mr&w7_a`y(gm|S1}_PP!cT^}l`YDt&HHr9j@)gzd&@cn_k@p?FV5LpR#W5) z?k(H3uBv?3x-A8M^rbI2E%dwa&XVl#*lShj$QpmtpVwtp#mX%O10!UFKU!Y+^oFj* zYo~PzWrrWGnH3o^&mX-bn`IW|`16+5RTo5xCst>GAu8kxvdqd=HTmhGKHy+yHE^X0 z&58t6{h8A0-Yq^DIzDAf0do`=bt$SpTN3!n8h9il8c^onl0tuUarnJ8vdwr&p=_n1 zcJuFG$+GqT)NsYFIiTsx0G6iSQq&!<%yH@Q!wHjn+pKCCL|HrrNJK00zoZc=dVG+xF2DU^lUv}T=}WsMsP!$s|v99UPI$16_?vXtayK7Vv$VZW8Bc_-^?dEsjn)uChM z8w;z-?=LR9wP!KQl~i|&aW8FlWYMQn9xGt`YV%lcJ)@}o(ht01Ugzsmd_ndS>q-xe zSe}vnM)q}v#?H^7oJ|c#o1Gfj6NYp{7q0geQd>D*bSv`}rbTzGziacQCHq3Wu7fki ztoKKG^?f2oZsXZ`p#PT-(-h5Sm?lAT)HfS?j#mbu(Z8eA5oyggOQlFTWOEO%l9JyDjc4Dzk-fFpOwEcM z4b|pRwu{0$3WnUiwV3)pGHi||CNpU{DV?hFdR_5Hj~D)Sg)dk#xfC{GdiJh$$=~)z zX;ptehg+Ri)aLO_>k3M9=7sxU^wZD57WR(Tw~>0tGe~Y)8rq8K*~9a*ifi+9+m}^L zX;+oA9yz!6 z>Xqz4YSzJJyKAPE{H(Gz@8kTR)W{ii3ocAxkI_E765do~T9S^TP1DD$|61vDHFMGK z3qoHjr48p5i^O;}X_3*9|1N%`;*AaaS9F+t!EBymeolRh&E7HXbHEs#HWt3O=7n{v zixS)u4i^RUuPV6{wyVCktX7JgI}3Jayqcyat8b`WHBnuyLh2gqJ71NmCHT7@t2dY8 zZ<+BoNR_EM#*sm4p}IjuaO8aCo{pR>Q>2QKW0qQ=<{A5YDAAj@w3 zC+svL(*jcyrc8Szw`cLYGgGFCw9PRip!%afXgN4aKEI*Uic26>N}Xdp?Xj@dympRx zRplHn$kFYvl+r$bv?#L2u;3ySZ?3- z{|cFXBDUPLeaCC0huNn3@qt(VMs{@p^>yF!-Owrag|wx&!3!Q|sTBBkFI8Mng@iosqHF9<~&jJ3M@FcS!V3D86lpD=kZ4$2(j#Y zYc8njSv)+%d~6pjY;dsuN z^;P9(g~*yuD`_2hr6waNf9|2I?Ig{5HX0IOTRKPX%jNh?>*; z>kb!j#N~E3!v~{x^2UY-D*kY*DG#=@eiW0Dx_`dODsoEXbhO0u7}=Mslh;!}i?}e?E`ho!f4K zUQ>D=?E*{Gw^&9mvWVBl+1_Bq*6%D?J8jK6wqDxpuN)A0dG@5-%P_OQXj(>{$tIqY zTJzqTZMi>RKCzP92Tjk>@EwPTIJ$BlITD1d50>0lvuUM2`qDhsHW+nnEM%+La&B=X z9rF>6Q)(tl(N zs>+|=@TJ@Q(N7AFtYN-~s=4=`Fr`o3{B*+$HPYIRg=`^n?VmDd-mBqYZdG|{&Y?P9 z5!*Zi`pY(n{*zZS;@8i$U#j4|o=i8HBX)%&3;R)QOI10o3$-9UW^I<`q(%IBSFa?a z#+Pu0Lo36x$S7}K-GIo~r`Lq;gig>~Z8(lnJN;3P|M!)pMaWBDT}IvW8|qug_8nNa zBM0?|uA0TIp2hs%In=e7xx+;@u^c#EprSPi|7l|vb|b#)vURg$JF<}_!9#oqVF7o z4}>aK_AFjI|4L{JM|Zilik0~VsnJ);_peBcrswrpI^OVCpmE@}bp=tBREY^*}{Haow;q!j;K@N^7v!TE=dh^*m6)b%%ztRQq)#*6%L&!4j^| znSW=&vmL3%!Mo+`w+F1~*(Z)s!^m$v$8g;jv zmjKgK)e!p}BL?`Em7>n*{PAfiivg{O>N$GCz>{lTz$AM?}`vsll{Gjtd|;cUoJL5_@lR=LP=)MXqO`D*)JpTk;*(>bl}w380^#n-5d z>@~`Ik?isw?5`|f6fhA80hPd7pbq#Ra11yOd;+Y(Gd>kS9q>Ki-PeEj%!7A*zvkZ; z&z&}L#9$jE&}-BH-}R+DVCfju+vx}{)45brj#y<&4rH)7=zFBCk~xV(XIYw_Ro!3p zvg|v-NadcZ&UKvYRC`u6p33U&baZ;^nu{&RAj}g@JNZJ{hra}N0WShC1AhWu0sa@* z4;%vC1KtPz4jchKJh<;S+wcAUx91gvM)ZsI`k+So(@x4?Zalwo?&)|X&%~BEOUwC) z>dd}83LFDI0X_p#@WfAhzz?JYU4iaEPoNi&0etw@o+p2D*G8fY)ndH^)%t^FrAJyp29KbmYi-)QR(c*B|tgw4sZxK2BhO@ z&#}OG;FrKI;LpJS0B-~D1Mlqn?bDBJuBliwXUh1m4vY-~vFKc%Jafd_ymfTw}C zfVY8nfZ=$Q8$Q5qfAP~FY+O0-o0GCGYuNt{x0+W50jE9?H(fQNM?Q3kvP!BwCRXj~ z3>wbTN&XbB*e07ec;#d#o8k_fsN@qWQ4LAlI2Ug+f{|ewy;4$FO!2bXnyD4=$ z(7rof4FJ9clmRyaG6h4_5&dz+1rE zz)|2Ba7$0j^MRwlG2rfVl)4v~+)Jrk;9Ec$5E}<>i|PL*rtNpWjx*$@O1EyzSZ4g)hJO#W3ybWaaM&BR!!;No*k)Eull*)OuAH_TeaC0x%KyE>H_R z4Rk#hb3b4@uoZX!*akcdJPJGk>;nGr<{x%F{@??5Z&_ctcy@mFRVR_aaYrM58djuz zT!(A0{_3=L8m}JbK5ohptO6S=ht`#5+252poy$0~uo=c0)s_9X8+Z{o3LFDc{tbQ} z&=K$h=|BcB0>}dX`sy=3{lTrP7JhTm6@AUF?eE0TlNKD^=X93H>8&IFY{R*w@p#$D znv=4Mj{4Bt^(jC5a{@30m%)YQQxaxb;I20 zlgDa|Im_lwTu+;QFnp>d$h!|A6DS5*o>?7(L}_*@5X2=f_$RlrlgCjjs1^WHG; z2g^NR-v8x&L*6sweFEMiIIhd+|MZQ+`o`b&jraA9zv>%r=^Jn88~gN)Kk6GV>Knh( zH=fWpw(A?)^o@JWv3+6g7*MPa!%k@F74RT$O zYl0u?^7ugCXwZpo>PKGHH(t^=p4B%V(KmjeZ+u_hsM0rX)HmX#ae1>cRdS{poar zkL!J0>*E>`*N3<^#C4&=x{wd)8~gQ*2CDjke&iSWMuRTDUq5oYzHzg@u~OeysBbh( zpWNG~oKD_<>iv9josFOCB& zt_g4*fNKE%)P>wYv>*>2b; zl=>^s!&iI__fvkRbWh%&NS`;Qey8&`ZNKu%nhxjvoC|U;a3{d|z`X$H1oZ!(1ULuS ztqb@U`bL9Jbyn&h>SZ?Q8|(Fr75c`v^o>$|2$<1fCi&pCw*R* z1>c`@fiVE*1LJ`SfXoZtH!d~y@3;EKgZjocedA7jV~f6Vi@s5-Z+utZK#NaH(0fLF zr%oDAEX+_&z0I-#t(@t6X{DX}1z*s^)z0JOznPMW_=4@2H*60l_u9TcdC24>HMnlIFZze1#t`FUssxH28$7@0Ct9=4%FNk^kUVtU3@TA3`fD~Xq0CiGFfc5}0 zW1uy@S4ETAAKREts)>j(^-xn0VNh?yc1=34W*F{%a_g1QWJ3G234XK<8P3<4)30u^|HM~fGj}WJ>n;`^MB(r z(o_HR=-r)vYosgZ{OT`be*b45Wh~f)MlgLA8ZZv=w#R(NH#fPDGG<^qtBsWT@2iN+ z)|uPqA>;Sa0fBE!!ZMssZAKj<&;jg2w!O^ErmpXSzJ^e~f=>KIE-Ena_4G zuAJ`iOUX1Nt zfP~>lay3KuG16c29 zfbB&aPh2zmSFTlJUi0(VZx`e`S*|}I*U#&tzEBod;Jc!TPJ}w{G!KEb*QxpVzZ73E z@PfSrM8*_bJkDTRWb^^~MtdUtK|RuS37bYRyTVMLgSpk?UpG5uV(hpznv5Oz;+4UN zYmjFB40<|V8&4;%?+RcwjG zZfpw6iA|w>5u3uaH;`Y2pB#)2kr*=qNM z%Z6?=!e!52bP?jXfJnMz6{Oq{MFO9E)s8?)5kZowCK3i2ad-dH#Xo>+<>|YH?>%IV{5t{gGy~cCVdRk)A8EU634S- zT(ivcBzCj@!gBiBfbJuET#wJ^#&QVP?R?~!HU-IB3D0ylxy=4?;xcu@{M{BjCx|qG zXNlVsuPGDZv&3$A&2qwP%20UCw8v`*;S_@K+G;z<$Eh~;>b&N?3fgGugcGlM-;KX{ zL>t}K?6k4=5R*|Ne74#U;WK6H@i`{YCPu<%tIcr3XRc+q;WM9SX^VWO4G`iHd#dnxW_x0 zHq&YDH659)Z-DW6pT?liY4mholnVg*oJLQ#8h@&LY;geBVhz$CR@=h&_l8cjQJ^C2p=^X@e3c;?Q*_YHizJP2Ey56yWsdrfRa9s14iDUfT z?;W{mQU0b}&($A=Gw@Uc-+QwHyBh4%bxu#WM*XAQ#BY)~QU8g$CEPd0Fw1kL-k3uM zxevm9%2L07OYWE1RHJkMd3+Cr+;4|`PGing92ZBHVd~U zcEfR&6Zuod!f``qJdQ&mO%K9x%2H%ZzLVpGHRv4ggZne&IQKbmocGUDWAf*=kFK%Z6@vT(;6Jp>5%^)n1UtGi_?pxy*ZwuKSx&f2UJI8E-H6L&@0%KpF*tHSXCU)Q-A3!_)@ZN47(c}0xEqdJ?T8zDeIdT^OO8`Nocs{unbbCDuMPVe z6Ko>(+GJTAN8a#MVd4_tCt*86l@tfyO}U8e2}hb3-q-$Q@j%X?3}F&3N0 z8uLA7+7zcNR=v>y*t&!KK!{5+(?S0Dw1d0=c*W{)? zZy_J)4_kxI>v8ywBYDk!b>g+W1Jf4$=hNYHczm`pu<>o-vz^ZokI&??8$RDF`&Md)|>{hq%> z4QN?G^wG{=qE1?a?k{!d;7%VWWqbN~rrzK3{5`7%HNGwSXy@<6)5lGz?({L3@9ASp z)JM->ZCp3Lh@+y9cK&L-`4``*qrVz&Js~SUBia_{U!K4BMKHGUa|)mB{Jnc^it7Fz z*L}%nEGsy!kCozBe)z|sq{5=IH3h@N=Y|$d&#w;Gy-+r^Y{c^6C)OE+?{=|%e_D>~ zb00n38s|ZjDd$5B61%w{Vzohy-P`m0E>pg4|BGHU#qZ)bpxvxXES%eR{4SZ(dp?U> zhMm5A!ey(yaB~lY?;gAHSzajnV0~M(&8ORE@$OZ}*9+mZozF7Irnv62@ZA&gnZM8G zbWe|Z!S_!1C-@ z@30W51#AkJ?fi~-YnOa~%8d{GQ5(9qCqDG)_Bp)!)bW)?xNPThtgUTJz#?aSY*i84)?^(^MRrns;zUYIIu2d%vq*j*=4jTkV;wn;SY~nZJ4*7wNf+ zMaFi1QaqoZ?{|^oo8B9t?R+9v`x)k@+O>K0mpP&t5v( z`g!{EdfmD|uc!N>-2p)V_K2QtiO+?x-(-xC*v(jBwPT`Rc7Bh(ZttxRLEv$M=$F-o zQNP^y_Izfb9r8N)JyUV)^5O&Gtm6Lpd|p%5g!EYFwVrN`95-x!Oy-Gj+-l#1I817($M-f1Ef@-`?|wn&uCATy{y~ST^CZ)n1UxZ`)MGd=mIw zUUC_a5;$_X636U4p2qfmeruaIqCEnAY|zuKah&jcA#`jD3!+WY{EC3fTcTkV|Z7sZ6w z43Y4?DSlBGJUmanvwxiU?)gP#CyAV`c15^s=td(xzW4lDe6ov-t@eU`Q6+a;g1(F2 zmLQieLmnqCKZ;|Gc|gr~tlr?3 za*}xoa+1&dcDc}u<#`j@kShQNiQSB&y6s{8Vtbgj{+@<6C!=)T2fKb|U()tWwW&t8 zJ^c0npBZF7IN2VKqgR$LnEgCrLf0cb-5Pa@vJf6i?1s-QC-(!$ZQ-+_AB~Kq-kLCk z6(Mq<1fK|(t#(GZZ0LsPmz)ZfPkeLFYA?v;GMk!pTb|v+vGT<^ zd&_Dt;S!tOR_(Rtuh@|03*II6+G=Y z(|u7-0MOT|^>j;Iv-Z|N&|xhs2;a$L>ph!zKJ}{?y0hJF(WmzOkQPQ{G)K7H6hGu1 zkl)REo*zm<+pODSJ>44ZGId|>yGZOk7xKmOTexiJha9$P8v9TJpO5G7miNXECqJYL z$GXg_Sh=NOU?erqpXZO37e2kAYw_A?okH2+hihgDH)cr^Em8zTY5P;4T(Dv-5wbBi5kn8J{QQS_~e;a@4b# ztRUnIQpZ-Vs>x3e^@&Wry>XvkbV%pBo^FlYryY~AMPfH&jMe_h8l<61-Wnv?)#M=D zxAT`~+SF?0{!4-Qzvnw}r3%f8G~~O^Wj)=Rc&)cvPq(IPz+)GNPO@J`Cna{Hn^v1C zx@qXTr<*n!>qwbszpU!(n#D?&V>=NCv+9&Exyk{Zk(-oZ`|5*t7 zbJP{5`H;?QJ>8l(uD4rHd)J`U+Ce4(hau_;aC`SirDU5&t=ru9H6Rrj4fug}Ks0D8OibZfc>@t2a(XW}PGth&d4H<9IJjh*8LOikQ(6ZuV?3n3)F1I}35 z&>Z6q+vE9c5JEEr8F%!4<`~3rXO7J|bf1m=%W)?K`J8+<*0ZG0A6*=NZ;jO_({)Nu zgEA@r==TQov}YqA90bo+|BzHLg3cd3?QzK)_s>M%MaEp(YZ>n)b~Em?T%zMXe;0w{ zJ>R8i?s1>(k#XO+CU33AuzOAYC*!`}&-4K}UK7^9d=l8d9QV1;Y22TTV@nF#&V6Tn zOwrTc_~MN(XY%+$n<3+h#BRnHmP>Sec@y92=D5OdR5thc!uH7cVqB9qzCa>P4>G>! z{mk*g&G^Fpbu+%CM!fOGn+uy9_2$BDYcBjn%wPDM;2c*r;0Ga^dwikolktUV@4G#w z-Zu5Wj4za>_z@gyIKIpTtbzF?uzxweFu&9Mh4maPS+@S4YrN+`L~h<#V@kpsYueUW zqtEB{G=>5d0Q7qgdb%a)gN&E7X)+I!`16{N{T1I|;5f}i z9%Q_vEM>gpnCNCc#{P9PAM@7xlxj&fLAPak8Y0Nh~@r5=+#ubU( zj4v!Fc8AY_xOt9*@7WvU3VikE9$(lV8DETR^2QfPr0GG%7rmc3*0>p8IJUSMU%d4` zZ+zi%iwxcztZmJ~WPG8`ka0y~H{%P-B|5%vOd%f6Z2s|u?UC_?X>Yv`LO6vW;|pae z{mik3^Q2{_-2hh_k$^RFfqAWz0 zBu?~QDauax%(Ruy-aEVyN&-RnOj!z_$!$0O5`Xi>jlYx>Y&-Wyh0BI5jmbF?E?ez} zaM{oei|_AoIRPRgt8kgJ6fTq3Ng4a_Hj?=F1+quL2OoNP0nL$AgA=m%#^&%P;V zXF+WqQ^EY5*EhU3Beq*&c_&@Abvv%7ae5U1bUUu6TcS-jY%to)kcb{w?TzSxp;s0? z@ZRAwRASYG)u@1Fo%De93|v|3y_^1O{oJP|>85T|^mJeJTL92) zik@ys*C698xhZx<;^y`lJ^KP7ClJKGSZz4%!i_fd==O#0*VDdWxOKEI{LbT$r8`U3 z6?|{@w9w9y{`rlMuR6!|bZg?f-flhZUBl_Uzo5%rPka5~^+PJ`(rCaBv;*RCLdLLU zO^g|&L2JP zaq0AONyaOVWinPu>}Jf==Ok{&OupyCv68>#)ZAkx+aqJ9aZTRZmUUQ5Xj{fiy`MRL za_%|RrY3#NWdCx^WLc*%)42bJRb3e`bz7jPTa#T7ouZsXmn3$xmcnx4pHQB{XQr+9 z-@LUH2ql3ae5Nc##^fjYOjv`?XMS^?eCEE!_}mtML%^`5G2tY_Wvkr~E*rYx*=8%< z6519nTkQpTe4|ZGI+ytjzngWzWjW#Os`5?KCuL8p?7P10uM3LprmTeL61%b8ET`KQ z;k%(f9^Y+P>3)~+-D*?Z*ls@W<;Hfm#oz8SY;8=;iE!C!SA@%kZg^aFPh6Xv!ey(y zAeZrY!%2bpB=ETja=ATrIQbz{ag0w(tXVgzEN%9XrQ%=9dX#MIKDM65DO3Q^?^o*S zmb5Mi;)-^JgC;WKYoDsbfaW*pmDQdOQ7S)9XPd*<)H z_@lg=`9b(#=*rRsvj;|e*%_g`viFt^%iU7I--O=3Vp#42g&!C2UMF>g-p%V9CRQic zE-4Ycu<8l-f06ri-PF^qQBT>&G7d@X`Bo6h7Z^lOo8ntVE;99${pvIxah=C|F17xd zNvl2de5>Xozi^p+)aPPu=EZz2mA|it$dSu@UPi_aK8-p&bmq*9Js+$2c-mYw3E! zy!Sc1_c>4co>6ms-$>?F#+Vo5Y$Eo>YTsoHHSCq=i`rNL_q)WtP?lC-bel~zx_#mM zoU|`pkk82%-L_a(q(}uta?h^g5+8~avv62qH(X{p-3|$t z4c+i;J*3n0AY8WE3-b7Sn`(6~^Zg0h`gqTI)fRLu=A7wYq5E>3))jO+t*85<4FN!3 z3((Uoab3XkX>5q|1@97`TWyIOo6YZxljkgZrrB)6#>V&H$kMLwfp5^^Lq|T0n{fg6NagW>KHw zJ=4PV^qr9OOD)Do7XLS;mmH?<1o>U$9*}@Sm(Z;ZjE|ij03Sg^8Bg< z$oe^E6+N)?t7h7qS07iX2S2*h&3%>Er_72p8mkt+!iGx29{5dlKwd(MgHD zbt)SLIE}kRH)%IzEs%Q3F)H4-v3O5{x*6}=SoG-ljY%1EH*RsCZtL}QUmObn`ns8(K2z7wyyuQViUfk_iq#%bSMIc_N7ogu zNnbu#sm{pfWcN#P?14gl0YW^vs&e{q{8B_y*PnGg(9^9^Ck%TZle6aw*%0mv*d;n) z=L^lWX^*ZGTu*y`h@17ajox$0E%Z5MnWwN{MJFY8Gf%PFQqQ-uQJN<165X`(?Rw~n z7^`33#q~7m=6N{gG+yz(5l5k|-CyhCNtWOisMXL=LT_i8O zch+J8ZDJ(4NLk9d4E2(}qnmG#86xTyV-1M6z@ z_&h^WP}aYBjQRZ0jfMSIrsnM}t10pY_m=Hi$FGxZDWLu&szXvnVkcdb;}R#5PuER7 z-5T|j{V)0{v70fI<%C1jSltEP+xQbyuL#u6zbu~S)n zoYB*LA;$oquO;efZ(UKTu)0po$N!~j0VL%GI|+!MT5X}*F^75@Z_JVPL!G~Rx;1jy z7$af={k;4q?>SRD#yam4F5CG}TraQ&@>T-Z1D9PM|2c=(r}%<=V}avMdT7M*jO;hE zue&|5bp)OJdb&000c9xsm)Olb!fJ0k-%FTy>apknZInJ&qfMV_Q@vFWI$Y^yU3`9y z_nfKsd+gTlby&Aax32-Iz-Yh^v;*SVI$7H{))kPSArbpx9RtL^81~AW+nEAw;(xI( zRvS+H5^tR4+CJ?|C**Znr{MF1oUNtJPL1pd<5|Mch3kEV#YJ5}ig@!SInG!gTlI82 z?zP0ZQ!=hc_%E@WahB!uF+lXd&?`?5Af2WM(F3ckaWi+~Hx;P|)FCH5;CfwZ)a1O* zUp?I#xop_rnAj8Hven)Qmkr(UxZHFiqoi=zYA@XQjBm!c%(6~gW<5`ax|R6~8`^N4 z%X+#s^4jxNPKkn_OJU)))poeC(R^Oojg3xggErc*sj(hO#70}~huCP(-#B%83zzNu z4SrL=8pwGGd|sMd<~+>FMo-7F9qaGfd}+zP5bx0+oH1s-_|s>?{Rz)65w^6PSU7LB zD{k!frt{p{@3#0YJkJ+tIUJUj2$$`Ak$CpI=zMqfn@>0Pi5$6&>nF71XOjK)d=_C^ z%ZY{ac0NnBO*3_$h3~BtVKFY=x5Qqnptn+0ems9*J#G3pvZf}no8KF>&Ko+Qo}mE|oozwR2 zCsIbjVTs*vndSNeet`NQTsCyW<1!@D^dMZOEJeoTrW-Evw=>;v8K3_!eydjZkMwk3 z91j5cyiHHHq&b|}Wy(l6EU_Cdvz*A8ycRASy5Vsd5@~u6E?ez|8!q!X4>w#+jkG&$kd#kh6 z4QioU49rzaR6u1To@?wc!B#%@-(Y-~nzo4n(|?tcY9zgXrznAbDQ9Cj&Zkm*U5syf zvoDj3l==J=`>!#a;_2fS=ebX3NR(EI=I6DrjJ4b_{O}))ADZt91b`rr1xyBJ0@nkTKpo&zPU@97fV@qq=S(j*3ZnlEiLOis>B9-OIEFEx6?+3K3{NoxN1w3Nlbg7GP-OMnvvPb4zc$gB^e_bC)R z5!$PLcW8!-Xh_ zfGY6#889w|j(1X0yX1B$?b4D{q8-)#(GVMU8sQnA%2Mj1H}i~7ZpHZz414tSiMFSKQwYx_smkN4Ub=#=8-uj{4|h{lFC7}Y(zM2}bbE<; zt(RpQ67JMKB`GN>Ey+>BdW)h_5`^8vPZOl(`SS+BMVySq_HL?Uut+)mmE{}@cM2Ls zA@7m+R7xp6meN_3Mm8*8Ha`nWHyJWuFD_PtAd7`&R?GhY%KB2P!Nskl=3D-XF>MK#_PW+D!D6NRteBkB+Z;k%`!V5c^}2itn<`GY9P)( zTzyqtrDmvqSKHL%>iMJ>lFknF416i@Z-IV+VdwjTNx|e`yI@+dQ?PTeOR!t;?BF>; zUo^ zJux&`mdUtJ4>V1zlZKenN z9D6PXE&Bgy89VhF9*xcPPE!xDG z29+_}r0>V(N!x4(gSB3k8-PCs$-{m3;{s%j;S+329|e(SyOscK>necxw*m;2yf#Db zh*|y`Ff`j3Ru*FYVky=+mcV~riovA}+fy*u+<;tW9hC9a_-X_dPL~5^HADr~SMdK5 zJlQ!&T@DNeuE5_AUvCgMnm$8(GE^p6Km1AupYD_v>R@( zpS~P*m!X~U&C2=4WSQK%8ofUk?YP0%v&5K)Eikj%{_h{Ve%dUz=ciqH>It@*=eIJC z-_Ug5o-1(uJg)?!(m4O{={+^wRFgP4KmWs&0-GWnZb|l^JZ*~oC%-6TH}-!d@{IyU z17m=105n?Tfbqc9zy#nLU?RYo08IyH1CxOqAQuP&dBC**eYkvJDliTBCcp{QbYKQh z2owP`fmuK?FdLWy&68IIc3-~p#8+ac0 z4e$c62ly@UJK*=gi@;03%fKIi{|5dD{0Z0#yaK!m>;ql{{tUbh{15O3@V~&Dz<%H_ zz+1op;BDX_@DA`-;1KXG@E-90fcJsF0e=TR01gBH06ql%2^<0b4>$^Z1RMiC295*& z0zLsg1wI3!01Y=+ij#n3fO9hH99EK52Ot$l13Cho0M6Aq1I$Bojiojk1nXaY3&-wjB?LC+MZZ~w;$ ziYE5vZ=-*CrPZ(m(f;o~a^?zmv~X{oDB9{;Tfb?i5!N-g19EdC04)vP-A?yU|BERA z4Bx8(H2hKk8fhzA3tjU!H}V1K-W0f4zsZH1?L`#q-~CRbuM432w;twyPosd9rTw3I zV|kOw18Agx1}3`vR?Yx*+BX3c8xu7HfR&MsnoScxBdcrs))YYhS4(-I-5+1r0p3=_ zzq@)Xh$y@n`o~szZtk~1ymj&3Mp%)K5%6~X>F2G$26(&vF0cYv-ygZ({{CgXtgg{N zhW>8e-z!hp#N7UEc4>so--ad#)VDHt8=xf6(#YNzK+i_|K2BIW`?uv}fdK2AcC4{H zyg7*6fpmxKjvL%iTKWK?%a?c*5THTy*vri;ik|ok&d@w~Sm;o-T4E|@A!gTfhZ(4P zJ3Kcx=Wurb@M7;)?c?#sOxVtu`QzS^HHPthUA`ONcrcy*XL@yb>Vdv)(c>hYrf z^l10===NH~{<>LsS3a_Qw|{9>h;M220hBxj!ko}V7XzFI0Sgq&PHDSQjo z4GvpqxCC9s;)$*tYGIS3aZeSQP*nn#TbVfqhCj&>K~P5xcYfh0@id3oDwnON*{(A4 z>Apr=<8I=lo|FHH*(YZnOa2ID4rV@t^Z;TgyX=_OBDF`ZRuu)lw)G*8SvYnK2q6#8 zN1UN0vQM9J!IG5hu^!;nluh$aE8%XR**!jgino~5LRtYyC=DjTo^{@X%p*sw)TsgA z@561Hi{vogBqRJ%WdwKxRJi`$-5X2m2@-OL3hQ;z2UM*R=*iy4r&aOzm;QS4oECOs zpYwXyypniLr^9%5_ikc8V2r`gYH6B^!^A8|#rkF+0D4mDXIeP1(N~qg=R!i^CsjdUpHihjHx5E5u>T6(LdfcK*I8&SR5qF!VKwGhUqK(& z+76Ch?!N6y0YPcD>^#xXOqjUOY!)1_uJ}!kBX&<-XjPoXc5Dy9v?BqJYy_skQMG~s zf)HXCB3-YbxhVZmS%e?DyLEI+~>hbgoeZk@|^Go?+QNd zumzk>1Fm7C`VL^7wwXkVk<|_R$mutm!(?fhK3u!3Wkg>Lhl-JDl+b$tlp4XFOOXF)|MbDa9j943z4jMo2ks zQ>N zbiq6?P@1H?1bZ$AYA%J# zZKCt1Y13RR*kT9PScZ^5Yu(Zz`Lwu9y;i0Q0p^~xd=Qery~^9i^V>;8jHGs6qxwwi zXD?44%=`@e1vkRd43evQ(z*vwv*U6CYg|XiK|aJ>kgeFT zk$-2{>Gnq}Zetj_lZ)z%ckmh8HZ!eLMfXJ&!1-GNvfC$P0}^}d`{;5Zgu5Q%9Y6hB z2M7LfsQF?@h{@*2N5Pb;)TuoeIcq*bk~qYcoeOgs_kc6a`e9Z?VPM^$h5Fu3lxkxhy!{lTD+5GNP-Ds+IZ(ca zQ9<}hel4~utGJaXs$UX9`gUwOLfi(*^@)OM@zcEA*+I5%w@9GEB2$#F@(JN_j-sNK36;_esV!mol|Hk6C)d8to+bP@%WEgT7V zKvpC&u~JEZwL(F(yBb0Jx{NrPa3_mao;1eLh;E>k>pt9o+m9{26*`aj<1%6WdR?yy zn>2M-$NZB<7YVhZ>yJ}5>mFeJ=d>Sw)tbcYF|34o%03KOKFe&rLxA1vp*lQ8_gkLU z{a?F7rk+s7cD~}N@|;~<#pIttZ-Zj1)o50 z{1zPO3m#yUD~{#7Q=&E=SVlQMzpN#l$W}pRV_5>JrGxTy#=O{_ErMIm8L!o(+RN7PDCCa+yl8S6XFhx+C%T_r|1jl9COP>~mvd5`WfjmU( zjK9cX8RjM}_|t?)i-w%bt9QV*({D?KSJQo37u~?H$@196@;Y{Hot$MpOB3cU+fcyDr-{`l_ zh(dwB+V`P?*3iIZ5#aq|m~D}752!{Al%Nx;mVk~zHos6e@JxiG*O=TtU03lDA?eX) z)hUoJY=TV^Im!?Yi+J!=Xmuj~2$r#(tOU>Jt{n7+LLL?KENKq$!hS+3>?2Qo&qk;* zQ-^kKUQNn^U{Ek!_9(KKp=EuSzqn}yCXq_d1i!n6EIX)6{O4X(z?u}=roPa4={AYx z@228U0)aX3Ue2S~=|WEf8*mTC&GrnCD;M$YkSB`U&m%)IIrIgz3X5CF-mz$2LTaY2 zFk1;~mrCjxA8f}F6l^ncFHyG%xT_dBveSjeF4rGO7^ZK(yOkV1MvobDa1+&%NCq0m zSK-Dmge^L|aA11vsB?$gy}~4HVWNBlY95{qQ*fq83M`rM^xOHk9x5W%ZyV8sO#I(AKI)?czy^Xrg->Yru6SgTW z?kQnUFSozAkb&0^unFM(SOS?*+tb0F&4L+n3Im0`L*wM+L~U+hY)@@vYee() z-z0_pogDlP`TuXJd?z3O;;)vLR&R7wo&KE+$ph&B@Q*i=^B;tWp6O4;|A&m||44%V znDjqlLCmcGD+}7zST3P#MD{>B@=D(IUT%rE)B*EIVTQPfHt^#C5JCEZbGr+AJt+A({iuNp-dl#RYqHv_1{Q9rf&6b#^@8IJy}6?q6M;Ew8V8 zK0NPdTIIwYT%EEwJSaRp4&2=)9hUySO2Xf)47}Zb?Rb8=d44*PnGTzI^ze z^K_lU&zFw`Bq4a-L=ThmZo+&lYYc>F@`~WL<;SXX?gI7P`>C2qMBls!A3`wrAhI2_HnjPgD;KI=0eb+k&Jmy%Nc!*m#u=S|0aJho)3W_&Zu1s?Z|t~M^xt= zoP=o?Ud?_km%$$QF+3+ER(7Bk9gkbiVnCo!!I!aqbr%k0Udn~pK4hs80;;ddVIqg7 zjtA31?{spnFQ$xnfv*Pk^G`@ItFO|CQEbMLD`FSl^Sh|N~+0miJ36=G;r6tR=)k&H*a0>Gh zSAntS(cuVdF%q1RA@7F6u8qpLvO5=e@(U`|R+uU}Cv}Q%x-iIz|72Pi;CGRt{5)@_ z7aL;xb8kgVJ-#}1(YZ()QxD_k zAR8}Kf!2FtIsT>}I9OxT7-Y$=$*!D^lE)=3UA%MHoLyitLkd5Hey_k?4IBQ*%JJ^i zbXF$dKDc_J$}sZpPdZT0N*;7TLl+&Nb#cflnc=HwJ1yLbyG@ph@icu15UPu7S8hdg zcpQlp425nVbyxUe#pt;waIt1lzHREnQM!VxI@7%RfHdR8hjq@M|ABT;IOYRuZfi6_ z79lx^;L4euKdysOkxEt#EV>GI&Oq|3JoJhsrhW^3o?Q0^i>aAd3)q9_FO1wCnWfG zt;^{q9@7$WB(ner9)vWZ%wYg{DKh-q-d#n zgmDuTsZvz~2^X7Lx?|M2)g07B^Q}?g%D`298ocv`C64G-qn?uOlyEg+SBllr^0q#? z5fQhOYGhd(+-JF zJ(x$Jm`i+4D6^-koA>2LG6sg;d>FE`=`WIyHm;aUsW%gafU)I8ZK~tv3_LSp%qN;M zsy`R`=4p^|azZu)R!$wl{&{7!5c0*c-i$u|Rz30Lh46mV)&1=Op#OM+YfN++i1T>6 zWqe_O9SIYV5JZ$+-)}b5YIc-lZcbxvXkmu5m+1Rlk@~!$05;A^8Ia%}VIK%pY^zOp zOpo?u7LVa{m@O^?6^-7k)vsPLUUFx}vBU6jrB!aAMutJ&mZj@W7iC6UtKRJ>rZN^c z4SQ=5ZqyzeFI6Se@DRZZ3>bU_b6L-i>E5<3-1>leB#dxIEYiLZX`(ArD2-qJm0y6X zAhO3KpXMPEdY~aue$)_UdTOGX?z&NiTV`z0aU*M2hls;wNn_V>eQoYlrw!4qcP<_u| z!R@KB^z{rRrPiYuzaXzRzo^mwkPUo}_{09|b71A3i7K_fYJaRt=wfd9l@U6>zEQJ% z>=d7-ISRMn-Q;Ld+Z}|MP>-1KD=eG}CzX)l4!C+*$jYn%Lt@FEqfY|W z(mZ(g7t$#S=D@DFu>tYL61?Z{r86kCG9?3s`I2EX{`u})NMt{WKXh({OW8GADYcM) zJ`qb1o~M`r7sdOK#Y;qwsmqPJL^02IlaSad7%pKu-!ser+a-OfHS+BXY)3BBYpDJZ zhc4(ePzTROsmkbLzp}+oXAAoz zG<@$a>!%Fv=CC81qMP~+ewe{gF>*g8V%!KYs7>OL6>j9oI(mW1-6t#1&2J3{rmy&4 zUj@~q#zarX)G?m&Af zO4PP2FI|tFJDABxA)M$IMlu0NnMK=+8Uw!@Vuws)=>mC8bd~ZoQ{~wz8#PVR<;^_W z!HguTj+;Dx%VK`Z!gNr*kW*!5q^`=&?C(0hAecBGYSufXu7Yi-phkQg1X}c2_NWV> z6Dpb5mhZ>Ae3{iBIJWoeXh*hPAcjBylVYk;gjJBSU^=`MB9gio1$^%+3R{g*=#h!xM2hr) z+t7*Wwz60oLdN3*64q$T{tDDH?ATGEOPrR;Up%)&Xmmx-JR6~PHZ$>wKFVntFwJQ8 z?W$hP3K8OtxXT{Rm!?VHhA-F_3zk$yQ0Hic;JsqdYe>?|j(w>lF)^m-!L>RuB07QA zH$eLD1mnc4b z_&6F*V1Q&QZrj5F@{{|r-l$6XRL(&h#frH3S$Mae^@u%KZ^%>9yf0m!ga+rE5PdJC z`~{{n0*BRgZ0J5G)VSGO?je&14Hz=m8$#@R1+B;#J!FEVM&7>F1;T+3=T>ZDa` zWtY?e0lqT(zm_iKX348QH}NpeYuj;cmY1ZLRn?TW!#|P`-Rt2lC_^5A3qP&6!F*4t zFFGQyOM6}-saeJmI?~Kf>Rg%YQ!%qsUq~c6 z?c3UsW*s2E+N(^=4rIsQr=IZ|k#`H~PeI^C(`^9bSt`+3ETiyF`f_hk`Rd^o=g@~O zn7(i`&}mv~7J_XuEA;crldY*j4mnJxns&NjbVO`O4T@Fz$Q&&%S8<6MO`XUN#mv-} zw~p!>L~B>*1sLaK8m;68iF2cc`^bjY?eL8<&1##9eZ9tEzoTvANc1x9rAVNwKy^uV z`*eL@v>qnyeP@%x#lGAU(XvvrF##Cq7^zvA089+@e@YCDZ;a+G5M}=-9wcdEY4#sX?4ADngKWLi zpMNo8ZW;a&(E7i`@fhCYR{t8w-yDzOPsr+@k4gU%a>q>n zpUB;@hH4nm0J0~`mDiq;pSX*l769I%UZ)$W0rCnwocN=f-#pU8<2bt^$zj;{lcP`+ zEkD)3)#BsO@bK`>Fv7>iu#S?a-PHX`(H5}&p9Gg_nEaOHs?q^W)RE+nSe~?cI^a#w6!CIZbWP7Wcw{}PE(zTb%{kF{J$hK8lPC*b+t|eyzTEg^;erS}7BrNpwPj*ng z(jq!+NP$vf_3}5eWK3Qkm_O^m0hHrHtfC6f-WL)>!W6HaLWExyAUbGWQ5p zk>>=c>jaq5`XD6i_4%1CAv1pPS!5=Urv};Qm^8`^LV~e-h>)Sz9KlRZRfBt)(}K4# zVf_laqJ{Q1p+YU#&-DkW0*Z=_rmHqDV;fs=7uU`ud15EN5m+5Dl4G?vUQ*&6@sU-uf|Cbf;#FT?;e?LcqUFC2VX_el8|Dvzo`j7>;#-Z zJp}*rWYox0#JB2@!xwg_fr7NqC`JF%z#Oo(081dK$kGpCh)7#=#iAnmrY$bo2E1uq zUjndhN;V1t3~&?!Pr$NsXQ|4z5oAzk7Gn|jHOv@ctO&3f6gL+YJcrk3Qp4lIcx-CSav7f*YA7uk?(!EMXwx&2AkLl1f~OuM02`V9UXn9ptMscds76;edN`WuK+bRYfku zzEw!3jbb2MC_LjtPl|3++GQp*Ix3AsP=F9^wMdtJ^MG#m6W;@?Vp^+)JVN9)OrYm{TC3&8?f61M2zKPcq$t$|#UCXh>nM*6H^MNe<47yb~^ zH%DDKfufi_MUxbPi&hLsEpja;tRg_Wi%$X8siACLR9H)qyM8n73dP_-omLU4ZAsdK z>Hlac0^M<}p+^CY6)}0^o`g+FNgiHkR$WIlh;t-eTnT29u9Z_uo!07f_}trmiUz7; z+OB`fL=}f77swLzu}3(sjfg*}-Pk}q5mZlHB341R4(5YFI)C-BxL@f=J`T!}TKV?2 zTaYCw3YPu{;R?2bbnc_y>KX7-TT{U$B}d41`nXnmMHG$?*)7*~9N;CHNyIj|p2-?;r# zN5X)%EsJPn5o+-ydtOz{z*l`(Vj&Cod54_tl*;eHw1^mSr6dckKaiO;^RO!SY&s|h zI_yem6xu=aT)uT@6S^~wdA;4h&<|B+P z@0yx$#jx|Zj|J)vN$dBwvQ8h&PA+fgrs{(F+I@+sO@wW$y2sHSpcM$*bsz0{#am|X zKwQGXJJreF2}cy@{>iq9`+@J=c>qU8+&8G7!#OBc#^;t>3my#ExVaN^l`6Xhfy}R zH00NdekdeT4xwe(pF=^oPV-nhjCWPw;S{zMLW&BV`H|}ksH+6(K`1QYtdu#+$m*zS zU%!zD50>fsv8E5`70_XPO_~Fq1l5_1E_oWXRid~1kT`o-?Ur{%2C1X8zADVYaw?Oa z9(2+j9l#6B&g$3tOi)D&aZX4$=ZQdc8P$Oe#o8~$roVozkM^zh6GQ3r+dO0L9fwRu zj)-RcFe>3uzi|0{>YMX1Us8dZR}NZ!9$E>76H;Dnq{RjPp;qBjM)I}p&7!(ZFp0Kp zWROUIVA8h=7DOn8?8+}=Jn(__dZr|llNbS{#H0NSXFEsBlbg-;A4w7=X)+{^teM4g(u>ta)+Wtp7_9*jc7qym&dEaj1 zojDgaq=DTfXZ#?VyNwGsIH9Xv)vt=wxfzRc#q&Ojz7p|fHo(DC=OGq8*S&Kg5;{`_ zUA4v))%Vh${%8$aw^hK{AYjco12pgqEd}wsk$X0KQ+uk;K$EyKECI)g!YuEu>_#z< zKGLS#cz*E$ed#qb?MiS@wLn6svOet_*sY$RE75eWGU=7Hbzw54f|7+CB->F~g>j5P zhW4s~jXBcFmVV*K{U3Iz?qDC|anr70pffzJnX^B3z$!dxyMM9AM_Y?w;jd2L&&I1& zKCsDf%*9eze!Lz1Svb0ZOy2VeDbgEk7Q-(F!(ZpX@l{eEcVDK;s{hWlzfffkEzI@^Fa{*eCV-FNnw=%=|AL4fq4p{~IFm z2XVjE{Y69=SpOm-jI`8mbcBKV4-a8tqh@CMA5xKjFcE%TJK&$wV>D{tzKMtnQYe^M zC^)>uj|72s`frgZT}%7->bEEk-8<@+f9pZW^6fmBiKP*MM$F*tu$qaz3zg_UDA4=C zINCQ3WTj+j^5@{#-%RJP19R`n{>_R05r6vM9yz0Dq5oflEgs3srkZRhfv1;fk2SkF z=hLdbA19G*>7Ym$kbeE-!0i3O9BCKRh+qE7usRBV1&Pf~q?Ax1p0C!jk3TRl5VSBd zwyu?N!mX)Iqm74ms$H?3>DjiTNt0QrjgPmE>DKJke1ACde7(JKez=|Fbt99?^X%;Ta(lgeeSU50 zerMg>>A9%);{N!Qv~0C767lW%;oK96r|sp*f#%+$pU0D1o93H2?e@w3%fs{i&B=r9 z+VL;}GSv)~=3g1;zaLx_bs74kfBA`bE) zlFRy#D`rEsi(yWBlQ0V5r9h_Oef{~{Bgmi-Ny~r>EeoW2yoNgKEr?JvUQJCuL&u<7 zI50jM3C1xj3L`oMCm-$}l7k_Xz(sU3i7^sh4-bS!z(hbe#ypN#HFR7)lkb;E=R&)o z4KKn9u6|$+ya{y_vQzM#RGwmazt1IlY2c1EmwJ6hkqI0b>vDB|KH7hjO9>UP`c_Bj zA0L1gMpepp=Q^~|ZQ>P{hJ;6NrAV-dIT#`&?N5X-l1qR9oucYk;hoOL`!j`uNXEZJ zv@%aLh?+yWdpvUVOUk)7q`_7#({^pL^h8j)ZVrl|+(QndKRCkI^hdaU>^BDtN4uv- z>qW=H>YrPb@gW?@20;>WCMoo0eh~LUPHr0}a#%sn&p?6z3WrU0+MlEj0F)RS zB(jr`j{c%J2r7bz_WLNB%`KvBFhNcAGjxhl(=ah;KPs<|&fg1+}V-H7L|R{+x$`%pHBgvjgLT%4xTRuWd; zs(Kyi@l9Kx^zb13p!r}P_rm*FBXDlFoE31F>c!~QzP=!{3VY2zi2e z@N++;E>56ph*cNqS+PTE=?2RL4I_Re8-=sS7YPWkNQ^QURZVii5 zWlfk^3sx}; z(ILh)Yf?M_gCg99Idy!8QJQ3(C=u~zhj(u1# zO&=IRSMq7Y%)+?&YQYv#@Y}paj*%#8V2<3YFD=T8_~1e`Q3#pz ziw%=iq+5R@1C?>46@kjLEzVWcDPO*a%1(Rc=<srpJx78soZuAM!4%7Ia?0*{$^N)D@#=7R!D=JaqdiXeiy7)k$4Us-%CO5y%hYRHZf8v08pKE= z0P;v^L9T#9`_doT>Ocjby7AJHCFP=dAs`=n{0HWJbCTTJX1EK?>aCrR#i*+bTPdgw zUp>E)c{c1fNbxn6TeO_YPkI>1EQC;Q0bHtz=kv4Fj8dg-VA=Z`N|Anc$0xNsl%UNi zFJ`ZO6}pywOp|a^%+w!xTh)ujZPH>c7D;-ku+^GU3=_G}BA*s378xtkV;q89Yo8;c zg>F8U3*?>NDIyt`L@hB2g;iJ3+0imH8qO}{m2~uDUaJ%r4=l}QQ;UZxUkWJ_=M(4s z8Y8h{&s`CdYThiXVQ>Hz+M!P`lxPhnIyQ+f=qD;-@ z1;_f*`_mx2?Ry-ZQZ%CypT;C^T;d7FXM6eSX)znQlvE_(Nde*z4ILle_iwY~X?9}i zy44H-z!R!;Q&=rL16y_oQ^aGEFpfG#6aR_;$OUlrB(|hCGs- zB!0AVjU3F}#lbQVGQhpv5bm)1tdUQP92zuUbZf`%_mE1aWg=HfBMv7Wk&p67= z%%E|byT8T%#0)_Eudw1j&eh1+8UStI0T|i80N7hXgFMj4FaS{*TS^x9`60c>;t zCR$p}f7tr2;a>px9gf-qSee)WG<=Rm$|eSHxd?B~-;NXMT8jcrjEwC8%&ZLm0;P{_`;T?}f6^|fSpKT~WA&TDH%$9(hOn-MiTRuV{zAZS4*VlDF@H0b z?(c+*cZsyF#alAOe?rUe6$)<&5%&7V|4tNn7b^c{8RNU5V)nY`Ci;AqMsJBKZ$&9x z=f6hrm-nPh?Cjnq^`9bzw;A{bU}FBKv;N^=8kN7t&B(;?52wA``ajs@rlV(kOXc`i zS_j=*T!V@3Kf~-J4;XKtki$*6=jwB!+xm3n?Mcf1!s-kcFH&z8G}xC&@bIG=c-ArR z@%{ozDgd<&W(3^6puXhq%)WKvFtPB?{i&x=5+1Q&8;O-9qR?P}{~tUXq$G6WgIC&0 zjU7&%ZY`F#@QAEk=`4lo?e%Hw4hOSrh0=5FG_rgj75zN;2%3yf5=(cfJDOf}vbcX@ zLg1#e9JibwlzJVMp!!vuMexbGFGsX$(K#*8UEf0bu&XYZu3BGaXg=aSJZOJ75Xgj| zU5(Om+O()_+Gc5d2^(L>D{6fC)pA&|HkYk6nS1(Ze_#LLcZFejtM;B*|}Reg)(^|8q5@M13LX2nyz^b+;p63a_0>Gekjw$qry zQ_*WD*!AV-4}8ZbwGC-r{fX7PUgUe+-)5X6lh8^Z}9nyQG9>tch&$i?>j2p4{ytKh&gLzH}$L-44G&>cvPJ=CAN&fT(RD&i7y zXRye~+h7zC5Q^%J4%{_s66|dX%w;^9ukyK@2N<){9t*Xss14BYk`4RPAaAqmYx&|D zMcnF%%(x;Hn*1!=Sj3XHU%+NeE7>4NAZnSvzDKEu*5cg ztv{QRdm4^O8KMb$gm* z_4+F!wRN4PwYA@)aQmEn`|#ZEV}0Z^wG-Jriorc0!aO^zt%-r_EMHjG&M#*ck^($~q3}67rl$spM^zTY zQ%7g7Rgcd31QxsBPKunT+WQB|E++QADPe}v-`QR%ovaKNN3{tb^&>OlG@mb5>btFW z;!*~LY4}JnafH33njEFn5tN-K>zOu&vDzlA*L$fa>Wr>VZBEwq!sif`!zvzEpR-EM zhRbcPgK)!Ju8^T79IqGHJ2*$K=YFyFUF(+~h7EBnYeqHMunyI;ZU16j=(p4KVs7q8 z-yY^3N#IyZNVPfcX)w3Fs<3-zThvUgad2waBCgRpV8)f2%x6-r*tu(%x}M6zOli{U zd$8drZW;Q;Ax>=D*gw^>czv(3ARU!!*40f15uLJ$gM`q5y6T+jiGja?!_v`@o_@IG z8kzgrOzkmU@0HVAKO8YrTye^P_^Ot~!prFb8S#p~u$GKnY1DK&7WpbAvI)7chPBYC z+rxBX?dPO_@(4M=qOhA~Pw6fgB+%n8zB7mAYm;Que=byUs*b~z%+UGE*(;}%NBCMN ziP~;qmz`Unl4l}fF@SuEp>!1^yw$2VE_W3peX9fXP;(7&pfbX;j6i|RspgS;)dT-n zr@q&wEcdmXJi{q2(lB?Eduz{+M`tJZTuAHJgZ^Fh!ueqMEp@_Pt)1rguU^EUTT68w z1-&LuX^llH_}@J-?pepDEca+_&-p(+HnFg)^=oEUI5oMh{*-Kfkzw;pZN9e+`us9n zjd(hSrM2B*_06Q!x@aE?yb^68^p}o%6y=`Nx>u0vogJX!wh=&k_y-7`0E2%EJ2IqPMl?-IVQd@)a+!r^f2gmy=TIM7NcnLegu8ig@=ikBFM8_XsQ5 z*j~R(KT?lUm4%sBR7@%CwjLr#3zKMQjta~)OnDf-Ah10NJCZ8ZL>8l@N6(a39BbX; zvg0Tvp`%!AlB;I=@_^4G{el-t3C-PvSLU|eK13AOOf`JDX?9fqu^VXCdUVip>zlEU z>R#T;J&!{(3AL52k#II!)S10`+tfRg_wWgeCR|DIID7q5P^OOy827xAt44G`=dnTv zd;oPj-?7lFR@4X_%$EmC6a+%@7@4!&qZ0}8nBC}U3dPQY3t5^R+pdvoUV*ot>E~p^ zL&Yzo8a!9ON@SOdCKjp)!!r+cD}?Z(pOKVWG9g1NB3i^R;||5Y-*pfTi)k~-n|RVA zF1}JOv9(i9t4QB<2rg(qdeESrB@1}bORex|Yr8+i+J1N_Q7rtjlyq#J*)%3fl`k@E zuJj9{@{oL$w0~$*TfwBIBz^!e)8#f8n3p<`dS1JhaHrx(C>I3uUru!Puo!CYe%?Lb zxT@(&$wwtR=59{9lp4onbF9H@JS?5i#|yo|WI0qB!rw=VS6a##bnSQ%U+v?pbe$#= zO~*8hzrN<8Xs4{$Zo0clk+Bl<1dbib)@|ov+K#rA3N{f*#TLe*pFmw!#LLNCxUsQC zd+50(=IblR&QW6V)>`2Zd31CrG7Jy9ba@!I?#|Sza_X~t)QK&g4Vkdzrc-*}+b7Sq zPe(rXhj!LF*Q1=~tpLv@hK0YaJ5!6Uf8chm6n~loP59YCkZ_Y;y|&?i^gH@2b0d{m z1J7K(DDk0FGAZ7x7LRp!^|GZ@Y}>8ay;8g~pZ@^=z$Z|an49^|Y+f<=FqP>QO*0os zpRpnM*v5QlvPzt|`s1YT-Ao}$Cdifd4tGMCk!(Xw?6pW-Kb%=1`35*m>JROgeg`4q z>hDOCQRz_LRE9$|B!p-zeqm+&igypeOzU3AR44GRg? z!b!CF?*kS6vmlenS1k5#4e2RcctZ)96X?pvcd1{r21UH~Jg zw@BM(2y2SP5qvCK$#Kzc>q{Vvw_y)b2|I)%ZFM$BulMuj=7(56jJ~QD4Omo5GQ#j2 z!QLW03E=$}GS3BcVyHJ*KuYJ==_(u6W0&gQa{Y2<1OTcvWZg^ zR|iWm>xt!oREeN8!n0Y?>Kyr0$n>}F6J?FoXu`q#Ixn2f*uzfQ{OdH0+M38X8)3_! z&8(v+0=&hn$G|S7?59AGxS%_{WvyQya5+zYDdkPOf;`7^Gd*DrLf)o4B0MulJ{xKr ziQrUDvzKf<_%ML8s(Z7cG@s*>EKS`aE91X_xYrUWe|9TINEijZ>+qvSs4+XhYBnzs ze94lLRNZ{N=AZ@6w?1Tna%(CPdy@U$EmfqCf--JPjkFvE-a%)1`S*KHCV-wkYP!^M z>Xd%wi&$Z%qexr>@;ona zi$FfY2{93()-#vHaE@({X=VD!0(VnH@!3`Zd(9F%1*!0RW)lDCErlt(-i7GyEv-S9K$ zGbdy;)S2&XfU||~dGGg1>FbTm3s;y+(3>hT3tjlsiZQK(wZcO}P9{_E4jO03j_XOT ztwv>HJ2V$#BzD+^J)}K|2!w>a8rTH1W5FUzP!?EJ!?mzy@}RD5`aKCF*iQDSmk@({{#DrMYNJxLA<@J}9Z((zXY0o;97l zFxbN_3nohB&5`WYpsmzLJ6+1)3Yz{boRoslVXJ*xxcC6lh^kdT%X-1b>YBP#*po-Ps+t2tU6}f znF>Lj{FcG}5j!@hZ9I`q7*qe(%iZrX4VmdKyU%7Sn0A}Y=bE2QMmg}zPIAM8ayn$@ zn<9VE46rInJbv8_2xqvegIWJT53T;AI-MjT$#&v0W^;0sr@&8tC6>GY>o(u`heWB! z8Bq^{UAh6X?X>9Q5za)5%#Nb42TCLEH3lxu0;Ys9nlp%PWVe7QSgpI?7dY6kTupi5 zVwxY?2Oi4Er&)z}X1Lm%dgxQ~bFC+K!Q?V0!a?sOeShqx8ZpTU_w&1PHaVaA?t(;R zQ&IrK&ULv6cVWSodN4W4;#f1Etp>^FSAw0Gm1?XYGICzX6Ur!5pse9Pd+@ob+9L2& zNIOWjXxt(e)wOvSaR0^~Sf{D{*1(to@n86h0FQZ1lkcJN7Zz&#`wggA6- zCMDS|h(}n>2!_)^N6)mZlKzpRi>86aCshg6&&}VB6m07=c>ymK@}2MM;QpaH$kMAYsFcU6|$xkIx)95)s7{<=VxjVawp|^mT|F z6==y*_#J2GbErHkk2!E>qeXP$gKA6RLA;o>eFUFL5%Y9$kHej^YjZvnR!R9lyzu3d z+}Z+Czt275GgSJJ$N9ZT2i@&dGLw_aO|7|)l_YP}O49Ad?V|1Ex`+wqM9mdimU907 zZGt`#(pnNq)>*1mBR>l5K;f0&;NI)_GOe zE(g{SZlpd!8D?a0>SZ12gOlDtwEAD8-^BZ1@3K}^vPfDmXag$GR_{l#Gp%&n!<#`} zprn`*GMwCB5IDi4BR`ZY?6Xl^6Fnd;8es0+hJ(-?7E)?MGvn=6<$36F^>lfDHZBQq zr2S0Wn3CCX2XzQG4~l}cbg{^PQ!hFCneN3VVUW`R(hmALNJHloitap7e2u5~u{s%w zENYjc)E}t{+dpnXK&JG^&UaUmL{8#89V}O_rfO{;_BQ27ilv~4Qv!FE<@__ zZ~I*EJIZ;}&qi$nAD@-PcX#RTH9idFlPak>`7)nHPEY_EW{94^$;T*eVjhd&Gi`Jp ztOEyEDnA|HOnXSx!F3GT^!z^zU1uN^ei*J4390-=W+h~=Y))nGY?oPFmzB-o?o^75 zoV_{WjO?@5p>Q~ymCf1n?0I+S-^cgs@5A$Z-sgGV_YIIH0&`j+Vp{5`m21aOY*-Z= z#yS6iqLrY&P?^w|kN{RAMLDt0B&r!D^Fg|7M#(UrJyu9%!KZq`-UC3U0Pch*?*w9( zx~5@*!taYCAko=k`iWVF6Y(AJqztM))p_yoc-xSC!J9WGMMVMBDiGpq#lu z0dt5g3$s9gy{Kfc$9+c1$8}kpmD;v_-$Pxf?YHA_B}OVrzaQw!IvZ*~o?N%s<=LV$ zrk71ao}J-F4XPLOw+Iqz1@rZyFR;&)UliHSJ4|vwz!hN}O<9e_Bn&_Cx9; z`x?iYE7#dqWgs#0WfvmHGt@QeE_{Nv?=Apqk*g3>#I^t?-1TF1Yp?rCuiILoAt%8o zkvwm4FekS>IvVEw8G06%9fnu?SWr*R5F^nH4s#f4qXVu!!>meQAI?VL1cB*o8Wr4& zOPo-a}pkx40V+yT*3>s5Jv|$v#Vs zyoI6ikwT8E6WDz40CCs5yPeNZCjCF^Q;T-tWjIL9_gA6S?U!<6()DZgia5eIoz>Bs z3m?lqaxK(Qtz$1(hkk9K^^@GAxwFq>bVgqPAPWbB1Wit=Ig3!Rq%EGyRm%`D{W9rF z%(A4Cd$FnJ<~`eTGg^pS#|dNoPoMttg#!+6d-lFd_Acf#`$Nmxhu9eXGgc0v6)BMP zFWM3?V~5#E$4s=7c5X=wLlV&EBSa%rTRi zqo1=anrVfIT}zGkz6!q4064ek=GnTHq&^=e_FG1)mwQn}IRWdbHSu(kJDOHv_f?qh z^)tEbdp7v9I|wy}rI%3ZE!hN`zju+~#kqj1otPZt9bZDu)=^Hj^%eDg#>4Zl+y zxIT;ZQ9dM|MM9%X3hxEP065-)N+`~P0Tzf+!VewF*t`&?U8?)R*o&04?1II0P?NE7 zPa_jX0=qcT)m_hV*5`$n#KY;1zEmp`TPra_#!Z$gRhd+7EsJTVGHq0~xG1Wno;62% zaLR3`AR`vH5O}w`enPTehA#lCK4DijEKR9X9u-}Uw@z*cRKPxSRB+QMWFww~O{#k? zrIw!CJ%J}#K~IaTi5?s&E0Oq-ZslCSFRbUgF;e$$>zB$%#t;QoFfsj2Upvk6;Z(uP zv&r&b%3~QbDmzTO8_a)<101ZCAD0^t_<}RK*nI2VYWdBK?&GB@c5yO^_%D0#$B0Le zXaI9KK80{4VCUP~E#l2`S#hzv8Equfxrz?2B=uZ-EtR{?=zfndLK?wL*(_0o!acuw zbRWyLs#a$hl|MdMQ7-VbibA+CP-f!lnuB?!SA)8G+`QSwV<`iesu%a{Sy=yJ<>jhp z0yzwS9oO=tt9=~p3al1clb_ZcaF{C2dPYz>*bl)*y3xA2npNYMi0Q3;LP-scI=Di^ z`>3c-++I-F+9S4{EW?2(jxAt=;f46%vAPv{CE+HotVDI7d@#(XTO#oY4R{lnXN{|^ zf`~auIYGHXFN8-=zlY={VpaazJ|m|eXEL(`4e+32WWZ7+!`b@c2`o^eulsbrqhLDE zkhla+B5KXO>+!q4Q&M@j;g+vjyAdD64IkSDI_yNoYG3wLCM?y&8OWu0+I_${9)AM8 z=>1wQR-XjcH?r$myZx?NH_L12)_9!74;nec0nQC3-O0UvEA2?baxa)2p~~ldXNWA$ zwqmw85)CA)4L^bfBFpS;dFFW5LJ+e5w&a92y3##IAQ*w(FIGYQR;hegBVqI}u zC>$y;HUWP7m(9gDVXfu;Bx7@Vo3(>-mjqRSM&{COVScZ&3MKETsf6|>YxJ3*Z$>qN zsOm*9qOLSdG6F_p@4Fpl@sW8Qh%+D5Q5WwU;Ld}}iw%8X?(P;z!uhhV!q$Cj*&ye%CQ`<*34%&LfI0Xf<>-oCZ(oN2L{7O{3`p}m2jIr&;; z=PMMR>J5QDXqL}ycqkk^jWE}aN?rQ1WzFd=`bL9S0#21%#xYR*Z~y^!5Q|y43g#oh zm}4X|)EA_vG)>q}=pQF4iA96E+j-^++N3n)lH2gXhd>j!EZweRs2V?V`j@-5_G{1Y zRXpchcRQR(Glq;dOGK*Wh8Jx?N>Gi@ryTp2FF!DO`X#6;M{y$?o^z*-uHM{_#!Hx* z-eaN30wm^t$D@vmQ9k2lX@o|Wzae22(eY0&W zmcq+WlOS5%iM|oA2$nljqFJUzfA$HG8O*ql*XZ;V=raKne1-{Ue3u-@7D39n*5pZg zS5#aq%r{#2or>*4faU1SHp*KP2hWz)3C-jH`a6|-w(Kj$N|{=23`JcDjE%*SOE#@?FtR#&>o5-kpBBT3L;6!^5vn8_`vzDR zc}{r--ID}1(xq(7JQ1Er7$!{d0}#(I{_&dtE~$$jPkAm_1Spb*ClnoLr87|H064sC}uxqL$)7xMij2 z>u>>{8FOIu+Q@HZAG}*n5EoNXJg>g69q*_3Ez(g`wTm9jsm10kr(Dar)Ms$bu!I*$ zS6BJ}JsK9vBQP=}JC-eC4(FyE*Tq@{+gGe8~IOwh0-Q5C@S$+{r_rF)L9KKxS z&Hh0bC5<0-*^V=yx;Yw^(9++`=G11lyF>byq+)Dos}FnJxo!#n`}j`%Ka znD1mwo>3lXpp5+ou$x)K?@wq=vZyOSX&w0d6#Q`dgiY6{W z$EpXaCyd^e`AH}2Yo-C#gZ$BVap13y6FaK5MrDu6-vp`<6eKOOi-*k9NaLdD8h8qSQyqH5hig z8?3aM&~@=9=(o};rWY=0&tQR4P>M!#b;VKopsUhCtJ27-6?a-;uo6a(sbK&0G)q|Q zxRSd^KkUu)qcy;0)#VDtfopFj?tD4m@v-a{jdqhELi=*`hQR|N2L-t~UhEfNtIS@H zU?D*ff@CcNH2c%BZQd|Ugf>en;2lTF2X}41fw#p8h5$kB1iG z{E}hnMUf54Ne@K%k4Ys>%YffA{2Qq4>)#I~gX^1QmAM#&pzLQTb&e{^5wZ3}>DNFie+yH7;;%;L};_35j z(1?L{1(s=yvuX-dF&iZvdw-oODSjgQ_*fLLtX)uza-{>xvZxc5GZHT|kC>JrlJ4~? z%!#%W2edawcDQko((z0Bl>4;A&+Yia^I}uv+40p1Gq-~>tHjrbj&d$qO@e|Hx_UI znUN^gQY8Oz zvLs%%S9-VLE&QcFdV6in5`~Cx?+5`ViOYJfGRWvevu{1`GP}3^_V&v)5Bf0Bw+niB z>(evtqUvVJ@&^5A#>D{tdA1*ZlYqT6tIe4Roq@(jtr~KcI{|QCCQc<;BSvy=&hSx! zsn=M{;R1i+O{6w=woJPweC-Zoq&!Ye`854PE;>2=D5+kPv*R|2D2w|%dLss$RR_w^ zpAs<$QA)mUJ5vR-2ihwd=SUK(vPD}9l1>{S7ko&S#9iY2>3wvxH0a;QS)`Kp!SMl2 zsTV=(Bp#bzGQ3-L9r?&ABL^hC7Y#DfDe+v?sAa@=4&q#+4!jGhA%zMrnX3I)+U>%A~b03m7Nk5)5g(ii;# zZ;Fk)I?6jt>sT{6xU~D%nSMO}G9&b*Fa5I2_iWjgYL405e|$+WoIBP1a{F!u=bF@z z@rgtMV?aJ|OY|S7a z_P-|PmLZP-V?tlX#k zxK6u-FbIYMJF|p^oOk3 zyZXN@SnAmYXIYT+XGC#4UfAaGF`C2WgJITCqJ-r9!rJ?w8vuF66ohps1@Fbb7Ef%d zb^>s&j5zTA!h0)5762}U9i9$j4xxMfcjd=ZWxcp`D0~62y#A7gz0G~9%C*Iy#4HO- zrm8Fn&>5$1j3?6iK&qD60^#;fB@skv56nbnsF3)C*p)%ClhfQlnVdL)sv+cDrU_6f@Cqe0!vRsu~7~-`>@FzIi))q$Hx1Rxk_Zr*#wMd>yT|qL-aZ(p@oYReh11UFY&ZyW`wB#Nk{wYza^Bni< z=k3HZr56RV44=~%Bd;9hCr@2(%cl|}%W7;t`cBXt()?#X$hC1(y}NuA`rDf=$S;OD zFGQu~GtJWxp7mw_AM|wR@2hmG_jkt`ZqBqEF<^4+SeLFgQj5a>-dyZF-(g(+b7f(@ zK&CxCW@^FTvqJ1Ei5{@f@@G&Jf@PV$HuxJX3Uko{*nPybiN#hj*RJ3u!N`k5muF7y z6B;C9idg^Jh?a^@Sr}BNT6D^GfF?oaCD~*c+<(wAu^V44X%_(Fr_ct)fOb)>E*MaA z)+f?@uJnJPR70+l7FPLnA%<{MS| z!ivDl)C^5PYwo6#2{|3pAUeIcrhu}>!Pv4Q0YJ^hUF%3OjV?DGl_KFXP{4zr1wx5uhio#CS*7~AI~r$ z5MDrR=f#c{s3uV*xCIWQS{531BkL^GCQIb9uU4_RzJ1B`A!FQ_YZ`d@oiESf;>*Pq zB)wL{;Uv3&lu~fLoh#OoQ9=|T`*`~8`dv3r&-Wt29XXURrK=Z=K<`u$#jvp{EM`Ob zdBG_8N2fx|N-vSPYponSc8bh~iq_^+batFbdR+i%vzycgTe^IE@x_uL*cgg%CgU+m ze?$3N5gw+faf|by2}>juOYbATZ^z87hj%=!N`yQH-fE#Xp0m@xXWV*AYRV<59`1g9 zeDcYkMZ*VwNq(5nY7;&l(dhp-b`+$UKYg z`%_Mzm$Ke7g*v_|wOS$<7Xk_FcUGW5h=Z2!?&Qi3PEA5tfrTLu-#;UM>NBT>DCp{8 z3tx-j{F|VM0gFMR>b+N&!qr+rUkjQ5=HYt^uWEmT&Kh_h1RW>~neOMQ=fG2pS|4qC z0Eq|>uFX+(Hsp^L&ntVo9-fMW+fiY=srP}ck2n8}8o!1?7d-d&?_7T6V9W>Rp9-p! zV&IO-ySyun8lV8UF7TzYZQW>xtlc3HJm!AB9U{)dDQR>c1XSsUXS+eG$`Z~d!wwR| zm3%h^V@Xte4uTC-i9(A;`?vT2)A0PPnf?mKdLk!lA)>*Ym!t=t^ z!$Xc~-tTP$QbEUL?t$t#rm6A{XZklsbl_V$8$i(G*jnr6=UHz~77j_NfVM+FSKYok zpkbTcVt^nh-1i28$?;zjn;$(>!F|hm7*Sq$B!?zEy5L{enHyh)zrY^uD(eu^SEAh% zumRBXB+yGi8rV0OnO#%KNI*+S%8dbZuk^LU%z&Qe?H|ubfENU|uwaLbjmSd%jo^Jb zUAv!pzK}BQCJ{H9Vcv3(9*xV5qwuBodUax}JPvRr(KaR{;D(sRd%Z|g>Tw=HeW@)) zcz(hfPj-44NuJSm*IWJrmDg`K%3Lbe>&r93W!1997)Iu;H>*oD?^2^CYU7Gns^6YA zX1CY>Lynf*^6r00>WxK=^2HG{^v`a5NRq0i`j^3Y_XS8#FZq^9xH1c7?B;c{NQeWy33sM>M6QP=CGA$II{?Aq_S7|v0l#K7w3BE^|e-S?{ zXLo6SeJ!0}B>}$FiwcM!fAPEH@;U10RjR-u`y$Jh=|Qwwbm#o}LiQGz5tS+_XCkrg zkuq^ws8g;!FRS-+dFE>r_Z2Z@?E8DSkG=rNoIStK)ik+Z*ZM*efE|z`_JXRD^Q-ZYO}<3%lWsB zI)d_l+sOZ2*f(`5tu(Zgk;gQLG#kw)Aa2zz<|hOc0$bf(9iio*ge!%maGm%*%JrJ< zg$oy$(b6Qpv9}UE6hDbVY{W^F7}Y#`mvMvmmwd#s1+)WfIdt==sP?#Ut|~i%;)W+q z<9#i~xO9D^Q8H(#I%7j`x>^#bl4#0C_w<5bFh$Zq}@NoB%l+~Z_5##L#!Z92U`Jcw&80oJG zv>SF+%CQirQfvU`(tA4O^EeKsYqRd+ARn`mYz00m1Fq?CJhdoL;zqy@M%SFMn&k*A zZr?UKvziH@7>{BxUli!wpM2`1Xk@?LzcadOvIm$XNz7+7ZIR-qe!GeNK$M29xfQ8nec)&;rPuT-Ez5W8D^W2xJe9a6~g(nK%#OpxSY{7@G85G!E z^lh2YhF29Jb_o}xG#x~$_13>WwocSB)fKY&B|&U_=|=%)1LC@!VXw-FyqYh2jy@ry zU1}R(Cuq9iE7cV@UfRcK3)UqtK@$(ZO&^kiCFdRobT*py3LzM=*64G6fkU+Z-h(ym z3FK-`5c0L4iZFteeJ*(kas%s?VnDKrlC=Hh73#NnO$84V6#}JVISzzZtZi{AbX!y% z$1hYCsm|&7PSv|+VBQRF-H^Lo4EWHqOygIFj{S< zIwO1=uGYL4GL}N#fswyse__2tgyT3pAb4Av1iSJz? zQwif3=^F)AS+VGw^JijPqFnwv1f7R4tNh<#DhE(mb&Pe$I}-XHbjv{751yy94@Qt4 zFPZBJWo@iVB}r00U5dacb8L+Nb1!xSTGb9j?``*xx-g}Pq*wFtcZ?o%wM!U?@@$EV zb{pG_9(P`kbl!MS`cvn0QnOV|18A{vD&l2R>y04~(&{-R_IjMF4LHC>za3XCqh=yk zU_p5={=QhvQ}O^lNI%(6yND6|<6zC4IJb2rI<5^L)C_vTLlM(s+|njk$5P}lDat;2 zK+;Oq8p2TYinI>6>D65~@)^TLf2BV%`fVC}PV?v+FVWNkDrcv^mPt>zc~wxDFk)}R zRGv~bqFw|igcHGoiJhJ{W3e3Lr{80j71wEO+CM-+{J0yar!1MumdJG91`#nXBVPaw~ z0LsB~swkW*@9{7M`TgSNBsI3I&9Igtj1Z;}u{u_K(d~)C2vDFoxA^M$z{soWtC^2Hb*3It>9PN_Mh>nJ zn#3*3L4OYSm+i4m@=y#M{lTWl_E=)o)6RU;L4BFz+E9+Hr{P|9AJ2}8lCo!b=!w3J zt@a8a(ceb6eC^%KI4V`yo7JuF9nI>nx$ksPC;!rL`YFHfm*v)75>r>Wdo^do3EGwQ z^y_q@cMyv7jfAa?<%+F~6Bd&dLpw^l>~J_M>NSntFl(goIA`$P6TUw3(O-(}=!b$o zOzQQz!s+bc6Yd!g6Qj$3P)9mk8OAT|gE{*p94F-G=OK5dL%U53vO1UK@s-?ToXs@Y zK>aeSJUo?xYGM*BT7U17qg-=H6d|_z;)`k0aY=?IX1_4pZ=$?Sd6%Lc{D)sv>78{l zjBkTQdp_mP$ns%PUh|%u7i>(VWY>{xDJkg2Iy-_HS*og(i#TEbP#}W*C)c8D6q37^&b3TE%2fv$mw^R1&Lw_8DIiH zI){G2+XNHgfLlKHVplJcByYT~m=hS5Z4Kdes$kgzdQyKws=vQA*9+$VNEcv+)g9X) z)`=SqTI|7I@=8XXioBV7)8Bc}d3hJFIH5H5cYZoX2OHv<-!{LvCM}MMJ)jh8l^X-O zsEP_K2?tr6XT)^&=7IsT?&bYw)6rVXy1{yvx6w!1AnsnFJX|2q?H%byi3;>AuPg!X zW-cEkv?UqMuGOlssWfn!DQ!>qxTW2yzQfSI8{;A_AjRx#IHfTkOBunia?FYJd9987 z)pO8BQEcz7tORP7VfRIrlxAc{OKFveVrOqv`{*W<(XSOky#{)ejkO)jP%B?H5@nJ= ztX??uNWt(?i0rJONU`x83I>ApV8WuL5|rB-Uz1T@!8*}K%K}u>ktdb_h&3xW?%K5B zDznd1O02N1v7LQu*Eo1pJ1KhcsD#kFtT3Ck|0%O4t<{x%W%q^S)xFU_!^d@o22k?u ztMKOuJ8lZ~R&tWgBn1ofzzt62W?dZxgL#y`WCJp{`)e?SVLE!L&|Uk@`iFGL6gStP3zeV?I}zR zwW5JRq>Xc(r`!I}$!bdUW^CT?9OxBwOXDQ^xcylSO;6uM%_(w}@(6XoBF)^bqn zuZ>;0tH+Kl1hF6JB~%L$6`zNy)+C4#=}grC(;S1pYBQZcq(p_J!xex+Io?vkk=|sA z!>&<}s#^vWu*$tFVrojZNskGrE%+AxBN!6>=|aZV+Y7dXs73;;nrAL_VnBpcZXAgN zw~q&hR6Q)Fr!X9p%;_vqmJ<43yK}j+$5{;OL;IUgOBL&hI0)o+SKba3E-7JU4{&aU?h@{$~uC2rdN*mM`|J0K|N#UYb#ZItSjMAqoPk?S+$u~Lq?u3t^@#M6d z3E5qNKS3lOAz-kB{q*rPJ(}GDVg7NT%p)8)9&>*UJk)&#MK8r)2rI?m2a7Qczm*cb zIw8wvqO$%gmfC?(^d^;nOIU>J)PIEL0~_wBte(XX_scV%Qg=8`rfS``2#@j69&xDL z`ca5GXmjZ<@vB-CzzuCJ*_f9s{<{ez-33Trn>L9*UJjrmi5#OwH$J`+NrYXV^mhu| zbe`Y}#PXv1;&z2E9wERFM*@! zb(-$b&H$m^!(haX6o3;I`uv(*0J3>caSG<})RVIOsF)#@y73M&CJ4#_eowo8nkNx8 zB5VJ7m|sm^H2tds>cm_y{>BRP0alXC_on|vn`PH=`%Cjo-<_b0VLqG2g=1$!8w|}~ zJ{vo8Bc!<^NB;TEKe4b6cjxXeaQlj{?s=2`Tg{XUqZefQtNk7kpgkG)ay zQ-gosB&+D14ROK!Ipogj#$HGrf>I7hr>!)FnBAZ+z4UhggK|%X2dV#_oPTTLLID^e zBoFFlO@t#%LUwo(iDB~%yz`w^ujIft0}7c49(?yMx*j|~r{;lt_J8_D2`3m3 z{62MWi5_Ib3s&A^k7RBA=CL5VMC$u5Ywgzq!}(v7tuPJ%v1o4><4{i1y7cWbBpGH} zRx}($4`C5{-vukzS-o+h`%sKSDj7T^ANmk48~S}oTg zqs^lW53bx2fcP%^HOBHFNO^Df-Bya>fe6m-E1;-w*}99(=(d+2sS9*AOUuVl`w3=1 zIBe|W@0NT2tC`K%=`w*9xs0?nbeyH49v!a4HXrsRSvTp<9Z=c==HA$LTc%%c@Y4ut zE)aI3hwxFt8_*hic9^ENI$3`F_p`B1VZ?cmO`ms`cSzQ=K^_pZmk9o#`}ol+$6*IQ zBW{-RP>64FBp#y#)rE;{?HzIe$=bu4xHXA`p>*b4WlMX-nS!L@`Km_%c&Sru_`sXgF6(*V4i4_j86a6=s___=>DXLPy9dN+tC!7ae zP@+d(bzC=hn%vv2>GfYeTNDI(S`kuk{#5(&-1#EK3~=Okgv#u-R(E8f6$&p@{_L-# zB1X9Y-MyP%t7AH#S-1SD!|a}0x72&}8Xp4Pc#EL29}vTjB`#Ol7^Z&PgX~P(PVSgYLAWPTubTLKaN=L)DYsl00ryGwa`*Tjp>^;jS-)$#CDV z*E^^o`rTj>q(zLNiQA1VGvXrQIhRDe$!y!F?&h3p!mrJrDc2ux`7To+cdI0h z#8~3hl}0-H@(XA0YVy3LIadtIHp+^PC%3g_gRQHG35LmML@_uadZkF`)VKh=?dAzv ze=(5UXt!xtz%Q|}F4M59oP*W=F#c5{zX(#YWCJ^0O8$N<^$AWi_3~NQUb2|E4RRl@h7bKB;9m;qkxNEl~3p>?usqH?)3Kqz~9^_2QWACY8qZM8D7d^QB**`H+i_S zzTZPT?d|REI$qtBgcsd=pdOET&5JQa6!gbzdMn-ZIdBieLG)954=8;L{8xgtNRpyG zfCqoaJ}cQ>o4QjV1!eO)YA;xxAU_?tqSU8yB2S{1Y4yK-X;e52;DvtZV?ODBnFz;n zZoW1AGLGSp`d~awWYlRI@Q%gLcb83*ZCJmvT0jG9xu&;1hI|LgpD@nJb@G{)_dV_c{cZbV2O5S zA~FOY?jP5E%AhVd^h3#ip00`uyK7qEnrqX2>m+Z!^&l-~X=;LhzDFCJ*SB`AW6 z)oHc*diufAKZnclBi{aSiVE~xu{A#A^c(b$>LASLs7d1tMWBLbbNH}gWr1Y@P(i^- z!u-BHYyf(&VK&y{`-#0V5sCQuN_j7O<@r=yuIa&mLvtP7)=Q=78=1^Mea^LafCqBV z2ZqE*eWt2;JwXl08&$a_(|P(aGXFB_7_gO@i4$z2>d;ke;^eociTTp@YcO-4ck%G8Sryn?=OT-9R5mG|0gl5OvF_< zD+TPDY{|I5E_v1Njt{HN_e1b{-H)y7%zsa|V{&_-K?2uN51D zxUtJP#}0iNeE)OZby)jzj}T07D~4j}k*puAyZ3WmAswKFd~_-yO+E&H?~?hiS*dUX zk^d^0_2#8A1muy6X|f%^NS)8OclR*!(5?`I*i@(%v^=Zlt$$oEnKDwL45`5&>HKKK zMo31l7zAG+x<+rBZ{6>GQJz1YcYAZ|m7O}dDStPe?WY27wEzfaQ|qhS2<^#FSB$r+ z@@LMpL#MnmriWuU?c-#^#!Kxsi)tYVk~3m(rL2ptg)JF#y6t$dsv@QDX2}>Kw#B8^yT3rw z?sOe`I9nlA1p@!B5E);3|4sMEN{~L2VYaqt$mOuO(Fd^tM2gIAd8GRFp16=M8Tm+g=4LQr#C4j7U6hb#*3}+)i{NlqEe%=b zAEMuvQ}-+B-Em)krm@^5bW0T8 zb#ZHXYpV*Z4)ZA(-Yy;EtR#>01?D#BPo^2TSr$pgu>*0V6;-G= z(!Rfk?=Ka;2FMcm(Z*bLe_Rf?%3R;;%9N~@0tI!G{)XDQxPBNqXj*^(>yEydv#BcT zNGrZH1OFJG_G7?OO6Ju5ZkZ!lBQ!)-7z*%#)_4Z@Mz_h(Kby*abFyym11GulN>EV7 zm+qdoYZSS~@^)=}QV@4(1=%CXytcfBSp^ZOtNbHUbQ#hf31&=O*i zeq*K_c=n_%=13JEjM>DPrI8hTTV!E8u^VZX%S7M| zqJB0ao620&C+X*6iOy*4f5u#Iw0V-btp>Y$tUBNPlxw2zeZ*@NV`(=LrE`TK{-Zjs zSBQ>6#qipBX*pxC=Zcb6t;q`S@shNf8Z16ge&HoCm2WHDD`w*Hs%iOe6Ld?*>&NIr zVIS#0&H~-&d(dP~8Iy9trh3Wq7%F5A@tsYP$W>oW_5Ob0cmmi>r9}778@(sanKd>o zSCvBZ$DZh~<#x_F_UAK~g4uwK{e!b>K6=EOuB^)TQFqVNCh{43`Bh2@at7>Tnu+h@ zC5<@zO%mWmyFK8LUSnjBs>FOw>M(2edVh<7&HH-`*2`)bl_`pHl{@=4J+k*IU-%+H zy=RaV8s7J2CRb)QlijlJ+vI&_cxV^QF15Y1GR)GHog9t6|iPF}wR@X?kNg zoqAoGXf5wE(igX~WR6uwjsG|eb&HGy6llvE{ zQ6On#Wjpb!r?FnBW}gQTG88FLa>J4|c5ULzS;$kZ$x`v+ZBmz%^o~i++RM5)$M2R5 z_w>zN@VOQJx#&{v8gKE)d-QxQLl_w!zY(nt_$%a=XaBKStZ(0)-&J!D!*B6rnXZqy z8wL59Pd9+DQhgu~3~O5f169ee%T?&3J;39(`n~!?%T9sL?8iqSd0#WnV&lUNKl4`N zs!z(VjEP$(W^ap1>v*z%tcWyM=v--KRFEEvaougVST&hczgKxbjL0`<`!dzKG&#J{ zk}^Jh{+YV0;LQA%{a3;5r}iAD!dAD^3@p-Gsa_Ck)NWhYByajj-;sDmurVk2QF~@o ze>3|BZc{qE=Ozue&ZJ-(=#) z^_-Z<2k>L9u;@w?sC)=o`zLwny0vt&WgS0!=JYGR<6yCIY*j65|YidxF|*J7cr2vNLYfB#dWbifO*>b;fRF`E?AvCxm9o`m$%fFhCsg zeQ$A|!u%2b+Pzy-BUt8&=zMz+F}CDHy&v%mJ?HA!GF(xr?>vBHaGOm_cQ9K+9XDpB zQa<@{h`DNJy}ey!w(U-7llb%FB05*Ha`opl#I$bdU#O_~!%Fo#WoVyY2XMlCEab^B zr>@CVocR3H$^AF%#T8y^xiD~P{wSP;VeEkrUhCQ zrGUe!^_!K}o1Urf884K}T8rW!Y3_UH5!cwK%Tgl0o5C9})LP4F<&wIPg(4x{4vK#~ zT$bPkHDoHaW*5Js!FaVKoao8Nt+%iIE*L{P7`wWk$VfY}v++k$na=h$Zu(gE!z^q{ z6vnc?XYahl$HqtG$Nxh-RcT`4hTCkfXY=dbN`+cyoWq(D+DW=Nh(}Rl9~z#)C7sY| zYuii}^4_l*Z-P#_n_S-5@=Xpsp}KzzsY5L;-eE4*U$R*WMY#{EkGZ(b0_Hc(IBnHL z`~N(@qoDhrJm>6EiM};@^Q4hYv|!bbcG0Hr>hy2O-$EzvT$#Ezii<7n-GOP!_j!~q z)2fuD>i}-`Z2LQ=?uxzlcEiG=8}ql_qjKMtLky+SY*%X^y6Wmp9yh+FjBGvOt!BH~ zL=W43s&4r4+l>>z9g9%$^5}_2oIo$;ok4ae82rkJ4$-6ajX1 z3k{GhN1d{`yd29nvf_DC^^Zm=Jkoh1&w-S?{Ak&2yxnQ*RPw^XmOqHt6aBn7l=GqU z(op!g;XZ_ajWsDS_0kYo%C0~`Kcmhx5Qxii)OP_eD9U6%Jh>W3^?u?@ezmRQsNvVk zt`pR%*RW#FH-R3*n#}l&^TkAM5iP%$OQ}Qji!CHaUi#wEbT(XvGq2}u`P=^%7CFU7 zF`qD1s4(s3b%9g*Y(`3$njc z?q(i5JuMVhVEjSz8bsp}`P(|fISQT2Mc7~G^+bPR>bZTxp^KH@E6haBI4&>h5#Mrr z;Y0){i=zg9 zqfdvvDrw$jmqo&x%)gjN$eY$An9crloA|TsrmH^US-h?xm*w6Q!RF%HQN9q8SDsUQ z8961D!||LT^0%FhFSbZ0J+k?}Hr5QcUDH3@?BkR3pV|lF;z`;E|B8DFvu5&bB<;By zkzn7y_jH7co~?gcPIxdT-m_yaY1l`UTAS1$f+v1eVC+@^tAP_M9Vm~N{vBPvLtT3- zTDUw2)aF~GLnpM05}7jjBYiY-Vaf%Y!^o7+^|NO8mttHTCWe_b*+Olaf8aCIO62+@ z;&1loJOof(osB3A`cvApSpU4Al*C-Oi74MZDKUDFlm4s`gp@mJpg&z2iQIR}v0ZaN zel3QBB=8JG>Or#|kvsM(PA$vHizwTxfN-KO4U}3=-cwkm(&nRd;j}z!Lq-RCI~#gp ztmNsZgZ&-wdyyc!pm)5UjpA)-`P0!FJ()nF)0P}DwBw?%)`53oMOZ=GVA1|fde%_@ zs3fKWjBC*!du3_CD|BA3Cf#N${q{x=huu$tr%m1#$^}rJl|j_oNsOdDWA7NB8lIo`zJlYvMnZ zrWZMHJz#ul3@0Zj8yxT@aUI-E-Zb$1Iq{)fQOobMIL$p|Y5I<3lI~;F#E!SWqI5Xm zU-$8*fk^-n@XBpGoHXo;lQ2-gt|`V}ycReOuNzT?Md|+0D&8Mpfpi-^&kD}4^=Z~x zlb-2qO#78t#(@71_VvFf_qk3JWsvhWhu>a=c{s+d+;W)FXH~_vckn&A&%OU_?5P~^ z?(QN#KpZ-j(xgX4z7-76F z+85aac<+%LRavF$IPKIWtnDs2uA0$J^-;vdM83to&1w_zGLm{HotF|{h}PcsEePFe?DOxYn7 z+&9OHkVx`M(WySnar3VpEO1k-03&yNIx;)5ve7?cCDRQ5%`Q7rxv(a4j$ml5TA*SF zR4?$b18Nr#GSHp+zf+bIa`m45UAeMZn0*#mOwbae442&HI#V=Y;fx9HJ%V=+Jk39K zBQNKRxsrSH%kB1B#m?eS&rgCdmsy=O?vTAqM)+!bFa_J9^7?PC#pQZ(@X`4v*xnv4%n% zDk@!;Hoq^WMdD}Aw%7@E`$J1j2JeBQ7`Kro4wlyDjV=65(X;ih?Ll*Yv)tc{VvgJY zr|qqR;|z8+z{JdsIcA2KnIYzwnHgec=84%cGeaCRGc(1^%*-_AG0m*cJ-hemzV6Fk zU0p4;)KaTT{j2*^5&`XqJYwNdP7WDP4%JE1km`8;aW>u$RDhr2tG(>K2t3DK-1Co$ ziUPEN=0$5k^+R1zT1bUtlg`JZgT zU%g-XZ+AZ-i#lD?UcWAN^#ok6Uw1rgH9fsO2<7!0zc@lRTY7FTYqsVAO3N*fN}b2Te=u6oUJ?`gW~I^XKLT3+sQe|cls zCWbNUx~ND~)arg47X=;)aCV-_IlkT21e|6W`GKO){cgZ4;8Q%2$05l3P@MeR(qLem z14q1SW&T*2gy{Jq#;Cj8<!YeeF?gVoxT7?5d-S?tBCmAMR25asz%R{jKq|DGvP9>i)NIu^9D|$@kA-HVw zvSQiBYoPw8k)QHcfZPV8S~Js#!LJJ#({BXdfecUsts5)Vwg4ylTaccxyA4`ffI0pC zeT#iuq1U)qEN=$8f&JU?%edftpz$s#GDIC@xC_Arw*gr~!;v60K&M?J4j0_v^ryCTvGeTWgwf3dZrR<#-NO@CKrZKSO4f1(A9fj@ z<*L$SiT_-e^xlm-I1PpehR5R(jt7><^U?j$6U_|`&b3cYAe=v3Ksrc|UpiPXc%5Gh zmgQW3v#r>*(v`oJ5|q)eov)FOK*A&s2O%axz!iv74OGpM##<_noiY>PDRro7iBtR7 zR{eE0^7iV(c-*+LT6y$-&$pLn^F9Y66~ZN8)%TQ_&Lu-5btC#tb$^}n+8P_C$A|u7 zWFtDiBH&`b5l*)@Xl{ey*pTZ<&9x@icXGq_*pR2i@#?|VG5z(ljc2OM`fAeE_vmrT zK*)1)qseu7(m>>CuZ`izW5R&Xb8!Rt*pRcu)nR!<@95)+}+2XGO#&rJg8FSg9~n&VIsK3uTOdAyj_+jpsX;CQwRSq%A0AR8XGKz1fB-Q+!h z?_{3s#v~YJ){hebjd3e+Y&${kVWa$5Q>f((l&0t2K3(;aG*j-l^^(fda~dP^ z9=3e#yJ>55cb4a~$Y6AHyKdyMUe(g+c6F5H(78*^(hY96t!eYQ&fVc{UDQd9cZU zjSl^*pzj5iw>7yNS|6V7Fn`stjE#+(j#Z3hu`@i$<{$5^?z;1hXRA;-(QU7ma^5OR zIyUeddR)@E6RIsEZmS55Q;qbm5pxV+2@fODGSg_ zZ@yEna}6(4&pzLb-Yi{Q9!^90TxPd?^Pt_f_oT3Ti|;1>zF>wZ&Qx17HanS`HwolL2oESfx=RFQ5H zcU`CQ*Dmp+2xyy>rqIwRcy+Imv%Af)*#_Sp&9)8-8<@Jv?-Hj`D;RkUJ;q4{R$D;M z!kW_o!_^|TfZmG5W$89ACX$ZD2NjhxJO-EJM<+FaG;4l;(Tc`#?! zguNe54lcmm-Op_eoMEHy9qlEaQxn5MCW9vR&gHnp{R@1)G%Z)0)QwK(kmHj=p|lnc z=8G-90v5fPsyn^Ol}q-b;J|m{(6aY(SJ@nr?CRGoVkUK(nyvaTiVoJtg0xE3*RT&8fd0%Pz^+SEL~bslP2-(dpzzlR}BcB`j18pkaLpsq@FzC^cvOCzis|* zc!bBBI@!uDp+0=(z`LAL8@=!35&>A)LXM_R4SYQ^UO9%jXpan*4l*pKRAU-+?~t=R zFIUUtvbk)L&!7KdO=oT_u2txicT{-0pB8p^?jfmJuH&p97jz~0(sGIOIOc8_bqKAk zaR~5>bPTg=FE1Cu4p(2xx<#mw6I)m)Ubaa=+8S^^v~3emWOe;fY`}n3JzK|^SCsiv)Uzayv&I?q--6Tx|$G+L~6QE*FCt4vD{T`6Ywv9Ota`` zb$3^%~ax=RmmIih(Xc$LGQ;EeJdT;w-YxUk_3syDYNnebKWyWsG?Y3B6Oxo8i zIcr>9O@9shr>J1ybov-->=qE<5+G`gS<-ygPw;*(_H@s8BnQdM%GhvkB9WLkv|V0o zzS2~vK8)?-_E|arRWi@9{q!!wFKL=}xU-z@O#K&Hac_5?d6wZg)uNcO4gS4DUxh1B z@}%Fb#^BhcyRud<-X$kjZ_>|q(q-E7N5l+Z-ox#pQQfM*Zr%!1pN8&mR6n9 zqs_$vRduw zc*+!;bMsn_@%Rn|xS&wuTf3iN7#|yj+^(5?$91hF(kldY>s#|Xja)%*KsddPy<@UU z0fDvT$4)!mTFJIyhNq3o{kTHvATaY&pt*%`vC-al+3L6wC*w3UZJPI6EM!Y~ehSc* zktFFnJGdTBX1?dmf7)6KbLEBY?bEV+`c$-!M}Lxs+&}PeXO)u z6FFb~J?+~JoBP?DAHuui0%W);>#H^%YkE!^B67{_e*SR#+4Q&3g>nUt0)2ZxmIMzm zy(MFaIVzt1Fsb7{gWJ}$lV5JC5;sfsx9__ z7)7q^7i2RT++^@oCJ7I#jkcv82YbL>w5z6(nbZ?1?k1syZq<2Ho_pBbRw{S$GNIZc zdy(9{=IEqt`07`M8l0P=y@>sNvd-oE zb{j~k!oo~rF+Zj=%>J%Qvq0K%vL(}puNFu%ZkWbnJ>Q6Po$FB~kG@q%Tk@?W{O8S> z0;QP%w>?RR>HOZx1!9!hdfYb8W6(ZNoI$s<^F@6OhuM0p9#O~r%F+roA$OcXr-}2$ z--jr(X(JI1k6ktnGd!XOUJvkWl-V)y;#YyE(u_LSSY09k-_4`C<0?I(b^b11kEMmZ zl?>abn4El*@q>{SSK6l-`z2uZ?@6-~CB|4GJwP6JH}7NR(%wp$S(I5xr4EsR_e1Xr zht_m-PNR?W#o%JraBiKAv)w@5>%Z8XRi5U6VlHm3PTuBbPJX{bb8B($SxJ^UK>eZ@ov!Cc`eLpO5 zT8f2$g&r~9>CbVoVSi$S(8;Whh2h2ZV0(_^O&$HtgU--7NdepiVq~?n%$~IygOhGE z^45z;;`JwnhvrqDu7|3=Gp1vv&iA=XQf8PJ)mJfMOZ`rNfaR`zLkk>7-^7Icjxfg+ z0bsGJr3~Ls{_nHuivZly^paYuwoAsw`l08)=bv@EA|7 zI-o{~Qn^KYj|X1kQq3j!ac$umVbAg}+EdRq5|n#K7%7~nN$?};_B&Ouk}!DYeU`{& zggl%m_6I273-)$1wk#IG)x<|f?wN7`;EVc|44L3NTfaas89z<<{|p^QWu^rsla{yZ zyXS?ynw55~gqR5eC+(~@zKBm#kBi^QqsB3YZgs&xa#8)`Uh*LvBEl&8&tG>LtiN`W zhvn!0+}TDamWt(z2D-9EJ0JvU%(GPnRD~)1t36sk0d3?Dtehe?!Vzr{!t7tm4-9q$ z8M}bl`rpIxmb!hs)7#m@PPxKAp%2$yZHA*sZzhHnwD|0NM;R8(4$EkH_o@XartGd1 zprLW25Rp46aT9sAka7pGhV2`rpwik2yBN>gD=`Z0$FOCCIKz`3-$B5M4i~S^-QGvx9P}mG>N2 zRO|jiw&R$a4eY!o7Us1aP(!&A;pBZH4o9ErxEc<=Yq1!BO?%z#=MfG7zM&I&JKdzx ze*C@*l-t4|DC!6{UguhMT8#?cxn)QT$Lr(|yS$#>b?UvS$n|j=ViT<`NchY{#IN7R zfl%E(iO%Aufx_Z*y&IY%9*x7EqMQELO+N{is|)+QdDMZ9;o zJQ>kz{0I8_&e@H(Pgj92Onj`JyO~6}(ZG3RQQ*Jp!+3EtK1OuMmxTm)$AH~iV5){R z$%gY`5^{ir99lT^QOogvMjQa$Li4)V!}8W&R(7je1f7A+n%r-GADJ4K*Q;5Gkr!Q< zr{C#Kun~dmPFl^6ot(RHw4VCMGU%SQwB8!`1E8&uK>e;kkBb#hL?BifWb(oCgKs$0 zwJ?OOH;qJ~)6(-xXG_L_8k8{Jz-z!MEp*S#+TkwG6EV@-66ui>@9z6CX#gnafv<39 zn^fTK3R)V?@6T!YcF*C=PB5~F4rs=fl<=)D3LTxXlRKP)d?WT=k0HWiH0Gu7hyJ{Cg1>Ay%@xb@F zWS|C85*??OrSZIry8$CRU(+2mJ#=E>uA7_S=}!8<=_wIsV4KoEpw_Tp9q`%6$EO!h z_-SQVu89)4+wXQaxW?~#6xyhR^<$5E@%QokC5lA=?c0IQd8mLq9q+)F;~JE0KS&(< zlbzSz&N{l_!>#}=wbQl%!+@u!@aT@Mi0}sB7z-Wn)WIj10PIC~9U(`BC<{ygtI52a zJPE)zm*Mc>(}dvg>vvrg$P;IfFFMQH-KGdWC=D17 zvyJ;df#rWcC4_IoFO(=-BrqZooPK9F!H_p)v~a8d(04SrfcxK+@Fc5`D?2^S!Z*N^ zA82skR~%_>B3=pYPKNLjUb?2EGFam4k+6OtKEa$nwx0hr>;Kr6PtZI6^}{p0Zbfh4 z|J(B)lk**XDZR}cCZ%WjPdIb&dLu(Yg93Z~jPQUf*PV0HLX*Z^*n%8{yoemc56oi` zpdai6=Z2%B`q)Y%2>Kj|0XROW;#t4^mlv?LPn_?qmDO!N2|0{3V9(?}a~Mz4f9H4b zB04pzhc_CZVAmfUBH^zhd||)ai2PW+YeCaC8adEGK z7r@BwDax>L@_*#~5LTMo4kStt+u`_g1vIi7j>S9?rr?W?mU|P%nGFgR54OzB2~*>* z-(BdEh7&vw{8;K&!VkiMIOq}p2Vw4AWC@QQIEE-z!s}|2kBBkb+2%j<6b3m~qF8nd zC|eT$C&uSa06SwpqMX0Ne~O2A#G#{?}6faoXSEcP+$%`cBJc!N}Wr zy>}wX&^vovap?NDO&@Fd(%;-o^eN4t9-A4kj0L@Z{QuJKofD8tid#VDf}zRCy)2n2&Hx(yEE;SfWfyEls-H}GgE8)P{u zLN|YgkCfl-Nt&Avb`67%H(akN0Z-`SX@DaLzridd;C^fvax+sAaF~ZH^av7R@_7s8 zo3vJR&799Zy04nS$^+HRC*{4r&Z_}Bm1+>B@9*UxJw?j_!!1%ZuU$PNUHPv5FU=*8 zn>oS<$CzC2TT*!cVM@s5d=}*Qj9!3)qU%~o%O-CL!0!IB>3=zMmi)JgRpR+p@t-Ed z^JEp=A~k&owx06@L(h6v0c`K52q7jX9RO2_?Yk7)(^uUOp&%Uh)ueBZ!CUWO2w)z) z^XLCa3I^W?4+P(f3sCw@i$i!%q{IG4{{L^54^{rpCV2Y~slUm8b^SjF{NH5%|M!Y; zB3_(ng_eYFaX>i!;it6c`#hl40}=!fYy-7s z4sZfE7C>^0ggccuexS2qkx%>epaMQ0qV{$t;UBu~E=Q3Lt^FAc)r=S1o>`^#5Mh*Y zaQXXU%Hk2oQdt#Bc8%+bc!e_?c)`7}pVf})BY2<2ITFbt#dvAk3pdW6;NQX2 z>r2Flju-xI5LUm#vWg2^o8<&jzwB?qo&?u6FE&n zDIDnQ;en6i5P~sm`SnMMw0K(tk2HQWtaESlrXd`!a|3tvbGMJo6rc510)nz)NPEbJ zu1{zOD&r?T&R2`H-*B~lS0-LCGr1>etAK3Bd&NxDPD>Iks|$Gn(?zOYMMl6chWg$% zMNUUZk5l@h>3vxE!Q~e&%zRg2SbED5DT*-JcEf%Lkd2r!RbdjKI@x*&RkS=iE6>hKN4jVed^zpU+#TdML>y$vkrb(- z(o8JYx`Fa)y^gzQ$00lh??$}>tE4b_feu|z0nlzeUG)U_?R{Bekrb>TTErR*LngOS zu6)}2(9a{5;w#wAGeF89=Lg$q0ZKd^xsq{r!H5u7;_Cg4gS8Foo2yxy9}e}l z_(W_lcf=;4;64xOr|TmnL*Ourc9IegNIKeJA1|E#%~^>0tE%(w@f(wm3#JmOky{CS zf50ckRcStM{uPdbYuBhD(mdSowpCTq>sfK_gmG}^abv;K){t4h>)E-r0)xV=Q8xuv z)rYVv<|Y=ryRl4oki<|nI~WPvC{EKIeB5n7f02l&hAv$ z8K!pocCiB3;vYeu?_^@dst5g?0HSx!zaeC9M873$2yN?yBHSZ+npm+{u`Q}1pEObl zdDIf|J*_~fuA@C-Ja^SomTl6_TbGO>MCQrU!x_D`AC%W&!_Y^OQ`PLn(<#5yabcqP zxL#y;-T8vLi`-8OgKxUoMXDSz=C$KSyel{lLuI0J6dYgn4?ZPi-IA*jDmXur&bB^? zx|kxcx3@j-*U5|6@aBqBJ6g!9Tw3PlG`B$BdFM=>$Z`k+Ol*Wip>R7GQBTK&IM03E z?xCeHtw%rLpdMve`Z*nwwYVe{Z;!3(LaVHpJ=Dow3E>!#H0-P|hE7g9qQH;8uRsuZ z&KL4UP4a7HC89QkEYY3OFXtHP2Qm16i+26{QN%mO;UfR)^3dtKadkfMhfd;!D9?@5bLoB+Fusk4_cnf2{}iS+PPQy`3hfmU+bUnYsDv(kTA zEq?dt>^c7XK+;z8GaF!GgHS7;un`5xeWoeI0qHRVOWwpISVQn&t8kPA!?50Nw089{ z@cN=yOYqDNL4#xTo>isZAX@d*p#YJPABW)(QToHf4Jh_WH`&C$Yg0CwOZP)&8nJlg zS`aBO6mw$)i1fv*@TUTfR&m$8;hLKj0ui{^X?Nxr+@89?&ml@WGQDru!XiV>&#-^u zRBJ(@6*!i`hyXIK9eYdwG0L6@8a`ljNy=|8FZy3xom|uYFenQae1!s$ioK;fbR_N% z{Vk40bBK#_=_mrT?GW(?Kikr&CP*A`)6iQ=<8&74_^<3n$*4?}tY-17Dc~#p@%ae* zo$6Yna#PU6kEMmjsi5ld(M7Xt+vf@ZQ#NKZtTY|;m}#*&;f5!JSIW3Fr;(w zRbQEh#9mziC|GAM?pF+g@=Lvh_xry_-}@2GP3+Zto^ed77K-u6-<>}<=!mfnKdfJl-zj{_B@Sa>GB#qGJ086TFHBD zVI>sKAja^UG3OUB<$VSVn_wpXj>w?MB1ru`6mCpmkSZac5mZMr9t+nXf!To_c=lzd z_}d1{TPP4t6yqKpA(0M|D6S9B9bTnPVj}Kkci8^2o_LS90%W^e6C(0!_MCKvk}b2- zvx+3a_|p+F-KGP=3L^?-kzqBej|BMx1Kp^@(qFBDol-bxiNs=a$C1Au<;o--Hjk7^nBfq)Db~m{-BR-vc?|Sjp*b@Qz+yLWWZ)g|;mx#$CZO3V!? z!pa4{+4KrxIjNO0{S+orZfJwQe4-zw40qK0fsImbhFA3kKK z!j^&V@18BnbvST-;pa{bvAURvsANqM_vLq~o6B#~YUN?eOQ^cUUWxf*x0;fRZJM@P zJ)bt7Id_JN^7BIIPV6NJ--S7_;V=G-m1A-9-SqxLXYFRodSRsxjc(=Yq9;O&Th$D{ zk-G63&hFAmlDpwLgUfOYbWQ-sC>=4U)3)GxgtlI?`eH-2t0E7t8>Z9ni{^)2!~1+ny0m3*4#bM~ zKoHs5A6F0TgI4NR6=v*ZTA0N>@nabTGWc6L zXdI2m%?L~1Qf#Ty#wKnV^rzdT{KY{+yL1v^q4`-($@{RoUL|I0!*2g1)UpY3Ioe~H z;_ppxH@5~sAUA{qqb47g18$K5Rd?YwpSLmaUEim{W>a!=CdNJ@z&Mg&{SRW?$k32= zu~sVR^3({+g`wsZF;l8Q2z+O0+8{l<-uBHqC!|qseZy(JJil|wj3$NJTo^+qmU%SO zLIBx%gCMi{lyz|dSGBc!)3&8e|2Z0hWRVR$%8WRfuO)}~V1Q=4@LSEFC+(47^lG$A3R#M2wcE;19vJkyT=dfq`Rf%*3z_KQ_hWT&%yHP% zzqWhc?=M;Z2*tD4D+Yg@_Y?tcU*m|?&bp-tcP_cP44kL&8Tl*Jo+Nsc&4yv1BEWb3 zS|K7((>sW;`G^^}u}<OMa&zrhB-vma6N>UHqHGLLr8sf~swPAO$IO9jj}L1x0gXuA#-6<8>7;d6g~8{k0u zEjZMj!cgNy5b&JD153R>OKI5tI-%Z}O~9-` zcpJiYxKRt#q%92(>hKZvThdJ-``y7+oQbg#D7gfUNyQt2)5RKSs2Bg4qp%%7sdkiK z56wdH4iyQ=oR{*rmGnd|q$AP6^3$4O`KD3)x5CW&tSH9u`_8D}w8mD~OMaW^Q*BwI zRTz;Ur#IcGCemh7AUPi^j(^NzVd#zsN>c3R7h(S})Sk~EG=3T&cOdfYmr6#;aW6*N?x zwH1C!z_&@*cQO96=HAJ1{G?cMQ(7X8LH3ZWOn*4IA#7ppOV7N~J=;_}* z`H`Hh;Tc6_>{eDqA*3VHn0zEKR$`RM| z5tG;7g?J9u(6xyy3CvISNBGa)8Il2CL}baz*Y0+7Bv`WWk3^>TP*i{H-o)?F1<{cF z0rjf}yp^1D*+?SYej5tuXXG=oCu?A6eWB`WM?cK0;i9~a?D{GUhYmVw%ww8)j_hA& z3PF>v3tX`Ro&qD)hx&HUPpUe*!sG2PCBNiEP+u0{A)o4WSHDF6=~`W@RFBTxtmEM( zklH60Zi=HV9^-a5t!wH%Xl?rR$Z}n<`oaq%0o-G0Lii+Gdq2hoF4e;B}D?xwf#k5qiI}PEIURP=su~8#44$fsKVwcdQ)1n5xdH5G2Gk& zo7*$!F`bYAU9N&1i|h&f(UqnY+|{(Qy6zw_=g<_3AWd^Lpcei$bTEM*O{FU!FJx>5 z=X)uITAuQC+y(Mcm!q+Mqcd+D^*SM#UJfTY9+&o5f~YSQ)GOZps$QJ z>z^C!FBmH^CJn@WUg##?KU-wsh-uE)L?9*j=XS`CFW1tUrT82o_>YNW_I{lm3+ora~v>XjqRl*I`=gLKLgifYW3_kg+dKlO1ewKA21O*c?pX zWpZ>R&{go$NFO7r|7o)IqY(Zfl=dFfUo+U4Jhoohl&!M6yqG~PxwdmqRlt^ObaPrz zYitpT67@d#`B!FXCWk7%095+x=H7Ir!;$iaE68{>_=pgTuqZNJTiL((F=JeHjlV(T zyRd>enzA<9z|Zt+g_7M{tH5nkxL{8xt-21}uPZaeY@A%d$?`qsU#c!KunHZaHmd$X zQf!)Zs*u=?sbj50n6j~$5n6sVb*i@lS}!(#Zc~p}lq9v}AUk&4O)b9NOx)->8g$(p z%}ox+R&jGrIqG+|ws$V+>lu|)+_!r#uuwG+5aYT zZWbc?@4jP1R~%9O$Ev;YIBf2$Fm6M+EmZ5tMc?H>{9$%&kaYXPU*Hv5E5+1Gi^n7v zSS`(d=tt{EHBeZf_fkqPqq6z>Q_3d}t>p*@*w6DgMwFbN)QQu<2G*ceMJktXBuxrH zQ2==-OSN7rDOEg{?O*Prg@8I!{i0IHuTw~Tb&BvAO~AlHz{dTJ-UdufbGjA;<>JWm z^RSqqg3xLd#d@eYJ?w~a|AYTHU#Vh}DJ;;tDnR&lx_5$wrT>t)7_g5h&>5XbO^@$s z+&ql&kW0LUry4cn9_sju>5bdZHqci|{m+8D&8VNS{IA)ue7}>9zTn&IJo#dQ^sd-D z-ycaL%-$X##IL>I1cnVd+u@e;INrk>?JY`*3|@bt-}>EROeo%c&RL3Ph!dn3M6goN zQwim)`4os9Uqqesi`N3azsi9id&KQ!hnZKzFc6s?x{yW$oaF(!Q(E5z3^Upgtd+19 zqDjmXjr`1O5?@r^g3=a;0{|sKim_x&F~33 z`uCPn>iaCAL=iC!?R5ZOY`ib~r56cttGkXTYq1s0w{$uJOsRME5!3|V%8z-J*7k1Z z_M98%5g12Nq2JMKFI6q5e*Ikehe|AfDpT)|bH1}W=fY8H_C3p{$xgB{tC`yVNi6d z#w!!WoCjXEMt!1`yM=aG5qoHE)&9OgMhe}VzHq`OAQ*$WZ9>POqhf?S25p$gPNf3|i$g^sP@)NdmP{h>cRYwgOIy`}ZbvP+U zBk4dG^?0U3czYpr)t~y5;Wk%TO&LD&B`NV?zar{bjyyQKdWuTKj{e+QHu>3*>=E;q zT>G!@iWQ42HPw&dw2B9>&*F<5$G?W81+n88h55gflvs$2^%Z}^M`wNzmr|u@foE<< zfbyQR5=KJ5=G1o$F0BiXxJ5h`wg^u1n-Mz-E*T`!{6%~V-D3>fV{Y|h&h?894rzZk zZCIlGMeWhI%oh$6KT}m|f|RPn^wA4bjiFCP>dQ#1APK@&U|M_$Jy{RZL79I<+hqQ- zfeoj9reX~qq7Ho8X_asI*BN5Urn-zizrQl9888d)8?GTs7Dzc9t}TE~G=SRj5=nFX zrr7W7<0dk3cM|nW=|&gWnpV6a94QHzV$X{$zb~XXWZmWj_DZ+K|Hf6oMkB{kRfN5sYtKt$s0dZF0Qeq_KAXc&e754BLZe8;@+LObsT42Q*~TCp zFO&7)I-YmqOQr_=RMEH71sKFog*{#`E6Bt zO(OZ-|CsdN;wk&~?XAIAr7(JoB&jCpY?k=u1?G>;(7Y@&8YPFfRAc!!B{y-o{xXYS zT|pINbPsg~+o%Sn-lK=H!SlA3o?N;@u3||NXUy)nKGHS>+_EuC9tv>`tbXQspHW2+ z`Oa@+^QNCXeAI#u`fQSsKFc4CehHkpypyQoQ4I0=D|HrrS7J?^MM>{HD@9@@fO<$zJ_eNVmZXOrA zCJhBVtRb6}o1po|-cu+4m`7>Fy@c0U;hD{F9zkv=V&b2q=X5B}6gA$3WbALO;u?b+Fuz*VhwR) zMiS)VEqYCn-#ERd;m8c1g5_LN4&coTMFg{@gR3#6Oxj@1`XyB=`dA07zG(Scc@Thm zz-He~vd6L}Q*T9uC!Iv_3IP{}3DW;WrO{*`Ge##s-MSTMbRKdcFUsnw^1eTMXUxpyzf-`nB?UpQL`7w<#_b#~L}S{&ADQ-)MV%7^<8pQj zQ9QYSsrMK5J|+JSp1b@Mk~Lv5q8pT!>h)l6Z9S_0LMcw~tKKGJnbi68eesJcZQZW5 zxhPi(>~P^nT0@uLQZ!qdW--S6hH%q9NY|_p;x3$LqZh4i#HCP2QDXj&Xtr$&skcy` z{R2(f^aNq#if}SIRzd!R)&%6eDO6wDQDXAOR2zGzu-TtU4y&FRm!BMJ9j^*~_6<&Q zTW{RDu%IKW-FJ$jq9Wbi!jDZ?(KgZoqQBLqA1=aFg(qKB$aWi%m7b%9d!SCD+kU+I zXqp)E2|p@oDEJQlR4Y#l7Oe^=wwe_Y@(U}9)_RZGP1c zi3aD{v>wH1nC$OdUW(mWLPMBm+OCNQo`t2vjer_%MSI#933Kjr=HK&Y?D`mI)a)8- z=0FePeQ!^uqH71{v`$TAmi_kx&qv!%_#AakwZ&cPA(dz-G7(h$I;F3Hy#=hAJYNPV zkTWVW8-0*DO6lPJioW+VRa)NCr5OL1T_!G@@rQA_Kd1{L zPZLC_m{3+c+7YLcS#V(FbSXAZ%?t$T{xzo;ZpAyh3JKFUXmoZ`Pq$#o!%h4}3*vXM z((xnaDN?GhR!Mcav`n;F1;(OR?d+4qqb92p@~Y=hdP~P@ZeIMM)8xtJ z_%RMtgsL>jawwfAyhgs_kg>s$`&(Uwn`w^>KWyygcHwB-|9u4QLGf2k2;v_{a_w}C zAu55R#Pl-Wr((E%Y3)5qM)4Uzx|+N_%+i;YDr@UpbLaK2Sv}GC4*9+{)_$nPZJ-1RjS*g*NkfUW+$teM%KF z3p*_TCA=9W>deI84#eQ5$NMQ7=ZDKOGiYJQGEMQlvH_NK@H`E}gU0!fft0_7Cywe)*6TiEo;Nrl#H6p}Np8Q(*^~S?=Dmf> z_Cn+6f2Ts7Nn{;D_0nCN+Y^mLFfD!}^O=m;`{nQroF&-Q9K(zja-bbGfa&?8#Vs@= zcNZgS?3A>=cnvWMHU|9?B$XyZz#PHl!&DY?#Gwf=qnXl#rX^Iu z7yRMJ3ez3AcV?%34N=yhwt8REbM#;DK)HTJVcN45Du?YmUNR?d#SUq$Kh9jK_owLT zj{r|TKGp?5GICpujJb@j9w>pI$bP@Qn+MkX)HB?dP~ zdlZ)CvkNmLFuKV5zTTzELpr{jx14!UJh`pnxccH4RIkW;8vyy(?#nDvehGE2{J_GQ zd(<3{cWra``puLbr@~ny3e+Llzq4f%=}KDR^N4OD-25k+&9^U7-Ip1>YV!t|tQu76 z7=g2=-}|&vuR!`~*mQtXvq~zYtDWGM1Rlt|!=#vw9Ltm?N%P#kE`z-hu7)vX*r-r; z!gf2=ew-9!VeDblbZz?dl`4uaWglTc?}8q4K5S9i4exIX{xLEDp-e@k6w0R*?NYZ* z*($t9RS#ams;O#Kb-Hlh5-Fed{qqA@^x@ojvlG`BB5`utzLVwN!Wd?{iy(GHv8&_I z@XR_#$(EBy_Qs0Ji1Gbi$*j2J+BLm;DQT1HXFF7Uy!O=4luzU(@}uzNnT49>sfl-6`iMh&Gi(RFtexGvRRp=$dW~N; z$McnIOipE2%_2Xs&;G+et}S;GYt>jGC|0u9|3Oy&-R`x2ws`|L1i9;S>d`5^EB@U= za%alR!60`HLVDFZ85eyx=z125%y&LhQj*4GuLe>zd?n`YVGa0mnFjOk` z8HS%E$-r{ijlQ37M}Csk30PI1(>lGyR69hH=?yEwD>uju!=JR|@#Zd9m?{vt0W zO4w+iOjt#gdPM!Ix=m>+asCdBsM$*Tx28)TB{v!a%1uLm+$?=~6~&6^q{<@Ap4fVk z^$>|f?m(hVds>uhgWfnUd}akDrbEe=4SW8b{@O&3tzRE+7QiAqWt6MkPjv}V+XnVA zYq+VY{R;FY^13qd7){01w`-D_t1JC`RFx$8njanWe)&^w%s2A(!|xed0Q7#u$|vnk zsmn@faaCN)Suy|q)y1sBf>hcl_IkYw@tVFO$Ey^~(P?El>orif+sZ-(+_T`|y8zDU z+{Yqi^vZt5o1IbFM=Gqwf8eOt{LrU%_|B>kT>W)xGA-fQScEcEy(Hec2mae6tF3@F z<^mVu>5!hRj24Y0u|j!f>fjVq(yjDob>4*1Oic2yr(M{6mr*>q5-0`5_Q+f9^z{4b zH&m}6Zm%GrTrAY&dR=DR@LQWIZo8^ViGdTRr=gV(tTetcH8;+&Xc`hfj~w#_E79s9 zV4kFfHR_<$m~}7}CXy&_{kaU=tnwOMm-4UVY2gxG^v!#sH;URJb&~9EagD?0Sn3L# z&4)}B%A=MyLW9JOai+|b)kaC(&-zV?f64=Sc(&E*WP~~??f2edxIyUY-DL63F$Ww_ zI4BV_w*448y_Xve+omMjs~1(wGC|`jI(HZH-*O;XZhE(6l|zd8!hve~0%+1q16RW~5 z*=3*b?5mv1qf;H<*ieQ0P-^qcg*gKBscTdOFFmLa@vGY^N86_uN8L4uZ({vL*lY3H zMaOW1mx!Cwm+CS(h|ML-3vttugR7qf`Cd z=&#u-&CA7D-5A1}(*98bxEU@*=Z^HRVd2!RxJsGB z*+paq7=b_3Jfh%|y6l`@6$d6;>H>uhxFZz8YM;#2T4*bnj8)<&3D_6V_F0)ou>&| z+xKBSUp=>cU%RFS%@|vBzfn@_dlGvcoi@qkQ@#sNa9}$B@izWxwWh{NP3eeiW|L4!-lcVzNN+9KXF@{DCe1B&~dVWmaQ|Uv7iiL?tIXg;7(QYk60!GPYz*PgyeA z&_(D>-?M8qvljGWm9}^>p)T((zk4!IRU~pXXWAY+l~8=^dc8h<0wa#K%dJSzHjTO| z2XKFaT_}`BgAnZd(n6cGN(a?pvf~WFG_OE%2e$hIdDEV4z`rTm;M1c21sivwZ@E2A zWWZ}JEs-!5tA^do!5jHF9ekGs7H~oUI^&OxIT+-$DFS*)s?9JNnO@GHmSbZ2IV+j3n{SarYmu6m^DS((Jqdiz}LMT^W zkq~%fwM6|XshDYr*$V9{JdBY;zO<5#1S(nFU2CZQ%6L9tRs2t76RFu2qYu1Tt8Ir6HE(^h;F4d1{gJ$l=(K4VO;trDk3={qWNMbhGt0q9P@X(bgFZzh0!N z&;;tJoq3I#jIt5r3R51+CDchk)M?MAaZO9ms}Nh-M97NRY=Jbq@Dyqm2*un_Y(w(V zdza;t5So`(YSL(g!lGSiXb>HGDtvUx&@Fj#*Y>r?7jhCi`U+GZ_;U0vndM?J!#Ij| zyBEAid58rYBi0V?sbB~4VB`TZ^m09q%Qm>lzjg_8EF|F9T4rhf9LvnlTT8op)|SCf{r zc0D$$YZWc?184Rd&%_8`ly9cFm)v;KAqRDtUmY@{SvDa+=t?gk6vwdd$HARO%|W0k zb7Xav3j1<1-5_0qK48?+(N}w6MDTA{$uwzw z^8Ga%ZPFA?zE1&>V^T#yayrltY%l{>vn@ZGLF0BeBMt<3VYI1LL>ihb9$&vN+<*I? zb+yPcY$brG!)b&uQC619Z-CL+jIxYBl>0&zLpIOedo{27s{##6l5zJ{RifZGET)YH z+gzlOe$^FQvwFRZMc|iNrCt3@S-B&UOikIbrzotYFp`7JfJTur*ZL$0}kmX ziWnB|uu>2CPoX*6*Tl*RNQUng$b_C2Z~OZi%M5b9hp-w3KQrt;1-cHkDzyqkV|UHV zg6MJ-soXRhL$VLO-Mhe(>`l;{8<0>&dlb3{~VT%9aqqxv_dkZ4*nM>+j$nKUq< z3b|r{m4ZOG>i+>XK+3-vPT5GOSC@K=Jx$B4=C0#L##zTqq#O4}Op}iTEzrGjJ#12@ z*b{x9uZPhUB%ZT^2iS7UR?y^rqgFH{e zT8-kgMI@D3Jd@(RNPM3c0mK@D<>Z7C7HUr2sWHLyHI5>d)bB|w@%q~@zmL~8^>0W4vH^r^p6}}J0b{~jm(Uk54Ie}cQda| zP~FTlC-_{a&A{xLBI;!I)sL=}_>WO679Ya_lNiPrrFy6pga+N* zHt5BQK{xsaeVql6RyU3q?o0Bj$!y(OLZ+{ml8ziC8PAGJ=qD119B?t4EydTz0gsuA zru~lvr_{|zg(Z<@MVb+5LZo@ilnl%85~`^rbT`Ljmd+BanU(Y!{87SNXj}1~`32zp z=i(|~hp(C?HCJ$pMcI^RR;4(vcz776jXbUPM-8o`7Ea!UbYNc`xQbvsT@r6Z$l66H zrJ^IuVJLyhX%7uWsfQI_Z%UeKE&Tq=R=@i5Dd+y8sb=yz@qNu8@8I=yNpznH?3^uA zNaeD6vdq@F*LGuV`6GarOMvfB@%|})xGi=TmPNJHOca{n1{!irURX$r>1#dREUrJ8 zD_!gAMxG8_IImpRC;LN4yc`N}Jqkki7P+PGit6dgpAum!T zSKMv8p`seW8)by_JH-cUj-zW@f`ZNPuJ><1Tpf8u&Xrx^CBpe(g|*KVj<>hMK|9lI z77Fcpv>2t+?TTA-!?5ib<#KzvhHy=yI=c(#@T$Ur!u0qufJd@#N8=9q$s_5E){T_N zuZ^;&TK!>U2iVU_0cFZdO@B8uynf2?HVdZz%<|}mVNhvNLih(tC~m~F6vJysuBwU1 ziub+QiHiC}DiAX#eM5E)(_b*Ww&AUqzV+S(rQPnWto)ehxkNRn??LVpzD|A!^)i`N z(~_B@NV>1{PTRuOKNJ2F_}DeR0!T6$&ucZ>`L?1stwUW4)BK$$)|2f}aZO|}sn^cl z??3#3Xm6e5%X`*)h)Lk)_$GzN1mimufqt^Cx{7{(%XAE->bkN`G*Y)*oowDuG-W|I zb)|RAbu@NyNw>9lm(v&GHGaO^tHtZ9X<6{fezRHgYBT0L?%sAer&f&qmW6G;5?)&|n-UcT6+;b%PaE_@u`Q1#X+f&J6eK&DDVM_oVowS_1JE@V|Jgp(>|IG6>^5GDC zR9xfzlJHgVpo=^v^HuRJzuoEU3C3GG543{Kt{o31Eg`+xhLHms!be3SzS(3vE8gK4 z_Aa1o4X-&B4i+=n+8X5Vm9*h)H6GC1x`7ZbR?c^%ZiI?K4yle zNv36qriHV<3Ybh3DJkggiY5bS6iHX4w_j2nrTCJp{x4(^8~@CTw;>X1T+#n>yu&gS zZWE3SGn*+YYA5PDFkS72(y3Cdrrnl3pDh;oxijBvXT(SsrkXpLsx5C}tsh$N3C5we zKWT71*xPleRZ1c6MFBfkWn`kV0Txm!0$Yz& zR28c#@(3N=o>i5X5|h5mwp8+K0kdTZsXt&P3`XE1a=M#VHL~i0calVP`J`izlDcz> zq?Ub}#w_P(C@5$uc4K7srMS`apCS^ z^HJ&68!p{`pM-9|UAhslPit>suUwW^kG7AVJSp|J*S3d4DP!A@NsnEp5`9e}dc?nJ z);bBzQGceW$!nT1PswZ>-r?f%jABll^T=1vn(%b9FvOV7a5ar(e*S@eM3(LsHQ=T~ z;f~r&V>Pf=zKQ8*#-OWZoesccdGR>KtWHaFo&l!sLcV~^;dgSo4%{LI2~kaIv|8^o zz(G@cL!+*Oc}J$Nsy3q9O;lDiidhs_jvbRJwrVFC$jZ6LtNWDcavjh!ecvsXuHk@txfeX8mQ?HA~vW4Sfmxv-F9Z{Wk%Ti!4>$`FpQ=-gpQ-c*x)-9PLZS!1p zy%*tRf2|C@Ag?P;%`CMn36PpTI?v_Lgs;FnN|0~#d3Qu}ZDBzy$ptwKMVLcMyw0M^ z0j#blF{|Uw@$r;mj2>LO^L!h?RRxk#Ysg#$>jMIpUYQ7$bc*D^J+CrDeCu1ewSr8(0s2uC>cVM` z8=L)|lUpad-9E0@`!K!zO{SA|?r47@vwNWBZE_Q@y_LBEu8ogFIaqfYqwhNdMouwT zb5hZE8hpVLfe)k4JRCa%Ab~Yy^NVLM0a4N-5~RZZc3p3RWIN|-axKSYv3aq^i!vG_l(Azdvy0WcmT(|N!4&!VhAGbHsFF4dbQdq`t}X%iWxgX8A|^wwr~CMbD+=R1lwSiL z*n%%4gsd>+_4_cJEx18Y7?zKeyWIkIoIzo5EhqNt_p#0nMtiCT)8Yh2M*F(&k9JMO z*6fe=b;ODR!F&~>QgUURpMqg@;3t{vJOvQ2L2RlfP%-ofkJg*8AdXUZp%~j}UQ$;Xk@!JDY3#W;gB%~b1li|x_sUt_4N*eP15;R(Lw=|f zSuTykNJD(*6Dcr5*{Un10-+gc)p}(;!rp4Ha(pi(){ixt8C7=y1L5T~qwA0;+`6e4 zOvY&Ke7@iHaj@9u{K4`5CVuu%PFRIZoO6hZH?5rm?ubQjcXCb((wb$R5>H*PI(eus zej0Q~5qTPyBR>w+d_QzJ^5IkWGGWoktft_ zh5dcJqng6SbkXqSh^5Sm0{Nay9X+U7qFb=jnd}dl-UolwbU&fW?-q4iEPEwa zmH~c7W>jF9mATuVWy1DMk3W#wq;D@cL zqWVotd9psbKf-8&$S2pa%bxn|@d85pZ1IOJ|D*rC?f(|=5}o!HhR+z@4yd2uT@uB5 zMFO0t=7Vru%+#eO-{2wUO%Q?qF)R2ri|@exIn#Ai$s>lBj0{hudJxHaq&GZl){~ee zql7Cu&G&wGBcb|o%#IUZ zqi3yPdQKAW)a?i-d6tZiYu?6gfA{!tuh49Ez3w5;Kl6RFJs_jQxkk@s7s%`UWDx$w zsDEhBm>-?yom()+g6gR%)p~%Pqk?AZ3t4R)LZXbR>>qc-G|DUJ9L(dD@P>0%@Hl$U zrcHt)2?IN;Nkg`D5^Vtg*36{tqKuVL^}H2f6WIxsO8UFo0Wi~0Nni!5#;8A(pRdL{ zEj)la=Y-xcu^FATG%TDudV1&dxpOD|)LLqBarxx(^$-3U`M7YJc|?BGpZl23ICF+)7eRWewfX>cBV!7*Yp+ z2*_aZ6-`Y{w@er+XV4R>YeGEmse9#&TBfaEbNW>Cc>`wvUDP&kr0Y z{9&&*ll&a2Rg>A{5&ALm-x2u<8kc#k?6e&|CZ6VP;7L?;)30?j0Lh|cobs4%CNyWW zth+}IzO`nX@52~c&wLLlzan4-%zRH(B0DSqB6vK>Y_K}fcwAB4Zzjt_bau9t6Va?1Z!F8Ux z(TRSWHzh2<<;SV>%Z;+F%Q|2a(pY#uUu%3nW||?4tH!*qJ0%zp=PzVQ-#dn~XVwa@x0z{hu$eO2e>#R?gV-pSd+=j5G!L&}8Sx!W4V!8h zrnyFW7i8~2WG*uk^64ycU9@7Nk5np)jmB#v9iUmxfSf}j8sIKVVb26M2PQ+!A=dWe zVUicE-^5G^^Abk%&A(0Bqy6Du7XDm#5puIO&rZvQge}{KvDj#}=psEBk;W&W9nLq` z4sKw&_Bycr@oT_`0X{7wE`&tZw>&bL{I>1+iKLh_^jrYbL6K>?IeC;#GcQ;#u zAdtbi$)$9<(*gA+{1EB`m}=U;)tRpHjf(5zp5T$9j=kjm;XM0?($ue6?w>(s%gB1p z3f_;*4E8m2R1X(M~^$IUVNF5TzKH`zUfaF?Lb!AmE6@AYGK=kfxww%olEXi?nY?Wxjk`=0`DLw9` z`EuOn(HC}vJ>e#<&&PMS25v^8Dam!K`2|5ZS3P&2IKN+c9!z#MU4PAUu#ZU&=sTI| zwBB#D?)Cd0Td^p8*l;gJoxoJ0rcrYmRa7alEkDT)+LtF)|FpOZbpLFqRL)f+Gb*L!^o)DlRI6i%Kd;S0xSN zx(5Eu7HgVhluTP}QpqeZM=Uv_q}1a)m7kFN+d>62iSJi5(8@}Ap_tD*WoMQ(qpI1Y zGIOBqd6H$1x^Uwpm5h#k{YX$`7E2_ZCb*bZqJ9I~tCTdU+!gaMfFh%BV$K@VO!mmMu z=#BPUiX|bNE%gg)Ywc2dcKnm$n#tj_LNfj7F)e$u^2@s z@yY4ZOn1MIH{B6B?F~3kljug>+H}j#^loBogX_$>zI2~!FIQ{*HyCQ9IocA$3&{E2 zE9#cQVR(cT4Oyd7I>~Ql?&UX9fh`zhCLUkAiqEzF)5v-o&p6&pZTjxF?G!wiASt~e zsY2|-Z+S~xLHnZcpM>8ROu-%PqRg~|R#2%Z0ALi!vg%6x5LO%W_27C`ya80%Z*>@4 zdzx!6+J!{mGUfX}uUy$g=mtPA$aJL~`acrC)P#4`~u;(3=Zy*F?PWG0aC zK*5wjp=_7y?rOvCYE$g)>Si~oZjwtUQW7agnIT6SNhC+4$g^@gIy<|Rs2xh0nbnL8 zNm2T-=gc{pJ+r%_3`Zkrr8PhH9B#x*g9Hjq4XO%Apirn65jXDruKRA*mEk3=w*Y2l zm1&!js~~}*X?SP_QhGUV6I;_m9nR?smu{J@<__?@Ctx?0))Yz6Zmf<-F_f6$;AzL|a1;Y(Oq z`LY#!UeNjqD#y>FpR6qA!~;(|k?Kaz?C(p%-Tm9)?#_#-{cN#l%rxtD?yc}cw5-U8 z9@^ehPgArDB|*0&j(@CWC2`Dt8(8-QugJ5+eBV8vhz5fk$QX)+?{WZ3>ee{ok~$LA&uDy0qe{yAkU_H_F}#nMq_%4UYga7k z4FAio>W2C&5oWrA*e{`m;h|EeGs~#zI-0TV3L31D&n2qWJKTn>3n_?J<9L>z)$Dw? zo0-j=c+^35WP%n!n>_xbmXUFsF!6-71eb^t`KlfS=1Rqtt<@l%QEw%^&9^P!RzR(9 za(6wlVVJ`{!DsO`#fg09#_eCzDv=%tifa_xzQHZLI%U~{%s8@^D3$Gs5#^Y#%ifG{ zD!Oi~nII~>zP2`q1${e-e+|xKTCA|h55(aI;_w5hI@Qxwc#`(v?N8#9ge}fl>dds3 z=~qFc?`+&Nyyqw-PxHfgS{%lcy7_OEV3s08Qww%$%8>n~spt54G?tf@*+_K^HJ(dh z-Q-VO;nMS$((fniV`+{I+uK2Vs)6}!W17~GWVz8S7R!zDsm|bW_OZtCgXxW(i^XG} z@6J06?;a(ojf>o;wYamlVS;Nx^f{Y5NS{qY4&_hrXRXf9b1ToZnqer8r8oEw95B^E~Tg~TtX=}05@teJ#AG(%R^{XdU_c%)R zFo8>dFmhuY3D-{1=pP3Tj5>Lc?*%(LCI6ijJf$N`J8y{?IH~4t@fE&Ex$xL8z4XtT zjHyMlnC1S4JyQ_e{<=bNI%JBTwtgkEl;P)~&RX2Adbc(<5{>_VNdG1AsXZ?79K|}O z8OmaqVXam!hYN8t&gH`6j1rTPpVMs6n8kiFGBs96>*9G6a;cYI+5oYji@3C71^-Gg z)~=Yo?1ZvUST?k6H&~{ra8UT?M}BA7sKiBq}iIp1jA<^L^HTo7xl*{a?0`+`2yaTz=97frm!4N z&4b0VX@Uc;WROCOPjZQRLR4~^Pd=H;a#+9xYEBeCUfQR)G@T-{ou8kU-H@xx@HM{_ zXoRXcovMuVo6RI^@K@5e`7LfGRzcL(wcC-EF}QCLEv~Gf2%X3pj4kl-F7ROyp?b22 z@DyA97_b2=dN9gsWrCN@T6xAcGj@vF-MT7s?@xUdSY2^#7IgDM_u0Dv&OaVCEEF$B z5YIv+D~9eOxlNsrS+b&O`XleksX?U8JX_ZJ-T%M`gy&TF&VQD^#aAZqg}^=Htk$+O z8b=q!rRnJ+M;OJ0#l>PJl@e*O+2ZVX>03Uf5>yD^!(Y-K{?dO0R!^*^gYAP}46H|l zp16wb`;V;iC5pdkWBc5~!FgQ`JvG{nxRym*TQ4u9Rm)I{3js+!f1{rgb*J%}M865P zGn`aL3Y#u&)J8HBNo8x;rWZ5i*s|KqpIQ7?z!EAgK#;&ncvw7H{vyM z*O+bh!rp&P-PU9T{1o_gf+WLNhjoODNyK|xB{<-gPVxuhJC9R=UI11`>_(cfcIHvh zZpz$8X0U}BhFQ;F>EC z@&~t8rQR;bsnM=i1BQ`RBq@94!iC-4Y^m9-)%LUd$4$vGl{~q7V}m`~n2`eLPqMyp zr(hKNn{0?m`Wz93wfx`W3hx0(+t@GznD2vW z?v2U{Mveo1T5Rew7IJYkFS1l2h_{1Dd-GlIDUA`*9=8UqJ)2uWMYJj-jJRWJvC8@KE8dk%hi90 ztN+q^Z+CZlyL6#_mdjb`%GBZ1++3;V=dR~ktIJH0ExaD)+&Pp74xfA7@lU0Pr zsyI(FfgkIm-ryp7Pv9lrN zsnHG9q=i8A@QO*3VWU#+Fa9a8J|phu&vJ0{%Ow+VIh+1W8*slvMVjmfzNl(ZVoxo9 zE=RZ>LO+w;TiZUDwtwwK4?tu9{|)h+dH$T=;>fo+s=k}*&CFB_N>(AHTJCn|s&fx3 zq+<+?s+NZj6S*}``X@bc*S-g&h#=4dAQwZvr*g zY-czPC^K0~i^Uly5|m3bQ%g(Lnd-xj|FM5lzPjJdd7>CH`-%nVssTAJ2Hv*UEA`25_aWhsuQXO@@i)AeJ^ zd}ys>J$U>APL}vi+_lr!yZJI-pQyDjo4%MHvz;+Xr(zz2MnVkjy(ENGXssPY+Gtg0s+cU}eNPzts23ArsGsozeyHF0@|Oo(n}3;9O%(>ct5XXLb91a; z>(yEoij7zCD} z-ptCXNWIns=LutJnXjwH*Q!&ezJZ{6!Q9Dc zm38(G#?8Oa!Fu#0=Juy*xKy2E^RBJqHIu{kLzT)DXH12g`-G8n&h)9aNu9xY4(cLj zIX2=xlxsz^g~I0Qnf{sm{mn+Lrfq7cz@I@M@^Jdn@s#8d={iw%~u!Eh1x& z$XVcKupF@UY0&JF5EmOT#mx$#v{Blk__}WEjr!6QxL_G}2J@Y0ad*X{&h)FgrS8`) zWD{9NQ#;G6rtGfIrQ5!2&1F?nE>$4607|p4;mIx2Rp>25N>0E?hi`z$a{PT#aTIfp zYkJ#*Sv}=A{KjVI3$ukv#mt(=g}F0iC_GJ42Va`-xQ;FQ=eedW8s2u%3Vw(`|BU$b zUBg8*!9|S)jv)N1f5}W!d}w$Nge7|)ck;Cl|4YlE+6HxQn{t`@vU*qWM9M=BJMrvQ zetvr*MzRsNX7`F|Z*_Zldv7mBo=0PP;sBGXo>6acw|{(w*2!k$ae7rt?Efe^GG627 zbxpfOObm%@$fZK8!KyXj`Kx@ER2ADM>r8O7qB48bkFdP?p3zfoMe+uJ%;;2eih06&foDp z_-4{?-AeB7?slParf%f3slryV>M1qC7d%V}hQu@en)bXNEYD7VK3wrc1ioZPs&8LjUOqc#SyWx4&UHgBGLA-Xp(MBTH%SaN9D0)1q?_B# zotT^Yft`;_t5@3nE45mD@$%*P+`&Pd-P$j1?4Lbr#^yw->6q{QkOaOB+rfxyfBfas zc^}62bCMO|J=~rMM6hKAzrwfd$uh&{$g!*rc2S@UU-uV|u^WUEMV zmw(Si@m7gFs`4LD6H2KD@sGF`mome<=0v^=Wu)slH&w#2Suzp12C;2Z%~Yc(m;6p< zHwj*mKaf<8y*EWZx!QA^l9cL=z(Qw&W3a2KM?7+q?H6MbCcgY(2yf7qH}DYmWLb9| zlHoc@GmN!E2W~EVdL;w7LlwwYAg59wk~KU{yMtMW#gdBnzxiRtn(6A28l>Wkf4|^>TD*IhyCcY^0br%c2n515O|jW%#{8?x>*pSyk5|hliEH z?B!#{9YbXIj}v$Ns1@7-Xy1vRc9si{m~2VNS_`(?SWFmr8gWnQD^`%1a-!b6Dgz@r z$n-XC6mHCL10M}%@eMr71>x=FX1tacMY?Lt8;(=es}F%RVbdD}r+IP0m-sFZ%I!rR zg-S$l52W>ZK~RLw+2J$6YUIh$yrI#>Hf+wZlG>E}W_oaMeY zqQ=tHT*pGr+$gYu}X^;rA7HpVpL!hm7>;`fMNWpv&SpHFpjmQtLuWcN%3&d$>vzEmgF zu^2{T+)p7*s4LxYp;uIWo&O+W>)z5lnC;Ouv;$^T!2q~_ zcIBU@h^*FC2kR__krx>jo@1n`Ram4(p_bR5aXpbS7#%As5yVsecrb{9Ym9JO7JBO6t-%>9Rm@tG)fHxt*!==X-Ji7qZ!& z)H^YuVch3c9&t(}ZC)el&dJ7ae)`&Y&T$@|w(Mx2vi>L6?VYuPr&7zV1DA6O*Dv*R zaI?4~N>`gDP=j*nwS!Pa?X&Pf@3!+nI%8Km`stN@g%^z}Hcuw4# z+0CiVogKK;?LsvOpcu%x5BDF+jjXhfG}O@zSQ;kewMICZl^)gAR7M@OzZ7yYG&g`loZNCB6gqRg!!lI`Uj)S)Q(e+ROE z2|@!RMZx?`-{s+mBd9Jn-9H-JgZ2knc_i6E2WL`PJ7b40M4*KDLHY%$n^KfW@s`M? zMT!#pWNM!9U#wbSQoB3x&?nL3h>&8?j5dMFPg~kxWp(XS+$-fk?*fR6DMyU#Y@rNJ z=HHc}XCz(r=ggmh*j03-fS;zm6+a)l3`bUQH{fbt=GXEzA|6*%ipEU;$B{19OYO~! zZSfzY83AJf%t}Mq6+P2}>#-x96$r}6BYvMtrPi6nMZe`Ar+XaCfYpw-Ee&hD>q9cA zkxp)$Smg2cdF`D6(cfVOOOD&BKv|*QRJc_KhviC+u~i*k))8ej$F)|WCkD(x`8@q8 zz+^}?ij+@N*YZBeG`{9K_bNQ%*g=G3qry}#4{<7=3bB%^yoN-_s;*n>X;)8XXorls zAf4cwZzU|KYO?ZkBKl&+af{|SPH9Q9+}c@OEVW7}mDFSf(y%ZlP><#}HxlB@QAeS6 z;+*-*<3ikdjkym@Qu#$II0RlzmjU;-C=TZ`Rb-S~f?aajyaXMVY`}H>i%>VAqCo|G z0lSv{A+C)|R^|l^%TH0n*nGh zjB*xo>ot#k80v$blI1wk6t0~}WKh9nsB&5V`-vXpEMNJGw6vWGV>bX!Z|ZXnhy!P8 z$_Zjw&!<(x(4{1W({>|%zuWC6Ce-wC#<#`ZXeO<8JZE_Fa;w^|bq6~lA}Z$PESd=~ z+!nNrnwQ#2?spHT-|P!J!{4MjMqzgK{w6yx>DsBe*MPz^WF86t#_xU zSRbB3_0TA);k!X~((%mAKcWoY5`h=yUYeuM!8yyj&+%I_x3_2FgRYJ9FB!mmN#NT1 z#C!@`EQzE)qJIfcuFo_b$-h7x)Ajke1128Ck<zpp9| z765kHp*r#J0y0DEwvDHb;R^~oDVQZKy6zyvF7i% zIx-85{eJ&vxsa(c!>kjTHh0EU7BJllrG&Rr^cR1VqoRg%Al;OnlHL^Tm3Mb;yVrLP z-O5hGeeTUSUwu{S@9Zc#0IvIr^2);`K*xS1YHFftV#Yh_Xix$_@02n@$CWd<}|Mc6uk2H_V(Hv^6km`=Qa<0GJxN$G${r3p^<#P9 zqehb`e7HB4A5yO|A-Oh}MBOxeEZR^J$E(E{xFwP^P#EfT&Y*7=jAVYdh~*pJbXfHf zhg8CD{-SQ;nS3sGG{?`mbzgDXUdutLI6Z}aR)N|F5GivgAcmQJ%pr`u3LRI~4AbMd z$VsLje?6J1(3iGF&+^7fr#V-f>vS|nlC+#@YDzY{s4bqnwv#(h9z`#PECdrw=1K2Z z((-paiR_O?dy3Ux3V{6*Uk0!qSnnbVP!vrKKlWfZ>w*V-(Vygt&e-9Z6e`c4JP=bS z<$Kwe5_ji4FpIR5ueMRVTUVxPe~sIKBjh(!};r^uOCS!UvW|` z*Qlm+xec<}YE6>DYIqC)A8|uXbP;KJIPQHL;-KN$$E2r;zB+j0kw{oBS+&z^1(_Rs zRd3}G%H?3n)1dQevfiG%P<7=})BMf(`Dd7^Dmt~D6>4yYO;t!hUhb^;C^ffshTAU1 z%`mRP8j1|5IWr?_uFRxEGpPOBlL_y^>i!yE-6HKQT#640Rn& z&gdS^HX7N&{z#3FSt%+b9Y5$BEsL*>dJk7W%|XGZkrP7GM8N!%W`4l;P(t?R_0up}mx5Uxlp#Z%Q~=G=ye^i@*>#+<2=re>dC zUS9s~AV3yV@M5>0fpNEUAwvl3q#T&dnqP9yb5GN$h9)v8R_A@%Gjv8}!>|!oY92KZ zqJi!ZLqpjNQmu~dLc1F1kTMsl8m8O?GexBo8wZXPxvq#Dm%j6N(*Me(G9&q4&iGt% zNs5e>y?|kf(j9Bvp(0K`{Cj>efc0ZyXh?*&glc$#{{ZqtWNW@|YLEJ!uD$!m;)rU$ zDv(Z{+v)#}+i72#8+ZY3CFc0KzuN2V?P-1On28y7z%Ct|AU$XY4Wkj{Ini2ojQD%P zR(vT-{;4Q5{3oJ%G=RCsvuU5Ylz1UU0*twt7OY1>HpiE-&HrpBte`)vnNWu6>z}~Q zt4&e#^16o&{5*34-8anowrVEBGz|qP7C;gw& zzmgnLZJ;nF8chg1qF5j2367-^GheiVCwHCb-->wIxF*s@DVL)S*!mBZoP#}^e zA9}-}zsXE(-BYdGHURl-7rGFnU$BXWcZbjLXVQP-cfTbvJYLZ#D#`?N{4ld$b*G=(Zk$ToFQcMUyq=aB0-<->r%b^$N>{ybHQVtK%J&$TX z$9x&V;xQ7uNoQ{yqko})1EnZR#%psoo4gu9pA;4iZ?j^D7sTePm*H+giS(n}+ndO; zE!nVN-WD}5raWbM+jh7UtGT^{6w2)krf=$r{Sp%6Nfo}9H4&MyN_q>Gq*l)NOQlY& z)tc=bD~CQFK^=(D(NY@2vdW`Gc+&0e4^E+X^e+yI7*SdA7efbOphDTZ@b17?BF(b> ziWB{UK^^N;cKDlOI-alJ4*B)s7vUo_p{7tZPR;1~LZJF_>tl=kjEQQs8Ll3FL+9dl zW;F||dz;&gAenHd(;LiI7|e<_xxc<3ofp}Y%50+;X1#2&*kA8cR?l_(}a-Qy=7m8 zHu7}5YXx65V-#plzk&0QrCe0x?lCH9FG1x6L~4v_dFH-s@%t~|;DB4uDh1CIQR~G8 z1zSO7ZL}^Nd?~{qDJvbrQ4pJ-C0SecN~Rse7?NAb8chf;>E^J z)@f=Rum`IqrHa^Ih=_uM5oO zE0JAXv{5@dzYG?qJzFREKC#~6dA_;5{C&<9?&qLL!)HwYHvsW1L4t72PeW|ERB^c9 zquPd-Bi!H4`m*$vr5m=y2@OF{l1@Whi z@m2xjHb1v2heL=u*X#BAQ}Zdqgevz(Iry+W7ddg1g()|jC5Eyo2nLi5VpJntBiV$I z#`e_6g*csz4TzJ;0f9xig)vm0I{8#8AIDNsVKJDu>u_ma6L)zgD!Z$Wq3f&te2**P zxz$rio{~zwQEAuE+)ZdMKT%bAD9SR_FbXT>IoKG^c#Q6>f{jJlEM^>jZLb;L!lRxe z)D}{0AI#a|4cUu*By;achO%5Mhv*Lf#Px5znK$@N>&Q}cA zR&C4EY{jw|GOFh=cS=p^c@n%MVMj5F4Sk6KUo8y#?}55N-;d+F#NLNHFU$90>5V>KX4IiwQUqeHVUQ}UEq2-6)A<* z`}g*Kr+EA36q>yP@t$Y{-ZQ+%J-%Kx5BImcni)e{TwLp}ZEeL# zlSn+9o#v)zC*C>xxnq~pdg)C?J%X~{)VsbijOTe0LG56e( z;oag;EwTK`WFA4?)y<0%G*8f;Pft((>$(q(YRX)q#wWDcc_d{abGBi}fv2k}4y9Af z&1#ySV{XpyO0MG7xY|g5rxD3;KRA;qrPESU3ptofl1;i3HwQI*BGQ>I=OXMWUKB05 zCZ&sy93C!Sxqf|d_wwc1V(rvO(=j}B6i_>!pBhS8B4ZP?q4`Y>`okM5#?e2_h`|4R zDvy)e2vI-vZt7G7C<;KnB1V%U*<1%s1nr6|dxTlo)rnrTVU(K%z`P**Z|i`+0aeyI z$tF-I!`!}$b=Eg6lgpcPF%Hf-mTXStbxSkd_y6yfpxY$KuilAzzD7*7<7req%x25^ zyd=@28}jdm)wUwXvmmV`{dCVtFH8T8^xXFR{oFnF)ZwG;8;6(M{liZC%;6#9;>w!U zB3IE&_8SGiQehVHh^D>DUOio008>D$zfZI+oroz9`Q-+@5b6=V+|k~3M@@9YYE-`a z^>LWl?{c?yaTH!YRs=SIRmb+>*qJ>m+V50u{f(t1jVZFq3HA{hKQJepa1Kr^89y3R$OqO+x`JXqq z&bFa7eeFY9+CS@h$gY++{xGGaLhK);Z%e<=&vH&$mUe`Hypdg|3i|QjHHsZC~ZmS1kyj-J!7tS`)0nIGFwT%4vdmI-X83o zv_PE`QusEN-q9$jF%Y&J(oD6+Z_qd+E#^0PIEYE{!;5bn(aBF})5j}$0tx?SKxmNx;20xJ}W z`KOTXcrrB<$Q%!w2FD-4cm7WLYi?^s^U7wVB`yWGL}mE;jlS8vF=w`JRLz5%Mbo^Q zEfj91GMSsQ??=&1t}}=1gQo;$C-Xn*G3K^mW9H=aGHsO3iH<;L_b5p;v5li<8}+q7 zWAa&IV_@I6If~BU%6;*64%#kADlgANjzbf_@r$xb6@?2_Gl}1=iGm}jE2-V^Pq-hf zQbXI|o+!micqL7|II$DOY<3E1TuEUOJhdDSOv4<9u@GsPl^m+(YzSi8@R1E9iAdph z{)_Z2>5n+N8F1lka9{embayZx;?W0>sFxpHQ1>5fsrT0UQeSjP)s|`;<+Zz3w^~gF znYm1-^J?m(GCcX=qy!G^@VFsv_~M9zdt|kfSB(?Aq!Z(LCc_k`3GeWFe}dcSEfI{{ z5JaSApG)piLR!+_RBa7dA6+v0AKNmwZXzEi+3_=;2UH-dn)PE`qAeNIs^IXn7nlrW zh&2-TvzJv>Ay`%IyoDoKHM0h$E>~QHYTUGI5z)DASIJW`e_PM-w`It2;mzIch^1jNKTXaqgiF+-5f;S8k73)WbML9_|$8R zYdA)+?WMtpl;n8Ze9wb~HWw*6KMo7Y8rPNzmVcZyn7f~+&ML>M-_MoE1&hC%PkJ~( z{_^F^cg;hMpHc|YmJ2SM;J@{Ks%0?MRZQt8ZC6VNAI&A@bgG`7RbA$$s1pm&8Y7ZI z#PL4t``puNiODFJ169W&LGRCMx%0v!OR`vhoJ`#5a#VGJ>xXxW+Ksg3R%DN%Juf8SI)I}URiHH`(UNrez3T_{Gi_JJxC1RgA&pTCo_&^k{1pL#rlyuKQZEoY@te=Sh)j36A*#lT*{ zPx5|-b}UsU08r1sSBxd|<%HPtfeDNU4<39>285$owhd#FFd*!pY?Auaen6!0**X!1 zR8#!3{vQ)dGdvxIqRuH|1j`5mf^F`^+BY$#>NHikt#w;U5M+X27Gxb^V^vZ`R99pj zGZ~qNB2(Qo@|3C`<2K22EWi@$z`pahN!1tPXS2@zUtt@b=eFT};&k4;_j>1*doOmb z+`G`(xwp|-xz~HF4mzFqtR%%bjQ98BOvKo8@pH$iB#)wwr{9$%0x%#!sQ3wd9j3!8 zlXTOA0ZUPmkr~GkggqWj89f1Jx)Wfgd<161tHIITd-{QxR)xS-BaaLl0qe(2_nL;6 z6GEja9|SrfO=42pj-Vg!WitR{Qrda?Lo!fZMboRv;dE$Hl7a0rb{Yl@h#`W?Hnajw zkehtm_Zwcz)u{g1ZXHGo2SLc)XqO11QMVLQa}}BlhrnfwOqo)K%#i7(iwzd;`u+!j z7YJjnVykK}HDWMJNhq6O8;R^ap%7uy)^uYqi(D|$f1RJ>f#}5#m2GJ`H#4)mxSn0# z-kw{Ydni&d=oB6gZsJPH7g4i`uy1$7bT=SPPj)>yo_dm=90rAl`OIGlOfHW6ah<+E z#vI$#<3bHI`DM8R=7>d*ZlaWD$*_$YFpYqF3Bj}m`=YH?3HAdVaS0W%B^f`WfL z4GKPy%|g+Z)4AF?kt6~&{L<#ku)S2i}Lyr~o0G6;}&oXP%@BYKocCrl0e@c4cm zODi00Ow`vB3-E4Wbp$M#Bj&lzVM+LYQDxutw4XUe53hy*FhwXc6J z%Nnt*&cvZRodKSlBhqljz@H}UC{)Ud+%5=1nafd1o}7{~n(Z|V3LGiP$2Q=m<4w?8YKVyg`r$fH~tx_Q^|aX!BJ=pypZ^rK8jOy)szk-4nX zx#XBl?l4cgs@0!{xsB%oq9j>zJ?Q}Zn$#3kF&UB~hGCcFyo~V?`}q+vo9O5{DrXwk zzK>v5l4<^3H?`Kag4+jn_*z`@qhxeMR}A{9FPypcwLqJ*g0>y*Mw(z~1aw0%Bam4L z67vA#XabW&Jx=&vT-3QR4Q57vZk`9_Enu0<2l()9UNtu6Of~QpWweI7Dvt7 z+rLZeIffTF2DGU&(}1loA6b?oJN4HqqE7dt8Lg21!pA;#2#gNI)mY5+a^_6ge?;`a z$}-T;s=Au69egXv;H*hciVlby@a*n|?F*MK&7GapW{GUnM@$CEwHyr84g4jM1mJez zZK;ekt)VSFR9`g#Xp_bLAmA6W#C-@PN2ZLB3ZM$3v<%BYiLhY0!e+1lryZLC!w0JF}J?Tul)sINIqsOw_?VG|@@)2g#kjF1+KkQ$F86 z+h1F2T3Mp<`6uef;bOxq6-I;;01s5q3B84%`TFv@A{3#;^49@c9!Rt*Vm0jx z@%v=*PHf76TC;Q7l~RwS?mrPHGHb34D{-w0+ZCS@+{sgZuVP*B!lcQe;iu?VwMK^F z_K}={02X5-r#!2)BW82#NLV_{WUG*!fq07Fw}9e0bEk;aOl4}G)q}W@VQP~)^ZW`v zYHK1S2gn6e8!|WibJhXTZ9SR$#^l94rv)P6{V+N!z&ohiWrsc$T~A<5lN#qzpc_U~Bf_6v{8} z)Hz1#S(H1Vn1#Kir2}q$`KiY`ZVt4~O_vzDqRRNZlki*NawGSmd}uycHFK z%{^lA*SVaH;vh(ivaod#KnFlK6pv8XB)_G$66HI&GbytKpL@{{!)61?Y5yv*l$xJa zxZ1K(p`)pl=zHRb5K}QN+fog+QV64b1ngwLs5acMM;*=J?geD$>}0?6;hZ87O2nF?Asoq^^n-ac39bVP$pIG!r8KgF$O*4!~j|~Ten^ETXHjGcxrf^%CHIo zkPqw}{Dq9e)HHQdFk0mM*N6;(&2=kVaAda{^NV$P0l7Q3LtR~5Tx+f2HBo`OCHTKn+3a#)n#+#wFAp+~%d&0PmTRY6u_Kgr3RQ)6 zlvZdAjP)=;a|$=gTO5^c>w*n*kH5Y9{igpxgQQBZ5`t`Kf&w%tw@UoFiCwXqapqki z(JkdU$Iu-6NBaGZ)zw!`AH8hL;cZ_``9r|m_f!`;3lJAGOl*LZREYn1QoAo0@RQZ5 zx;~vzl<6>?o*ooVPdh=dGQIL$S9_G^8&Vk^6}}0A?x4{*)uO9Ip634mtb@yj_lj;{ z;OsN^xeLU6`rs#vmfA{UQCz>8y4@_GG6*uh^`5S)+FORlk>95yPiJnMm|%6a?yIiH z?Qt>9w1#fa*m7`}yLv=HY{-@Oei~LcAYQk=Jh*Q?%ch5 zGbM##?q*IpwNP$!Lni9vyQfgxAJ1|gkN5jgb3 zPczv~rP2^)%^>)rTn34*L9_~@B>?j&M*akI;F^+mPD;4#$ zNx0f^y55tnz-YHbry5_Ch5eu|@)?33hp5ga{~oaR#pdm5&g1+0`#&kv3WsJ~*#^1L zG%aMXk{Bxb;ax^?Rf}~GW9As#*u`9rTcCIb+p1@3nH{c%m|QM5OQm-;J+5Y$JLjVK zrU8`Mh<>O1~O^0}_vk9ey-A(RX zV9~Q;#Ms(mZr)^3AmZS<5z*NuT;n#4GLDO`(8KN8Tf?VY1;1P?+;_sLdX|h?+iI;)6Z1ra}H7@F0(LLc{ z19Knc(8adwa3eQVf+HRO56Ld?O3w+V!Drj$nR2~;Bg%F7WxcVz8mula-?(=B%I(J= zzrnY=b))rCPinl$Hp}r8qTz()?^a-)=s-np9 zUEMbPO~&dV)MDoLxqO`+6@WCj%WV)>$8`z=ovcvjKg0%bOy;ChdIBIQwQ5|Z1&!)w zBM&P8YO3Psx&xwY7=;eOA#+>Ity9gfBGST)`>qhDjig@ggAgctm^!k)#$mfjag91l z2J#QNr!VlAn#>RRHpdS|sV#N6L?3cpu(+MFuV30)S%C|4bFir7#r)0M{QP-%{&bT% zp#df?^U`2k#CW4l2??AWZx9V@x}s5twcc3e219TF=3pU!d2U>CvPElIieD8o*po=> z_I3o!8=_tHy5?NuzVyrjDA%dp%qC5eS?^N~FTqcB2Sra>#1K9E);XUzDCF*p>l9oO z_)c3YQQMWu)vl*pzNDs-;t`9gvuz#G(p<|h9aYnPJ*g!Yb@ty#_N*`65fVB>7U$-! z)nvKXyOu6$#cK81KtvkA1SvhI+x@g|_j1(v z9sWhE+vNf*aYU2xthB;tkze8{Jqz)iU@FHoi)mlqYNWK^$Z}*-*6mr{o}HmN{*uXJs@{#5aXeED@_Uz59DwBQ{c zzJ6w!r@NK8bhPuiix5G^iETB79r5xZ$L z~J_fEbC2C+gVX+vdo^E`8=EL}L4qBqHN?b${!8$=mhuM*tX)fs|s z$VB$J*IHxllkRlr8)TN~T#+UNGB0Lf-sb|WXJo@o2Mmu$@_var2v|Cl31M93nOkI< zMP;O@I>X%gkdfmO#c?1OMaYEX2s5x_n>N?YT(t3dTXht= zEJT|Qd=zCoeHTe?lA(M;y1hMDsHSEkZxJ{LSFfJGu_EE+#zOYDv*5QEK{dPR+>n-+ zr5ny-wyL!*J>E)~P6kW}f(+pacV%U8R>LD3#|uWeucO@Kk!*+EPCrnC`XtrYfKy`F zQ`s0}ty2QCOO20iU*=k4Gf6dWFMQPS4l2~yay>h6ms*ZffFfgs5p<(zgEqH+&Gvvn zx!~tLhQ$zD#sUOveFR6gJJ);QFn>Z-*(9LMnZk7~(CR&|ih)FNarlnj~TlztiA z&t|_20X>hg<|1PW;)+i-Zo1}e6mkDo*G1sJETjj;Yx4eagx8C#qH{SoD54|;K7;nk5v(LWu?CF8alYhFW&2c)h=_ia^ zW3u>U>Dc6rqvXz*rfuk74e)QbbNV%Tx0uVxZ5J>XTl{5)Fbs_B832re_^MItEndxb zi#x!YiD6V{RY9}G<-W_@1E(6e$jdMYUWj4FkGZ47tth2wq0&U+UbkzXG;(6pr)t>> zR?oY!s}ZL7jzTap^A`U^4J;XJFQ(I*qBP~7Waq*%LA2%~Ld}E+B8WuHPtS*a`+F#I zpKw%)MdtA;SByxXhd7_3T1y~2IGok|O{u}pPOy_#J;$4#&c=n1t9qKH4|!pO2=YnS z^;CaV6S?^>dzqOH8*P;qcEILz+Th!DHX&UHF*$1m@2n7e*AH*G&o_!i85u^tfss`& zay&52T_r}foE1##)~-J{EfA?UtW=hhin`1;omNs@>2Q0bjOQK>Gk)X2XanteVt6C} zFf>5}z%*S3j9Yo)L}1sjEoY`Q4>EWb`=QHTu+gepI_Ud`|9 zGR2DxZ+oW3y&}WQmK@~Q=2uHGT)MR32UL5E-LF#oP25^=OiLNALUb#~QbAf5d8VGD zD7D#EI$aBWf4R2&P||Gd@eYVe2hqo2^~pflk6MpUUGJ;FT)qx2nYs+@{yN2%v6(~6 z^W;_HqpTZX#n_@w4#lOcCC71P33Q9$7iJtn9xuvu(^2ep7-9}yb)z6}Wvgo~!~=c1l?^^5J3#LmUQa}O+TdoOP=#Z2l1>}+2STji73SvDFjKVUtUuiW-HN9e z28ssxP0NV(R1S$I(1h?G$tOTNRZr&&@YDFlbKC>E&Bc6W zGnq*H=SjZ7PS~>6q%+c!0=HjNc9t6Jg*b4`gR|@FJ6uzm=FOd(C)?UOLt5ly^~V~q zI0A@H4w}$LP2>-yzXpEjXjZ~$TNEt=LXl^x+}hqTXzY=khcXK=G!>D$UUO`2Of|XMFzT4Ec+-^X|>We>Di~?D_g0JRKNdL+#8;Pr`$ z`3ViRGXE=C(1^lrV?E?XB?GjvJBN4Q(a=XN-FY@8H*HK(%6+| z{d8@4Rn2(Z8Lm_{S$Fak8+jb`#{ukI9dNY#Ew}G~GOY0u^8J2MVcOhz_r3SO|J66% z*m>~I7hc#|T!~i{Wv4LTn_phuIXHXf%ua2(Jl$@;we!~L=i;3B(wO6XIBt2!d^16h z`Q4{5bd!<}fqECetI|JeIe)lv@BP3?B}rj~ZD4`032MG)h$+s4m}!VJ)Zzb~aV<7d zZDN^9Dqn!gztFU-y`S?9u2%!C{pi;=qonWfFNSe-COfQ$T^Q03*m@n-DVBQU_%sY8sy%B8KP5P8*^ zYb7k}nggJ^%G_HtKlUX=t&8YeUY1^#9@%cZQ(x-LHeM({lRemecJtX6UbrTuH0|2i zCwHHG=9z1^EDss>) zd1O$I&({$c_nUl=i?9|$KjePJl;N1^;)3D*zDbj#V5=TeAw-JR^>FB;fVu|v`xZrc z?*%HilN(_`R}&*kDk7iAP0N}{?0fO6ckMu1eB{isg`C>#-ZasfD=RC8*X3YVByqhL zDN{tPsjD)%;=r_x@D_J45Xo`@Ha)0QsBn|QO_s%fI=?)Y#_(uD^#3h=og=3yQAZN1 zQB;(rT+S?-lb~XRC)A^117c*vu<|WI6i1s#|D6@QAIN8PTi3B;$vKd52v&EUYw)ox zgWgUPEE*uppR)ol)-Cl?mdZAhF~HN^+cmlSCR)`^S*Fx7x#e2=&R=u8_3!yBjiUmN zeteTEVMzIppp3gr6hFlLHRK`CCcVgEuzvr|H{bkz0Ic_jiKEt=zkYal{r2tmu06XU z#y)-`m%?e$!54n#FB8UzXxJmQDl&5U`4nl+&%En`OO*

}@h~-4i_~V>_5s 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

*>kz@IOy3D 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 literal 0 HcmV?d00001 diff --git a/testresource/ark_demo_img_1.png b/testresource/ark_demo_img_1.png new file mode 100644 index 0000000000000000000000000000000000000000..39ae3caa17786111b60ea3fcceb08dfde4df6623 GIT binary patch literal 90493 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 z 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 literal 0 HcmV?d00001