commit 8392cdd8614ee999a0e61d9c30e73c50083b51c5 Author: 陈子默 <925456043@qq.com> Date: Sun Feb 22 18:55:40 2026 +0800 初始化 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 0000000..38dcab8 Binary files /dev/null and b/testresource/a.doc differ diff --git a/testresource/a.pdf b/testresource/a.pdf new file mode 100644 index 0000000..668f41c Binary files /dev/null and b/testresource/a.pdf differ diff --git a/testresource/ark_demo_img_1.png b/testresource/ark_demo_img_1.png new file mode 100644 index 0000000..39ae3ca Binary files /dev/null and b/testresource/ark_demo_img_1.png differ