初始化

This commit is contained in:
2026-02-22 18:55:40 +08:00
commit 8392cdd861
496 changed files with 45020 additions and 0 deletions

17
.editorconfig Normal file
View File

@@ -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

25
.gitignore vendored Normal file
View File

@@ -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

87
README.md Normal file
View File

@@ -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
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-bom</artifactId>
<version>0.0.1</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-flow</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-support</artifactId>
</dependency>
</dependencies>
```

856
changes.md Normal file
View File

@@ -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: (\{.<em>?})` 在匹配时会漏掉最后一个 `"}"` 的问题,感谢 @狂野流星
## 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
- refactoradd DocumentStoreConfig
- refactoroptimize 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 openaistability 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
- featadd "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 的测试代码
- 文档:完善基础文档

232
easy-agents-bom/pom.xml Normal file
View File

@@ -0,0 +1,232 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-parent</artifactId>
<version>${revision}</version>
</parent>
<name>easy-agents-bom</name>
<artifactId>easy-agents-bom</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-common</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-core</artifactId>
<exclusions>
<exclusion>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</exclusion>
<exclusion>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-common</artifactId>
</exclusion>
<exclusion>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
</exclusion>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--image model start-->
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-image-gitee</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-image-openai</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-image-qianfan</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-image-qwen</artifactId>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
</exclusion>
<exclusion>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-image-siliconflow</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-image-stability</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-image-tencent</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-image-volcengine</artifactId>
</dependency>
<!--image model end-->
<!--chatModel start-->
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-chat-deepseek</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-chat-ollama</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-chat-openai</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-chat-qwen</artifactId>
</dependency>
<!--chatModel end-->
<!--embedding start-->
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-embedding-openai</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-embedding-ollama</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-embedding-qwen</artifactId>
</dependency>
<!--embedding end-->
<!--rerank start-->
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-rerank-default</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-rerank-gitee</artifactId>
</dependency>
<!--rerank end-->
<!--store start-->
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-store-aliyun</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-store-chroma</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-store-elasticsearch</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-store-opensearch</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-store-pgvector</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-store-qcloud</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-store-qdrant</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-store-redis</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-store-vectorex</artifactId>
<exclusions>
<exclusion>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</exclusion>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-store-vectorexdb</artifactId>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
</exclusion>
<exclusion>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--store end-->
<!--search engines start-->
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-search-engine-service</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-search-engine-es</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-search-engine-lucene</artifactId>
</dependency>
<!--search engines end-->
</dependencies>
</project>

View File

@@ -0,0 +1,30 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-chat</artifactId>
<version>${revision}</version>
</parent>
<packaging>jar</packaging>
<name>easy-agents-chat-deepseek</name>
<artifactId>easy-agents-chat-deepseek</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-core</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS 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<DeepseekConfig> {
/**
* 构造一个聊天模型实例,不使用实例级拦截器。
*
* @param config 聊天模型配置
*/
public DeepseekChatModel(DeepseekConfig config) {
super(config);
}
/**
* 构造一个聊天模型实例,并指定实例级拦截器。
* <p>
* 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并,
* 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。
*
* @param config 聊天模型配置
* @param userInterceptors 实例级拦截器列表
*/
public DeepseekChatModel(DeepseekConfig config, List<ChatInterceptor> userInterceptors) {
super(config, userInterceptors);
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS 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);
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-chat</artifactId>
<version>${revision}</version>
</parent>
<name>easy-agents-chat-ollama</name>
<artifactId>easy-agents-chat-ollama</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-core</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS 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);
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS 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<OllamaChatConfig> {
/**
* 构造一个聊天模型实例,不使用实例级拦截器。
*
* @param config 聊天模型配置
*/
public OllamaChatModel(OllamaChatConfig config) {
super(config);
}
/**
* 构造一个聊天模型实例,并指定实例级拦截器。
* <p>
* 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并,
* 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。
*
* @param config 聊天模型配置
* @param userInterceptors 实例级拦截器列表
*/
public OllamaChatModel(OllamaChatConfig config, List<ChatInterceptor> userInterceptors) {
super(config, userInterceptors);
}
@Override
public ChatRequestSpecBuilder getChatRequestSpecBuilder() {
return new OllamaRequestSpecBuilder();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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";
}
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-chat</artifactId>
<version>${revision}</version>
</parent>
<name>easy-agents-chat-openai</name>
<artifactId>easy-agents-chat-openai</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-core</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,214 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS 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}。
* <p>
* 默认值:
* <ul>
* <li>provider: {@code "openai"}</li>
* <li>model: {@code "gpt-3.5-turbo"}</li>
* <li>endpoint: {@code "https://api.openai.com"}</li>
* <li>requestPath: {@code "/v1/chat/completions"}</li>
* </ul>
* <p>
* 该配置类专为 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<ChatInterceptor> 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<String, Object> 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} 配置对象。
* <p>
* 该方法会校验必要字段(如 {@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<ChatInterceptor> interceptors) {
return new OpenAIChatModel(build(), interceptors);
}
}
/**
* 获取一个新的构建器实例,用于链式配置。
*
* @return {@link Builder} 实例
*/
public static Builder builder() {
return new Builder();
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS 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 聊天模型实现。
* <p>
* 该类封装了 OpenAI API 的具体调用细节,包括:
* <ul>
* <li>请求体构建(支持同步/流式)</li>
* <li>HTTP 客户端管理</li>
* <li>解析器配置(同步/流式使用不同解析器)</li>
* </ul>
* <p>
* 所有横切逻辑(监控、日志、拦截)由 {@link BaseChatModel} 的责任链处理,
* 本类只关注 OpenAI 协议特有的实现细节。
*/
public class OpenAIChatModel extends OpenAICompatibleChatModel<OpenAIChatConfig> {
/**
* 构造一个聊天模型实例,不使用实例级拦截器。
*
* @param config 聊天模型配置
*/
public OpenAIChatModel(OpenAIChatConfig config) {
super(config);
}
/**
* 构造一个聊天模型实例,并指定实例级拦截器。
* <p>
* 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并,
* 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。
*
* @param config 聊天模型配置
* @param userInterceptors 实例级拦截器列表
*/
public OpenAIChatModel(OpenAIChatConfig config, List<ChatInterceptor> userInterceptors) {
super(config, userInterceptors);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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<ToolCall> 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<ToolMessage> 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<Tool> 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<Tool> 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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-chat</artifactId>
<version>${revision}</version>
</parent>
<name>easy-agents-chat-qwen</name>
<artifactId>easy-agents-chat-qwen</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-core</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS 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);
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS 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<QwenChatConfig> {
/**
* 构造一个聊天模型实例,不使用实例级拦截器。
*
* @param config 聊天模型配置
*/
public QwenChatModel(QwenChatConfig config) {
super(config);
}
/**
* 构造一个聊天模型实例,并指定实例级拦截器。
* <p>
* 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并,
* 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。
*
* @param config 聊天模型配置
* @param userInterceptors 实例级拦截器列表
*/
public QwenChatModel(QwenChatConfig config, List<ChatInterceptor> userInterceptors) {
super(config, userInterceptors);
}
@Override
public ChatRequestSpecBuilder getChatRequestSpecBuilder() {
return new QwenRequestSpecBuilder();
}
}

View File

@@ -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;
/**
* <link href="https://help.aliyun.com/zh/model-studio/use-qwen-by-calling-api">通义千问API参考</link>
*
* @author liutf
*/
public class QwenChatOptions extends ChatOptions {
/**
* 输出数据的模态,仅支持 Qwen-Omni 模型指定。(可选)
* 默认值为["text"]
* 可选值:
* ["text"]:输出文本。
*/
private List<String> modalities;
/**
* 控制模型生成文本时的内容重复度。(可选)
* 取值范围:[-2.0, 2.0]。正数会减少重复度,负数会增加重复度。
* <pre>
* 适用场景:
* 较高的presence_penalty适用于要求多样性、趣味性或创造性的场景如创意写作或头脑风暴。
* 较低的presence_penalty适用于要求一致性或专业术语的场景如技术文档或其他正式文档。
* </pre>
* 不建议修改QVQ模型的默认presence_penalty值。
*/
private Float presencePenalty;
/**
* 生成响应的个数取值范围是1-4。
* 对于需要生成多个响应的场景(如创意写作、广告文案等),可以设置较大的 n 值。
* <pre>
* 当前仅支持 qwen-plus 模型,且在传入 tools 参数时固定为1。
* 设置较大的 n 值不会增加输入 Token 消耗,会增加输出 Token 的消耗。
* </pre>
*/
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<TranslationOptionsExt> terms;
/**
* 在使用翻译记忆功能时需要设置的翻译记忆数组。
* https://help.aliyun.com/zh/model-studio/user-guide/machine-translation#17e15234e7gfp
*/
@JSONField(name = "tm_list")
private List<TranslationOptionsExt> 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<TranslationOptionsExt> getTerms() {
return terms;
}
public TranslationOptions setTerms(List<TranslationOptionsExt> terms) {
this.terms = terms;
return this;
}
public List<TranslationOptionsExt> getTmList() {
return tmList;
}
public TranslationOptions setTmList(List<TranslationOptionsExt> 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<String> getModalities() {
return modalities;
}
public QwenChatOptions setModalities(List<String> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

29
easy-agents-chat/pom.xml Normal file
View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-parent</artifactId>
<version>${revision}</version>
</parent>
<name>easy-agents-chat</name>
<artifactId>easy-agents-chat</artifactId>
<packaging>pom</packaging>
<modules>
<module>easy-agents-chat-openai</module>
<module>easy-agents-chat-qwen</module>
<module>easy-agents-chat-ollama</module>
<module>easy-agents-chat-deepseek</module>
</modules>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

120
easy-agents-core/pom.xml Normal file
View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-parent</artifactId>
<version>${revision}</version>
</parent>
<name>easy-agents-core</name>
<artifactId>easy-agents-core</artifactId>
<properties>
<poi.version>5.5.1</poi.version>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-sse</artifactId>
</dependency>
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>${poi.version}</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-scratchpad</artifactId>
<version>${poi.version}</version>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.30</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.18.1</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
<scope>compile</scope>
</dependency>
<!-- OpenTelemetry API -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
</dependency>
<!-- OpenTelemetry SDK运行时 -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk</artifactId>
</dependency>
<!-- 导出到控制台(轻量级,无需第三方) -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-logging</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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";
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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();
}

View File

@@ -0,0 +1,564 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Tool> 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<ReActAgentListener> listeners = new ArrayList<>();
// 拦截器集合
private final List<ToolInterceptor> toolInterceptors = new ArrayList<>();
public ReActAgent(ChatModel chatModel, List<Tool> 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<Tool> 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<Tool> 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<ToolInterceptor> getToolInterceptors() {
return toolInterceptors;
}
public ChatModel getChatModel() {
return chatModel;
}
public List<Tool> getTools() {
return tools;
}
public ReActStepParser getReActStepParser() {
return reActStepParser;
}
public void setReActStepParser(ReActStepParser reActStepParser) {
this.reActStepParser = reActStepParser;
}
public List<ReActAgentListener> 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<Message> 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<ReActStep> 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<Tool> 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);
}
}
}
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Tool> 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) {
}
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Message> 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<Message> getMessageHistory() {
return messageHistory;
}
public void setMessageHistory(List<Message> 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);
}
}

View File

@@ -0,0 +1,123 @@
///*
// * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
// * <p>
// * Licensed under the Apache License, Version 2.0 (the "License");
// * you may not use this file except in compliance with the License.
// * You may obtain a copy of the License at
// * <p>
// * http://www.apache.org/licenses/LICENSE-2.0
// * <p>
// * Unless required by applicable law or agreed to in writing, software
// * distributed under the License is distributed on an "AS IS" BASIS,
// * WITHOUT WARRANTIES OR CONDITIONS OF 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<String, Object> 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;
// }
// }
//}

View File

@@ -0,0 +1,105 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Tool> 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;
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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 + '\'' +
'}';
}
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<ReActStep> 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<ReActStep> 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:";
}
}

View File

@@ -0,0 +1,179 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Tool> 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<Parameter> 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<Parameter> 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 等
}
}
}

View File

@@ -0,0 +1,166 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Message> 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;
}
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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);
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<String, RoutingAgentFactory> agentFactories = new HashMap<>();
private final Map<String, String> agentDescriptions = new HashMap<>();
private final Map<String, String> 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<String> 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<String, String> 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<String, String> entry : agentDescriptions.entrySet()) {
sb.append("- ").append(entry.getKey()).append(": ").append(entry.getValue()).append("\n");
}
return sb.toString().trim();
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<java.math.BigDecimal> {
@Override
public java.math.BigDecimal convert(String text) {
return new java.math.BigDecimal(text);
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<java.math.BigInteger> {
@Override
public java.math.BigInteger convert(String text) {
return new java.math.BigInteger(text);
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Boolean> {
@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);
}
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<byte[]> {
@Override
public byte[] convert(String text) {
return text.getBytes();
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Byte> {
@Override
public Byte convert(String text) {
return Byte.parseByte(text);
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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{
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Class<?>, 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;
}
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Double> {
@Override
public Double convert(String text) {
return Double.parseDouble(text);
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Float> {
@Override
public Float convert(String text) {
return Float.parseFloat(text);
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<T> {
/**
* convert the given text to type <T>.
*
* @param text the text to convert.
* @return the convert value or null.
*/
T convert(String text) throws ConvertException;
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Integer>{
@Override
public Integer convert(String text) throws ConvertException {
return Integer.parseInt(text);
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Long> {
@Override
public Long convert(String text) {
return Long.parseLong(text);
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Short>{
@Override
public Short convert(String text) throws ConvertException {
return Short.parseShort(text);
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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 +
'}';
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Document> split(Document text, DocumentIdGenerator idGenerator);
default List<Document> split(Document text) {
return split(text, null);
}
default List<Document> splitAll(List<Document> documents, DocumentIdGenerator idGenerator) {
if (documents == null || documents.isEmpty()) {
return Collections.emptyList();
}
return documents.stream()
.flatMap(document -> split(document, idGenerator).stream())
.collect(toList());
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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);
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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();
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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;
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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();
}
}

View File

@@ -0,0 +1,176 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Document> 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<String> 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<Document> fallbackDocs = fallbackSplitter.split(document, idGenerator);
if (fallbackDocs.size() > maxChunks) {
return new ArrayList<>(fallbackDocs.subList(0, maxChunks));
}
return fallbackDocs;
}
List<String> validChunks = chunks.stream()
.map(String::trim)
.filter(s -> !s.isEmpty())
.limit(maxChunks)
.collect(Collectors.toList());
List<Document> 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<String> parseChunksBySeparator(String text, String separator) {
if (text == null || text.trim().isEmpty()) {
return Collections.emptyList();
}
String[] parts = text.split(separator, -1);
List<String> 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<String> 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);
}
}

View File

@@ -0,0 +1,245 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Document> 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<DocumentChunk> chunks = new ArrayList<>();
Deque<HeaderInfo> 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<Document> 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<DocumentChunk> chunks, String content,
Deque<HeaderInfo> headerStack, int startLine, int endLine, Document sourceDoc) {
if (StringUtil.noText(content.trim())) {
return;
}
// 从根到当前构建标题路径
List<String> headerPath = new ArrayList<>();
List<HeaderInfo> 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<String> headerPath;
final int startLine;
final int endLine;
DocumentChunk(String content, List<String> headerPath, int startLine, int endLine) {
this.content = content;
this.headerPath = headerPath;
this.startLine = startLine;
this.endLine = endLine;
}
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Document> split(Document document, DocumentIdGenerator idGenerator) {
if (document == null || StringUtil.noText(document.getContent())) {
return Collections.emptyList();
}
String[] textArray = document.getContent().split(regex);
List<Document> 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;
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Document> 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<Document> 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;
}
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Document> 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<Integer> tokens = encoding.encode(content).boxed();
int index = 0, currentIndex = index;
int maxIndex = tokens.size();
List<Document> chunks = new ArrayList<>();
while (currentIndex < maxIndex) {
int endIndex = Math.min(currentIndex + chunkSize, maxIndex);
List<Integer> 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;
}
}

View File

@@ -0,0 +1,132 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<FileExtractor> 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";
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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));
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<FileExtractor> 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<FileExtractor> extractors) {
extractors.forEach(this::register);
}
public List<FileExtractor> findExtractors(DocumentSource source) {
return extractors.stream()
.filter(extractor -> extractor.supports(source))
.sorted(FileExtractor.ORDER_COMPARATOR)
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<FileExtractor> ORDER_COMPARATOR =
Comparator.comparingInt(FileExtractor::getOrder);
/**
* 判断该 Extractor 是否支持处理此文档
*/
boolean supports(DocumentSource source);
String extractText(DocumentSource source) throws IOException;
default int getOrder() {
return 100;
}
}

View File

@@ -0,0 +1,103 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<String> SUPPORTED_MIME_TYPES;
private static final Set<String> SUPPORTED_EXTENSIONS;
static {
Set<String> mimeTypes = new HashSet<>();
mimeTypes.add("application/msword");
SUPPORTED_MIME_TYPES = Collections.unmodifiableSet(mimeTypes);
Set<String> 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);
}
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<String> KNOWN_MIME_TYPES;
private static final String MIME_PREFIX = "application/vnd.openxmlformats-officedocument.wordprocessingml";
private static final Set<String> SUPPORTED_EXTENSIONS;
static {
// 精确 MIME可选
Set<String> mimeTypes = new HashSet<>();
mimeTypes.add("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
KNOWN_MIME_TYPES = Collections.unmodifiableSet(mimeTypes);
// 支持的扩展名
Set<String> 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<String> 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);
}
}

View File

@@ -0,0 +1,339 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<String> SUPPORTED_MIME_TYPES;
private static final Set<String> SUPPORTED_EXTENSIONS;
static {
Set<String> mimeTypes = new HashSet<>();
mimeTypes.add("text/html");
mimeTypes.add("application/xhtml+xml");
SUPPORTED_MIME_TYPES = Collections.unmodifiableSet(mimeTypes);
Set<String> extensions = new HashSet<>();
extensions.add("html");
extensions.add("htm");
extensions.add("xhtml");
extensions.add("mhtml");
SUPPORTED_EXTENSIONS = Collections.unmodifiableSet(extensions);
}
// 噪音过滤规则
private static final Set<String> 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<String> 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<String> CUSTOM_SELECTORS = ConcurrentHashMap.newKeySet();
private static final Set<String> 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<String> 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<String> keywords = new HashSet<>(CLASS_KEYWORDS);
keywords.addAll(CUSTOM_CLASS_KEYWORDS);
// 使用 DFS 遍历所有元素
Deque<Element> 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<String> 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<String> 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);
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<String> SUPPORTED_MIME_TYPES;
private static final Set<String> SUPPORTED_EXTENSIONS;
static {
Set<String> mimeTypes = new HashSet<>();
mimeTypes.add("application/pdf");
SUPPORTED_MIME_TYPES = Collections.unmodifiableSet(mimeTypes);
Set<String> 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);
}
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<String> SUPPORTED_MIME_TYPES;
private static final Set<String> SUPPORTED_EXTENSIONS;
static {
Set<String> 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<String> 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();
}
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<String> SUPPORTED_MIME_TYPES;
private static final String MIME_PREFIX = "application/vnd.openxmlformats-officedocument.presentationml";
private static final Set<String> SUPPORTED_EXTENSIONS;
static {
// 精确 MIME可选
Set<String> 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<String> 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<XSLFSlide> 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<String> 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);
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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);
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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);
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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() {
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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);
}
}
}

View File

@@ -0,0 +1,281 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<HttpURLConnection> 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<HttpURLConnection> 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();
}
}
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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;
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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);
}
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Message> getMessages(int count);
void addMessage(Message message);
default void addMessages(Collection<Message> messages){
for (Message message : messages) {
addMessage(message);
}
}
void clear();
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<Message> 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<Message> 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();
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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();
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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;

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<T extends AbstractTextMessage<?>>
extends Message implements Copyable<T> {
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();
}

View File

@@ -0,0 +1,351 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<AiMessage> {
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<ToolCall> 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<ToolCall> 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<ToolCall> getToolCalls() {
return toolCalls;
}
public void setToolCalls(List<ToolCall> 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 +
'}';
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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进行交互。
* 消息内容可以是纯文本,也可以是多模态内容(例如:文本 + 图像等)。
*
* <p>该类继承自 {@link Metadata}允许附加任意元数据如来源、时间戳、追踪ID等
*
* @see #getTextContent()
*/
public abstract class Message extends Metadata {
/**
* 提取消息中的纯文本部分。
*
* <p>无论原始内容是纯文本还是多模态结构(如文本+图像),本方法应返回其中所有文本内容的合理合并结果。
* 例如,在 OpenAI 多模态消息中,应遍历所有 {@code content} 元素,提取类型为 {@code text} 的部分并拼接。
*
* <p>返回的字符串应不包含非文本元素(如图像、音频等),且应保持原始文本的语义顺序(如适用)。
* 若消息中无文本内容,则返回空字符串({@code ""}),而非 {@code null}。
*
* <p>该方法主要用于日志记录、监控、文本分析等仅需文本语义的场景。
*
* @return 消息中提取出的纯文本内容。
*/
public abstract String getTextContent();
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<SystemMessage> {
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;
}
}

View File

@@ -0,0 +1,115 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<ToolCall> {
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<String, Object> 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;
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<ToolMessage> {
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;
}
}

View File

@@ -0,0 +1,210 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<UserMessage> {
private List<String> audioUrls;
private List<String> videoUrls;
private List<String> imageUrls;
private List<Tool> 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<? extends Tool> 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<Tool> getTools() {
return tools;
}
public Map<String, Tool> getToolsMap() {
if (tools == null) {
return Collections.emptyMap();
}
Map<String, Tool> map = new HashMap<>(tools.size());
for (Tool tool : tools) {
map.put(tool.getName(), tool);
}
return map;
}
public void setTools(List<? extends Tool> 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<String> getAudioUrls() {
return audioUrls;
}
public void setAudioUrls(List<String> audioUrls) {
this.audioUrls = audioUrls;
}
public void addAudioUrl(String audioUrl) {
if (audioUrls == null) {
audioUrls = new ArrayList<>(1);
}
audioUrls.add(audioUrl);
}
/// /// Video
public List<String> getVideoUrls() {
return videoUrls;
}
public void setVideoUrls(List<String> videoUrls) {
this.videoUrls = videoUrls;
}
public void addVideoUrl(String videoUrl) {
if (videoUrls == null) {
videoUrls = new ArrayList<>(1);
}
videoUrls.add(videoUrl);
}
/// /// Images
public List<String> getImageUrls() {
return imageUrls;
}
public List<String> getImageUrlsForChat(ChatConfig config) {
if (this.imageUrls == null) {
return null;
}
List<String> 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<String> 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;
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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;

View File

@@ -0,0 +1,271 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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;
/**
* 支持责任链、统一上下文和协议客户端的聊天模型基类。
* <p>
* 该类为所有具体的 LLM 实现(如 OpenAI、Qwen、Ollama提供统一入口并集成
* <ul>
* <li><b>责任链模式</b>:通过 {@link ChatInterceptor} 实现请求拦截、监控、日志等横切逻辑</li>
* <li><b>线程上下文管理</b>:通过 {@link ChatContextHolder} 在整个调用链中传递上下文信息</li>
* <li><b>协议执行抽象</b>:通过 {@link ChatClient} 解耦协议细节,支持 HTTP/gRPC/WebSocket 等</li>
* <li><b>可观测性</b>:自动集成 OpenTelemetry通过 {@link ChatObservabilityInterceptor}</li>
* </ul>
*
* <h2>架构流程</h2>
* <ol>
* <li>调用 {@link #chat(Prompt, ChatOptions)} 或 {@link #chatStream(Prompt, StreamResponseListener, ChatOptions)}</li>
* <li>构建请求上下文URL/Headers/Body并初始化 {@link ChatContext}</li>
* <li>构建责任链:可观测性拦截器 → 全局拦截器 → 用户拦截器</li>
* <li>责任链执行:每个拦截器可修改 {@link ChatContext},最后由 {@link ChatClient} 执行实际调用</li>
* <li>结果返回给调用方</li>
* </ol>
*
* @param <T> 具体的配置类型,必须是 {@link ChatConfig} 的子类
*/
public abstract class BaseChatModel<T extends ChatConfig> implements ChatModel {
/**
* 聊天模型配置,包含 API Key、Endpoint、Model 等信息
*/
protected final T config;
protected ChatClient chatClient;
protected ChatRequestSpecBuilder chatRequestSpecBuilder;
/**
* 拦截器链,按执行顺序存储(可观测性 → 全局 → 用户)
*/
private final List<ChatInterceptor> interceptors;
/**
* 构造一个聊天模型实例,不使用实例级拦截器。
*
* @param config 聊天模型配置
*/
public BaseChatModel(T config) {
this(config, Collections.emptyList());
}
/**
* 构造一个聊天模型实例,并指定实例级拦截器。
* <p>
* 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并,
* 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。
*
* @param config 聊天模型配置
* @param userInterceptors 实例级拦截器列表
*/
public BaseChatModel(T config, List<ChatInterceptor> userInterceptors) {
this.config = config;
this.interceptors = buildInterceptorChain(userInterceptors);
}
/**
* 构建完整的拦截器链。
* <p>
* 执行顺序:
* 1. 可观测性拦截器(最外层,最早执行)
* 2. 全局拦截器(通过 GlobalChatInterceptors 注册)
* 3. 用户拦截器(实例级)
*
* @param userInterceptors 用户提供的拦截器列表
* @return 按执行顺序排列的拦截器链
*/
private List<ChatInterceptor> buildInterceptorChain(List<ChatInterceptor> userInterceptors) {
List<ChatInterceptor> 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;
}
/**
* 执行同步聊天请求。
* <p>
* 流程:
* 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);
}
}
/**
* 执行流式聊天请求。
* <p>
* 流程与同步请求类似,但返回结果通过回调方式分片返回。
*
* @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);
}
}
/**
* 构建同步责任链。
* <p>
* 递归构建拦截器链,链尾节点负责创建并调用 {@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);
}
/**
* 构建流式责任链。
* <p>
* 与同步链类似,但支持流式监听器。
*
* @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<ChatInterceptor> getInterceptors() {
return interceptors;
}
/**
* 动态添加拦截器。
* <p>
* 新拦截器会被添加到链的末尾(在用户拦截器区域)。
*
* @param interceptor 要添加的拦截器
*/
public void addInterceptor(ChatInterceptor interceptor) {
interceptors.add(interceptor);
}
public void addInterceptor(int index, ChatInterceptor interceptor) {
interceptors.add(index, interceptor);
}
}

View File

@@ -0,0 +1,203 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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;
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF 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<String, Object> 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<String, Object> 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<String, Object> attributes) {
this.attributes = attributes;
}
}

Some files were not shown because too many files have changed in this diff Show More