初始化
This commit is contained in:
120
easy-agents-core/pom.xml
Normal file
120
easy-agents-core/pom.xml
Normal 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>
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -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:";
|
||||
}
|
||||
}
|
||||
@@ -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 等
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 允许外部设置 invoker(Builder 会用)
|
||||
public void setInvoker(Function<Map<String, Object>, Object> invoker) {
|
||||
this.invoker = invoker;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user