初始化

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

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

View File

@@ -0,0 +1,99 @@
/*
* 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;
/**
* 聊天上下文管理器,用于在当前线程中保存聊天相关的上下文信息。
* <p>
* 供日志、监控、拦截器等模块使用。
* <p>
* 支持同步和流式调用,通过 {@link ChatContextScope} 实现自动清理。
*/
public final class ChatContextHolder {
private static final ThreadLocal<ChatContext> CONTEXT_HOLDER = new ThreadLocal<>();
private ChatContextHolder() {
// 工具类,禁止实例化
}
/**
* 开始一次聊天上下文,并设置传输层请求信息。
* 适用于远程 LLM 模型(如 HTTP/gRPC/WebSocket
*
* @param config 聊天配置
* @param options 聊天选项
* @param prompt 用户提示
* @param request 请求信息构建起
* @return 可用于 try-with-resources 的作用域对象
*/
public static ChatContextScope beginChat(
Prompt prompt,
ChatOptions options,
ChatRequestSpec request,
ChatConfig config) {
ChatContext ctx = new ChatContext();
ctx.prompt = prompt;
ctx.options = options;
ctx.requestSpec = request;
ctx.config = config;
CONTEXT_HOLDER.set(ctx);
return new ChatContextScope(ctx);
}
/**
* 获取当前线程的聊天上下文(可能为 null
*
* @return 聊天上下文,若未设置则返回 null
*/
public static ChatContext currentContext() {
return CONTEXT_HOLDER.get();
}
/**
* 手动清除当前线程的上下文。
* <p>
* 通常由 {@link ChatContextScope} 自动调用,无需手动调用。
*/
public static void clear() {
CONTEXT_HOLDER.remove();
}
/**
* 用于 try-with-resources 的作用域对象,确保上下文自动清理。
*/
public static class ChatContextScope implements AutoCloseable {
ChatContext context;
public ChatContextScope(ChatContext context) {
this.context = context;
}
@Override
public void close() {
clear();
}
}
}

View File

@@ -0,0 +1,37 @@
/*
* 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.response.AiMessageResponse;
/**
* 聊天模型请求拦截器。
* <p>
* 通过责任链模式,在 LLM 调用前后插入自定义逻辑。
* 支持同步({@link #intercept})和流式({@link #interceptStream})两种模式。
*/
public interface ChatInterceptor {
/**
* 拦截同步聊天请求。
*/
AiMessageResponse intercept(BaseChatModel<?> chatModel, ChatContext context, SyncChain chain);
/**
* 拦截流式聊天请求。
*/
void interceptStream(BaseChatModel<?> chatModel, ChatContext context, StreamResponseListener listener, StreamChain chain);
}

View File

@@ -0,0 +1,60 @@
/*
* 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.message.AiMessage;
import com.easyagents.core.model.exception.ModelException;
import com.easyagents.core.model.chat.response.AbstractBaseMessageResponse;
import com.easyagents.core.model.chat.response.AiMessageResponse;
import com.easyagents.core.prompt.Prompt;
import com.easyagents.core.prompt.SimplePrompt;
public interface ChatModel {
default String chat(String prompt) {
return chat(prompt, new ChatOptions());
}
default String chat(String prompt, ChatOptions options) {
AbstractBaseMessageResponse<AiMessage> response = chat(new SimplePrompt(prompt), options);
if (response != null && response.isError()) {
throw new ModelException(response.getErrorMessage());
}
return response != null && response.getMessage() != null ? response.getMessage().getContent() : null;
}
default AiMessageResponse chat(Prompt prompt) {
return chat(prompt, new ChatOptions());
}
AiMessageResponse chat(Prompt prompt, ChatOptions options);
default void chatStream(String prompt, StreamResponseListener listener) {
this.chatStream(new SimplePrompt(prompt), listener, new ChatOptions());
}
default void chatStream(String prompt, StreamResponseListener listener, ChatOptions options) {
this.chatStream(new SimplePrompt(prompt), listener, options);
}
//chatStream
default void chatStream(Prompt prompt, StreamResponseListener listener) {
this.chatStream(prompt, listener, new ChatOptions());
}
void chatStream(Prompt prompt, StreamResponseListener listener, ChatOptions options);
}

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.model.chat;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.model.chat.response.AiMessageResponse;
import com.easyagents.core.model.client.StreamContext;
import com.easyagents.core.observability.Observability;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleHistogram;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.metrics.Meter;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
import java.util.concurrent.atomic.AtomicBoolean;
public class ChatObservabilityInterceptor implements ChatInterceptor {
private static final Tracer TRACER = Observability.getTracer();
private static final Meter METER = Observability.getMeter();
private static final LongCounter LLM_REQUEST_COUNT = METER.counterBuilder("llm.request.count")
.setDescription("Total number of LLM requests")
.build();
private static final DoubleHistogram LLM_LATENCY_HISTOGRAM = METER.histogramBuilder("llm.request.latency")
.setDescription("LLM request latency in seconds")
.setUnit("s")
.build();
private static final LongCounter LLM_ERROR_COUNT = METER.counterBuilder("llm.request.error.count")
.setDescription("Total number of LLM request errors")
.build();
private static final int MAX_RESPONSE_LENGTH_FOR_SPAN = 500;
@Override
public AiMessageResponse intercept(BaseChatModel<?> chatModel, ChatContext context, SyncChain chain) {
ChatConfig config = chatModel.getConfig();
if (config == null || !config.isObservabilityEnabled() || !Observability.isEnabled()) {
return chain.proceed(chatModel, context);
}
String provider = config.getProvider();
String model = config.getModel();
String operation = "chat";
Span span = TRACER.spanBuilder(provider + "." + operation)
.setAttribute("llm.provider", provider)
.setAttribute("llm.model", model)
.setAttribute("llm.operation", operation)
.startSpan();
long startTimeNanos = System.nanoTime();
try (Scope ignored = span.makeCurrent()) {
AiMessageResponse response = chain.proceed(chatModel, context);
boolean success = (response != null) && !response.isError();
if (success) {
enrichSpan(span, response.getMessage());
}
recordMetrics(provider, model, operation, success, startTimeNanos);
return response;
} catch (Exception e) {
recordError(span, e, provider, model, operation, startTimeNanos);
throw e;
} finally {
span.end();
}
}
@Override
public void interceptStream(
BaseChatModel<?> chatModel,
ChatContext context,
StreamResponseListener originalListener,
StreamChain chain) {
ChatConfig config = chatModel.getConfig();
if (config == null || !config.isObservabilityEnabled()) {
chain.proceed(chatModel, context, originalListener);
return;
}
String provider = config.getProvider();
String model = config.getModel();
String operation = "chatStream";
Span span = TRACER.spanBuilder(provider + "." + operation)
.setAttribute("llm.provider", provider)
.setAttribute("llm.model", model)
.setAttribute("llm.operation", operation)
.startSpan();
Scope scope = span.makeCurrent();
long startTimeNanos = System.nanoTime();
AtomicBoolean recorded = new AtomicBoolean(false);
StreamResponseListener wrappedListener = new StreamResponseListener() {
@Override
public void onStart(StreamContext context) {
originalListener.onStart(context);
}
@Override
public void onMessage(StreamContext context, AiMessageResponse response) {
originalListener.onMessage(context, response);
}
@Override
public void onFailure(StreamContext context, Throwable throwable) {
safeRecord(false, throwable);
originalListener.onFailure(context, throwable);
}
@Override
public void onStop(StreamContext context) {
boolean success = !context.isError();
if (success) {
enrichSpan(span, context.getFullMessage());
}
safeRecord(success, null);
originalListener.onStop(context);
}
private void safeRecord(boolean success, Throwable throwable) {
if (recorded.compareAndSet(false, true)) {
if (throwable != null) {
span.setStatus(StatusCode.ERROR, throwable.getMessage());
span.recordException(throwable);
}
span.end();
scope.close();
recordMetrics(provider, model, operation, success, startTimeNanos);
}
}
};
try {
chain.proceed(chatModel, context, wrappedListener);
} catch (Exception e) {
if (recorded.compareAndSet(false, true)) {
recordError(span, e, provider, model, operation, startTimeNanos);
}
scope.close();
throw e;
}
}
private void enrichSpan(Span span, AiMessage msg) {
if (msg != null) {
span.setAttribute("llm.total_tokens", msg.getEffectiveTotalTokens());
String content = msg.getContent();
if (content != null) {
span.setAttribute("llm.response",
content.substring(0, Math.min(content.length(), MAX_RESPONSE_LENGTH_FOR_SPAN)));
}
}
}
private void recordMetrics(String provider, String model, String operation, boolean success, long startTimeNanos) {
double latencySeconds = (System.nanoTime() - startTimeNanos) / 1_000_000_000.0;
Attributes attrs = Attributes.of(
AttributeKey.stringKey("llm.provider"), provider,
AttributeKey.stringKey("llm.model"), model,
AttributeKey.stringKey("llm.operation"), operation,
AttributeKey.stringKey("llm.success"), String.valueOf(success)
);
LLM_REQUEST_COUNT.add(1, attrs);
LLM_LATENCY_HISTOGRAM.record(latencySeconds, attrs);
if (!success) {
LLM_ERROR_COUNT.add(1, attrs);
}
}
private void recordError(Span span, Exception e, String provider, String model, String operation, long startTimeNanos) {
span.setStatus(StatusCode.ERROR, e.getMessage());
span.recordException(e);
span.end();
// Scope 会在 finally 或 safeRecord 中关闭
recordMetrics(provider, model, operation, false, startTimeNanos);
}
}

View File

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

View File

@@ -0,0 +1,114 @@
/*
* 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 java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 全局聊天拦截器管理器。
* <p>
* 该类提供静态方法,用于注册和管理应用于所有 {@link BaseChatModel} 实例的全局拦截器。
* 全局拦截器会在实例级拦截器之前执行,适用于统一的日志、安全、监控等横切关注点。
* <p>
* <strong>使用建议</strong>
* <ul>
* <li>在应用启动阶段(如 Spring 的 {@code @PostConstruct} 或 main 方法)注册全局拦截器</li>
* <li>避免在运行时动态修改,以确保线程安全和行为一致性</li>
* </ul>
*/
public final class GlobalChatInterceptors {
/**
* 全局拦截器列表,使用 synchronized 保证线程安全
*/
private static final List<ChatInterceptor> GLOBAL_INTERCEPTORS = new ArrayList<>();
/**
* 私有构造函数,防止实例化
*/
private GlobalChatInterceptors() {
// 工具类,禁止实例化
}
/**
* 注册一个全局拦截器。
* <p>
* 该拦截器将应用于所有后续创建的 {@link BaseChatModel} 实例。
*
* @param interceptor 要注册的拦截器,不能为 null
* @throws IllegalArgumentException 如果 interceptor 为 null
*/
public static synchronized void addInterceptor(ChatInterceptor interceptor) {
if (interceptor == null) {
throw new IllegalArgumentException("ChatInterceptor must not be null");
}
GLOBAL_INTERCEPTORS.add(interceptor);
}
/**
* 批量注册多个全局拦截器。
* <p>
* 拦截器将按列表顺序添加,并在执行时按相同顺序调用。
*
* @param interceptors 拦截器列表,不能为 null列表中元素不能为 null
* @throws IllegalArgumentException 如果 interceptors 为 null 或包含 null 元素
*/
public static synchronized void addInterceptors(List<ChatInterceptor> interceptors) {
if (interceptors == null) {
throw new IllegalArgumentException("Interceptor list must not be null");
}
for (ChatInterceptor interceptor : interceptors) {
if (interceptor == null) {
throw new IllegalArgumentException("Interceptor list must not contain null elements");
}
}
GLOBAL_INTERCEPTORS.addAll(interceptors);
}
/**
* 获取当前注册的全局拦截器列表的不可变视图。
* <p>
* 该方法供 {@link BaseChatModel} 内部使用,返回值不应被外部修改。
*
* @return 不可变的全局拦截器列表
*/
public static List<ChatInterceptor> getInterceptors() {
return Collections.unmodifiableList(GLOBAL_INTERCEPTORS);
}
/**
* 清空所有全局拦截器。
* <p>
* <strong>仅用于测试环境</strong>,生产环境应避免调用。
*/
public static synchronized void clear() {
GLOBAL_INTERCEPTORS.clear();
}
/**
* 获取当前全局拦截器的数量。
* <p>
* 用于诊断或监控。
*
* @return 拦截器数量
*/
public static synchronized int size() {
return GLOBAL_INTERCEPTORS.size();
}
}

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.model.chat;
import com.easyagents.core.message.AiMessage;
public interface MessageResponse<M extends AiMessage> {
M getMessage();
}

View File

@@ -0,0 +1,71 @@
/*
* 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.ChatClient;
import com.easyagents.core.model.client.ChatRequestSpecBuilder;
import com.easyagents.core.model.client.OpenAIChatClient;
import com.easyagents.core.model.client.OpenAIChatRequestSpecBuilder;
import java.util.List;
public class OpenAICompatibleChatModel<T extends ChatConfig> extends BaseChatModel<T> {
/**
* 构造一个聊天模型实例,不使用实例级拦截器。
*
* @param config 聊天模型配置
*/
public OpenAICompatibleChatModel(T config) {
super(config);
}
/**
* 构造一个聊天模型实例,并指定实例级拦截器。
* <p>
* 实例级拦截器会与全局拦截器(通过 {@link GlobalChatInterceptors} 注册)合并,
* 执行顺序为:可观测性拦截器 → 全局拦截器 → 实例拦截器。
*
* @param config 聊天模型配置
* @param userInterceptors 实例级拦截器列表
*/
public OpenAICompatibleChatModel(T config, List<ChatInterceptor> userInterceptors) {
super(config, userInterceptors);
}
@Override
public ChatClient getChatClient() {
if (this.chatClient == null) {
this.chatClient = buildChatClient();
}
return this.chatClient;
}
@Override
public ChatRequestSpecBuilder getChatRequestSpecBuilder() {
if (this.chatRequestSpecBuilder == null) {
this.chatRequestSpecBuilder = buildChatRequestSpecBuilder();
}
return this.chatRequestSpecBuilder;
}
protected ChatRequestSpecBuilder buildChatRequestSpecBuilder() {
return new OpenAIChatRequestSpecBuilder();
}
protected ChatClient buildChatClient() {
return new OpenAIChatClient(this);
}
}

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.model.chat;
@FunctionalInterface
public interface StreamChain {
void proceed(BaseChatModel<?> model, ChatContext context, StreamResponseListener listener);
}

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.model.chat;
import com.easyagents.core.model.chat.response.AiMessageResponse;
import com.easyagents.core.model.client.StreamContext;
import org.slf4j.Logger;
public interface StreamResponseListener {
Logger logger = org.slf4j.LoggerFactory.getLogger(StreamResponseListener.class);
default void onStart(StreamContext context) {
}
void onMessage(StreamContext context, AiMessageResponse response);
default void onStop(StreamContext context) {
}
default void onFailure(StreamContext context, Throwable throwable) {
if (throwable != null) {
logger.error(throwable.toString(), throwable);
}
}
}

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.model.chat;
import com.easyagents.core.model.chat.response.AiMessageResponse;
@FunctionalInterface
public interface SyncChain {
AiMessageResponse proceed(BaseChatModel<?> model, ChatContext context);
}

View File

@@ -0,0 +1,40 @@
/*
* 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.log;
import com.easyagents.core.model.chat.ChatConfig;
public final class ChatMessageLogger {
private static IChatMessageLogger logger = new DefaultChatMessageLogger();
private ChatMessageLogger() {}
public static void setLogger(IChatMessageLogger logger) {
if (logger == null){
throw new IllegalArgumentException("logger can not be null.");
}
ChatMessageLogger.logger = logger;
}
public static void logRequest(ChatConfig config, String message) {
logger.logRequest(config, message);
}
public static void logResponse(ChatConfig config, String message) {
logger.logResponse(config, message);
}
}

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.model.chat.log;
import com.easyagents.core.model.chat.ChatConfig;
import java.util.function.Consumer;
public class DefaultChatMessageLogger implements IChatMessageLogger {
private final Consumer<String> logConsumer;
public DefaultChatMessageLogger() {
this(System.out::println);
}
public DefaultChatMessageLogger(Consumer<String> logConsumer) {
this.logConsumer = logConsumer != null ? logConsumer : System.out::println;
}
@Override
public void logRequest(ChatConfig config, String message) {
if (shouldLog(config)) {
String provider = getProviderName(config);
String model = getModelName(config);
logConsumer.accept(String.format("[%s/%s] >>>> request: %s", provider, model, message));
}
}
@Override
public void logResponse(ChatConfig config, String message) {
if (shouldLog(config)) {
String provider = getProviderName(config);
String model = getModelName(config);
logConsumer.accept(String.format("[%s/%s] <<<< response: %s", provider, model, message));
}
}
private boolean shouldLog(ChatConfig config) {
return config != null && config.isLogEnabled();
}
private String getProviderName(ChatConfig config) {
String provider = config.getProvider();
return provider != null ? provider : "unknow";
}
private String getModelName(ChatConfig config) {
String model = config.getModel();
return model != null ? model : "unknow";
}
}

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.model.chat.log;
import com.easyagents.core.model.chat.ChatConfig;
public interface IChatMessageLogger {
void logRequest(ChatConfig config, String message);
void logResponse(ChatConfig config, String message);
}

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.
*/
/**
* LLM 大语言模型
*/
package com.easyagents.core.model.chat;

View File

@@ -0,0 +1,59 @@
/*
* 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.response;
import com.easyagents.core.model.chat.MessageResponse;
import com.easyagents.core.message.AiMessage;
public abstract class AbstractBaseMessageResponse<M extends AiMessage> implements MessageResponse<M> {
protected boolean error = false;
protected String errorMessage;
protected String errorType;
protected String errorCode;
public boolean isError() {
return error;
}
public void setError(boolean error) {
this.error = error;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public String getErrorType() {
return errorType;
}
public void setErrorType(String errorType) {
this.errorType = errorType;
}
public String getErrorCode() {
return errorCode;
}
public void setErrorCode(String errorCode) {
this.errorCode = errorCode;
}
}

View File

@@ -0,0 +1,163 @@
/*
* 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.response;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.ToolCall;
import com.easyagents.core.message.ToolMessage;
import com.easyagents.core.message.UserMessage;
import com.easyagents.core.model.chat.ChatContext;
import com.easyagents.core.model.chat.tool.Tool;
import com.easyagents.core.model.chat.tool.ToolInterceptor;
import com.easyagents.core.model.chat.tool.ToolExecutor;
import com.easyagents.core.util.CollectionUtil;
import com.easyagents.core.util.MessageUtil;
import com.easyagents.core.util.StringUtil;
import com.alibaba.fastjson2.JSON;
import java.util.*;
public class AiMessageResponse extends AbstractBaseMessageResponse<AiMessage> {
private final ChatContext context;
private final String rawText;
private final AiMessage message;
public AiMessageResponse(ChatContext context, String rawText, AiMessage message) {
this.context = context;
this.rawText = rawText;
this.message = message;
}
public ChatContext getContext() {
return context;
}
public String getRawText() {
return rawText;
}
@Override
public AiMessage getMessage() {
return message;
}
public boolean hasToolCalls() {
if (this.message == null) {
return false;
}
List<ToolCall> toolCalls = message.getToolCalls();
return toolCalls != null && !toolCalls.isEmpty();
}
public List<ToolExecutor> getToolExecutors(ToolInterceptor... interceptors) {
if (this.message == null) {
return Collections.emptyList();
}
List<ToolCall> calls = message.getToolCalls();
if (calls == null || calls.isEmpty()) {
return Collections.emptyList();
}
UserMessage userMessage = MessageUtil.findLastUserMessage(getContext().getPrompt().getMessages());
Map<String, Tool> funcMap = userMessage.getToolsMap();
if (funcMap == null || funcMap.isEmpty()) {
return Collections.emptyList();
}
List<ToolExecutor> toolExecutors = new ArrayList<>(calls.size());
for (ToolCall call : calls) {
Tool tool = funcMap.get(call.getName());
if (tool != null) {
ToolExecutor executor = new ToolExecutor(tool, call);
if (interceptors != null && interceptors.length > 0) {
executor.addInterceptors(Arrays.asList(interceptors));
}
toolExecutors.add(executor);
}
}
return toolExecutors;
}
public List<Object> executeToolCallsAndGetResults(ToolInterceptor... interceptors) {
List<ToolExecutor> toolExecutors = getToolExecutors(interceptors);
for (ToolExecutor toolExecutor : toolExecutors) {
toolExecutor.addInterceptors(Arrays.asList(interceptors));
}
List<Object> results = new ArrayList<>();
for (ToolExecutor toolExecutor : toolExecutors) {
results.add(toolExecutor.execute());
}
return results;
}
public List<ToolMessage> executeToolCallsAndGetToolMessages(ToolInterceptor... interceptors) {
List<ToolExecutor> toolExecutors = getToolExecutors(interceptors);
if (CollectionUtil.noItems(toolExecutors)) {
return Collections.emptyList();
}
List<ToolMessage> toolMessages = new ArrayList<>(toolExecutors.size());
for (ToolExecutor toolExecutor : toolExecutors) {
ToolMessage toolMessage = new ToolMessage();
String callId = toolExecutor.getToolCall().getId();
if (StringUtil.hasText(callId)) {
toolMessage.setToolCallId(callId);
} else {
toolMessage.setToolCallId(toolExecutor.getToolCall().getName());
}
Object result = toolExecutor.execute();
if (result instanceof CharSequence || result instanceof Number) {
toolMessage.setContent(result.toString());
} else {
toolMessage.setContent(JSON.toJSONString(result));
}
toolMessages.add(toolMessage);
}
return toolMessages;
}
public static AiMessageResponse error(ChatContext context, String rawText, String errorMessage) {
AiMessageResponse errorResp = new AiMessageResponse(context, rawText, null);
errorResp.setError(true);
errorResp.setErrorMessage(errorMessage);
return errorResp;
}
@Override
public String toString() {
return "AiMessageResponse{" +
"context=" + context +
", rawText='" + rawText + '\'' +
", message=" + message +
", error=" + error +
", errorMessage='" + errorMessage + '\'' +
", errorType='" + errorType + '\'' +
", errorCode='" + errorCode + '\'' +
'}';
}
}

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.core.model.chat.tool;
import java.io.Serializable;
public abstract class BaseTool implements Tool, Serializable {
protected String name;
protected String description;
protected Parameter[] parameters;
@Override
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
@Override
public Parameter[] getParameters() {
return parameters;
}
public void setParameters(Parameter[] parameters) {
this.parameters = parameters;
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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.tool;
import java.util.Map;
import java.util.function.Function;
public class FunctionTool extends BaseTool {
private Function<Map<String, Object>, Object> invoker;
public FunctionTool() {
}
@Override
public Object invoke(Map<String, Object> argsMap) {
if (invoker == null) {
throw new IllegalStateException("Tool invoker function is not set.");
}
return invoker.apply(argsMap);
}
// 允许外部设置 invokerBuilder 会用)
public void setInvoker(Function<Map<String, Object>, Object> invoker) {
this.invoker = invoker;
}
}

View File

@@ -0,0 +1,49 @@
/*
* 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.tool;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 全局函数调用拦截器注册中心。
* <p>
* 所有通过 {@link #addInterceptor(ToolInterceptor)} 注册的拦截器,
* 将自动应用于所有 {@link ToolExecutor} 实例。
*/
public final class GlobalToolInterceptors {
private static final List<ToolInterceptor> GLOBAL_INTERCEPTORS = new ArrayList<>();
private GlobalToolInterceptors() {
}
public static void addInterceptor(ToolInterceptor interceptor) {
if (interceptor == null) {
throw new IllegalArgumentException("Interceptor must not be null");
}
GLOBAL_INTERCEPTORS.add(interceptor);
}
public static List<ToolInterceptor> getInterceptors() {
return Collections.unmodifiableList(GLOBAL_INTERCEPTORS);
}
public static void clear() {
GLOBAL_INTERCEPTORS.clear();
}
}

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.model.chat.tool;
public class MethodParameter extends Parameter {
protected Class<?> typeClass;
public Class<?> getTypeClass() {
return typeClass;
}
public void setTypeClass(Class<?> typeClass) {
this.typeClass = typeClass;
}
}

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.model.chat.tool;
import com.easyagents.core.convert.ConvertService;
import com.easyagents.core.model.chat.tool.annotation.ToolDef;
import com.easyagents.core.model.chat.tool.annotation.ToolParam;
import org.jetbrains.annotations.NotNull;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class MethodTool extends BaseTool {
private Class<?> clazz;
private Object object;
private Method method;
public Class<?> getClazz() {
return clazz;
}
public void setClazz(Class<?> clazz) {
this.clazz = clazz;
}
public Object getObject() {
return object;
}
public void setObject(Object object) {
this.object = object;
}
public Method getMethod() {
return method;
}
public void setMethod(Method method) {
this.method = method;
ToolDef toolDef = method.getAnnotation(ToolDef.class);
this.name = toolDef.name();
this.description = toolDef.description();
List<MethodParameter> parameterList = new ArrayList<>();
java.lang.reflect.Parameter[] methodParameters = method.getParameters();
for (java.lang.reflect.Parameter methodParameter : methodParameters) {
MethodParameter parameter = getParameter(methodParameter);
parameterList.add(parameter);
}
this.parameters = parameterList.toArray(new MethodParameter[]{});
}
@NotNull
private static MethodParameter getParameter(java.lang.reflect.Parameter methodParameter) {
ToolParam toolParam = methodParameter.getAnnotation(ToolParam.class);
MethodParameter parameter = new MethodParameter();
parameter.setName(toolParam.name());
parameter.setDescription(toolParam.description());
parameter.setType(methodParameter.getType().getSimpleName().toLowerCase());
parameter.setTypeClass(methodParameter.getType());
parameter.setRequired(toolParam.required());
parameter.setEnums(toolParam.enums());
return parameter;
}
public Object invoke(Map<String, Object> argsMap) {
try {
Object[] args = new Object[this.parameters.length];
for (int i = 0; i < this.parameters.length; i++) {
MethodParameter parameter = (MethodParameter) this.parameters[i];
Object value = argsMap.get(parameter.getName());
args[i] = ConvertService.convert(value, parameter.getTypeClass());
}
return method.invoke(object, args);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,167 @@
/*
* 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.tool;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
public class Parameter implements Serializable {
protected String name;
protected String type;
protected String description;
protected String[] enums;
protected boolean required = false;
protected Object defaultValue;
protected List<Parameter> children;
// --- getters and setters (keep your existing ones) ---
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String[] getEnums() {
return enums != null ? enums.clone() : null; // defensive copy
}
public void setEnums(String[] enums) {
this.enums = enums != null ? enums.clone() : null;
}
public boolean isRequired() {
return required;
}
public void setRequired(boolean required) {
this.required = required;
}
public Object getDefaultValue() {
return defaultValue;
}
public void setDefaultValue(Object defaultValue) {
this.defaultValue = defaultValue;
}
public List<Parameter> getChildren() {
return children != null ? new ArrayList<>(children) : null; // defensive copy
}
public void setChildren(List<Parameter> children) {
this.children = children != null ? new ArrayList<>(children) : null;
}
public void addChild(Parameter parameter) {
if (children == null) {
children = new ArrayList<>();
}
children.add(parameter);
}
// --- Static builder factory method ---
public static Builder builder() {
return new Builder();
}
// --- Builder inner class ---
public static class Builder {
private String name;
private String type;
private String description;
private String[] enums;
private boolean required = false;
private Object defaultValue;
private List<Parameter> children;
public Builder name(String name) {
this.name = name;
return this;
}
public Builder type(String type) {
this.type = type;
return this;
}
public Builder description(String description) {
this.description = description;
return this;
}
public Builder enums(String... enums) {
this.enums = enums != null ? enums.clone() : null;
return this;
}
public Builder required(boolean required) {
this.required = required;
return this;
}
public Builder defaultValue(Object defaultValue) {
this.defaultValue = defaultValue;
return this;
}
public Builder addChild(Parameter child) {
if (this.children == null) {
this.children = new ArrayList<>();
}
this.children.add(child);
return this;
}
public Builder children(List<Parameter> children) {
this.children = children != null ? new ArrayList<>(children) : null;
return this;
}
public Parameter build() {
Parameter param = new Parameter();
param.setName(name);
param.setType(type);
param.setDescription(description);
param.setEnums(enums); // uses defensive copy internally
param.setRequired(required);
param.setDefaultValue(defaultValue);
param.setChildren(children); // uses defensive copy internally
return param;
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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.tool;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
public interface Tool {
String getName();
String getDescription();
Parameter[] getParameters();
Object invoke(Map<String, Object> argsMap);
static Tool.Builder builder() {
return new Tool.Builder();
}
class Builder {
private String name;
private String description;
private final List<Parameter> parameters = new ArrayList<>();
private Function<Map<String, Object>, Object> invoker;
public Builder name(String name) {
this.name = name;
return this;
}
public Builder description(String description) {
this.description = description;
return this;
}
public Builder addParameter(Parameter parameter) {
this.parameters.add(parameter);
return this;
}
public Builder function(Function<Map<String, Object>, Object> function) {
this.invoker = function;
return this;
}
public Tool build() {
FunctionTool tool = new FunctionTool();
tool.setName(name);
tool.setDescription(description);
tool.setParameters(parameters.toArray(new Parameter[0]));
tool.setInvoker(invoker);
return tool;
}
}
}

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.model.chat.tool;
public interface ToolChain {
Object proceed(ToolContext context) throws Exception;
}

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.model.chat.tool;
import com.easyagents.core.message.ToolCall;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
/**
* 函数调用上下文,贯穿整个拦截链。
*/
public class ToolContext implements Serializable {
private final Tool tool;
private final ToolCall toolCall;
private final Map<String, Object> attributes = new HashMap<>();
public ToolContext(Tool tool, ToolCall toolCall) {
this.tool = tool;
this.toolCall = toolCall;
}
public Tool getTool() {
return tool;
}
public ToolCall getToolCall() {
return toolCall;
}
public Map<String, Object> getArgsMap() {
return toolCall.getArgsMap();
}
public void setAttribute(String key, Object value) {
attributes.put(key, value);
}
@SuppressWarnings("unchecked")
public <T> T getAttribute(String key) {
return (T) attributes.get(key);
}
public Map<String, Object> getAttributes() {
return attributes;
}
}

View File

@@ -0,0 +1,60 @@
/*
* 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.tool;
import com.easyagents.core.message.ToolCall;
public final class ToolContextHolder {
private static final ThreadLocal<ToolContext> CONTEXT_HOLDER = new ThreadLocal<>();
private ToolContextHolder() {
// 工具类,禁止实例化
}
public static ToolContextScope beginExecute(Tool tool, ToolCall toolCall) {
ToolContext ctx = new ToolContext(tool, toolCall);
CONTEXT_HOLDER.set(ctx);
return new ToolContextScope(ctx);
}
public static ToolContext currentContext() {
return CONTEXT_HOLDER.get();
}
public static void clear() {
CONTEXT_HOLDER.remove();
}
/**
* 用于 try-with-resources 的作用域对象,确保上下文自动清理。
*/
public static class ToolContextScope implements AutoCloseable {
ToolContext context;
public ToolContextScope(ToolContext context) {
this.context = context;
}
@Override
public void close() {
clear();
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* 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.tool;
import com.easyagents.core.message.ToolCall;
import java.util.*;
/**
* 函数调用执行器,支持责任链拦截。
* <p>
* 执行顺序:全局拦截器 → 用户拦截器 → 实际函数调用。
*/
public class ToolExecutor {
private final Tool tool;
private final ToolCall toolCall;
private List<ToolInterceptor> interceptors;
public ToolExecutor(Tool tool, ToolCall toolCall) {
this(tool, toolCall, null);
}
public ToolExecutor(Tool tool, ToolCall toolCall,
List<ToolInterceptor> userInterceptors) {
this.tool = tool;
this.toolCall = toolCall;
this.interceptors = buildInterceptorChain(userInterceptors);
}
private List<ToolInterceptor> buildInterceptorChain(
List<ToolInterceptor> userInterceptors) {
// 1. 全局拦截器
List<ToolInterceptor> chain = new ArrayList<>(GlobalToolInterceptors.getInterceptors());
// 2. 用户拦截器
if (userInterceptors != null) {
chain.addAll(userInterceptors);
}
return chain;
}
/**
* 动态添加拦截器(添加到链尾)
*/
public void addInterceptor(ToolInterceptor interceptor) {
if (interceptors == null) {
interceptors = new ArrayList<>();
}
this.interceptors.add(interceptor);
}
public void addInterceptors(List<ToolInterceptor> interceptors) {
if (interceptors == null) {
interceptors = new ArrayList<>();
}
this.interceptors.addAll(interceptors);
}
/**
* 执行函数调用,触发拦截链。
*
* @return 函数返回值
* @throws RuntimeException 包装原始异常
*/
public Object execute() {
try (ToolContextHolder.ToolContextScope scope = ToolContextHolder.beginExecute(tool, toolCall)) {
ToolChain chain = buildChain(0);
return chain.proceed(scope.context);
} catch (Exception e) {
if (e instanceof RuntimeException) {
throw (RuntimeException) e;
} else {
throw new RuntimeException("Error invoking function: " + tool.getName(), e);
}
}
}
private ToolChain buildChain(int index) {
if (index >= interceptors.size()) {
return ctx -> ctx.getTool().invoke(ctx.getArgsMap());
}
ToolInterceptor current = interceptors.get(index);
ToolChain next = buildChain(index + 1);
return ctx -> current.intercept(ctx, next);
}
public Tool getTool() {
return tool;
}
public ToolCall getToolCall() {
return toolCall;
}
public List<ToolInterceptor> getInterceptors() {
return interceptors;
}
}

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.model.chat.tool;
/**
* 函数调用拦截器,用于在函数执行前后插入横切逻辑(如日志、监控、权限等)。
* <p>
* 通过责任链模式组合多个拦截器,最终由链尾执行实际函数调用。
*/
public interface ToolInterceptor {
/**
* 拦截函数调用。
*
* @param context 函数调用上下文
* @param chain 责任链的下一个节点
* @return 函数调用结果
* @throws Exception 通常由实际函数或拦截器抛出
*/
Object intercept(ToolContext context, ToolChain chain) throws Exception;
}

View File

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

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