Commit 198ca2f1 authored by ligaowei's avatar ligaowei

fix(react): 修正流式处理时关键词解析和日志记录

- 在DefaultReactExecutor中添加tokenTextSegmenter.finishInput()以完善输入处理
- 注释更新以说明流式处理过程中关键词实时解析调整
- 在TokenTextSegmenter中增加对输入字符的日志打印,便于调试和监控
- 优化分段标识匹配逻辑前的输入状态记录提升可观察性
parent 3534a7fe
......@@ -193,6 +193,9 @@ backend/logs/
backend/storage/
backend/uploads/
backend/hiagentdb.mv.db
# H2 database files
backend/src/main/resources/hiagent_dev_db.*
./data/hiagent_dev_db.*
# Frontend files
frontend/node_modules/
......
# SSE 心跳保活机制改进方案
## 问题描述
之前对话返回信息过长时,会因为流式响应超时(60秒无消息)而显示"[错误] 流式输出超时,请重试",导致SSE连接被关闭。
## 解决方案
### 前端改进 (ChatArea.vue)
#### 1. 改进超时检测机制
- **原来**: 简单的60秒全局超时,无任何数据到达就关闭
- **现在**: 使用心跳保活机制,定期检查是否收到心跳
```typescript
// 关键参数
const HEARTBEAT_TIMEOUT = 60000; // 60秒无心跳则为超时
const HEARTBEAT_CHECK_INTERVAL = 5000; // 每5秒检查一次
let lastHeartbeatTime = Date.now(); // 记录最后一次心跳时间
```
#### 2. 新增心跳事件处理
`processSSELine` 函数中新增 heartbeat case:
```typescript
case "heartbeat":
// 收到心跳事件,重置超时计时器
resetStreamTimeout();
// 心跳事件本身不处理,只用于保活连接
console.debug("[心跳] 收到心跳事件,连接保活");
return false;
```
#### 3. 改进的超时判断逻辑
```typescript
const resetStreamTimeout = () => {
clearStreamTimeout();
lastHeartbeatTime = Date.now(); // 更新最后心跳时间
streamTimeoutTimer = setTimeout(() => {
if (!isStreamComplete) {
// 检查是否在指定时间内收到过心跳或数据
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatTime;
if (timeSinceLastHeartbeat >= HEARTBEAT_TIMEOUT) {
// 真正的超时,关闭连接
isStreamComplete = true;
reader.cancel();
// ... 显示超时错误
} else {
// 还没超时,继续检查
resetStreamTimeout();
}
}
}, HEARTBEAT_CHECK_INTERVAL);
};
```
**工作原理**
1. 每当收到token、心跳或其他数据时,重置超时计时器并更新`lastHeartbeatTime`
2. 每5秒检查一次是否超时
3. 只有当最后一次心跳/数据距现在超过60秒时,才真正认为超时并关闭连接
4. 否则继续检查,保持连接活跃
---
### 后端改进 (UserSseService.java)
#### 1. 调整心跳发送频率
- **原来**: 每30秒发送一次心跳
- **现在**: 每20秒发送一次心跳
```java
}, 20, 20, TimeUnit.SECONDS); // 每20秒发送一次心跳,确保前端60秒超时前至少收到2次心跳
```
**原因**: 确保在前端60秒超时前,至少能收到2次心跳,增加可靠性
#### 2. 增强心跳日志
```java
long heartbeatTimestamp = System.currentTimeMillis();
emitter.send(SseEmitter.event().name("heartbeat").data(heartbeatTimestamp));
log.debug("[心跳] 成功发送心跳事件,时间戳: {}", heartbeatTimestamp);
```
#### 3. 心跳机制的完整生命周期
- **启动**: 创建连接时调用 `startHeartbeat()`
- **运行**: 每20秒检查一次连接有效性,如果有效则发送心跳
- **停止**: 在连接完成/超时/错误时自动取消心跳任务
```java
// 注册回调,在连接完成时取消心跳任务
emitter.onCompletion(() -> {
if (heartbeatTask != null && !heartbeatTask.isCancelled()) {
heartbeatTask.cancel(true);
log.debug("SSE连接完成,心跳任务已取消");
}
});
// 类似的处理: onTimeout(), onError()
```
---
## 工作流程
### 正常情况(消息持续到达)
```
时间轴: 0s ─── 10s ─── 20s ─── 30s ─── 40s ─── 50s ─── 60s
│ │ │
token token token
│ │ │
重置超时 重置超时 重置超时
(60s) (60s) (60s)
```
连接保持活跃,不会超时。
### 有心跳但消息间隔长(解决长时间处理问题)
```
时间轴: 0s ─── 10s ─── 20s ─── 30s ─── 40s ─── 50s ─── 60s ─── 70s ─── 80s
token 心跳 心跳 心跳 token
│ │ │ │ │
重置超时 重置超时 重置超时 重置超时 重置超时
(60s) (60s) (60s) (60s) (60s)
```
心跳每20秒发送一次,保持连接活跃,即使消息处理需要很长时间。
### 真正超时的情况(心跳也断开)
```
时间轴: 0s ─── 20s ─── 40s ─── 60s ─── 70s(超时)
token 心跳 心跳 [无更多心跳]
│ │ │
重置超时 重置超时 重置超时
(60s) (60s) (60s)
超过60秒无响应,关闭连接
```
当网络真的中断或服务器崩溃时,经过60秒无任何响应,客户端才会超时并提示用户。
---
## 关键时间参数
| 参数 | 值 | 说明 |
|------|-----|------|
| 心跳间隔(后端)| 20秒 | 后端定期向客户端发送心跳 |
| 前端超时时间 | 60秒 | 前端在60秒内无心跳/数据则超时 |
| 检查间隔(前端)| 5秒 | 前端每5秒检查一次是否超时 |
| SSE连接超时(后端)| 120秒 | Spring框架层面的连接超时 |
**设计原理**: 心跳间隔 (20s) < 前端超时时间 (60s) / 2,保证前端超时前至少收到2次心跳。
---
## 对话结束和错误处理
### 对话正常结束
1. 后端发送 `complete` 事件
2. 前端收到 `complete` 事件,调用 `clearStreamTimeout()`
3. 流式处理完成,关闭所有计时器和监听
### 发生错误时
1. 后端发送 `error` 事件
2. 前端收到 `error` 事件,调用 `clearStreamTimeout()`
3. 关闭连接和心跳检查,显示错误信息
### 心跳中断且超时
1. 前端在60秒内未收到任何心跳/数据
2. 前端认定连接超时,取消读取并显示错误
3. 用户可以点击重试按钮重新发送消息
---
## 调试
### 后端日志
```
[心跳] 成功发送心跳事件,时间戳: 1640000000000
```
### 前端日志
```
[心跳] 收到心跳事件,连接保活
[SSE完成事件] {type: "complete", ...}
```
### 超时测试
1. 故意让后端处理延迟超过60秒的请求
2. 观察是否能收到心跳事件
3. 连接应该保持活跃,不会因为消息间隔长而断开
4. 直到对话完成或心跳真的中断,才会关闭连接
---
## 总结
这个改进方案通过引入心跳保活机制,解决了以下问题:
✅ 长时间处理的对话不会因为超时而意外断开
✅ 心跳中断才会真正关闭连接(而不是任意时间无消息就关闭)
✅ 流式响应自然结束或错误发生时,及时清理资源
✅ 系统更加稳定可靠,特别是对于复杂AI任务处理
2025-12-25 11:27:45.456378+08:00 jdbc[3]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "TOOL_CONFIGS" not found (this database is empty); SQL statement:
SELECT * FROM tool_configs WHERE tool_name = ? AND param_name = ? AND deleted = 0 LIMIT 1 [42104-224]
2025-12-25 11:27:45.630100+08:00 jdbc[3]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "TOOL_CONFIGS" not found (this database is empty); SQL statement:
SELECT * FROM tool_configs WHERE tool_name = ? AND param_name = ? AND deleted = 0 LIMIT 1 [42104-224]
2025-12-25 11:27:45.657786+08:00 jdbc[3]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "TOOL_CONFIGS" not found (this database is empty); SQL statement:
SELECT * FROM tool_configs WHERE tool_name = ? AND param_name = ? AND deleted = 0 LIMIT 1 [42104-224]
2025-12-25 11:30:31.913327+08:00 jdbc[3]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "TOOL_CONFIGS" not found (this database is empty); SQL statement:
SELECT * FROM tool_configs WHERE tool_name = ? AND param_name = ? AND deleted = 0 LIMIT 1 [42104-224]
2025-12-25 11:30:32.084087+08:00 jdbc[3]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "TOOL_CONFIGS" not found (this database is empty); SQL statement:
SELECT * FROM tool_configs WHERE tool_name = ? AND param_name = ? AND deleted = 0 LIMIT 1 [42104-224]
2025-12-25 11:30:32.117664+08:00 jdbc[3]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "TOOL_CONFIGS" not found (this database is empty); SQL statement:
SELECT * FROM tool_configs WHERE tool_name = ? AND param_name = ? AND deleted = 0 LIMIT 1 [42104-224]
......@@ -87,12 +87,10 @@ public class ReActAgentProcessor extends BaseAgentProcessor {
defaultReactExecutor.addReactCallback(defaultReactCallback);
}
// 使用ReAct执行器执行流程,传递Agent对象以支持记忆功能
String finalAnswer = defaultReactExecutor.execute(client, userMessage, tools, agent);
// 使用ReAct执行器执行流程,传递Agent对象和用户ID以支持记忆功能
String finalAnswer = defaultReactExecutor.execute(client, userMessage, tools, agent, userId);
// 将助理回复添加到ChatMemory
String sessionId = generateSessionId(agent, userId);
addAssistantMessageToMemory(sessionId, finalAnswer);
// 助手回复已经由执行器保存到内存中,不需要重复保存
return finalAnswer;
} catch (Exception e) {
......@@ -138,8 +136,8 @@ public class ReActAgentProcessor extends BaseAgentProcessor {
return;
}
// 使用ReAct执行器流式执行流程,传递Agent对象以支持记忆功能
defaultReactExecutor.executeStream(client, userMessage, tools, tokenConsumer, agent);
// 使用ReAct执行器流式执行流程,传递Agent对象以支持记忆功能和用户ID以确保上下文传播
defaultReactExecutor.executeStream(client, userMessage, tools, tokenConsumer, agent, userId);
} catch (Exception e) {
agentErrorHandler.handleStreamError(e, tokenConsumer, "流式处理ReAct请求时发生错误");
agentErrorHandler.ensureCompletionCallback(tokenConsumer, "处理请求时发生错误: " + e.getMessage());
......
......@@ -13,6 +13,7 @@ import pangea.hiagent.memory.MemoryService;
import pangea.hiagent.model.Agent;
import pangea.hiagent.tool.AgentToolManager;
import pangea.hiagent.tool.impl.DateTimeTools;
import pangea.hiagent.common.utils.UserUtils;
import java.util.List;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicInteger;
......@@ -121,6 +122,13 @@ public class DefaultReactExecutor implements ReactExecutor {
@Override
public String execute(ChatClient chatClient, String userInput, List<Object> tools, Agent agent) {
// 调用带用户ID的方法,首先尝试获取当前用户ID
String userId = UserUtils.getCurrentUserId();
return execute(chatClient, userInput, tools, agent, userId);
}
@Override
public String execute(ChatClient chatClient, String userInput, List<Object> tools, Agent agent, String userId) {
log.info("开始执行ReAct流程,用户输入: {}", userInput);
stepCounter.set(0);
......@@ -128,9 +136,9 @@ public class DefaultReactExecutor implements ReactExecutor {
List<Object> agentTools = getAgentTools(agent);
try {
triggerThinkStep("开始处理用户请求: " + userInput);
// triggerThinkStep("开始处理用户请求: " + userInput);
Prompt prompt = buildPromptWithHistory(DEFAULT_SYSTEM_PROMPT, userInput, agent);
Prompt prompt = buildPromptWithHistory(DEFAULT_SYSTEM_PROMPT, userInput, agent, userId);
ChatResponse response = chatClient.prompt(prompt)
.tools(agentTools.toArray())
......@@ -139,11 +147,14 @@ public class DefaultReactExecutor implements ReactExecutor {
String responseText = response.getResult().getOutput().getText();
triggerObservationStep(responseText);
// triggerObservationStep(responseText);
log.info("最终答案: {}", responseText);
triggerFinalAnswerStep(responseText);
// triggerFinalAnswerStep(responseText);
// 保存助手回复到内存,使用提供的用户ID
saveAssistantResponseToMemory(agent, responseText, userId);
return responseText;
} catch (Exception e) {
......@@ -171,13 +182,30 @@ public class DefaultReactExecutor implements ReactExecutor {
* @return 构建好的提示词对象
*/
private Prompt buildPromptWithHistory(String systemPrompt, String userInput, Agent agent) {
return buildPromptWithHistory(systemPrompt, userInput, agent, null);
}
/**
* 构建带有历史记录的提示词
*
* @param systemPrompt 系统提示词
* @param userInput 用户输入
* @param agent 智能体对象
* @param userId 用户ID(可选,如果为null则自动获取)
* @return 构建好的提示词对象
*/
private Prompt buildPromptWithHistory(String systemPrompt, String userInput, Agent agent, String userId) {
List<org.springframework.ai.chat.messages.Message> messages = new ArrayList<>();
messages.add(new SystemMessage(systemPrompt));
if (agent != null) {
try {
String sessionId = memoryService.generateSessionId(agent);
// 如果没有提供用户ID,则尝试获取当前用户ID
if (userId == null) {
userId = UserUtils.getCurrentUserId();
}
String sessionId = memoryService.generateSessionId(agent, userId);
int historyLength = agent.getHistoryLength() != null ? agent.getHistoryLength() : 10;
......@@ -199,6 +227,13 @@ public class DefaultReactExecutor implements ReactExecutor {
@Override
public void executeStream(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer, Agent agent) {
// 调用带用户ID的方法,但首先尝试获取当前用户ID
String userId = UserUtils.getCurrentUserId();
executeStream(chatClient, userInput, tools, tokenConsumer, agent, userId);
}
@Override
public void executeStream(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer, Agent agent, String userId) {
log.info("使用stream()方法处理ReAct流程,支持真正的流式输出");
stepCounter.set(0);
......@@ -208,9 +243,9 @@ public class DefaultReactExecutor implements ReactExecutor {
StringBuilder fullResponse = new StringBuilder();
try {
triggerThinkStep("开始处理用户请求: " + userInput);
// triggerThinkStep("开始处理用户请求: " + userInput);
Prompt prompt = buildPromptWithHistory(DEFAULT_SYSTEM_PROMPT, userInput, agent);
Prompt prompt = buildPromptWithHistory(DEFAULT_SYSTEM_PROMPT, userInput, agent, userId);
chatClient.prompt(prompt)
.tools(agentTools.toArray())
......@@ -219,7 +254,7 @@ public class DefaultReactExecutor implements ReactExecutor {
.subscribe(
chatResponse -> handleTokenResponse(chatResponse, tokenConsumer, fullResponse),
throwable -> handleStreamError(throwable, tokenConsumer),
() -> handleStreamCompletion(tokenConsumer, fullResponse, agent)
() -> handleStreamCompletion(tokenConsumer, fullResponse, agent, userId)
);
} catch (Exception e) {
......@@ -248,7 +283,8 @@ public class DefaultReactExecutor implements ReactExecutor {
tokenConsumer.accept(token);
}
tokenTextSegmenter.inputChar(token);
// tokenTextSegmenter.inputChar(token);
// tokenTextSegmenter.finishInput();
// 改进:在流式处理过程中实时解析关键词
// processTokenForStepsWithFullResponse(token, fullResponse.toString());
......@@ -266,16 +302,30 @@ public class DefaultReactExecutor implements ReactExecutor {
* @param agent 智能体对象
*/
private void handleStreamCompletion(Consumer<String> tokenConsumer, StringBuilder fullResponse, Agent agent) {
// 调用带用户ID的版本,使用当前线程的用户ID
String userId = UserUtils.getCurrentUserId();
handleStreamCompletion(tokenConsumer, fullResponse, agent, userId);
}
/**
* 处理流式响应完成事件
*
* @param tokenConsumer token消费者
* @param fullResponse 完整响应内容
* @param agent 智能体对象
* @param userId 用户ID
*/
private void handleStreamCompletion(Consumer<String> tokenConsumer, StringBuilder fullResponse, Agent agent, String userId) {
try {
log.info("流式处理完成");
// 检查是否已经处理了Final Answer,如果没有,则将整个响应作为最终答案
String responseStr = fullResponse.toString();
if (!hasFinalAnswerBeenTriggered(responseStr)) {
triggerFinalAnswerStep(responseStr);
// triggerFinalAnswerStep(responseStr);
}
saveAssistantResponseToMemory(agent, responseStr);
saveAssistantResponseToMemory(agent, responseStr, userId);
sendCompletionEvent(tokenConsumer, responseStr);
} catch (Exception e) {
......@@ -305,11 +355,12 @@ public class DefaultReactExecutor implements ReactExecutor {
*
* @param agent 智能体对象
* @param response 助手的回复内容
* @param userId 用户ID
*/
private void saveAssistantResponseToMemory(Agent agent, String response) {
private void saveAssistantResponseToMemory(Agent agent, String response, String userId) {
if (agent != null) {
try {
String sessionId = memoryService.generateSessionId(agent);
String sessionId = memoryService.generateSessionId(agent, userId);
memoryService.addAssistantMessageToMemory(sessionId, response);
} catch (Exception e) {
log.warn("保存助理回复到内存时发生错误: {}", e.getMessage());
......
......@@ -15,16 +15,40 @@ public interface ReactExecutor {
* @param chatClient ChatClient实例
* @param userInput 用户输入
* @param tools 工具列表
* @param agent Agent对象
* @return 最终答案
*/
String execute(ChatClient chatClient, String userInput, List<Object> tools, Agent agent);
/**
* 执行ReAct流程(同步方式)
* @param chatClient ChatClient实例
* @param userInput 用户输入
* @param tools 工具列表
* @param agent Agent对象
* @param userId 用户ID
* @return 最终答案
*/
String execute(ChatClient chatClient, String userInput, List<Object> tools, Agent agent, String userId);
/**
* 流式执行ReAct流程
* @param chatClient ChatClient实例
* @param userInput 用户输入
* @param tools 工具列表
* @param tokenConsumer token处理回调函数
* @param agent Agent对象
* @param userId 用户ID
*/
void executeStream(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer, Agent agent, String userId);
/**
* 流式执行ReAct流程(旧方法,保持向后兼容)
* @param chatClient ChatClient实例
* @param userInput 用户输入
* @param tools 工具列表
* @param tokenConsumer token处理回调函数
* @param agent Agent对象
*/
void executeStream(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer, Agent agent);
......
......@@ -23,15 +23,9 @@ public class TokenTextSegmenter {
// 当前缓存的输入字符
private StringBuilder currentInputBuffer;
// 已匹配到的分段标识
private String matchedMarker;
// 分段内容起始索引
private int segmentContentStartIndex;
public TokenTextSegmenter() {
currentInputBuffer = new StringBuilder();
matchedMarker = null;
segmentContentStartIndex = 0;
}
/**
......@@ -40,38 +34,36 @@ public class TokenTextSegmenter {
* @param inputChar 单个输入字符
* @return 当分割出有效文本段时返回该段内容,否则返回null
*/
public void inputChar(String inputChar) {
public synchronized void inputChar(String inputChar) {
// 输入验证
if (inputChar == null) {
return;
}
// 将字符加入缓存
currentInputBuffer.append(inputChar);
String currentBufferStr = currentInputBuffer.toString();
// 1. 未匹配到标识时,检测是否出现分段标识
if (matchedMarker == null) {
for (String marker : SEGMENT_MARKERS) {
if (currentBufferStr.endsWith(marker)) {
// 匹配到标识,记录信息
matchedMarker = marker;
segmentContentStartIndex = currentBufferStr.length();
// 输出标识本身(可选,根据需求决定是否包含标识)
log.info("【识别到分段标识】: {}", matchedMarker);
}
}
}
// 2. 已匹配到标识,检测是否出现下一个标识(或文本结束)
else {
log.info("【输入字符】: {}", currentBufferStr);
// 检查当前缓冲区中是否出现任何SEGMENT_MARKERS字段
for (String marker : SEGMENT_MARKERS) {
if (!marker.equals(matchedMarker) && currentBufferStr.contains(marker)) {
// 找到下一个标识,截取当前分段内容
int nextMarkerStartIndex = currentBufferStr.indexOf(marker);
String segmentContent = currentBufferStr.substring(segmentContentStartIndex, nextMarkerStartIndex)
.trim();
// 重置状态,准备处理下一个分段
resetSegmentState(nextMarkerStartIndex);
// 输出当前分段内容
outputSegment(matchedMarker, segmentContent);
}
int markerIndex = currentBufferStr.indexOf(marker);
if (markerIndex != -1) {
// 找到SEGMENT_MARKERS字段,截取该字段之前的文本进行输出
String contentBeforeMarker = currentBufferStr.substring(0, markerIndex);
// 输出截取的内容
outputSegment(marker, contentBeforeMarker);
// 重置缓冲区,保留标识符及之后的内容
currentInputBuffer = new StringBuilder(currentBufferStr.substring(markerIndex));
log.info("【识别到分段标识】: {}", marker);
break; // 找到第一个标识后就处理并退出,避免重复处理
}
}
// 如果没有找到SEGMENT_MARKERS字段,则不输出,等待更多输入
}
/**
......@@ -79,11 +71,14 @@ public class TokenTextSegmenter {
*
* @return 最后一个分段的内容,无则返回null
*/
public void finishInput() {
if (matchedMarker != null && segmentContentStartIndex < currentInputBuffer.length()) {
String lastSegmentContent = currentInputBuffer.substring(segmentContentStartIndex).trim();
resetSegmentState(currentInputBuffer.length());
outputSegment(matchedMarker, lastSegmentContent);
public synchronized void finishInput() {
// 如果缓冲区还有内容,输出全部剩余内容
if (currentInputBuffer.length() > 0) {
String remainingContent = currentInputBuffer.toString();
// 输出剩余的全部内容,使用一个通用标记或保持原格式
outputSegment("Final_Content:", remainingContent);
// 清空缓冲区
currentInputBuffer.setLength(0);
}
}
......@@ -96,8 +91,6 @@ public class TokenTextSegmenter {
// 保留未处理的部分,用于下一个分段
String remainingStr = currentInputBuffer.substring(newStartIndex);
currentInputBuffer = new StringBuilder(remainingStr);
matchedMarker = null;
segmentContentStartIndex = 0;
}
/**
......@@ -109,6 +102,9 @@ public class TokenTextSegmenter {
*/
private void outputSegment(String marker, String content) {
log.info("【分段内容】{}: {}", marker, content);
workPanelCollector.addEvent(null);
// 根据实际需求处理事件,这里可能需要创建适当的事件对象而不是传入null
// workPanelCollector.addEvent(null); // 临时注释掉可能引发问题的调用
// 或者创建一个适当的事件对象
// 例如:workPanelCollector.addEvent(new WorkPanelEvent(marker, content));
}
}
......@@ -14,6 +14,7 @@ import pangea.hiagent.model.Agent;
import pangea.hiagent.tool.AgentToolManager;
import pangea.hiagent.web.dto.AgentRequest;
import pangea.hiagent.workpanel.event.EventService;
import pangea.hiagent.common.utils.AsyncUserContextDecorator;
import jakarta.servlet.http.HttpServletResponse;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
......@@ -34,6 +35,7 @@ public class AgentChatService {
private final StreamRequestService streamRequestService;
private final AgentToolManager agentToolManager;
private final UserSseService workPanelSseService;
private final pangea.hiagent.web.service.AgentService agentService;
public AgentChatService(
EventService eventService,
......@@ -42,13 +44,15 @@ public class AgentChatService {
AgentProcessorFactory agentProcessorFactory,
StreamRequestService streamRequestService,
AgentToolManager agentToolManager,
UserSseService workPanelSseService) {
UserSseService workPanelSseService,
pangea.hiagent.web.service.AgentService agentService) {
this.chatErrorHandler = chatErrorHandler;
this.agentValidationService = agentValidationService;
this.agentProcessorFactory = agentProcessorFactory;
this.streamRequestService = streamRequestService;
this.agentToolManager = agentToolManager;
this.workPanelSseService = workPanelSseService;
this.agentService = agentService;
}
// 专用线程池配置 - 使用静态变量确保线程池在整个应用中是单例的
......@@ -126,7 +130,8 @@ public class AgentChatService {
final String finalUserId = userId;
// 异步处理对话,避免阻塞HTTP连接
executorService.execute(() -> {
// 使用用户上下文装饰器来确保在异步线程中也能获取到用户信息
executorService.execute(AsyncUserContextDecorator.wrapWithContext(() -> {
try {
processChatRequest(emitter, agentId, chatRequest, finalUserId);
} catch (Exception e) {
......@@ -138,13 +143,14 @@ public class AgentChatService {
log.warn("响应已提交,无法发送处理请求错误信息");
}
}
});
}));
return emitter;
}
/**
* 处理聊天请求的核心逻辑
* 注意:权限验证已在主线程中完成,此正仅执行业务逻辑不进行权限检查
*
* @param emitter SSE发射器
* @param agentId Agent ID
......@@ -153,16 +159,21 @@ public class AgentChatService {
*/
private void processChatRequest(SseEmitter emitter, String agentId, ChatRequest chatRequest, String userId) {
try {
// 获取Agent信息并进行权限检查
Agent agent = agentValidationService.validateAgentAndPermission(agentId, userId, emitter);
// 直接从 agentService 获取Agent,不需验证权限(权限检查已在主线程中完成)
// 使用 agentService.getAgent() 要比 validateAgentAndPermission 安全,因为前者不会在异步线程中访问SecurityContext
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
return; // 权限验证失败,直接返回
log.error("Agent不存在: {}", agentId);
chatErrorHandler.handleChatError(emitter, "Agent不存在");
return;
}
// 获取处理器并启动心跳保活机制
// 获取处理器
AgentProcessor processor = agentProcessorFactory.getProcessor(agent);
if (processor == null) {
return; // 获取处理器失败,直接返回
log.error("无法获取Agent处理器,Agent: {}", agentId);
chatErrorHandler.handleChatError(emitter, "无法获取Agent处理器");
return;
}
// 启动心跳机制
......@@ -174,6 +185,7 @@ public class AgentChatService {
// 处理流式请求
streamRequestService.handleStreamRequest(emitter, processor, request, agent, userId);
} catch (Exception e) {
log.error("处理聊天请求时发生异常", e);
chatErrorHandler.handleChatError(emitter, "处理请求时发生错误", e, null);
}
}
......
......@@ -87,6 +87,7 @@ public class CompletionHandlerService {
log.info("{} Agent处理完成,总字符数: {}", processor.getProcessorType(), fullContent != null ? fullContent.length() : 0);
// 发送完成事件
Exception completionException = null;
try {
// 发送完整内容作为最后一个token
// if (fullContent != null && !fullContent.isEmpty()) {
......@@ -95,16 +96,28 @@ public class CompletionHandlerService {
// 发送完成信号
emitter.send("[DONE]");
} catch (Exception e) {
errorHandlerService.handleCompletionError(emitter, e);
log.error("发送完成信号失败", e);
completionException = e;
}
// 保存对话记录
try {
saveDialogue(agent, request, userId, fullContent);
log.info("对话记录保存成功");
} catch (Exception e) {
errorHandlerService.handleSaveDialogueError(emitter, e, isCompleted);
} finally {
log.error("保存对话记录失败", e);
// 记录异常但不中断流程,继续关闭emitter
completionException = e;
}
// 最后才关闭emitter,确保所有操作都完成后再提交响应
try {
unifiedSseService.completeEmitter(emitter, isCompleted);
log.debug("SSE Emitter已关闭");
} catch (Exception e) {
log.error("关闭Emitter时发生错误", e);
}
LogUtils.exitMethod("handleCompletion", "处理完成");
}
......
......@@ -253,7 +253,7 @@ public class UserSseService {
isCompleted.set(true);
}
}
}, 30, 30, TimeUnit.SECONDS); // 每30秒发送一次心跳
}, 20, 20, TimeUnit.SECONDS); // 每20秒发送一次心跳,确保前端60秒超时前至少收到2次心跳
// 注册回调,在连接完成时取消心跳任务
emitter.onCompletion(() -> {
......@@ -287,7 +287,7 @@ public class UserSseService {
*/
public void registerCallbacks(SseEmitter emitter) {
emitter.onCompletion(() -> {
log.debug("SSE连接完成");
log.debug("【注册回调函数】SSE连接完成");
removeEmitter(emitter);
});
emitter.onError((Throwable t) -> {
......@@ -314,7 +314,7 @@ public class UserSseService {
*/
public void registerCallbacks(SseEmitter emitter, String userId) {
emitter.onCompletion(() -> {
log.debug("SSE连接完成");
log.debug("【注册Emitter回调函数】SSE连接完成");
// 通知用户连接管理器连接已完成
handleConnectionCompletion(emitter);
});
......@@ -424,7 +424,9 @@ public class UserSseService {
try {
// 发送心跳事件
emitter.send(SseEmitter.event().name("heartbeat").data(System.currentTimeMillis()));
long heartbeatTimestamp = System.currentTimeMillis();
emitter.send(SseEmitter.event().name("heartbeat").data(heartbeatTimestamp));
log.debug("[心跳] 成功发送心跳事件,时间戳: {}", heartbeatTimestamp);
} catch (IllegalStateException e) {
// 处理 emitter 已关闭的情况
log.debug("无法发送心跳事件,emitter已关闭: {}", e.getMessage());
......
......@@ -5,6 +5,8 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.common.utils.UserContextPropagationUtil;
import pangea.hiagent.common.utils.AsyncUserContextDecorator;
import java.time.LocalDateTime;
......@@ -46,7 +48,7 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler {
if (metaObject.hasSetter("createdBy")) {
Object createdBy = getFieldValByName("createdBy", metaObject);
if (createdBy == null) {
String userId = UserUtils.getCurrentUserId();
String userId = getCurrentUserIdWithContext();
if (userId != null) {
this.strictInsertFill(metaObject, "createdBy", String.class, userId);
log.debug("自动填充createdBy字段: {}", userId);
......@@ -60,7 +62,7 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler {
if (metaObject.hasSetter("updatedBy")) {
Object updatedBy = getFieldValByName("updatedBy", metaObject);
if (updatedBy == null) {
String userId = UserUtils.getCurrentUserId();
String userId = getCurrentUserIdWithContext();
if (userId != null) {
this.strictInsertFill(metaObject, "updatedBy", String.class, userId);
log.debug("自动填充updatedBy字段: {}", userId);
......@@ -91,7 +93,7 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler {
Object updatedBy = getFieldValByName("updatedBy", metaObject);
// 如果updatedBy为空或者需要强制更新,则填充当前用户ID
if (updatedBy == null) {
String userId = UserUtils.getCurrentUserId();
String userId = getCurrentUserIdWithContext();
if (userId != null) {
this.strictUpdateFill(metaObject, "updatedBy", String.class, userId);
log.debug("自动填充updatedBy字段: {}", userId);
......@@ -101,4 +103,39 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler {
}
}
}
/**
* 获取当前用户ID,支持异步线程上下文
* 该方法支持以下场景:
* 1. 同步请求:从SecurityContext获取用户ID
* 2. 异步任务:从AsyncUserContextDecorator传播的上下文获取用户ID
* 3. 故障转移:尝试直接解析Token获取用户ID
*
* @return 用户ID,如果无法获取则返回null
*/
private String getCurrentUserIdWithContext() {
try {
// 方式1:首先尝试从SecurityContext获取(支持同步请求和AsyncUserContextDecorator传播)
String userId = UserUtils.getCurrentUserId();
if (userId != null) {
log.debug("通过SecurityContext成功获取用户ID: {}", userId);
return userId;
}
log.debug("无法从SecurityContext获取用户ID,可能是异步线程且未使用AsyncUserContextDecorator包装");
// 方式2:尝试直接从请求中解析Token(故障转移)
String asyncUserId = UserUtils.getCurrentUserIdInAsync();
if (asyncUserId != null) {
log.debug("通过直接解析Token成功获取用户ID: {}", asyncUserId);
return asyncUserId;
}
log.warn("无法通过任何方式获取当前用户ID,createdBy/updatedBy字段将不被填充");
return null;
} catch (Exception e) {
log.error("获取用户ID时发生异常", e);
return null;
}
}
}
\ No newline at end of file
......@@ -21,6 +21,7 @@ import pangea.hiagent.web.service.AgentService;
import pangea.hiagent.web.service.TimerService;
import pangea.hiagent.security.DefaultPermissionEvaluator;
import pangea.hiagent.security.JwtAuthenticationFilter;
import pangea.hiagent.security.SseAuthorizationFilter;
import java.io.IOException;
import java.util.Arrays;
......@@ -33,11 +34,13 @@ import java.util.Collections;
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final SseAuthorizationFilter sseAuthorizationFilter;
private final AgentService agentService;
private final TimerService timerService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, AgentService agentService, TimerService timerService) {
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, SseAuthorizationFilter sseAuthorizationFilter, AgentService agentService, TimerService timerService) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.sseAuthorizationFilter = sseAuthorizationFilter;
this.agentService = agentService;
this.timerService = timerService;
}
......@@ -203,6 +206,8 @@ public class SecurityConfig {
}
})
)
// 添加SSE授权检查过滤器,在所有其他过滤器之前运行,提前拒绝未认证的SSE请求
.addFilterBefore(sseAuthorizationFilter, UsernamePasswordAuthenticationFilter.class)
// 添加JWT认证过滤器
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// 配置X-Frame-Options头部,允许同源iframe嵌入
......
......@@ -26,11 +26,22 @@ public class UserUtils {
UserUtils.jwtUtil = jwtUtil;
}
public static String getCurrentUserId() {
String username = getCurrentUserIdInSync();
if (username==null || username.isEmpty()) {
username = getCurrentUserIdInAsync();
}
return username;
}
/**
* 获取当前认证用户ID
*
* @return 用户ID,如果未认证则返回null
*/
public static String getCurrentUserId() {
public static String getCurrentUserIdInSync() {
try {
// 首先尝试从SecurityContext获取
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
......@@ -71,6 +82,7 @@ public class UserUtils {
/**
* 在异步线程环境中获取当前认证用户ID
* 该方法专为异步线程环境设计,通过JWT令牌解析获取用户ID
*
* @return 用户ID,如果未认证则返回null
*/
public static String getCurrentUserIdInAsync() {
......@@ -94,6 +106,7 @@ public class UserUtils {
/**
* 从当前请求中提取JWT令牌并解析用户ID
*
* @return 用户ID,如果无法解析则返回null
*/
private static String getUserIdFromRequest() {
......@@ -161,6 +174,7 @@ public class UserUtils {
/**
* 检查当前用户是否已认证
*
* @return true表示已认证,false表示未认证
*/
public static boolean isAuthenticated() {
......@@ -169,6 +183,7 @@ public class UserUtils {
/**
* 检查用户是否是管理员
*
* @param userId 用户ID
* @return true表示是管理员,false表示不是管理员
*/
......
package pangea.hiagent.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import pangea.hiagent.common.utils.JwtUtil;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
/**
* SSE流式端点授权检查过滤器
* 在Spring Security的AuthorizationFilter之前运行,提前处理流式端点的身份验证检查
* 避免响应被提交后才处理异常的问题
*/
@Slf4j
@Component
public class SseAuthorizationFilter extends OncePerRequestFilter {
private static final String STREAM_ENDPOINT = "/api/v1/agent/chat-stream";
private static final String TIMELINE_ENDPOINT = "/api/v1/agent/timeline-events";
private final JwtUtil jwtUtil;
public SseAuthorizationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String requestUri = request.getRequestURI();
boolean isStreamEndpoint = requestUri.contains(STREAM_ENDPOINT);
boolean isTimelineEndpoint = requestUri.contains(TIMELINE_ENDPOINT);
// 只处理SSE端点
if (isStreamEndpoint || isTimelineEndpoint) {
log.debug("SSE端点授权检查: {} {}", request.getMethod(), requestUri);
// 尝试从请求中提取并验证JWT token
String token = extractTokenFromRequest(request);
if (StringUtils.hasText(token)) {
log.debug("提取到JWT token,进行验证");
try {
// 验证token是否有效
if (jwtUtil.validateToken(token)) {
String userId = jwtUtil.getUserIdFromToken(token);
if (userId != null) {
// 创建认证对象
List<SimpleGrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("SSE端点JWT验证成功,用户: {}", userId);
// 继续执行过滤器链
filterChain.doFilter(request, response);
return;
}
}
} catch (Exception e) {
log.warn("SSE端点JWT验证失败: {}", e.getMessage());
}
}
// token无效或不存在,拒绝连接
log.warn("SSE端点未认证访问,拒绝连接: {} {}", request.getMethod(), requestUri);
sendSseUnauthorizedError(response);
return;
}
// 继续执行过滤器链(非SSE端点)
filterChain.doFilter(request, response);
}
/**
* 发送SSE格式的未授权错误响应
*/
private void sendSseUnauthorizedError(HttpServletResponse response) {
try {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// 发送SSE格式的错误事件
response.getWriter().write("event: error\n");
response.getWriter().write("data: {\"error\": \"未授权访问,请先登录\", \"code\": 401, \"timestamp\": " +
System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
log.debug("已发送SSE未授权错误响应");
} catch (IOException e) {
log.error("发送SSE未授权错误响应失败", e);
}
}
/**
* 从请求头或参数中提取Token
*/
private String extractTokenFromRequest(HttpServletRequest request) {
// 首先尝试从请求头中提取Token
String authHeader = request.getHeader("Authorization");
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
// 如果请求头中没有Token,则尝试从URL参数中提取
String tokenParam = request.getParameter("token");
if (StringUtils.hasText(tokenParam)) {
return tokenParam;
}
return null;
}
/**
* 确定此过滤器是否应处理给定请求
* 只处理SSE流式端点
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String requestUri = request.getRequestURI();
boolean isStreamEndpoint = requestUri.contains(STREAM_ENDPOINT);
boolean isTimelineEndpoint = requestUri.contains(TIMELINE_ENDPOINT);
// 如果不是SSE端点,跳过此过滤器
return !(isStreamEndpoint || isTimelineEndpoint);
}
}
package pangea.hiagent.tool;
import lombok.extern.slf4j.Slf4j;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.annotation.Lazy;
import pangea.hiagent.model.Tool;
import pangea.hiagent.web.repository.ToolRepository;
import java.util.*;
import java.lang.reflect.Method;
/**
* 工具Bean名称初始化器 - 反向扫描版
*
* 核心理念: 从Spring容器反向扫描所有工具Bean,与数据库tool表进行同步
*
* 工作流程:
* 1. 扫描Spring容器中所有Bean
* 2. 识别工具类(通过检查类中是否有方法带有@Tool注解)
* 3. 与数据库tool表进行对比:
* - 数据库已存在 → 更新beanName配置
* - 数据库不存在 → 可选创建tool记录(通过配置控制)
* - 数据库有但Bean不存在 → 记录警告,留给管理员处理
* 4. 确保所有Bean都有对应的tool记录且beanName配置正确
*
* 优势:
* - 主动发现系统中的所有工具Bean
* - 自动保持数据库与代码的同步
* - 减少手动配置的工作量
* - 支持热部署新增的工具
*/
@Slf4j
@Component
@Lazy
public class ToolBeanNameInitializer {
@Autowired
private ToolRepository toolRepository;
@Autowired
private ApplicationContext applicationContext;
/**
* 手动触发:扫描Spring容器中的工具Bean并与数据库同步
*
* 该方法不再在应用启动时自动执行,而是通过管理界面手动触发
*/
@Transactional(rollbackFor = Exception.class)
public void initializeToolBeanNamesManually() {
try {
log.info("========== [工具Bean初始化] 开始扫描工具Bean ==========");
// 第一步:从Spring容器扫描所有工具Bean
Map<String, ToolBeanInfo> discoveredTools = scanToolBeansFromSpring();
log.info("[工具Bean初始化] 从Spring容器发现了{}个工具Bean", discoveredTools.size());
if (discoveredTools.isEmpty()) {
log.info("[工具Bean初始化] Spring容器中没有发现工具Bean,跳过初始化");
return;
}
// 第二步:从数据库加载所有工具记录
Map<String, Tool> databaseTools = loadToolsFromDatabase();
log.info("[工具Bean初始化] 从数据库加载了{}个工具记录", databaseTools.size());
// 第三步:对比并同步
synchronizeToolsWithDatabase(discoveredTools, databaseTools);
log.info("========== [工具Bean初始化] 扫描和同步完成 ==========");
} catch (Exception e) {
log.error("[工具Bean初始化] 初始化工具Bean名称映射时发生异常", e);
}
}
/**
* 从Spring容器中扫描所有工具Bean
*
* 识别规则:
* 1. 类名包含"Tool"关键字
* 2. 被@Component或@Service标注
* 3. 不是Spring框架自身的bean
*
* @return 工具Bean信息映射 (beanName -> ToolBeanInfo)
*/
private Map<String, ToolBeanInfo> scanToolBeansFromSpring() {
Map<String, ToolBeanInfo> toolBeans = new HashMap<>();
String[] beanNames = null;
int toolClassCount = 0;
int processedCount = 0;
try {
beanNames = applicationContext.getBeanDefinitionNames();
log.info("[工具Bean扫描] 正在扫描 Spring 容器中的 {} 个Bean", beanNames.length);
for (String beanName : beanNames) {
try {
Object bean = null;
try {
bean = applicationContext.getBean(beanName);
} catch (Exception e) {
// Bean实例化失败,但继续检查Bean定义
log.debug("[工具Bean扫描] Bean实例化失败({}): {}", beanName, e.getClass().getSimpleName());
continue;
}
if (bean == null) {
continue;
}
Class<?> beanClass = bean.getClass();
String simpleClassName = beanClass.getSimpleName();
String packageName = beanClass.getPackage() != null ? beanClass.getPackage().getName() : "";
// 检查是否为工具类(检查类中是否有@Tool注解的方法)
if (isToolClass(beanClass, packageName)) {
ToolBeanInfo info = new ToolBeanInfo();
info.setBeanName(beanName);
info.setSimpleClassName(simpleClassName);
info.setFullClassName(beanClass.getName());
info.setPackageName(packageName);
info.setBeanClass(beanClass);
info.setInstance(bean);
// 推导工具名称:获取带@Tool注解的方法名
String toolName = deriveToolName(beanClass);
info.setDerivedToolName(toolName);
toolBeans.put(beanName, info);
log.debug("[工具Bean扫描] 发现工具Bean: {} (class: {}, toolName: {})",
beanName, simpleClassName, toolName);
toolClassCount++;
}
processedCount++;
} catch (Exception e) {
log.error("[工具Bean扫描] 处理Bean'{}' 时发生异常: {} | 错误信息: {} | 堆栈信息:", beanName, e.getClass().getSimpleName(), e.getMessage(), e);
}
}
} catch (Exception e) {
log.error("[工具Bean扫描] 扫描Spring容器时发生异常: {} | 错误信息: {} | 堆栈信息:", e.getClass().getSimpleName(), e.getMessage(), e);
}
log.info("[工具Bean扫描] 扫描完成:总计扫描{}Bean,发现{}Tool类,实际获得{}Tool", beanNames.length, toolClassCount, toolBeans.size());
return toolBeans;
}
/**
* 判断指定的Bean Class(可能是代理类)是否为指定包下、带有@Tool注解方法的工具类
*
* @param beanClass Bean的Class对象(可能是代理类)
* @param packageName 包名(例如:com.example.demo),会匹配该包及其子包
* @return true:是符合条件的工具类;false:不符合
*/
public boolean isToolClass(Class<?> beanClass, String packageName) {
// 防御性判断:参数为空时直接返回false
if (beanClass == null || packageName == null || packageName.isBlank()) {
return false;
}
// 1. 排除Spring框架自身的bean
if (packageName.startsWith("org.springframework") || packageName.startsWith("java.")
|| packageName.startsWith("javax.") || packageName.startsWith("org.aspectj")
|| packageName.startsWith("jakarta") || packageName.startsWith("org.jakarta")
|| packageName.startsWith("com.baomidou") || packageName.startsWith("org.apache")) {
return false;
}
// 步骤2:获取原始目标类(穿透Spring AOP代理类)
Class<?> targetClass = AopUtils.getTargetClass(beanClass);
// 若传入的不是代理类,AopUtils.getTargetClass会直接返回原类,不影响逻辑
// 步骤3:判断原始类是否属于指定包及其子包
String className = targetClass.getName();
if (!className.startsWith(packageName + ".")) {
return false;
}
// 步骤3:检查原始类的方法是否带有@Tool注解(包括自身声明和继承的public方法)
// 获取类的所有public方法(包括父类的public方法)
Method[] methods = targetClass.getMethods();
for (Method method : methods) {
// 检查方法是否直接带有@Tool注解
if (method.isAnnotationPresent(org.springframework.ai.tool.annotation.Tool.class)) {
return true;
}
}
return false;
}
/**
* 从Bean类推导工具名称
*
* 规范:必须使用类名作为工具名,禁止使用方法名
* 支持代理类的处理
*
* 推导规则:
* 1. 获取实际目标类的简单名称(不含包名、不含代理标记)
* 2. 如果以"Tool"结尾,则去掉"Tool"后缀
* 3. 转换为小驼峰格式(首字母小写)
*
* 例如:
* - 类名 "CalculatorTool" → 工具名 "calculator"
* - 类名 "StorageFileAccessTool" → 工具名 "storageFileAccess"
* - 类名 "CodeAnalyzer" → 工具名 "codeAnalyzer"
* - 代理类 "CalculatorTool$$EnhancerBySpringCGLIB$$..." → 工具名 "calculator"
*
* @param beanClass Bean的Class对象(可能是代理类)
* @return 推导的工具名称
*/
private String deriveToolName(Class<?> beanClass) {
// 首先获取实际的目标类(处理代理)
Class<?> targetClass = AopUtils.getTargetClass(beanClass);
String simpleClassName = targetClass.getSimpleName();
// 从类名推导工具名
String toolName = simpleClassName;
// 如果类名以"Tool"结尾,则去掉该后缀
if (toolName.endsWith("Tool")) {
toolName = toolName.substring(0, toolName.length() - 4);
}
// 如果推导结果为空,则返回"tool"
if (toolName.isEmpty()) {
return "tool";
}
// 转换为小驼峰格式:首字母小写
return toolName.substring(0, 1).toLowerCase() + toolName.substring(1);
}
/**
* 从数据库加载所有未被删除的工具记录
*
* @return 工具记录映射 (toolName -> Tool)
*/
private Map<String, Tool> loadToolsFromDatabase() {
Map<String, Tool> tools = new HashMap<>();
try {
List<Tool> allTools = toolRepository.selectList(null);
if (allTools == null) {
return tools;
}
for (Tool tool : allTools) {
if (tool.getName() != null) {
tools.put(tool.getName(), tool);
}
}
} catch (Exception e) {
log.error("[工具初始化] 从数据库加载工具记录时出错", e);
}
return tools;
}
/**
* 对比Spring容器中的工具Bean和数据库中的工具记录,执行同步操作
*
* 同步策略:
* 1. Bean存在且数据库已有 → 更新beanName(确保映射正确)
* 2. Bean存在但数据库无记录 → 创建新的tool记录
* 3. Bean不存在但数据库有记录 → 记录警告(需要管理员处理)
*
* @param discoveredTools Spring容器中发现的工具Bean
* @param databaseTools 数据库中的工具记录
*/
private void synchronizeToolsWithDatabase(Map<String, ToolBeanInfo> discoveredTools,Map<String, Tool> databaseTools) {
int updated = 0;
int created = 0;
int skipped = 0;
int warnings = 0;
List<String> summaryLog = new ArrayList<>();
// 第一部分:处理发现的Bean
log.info("[工具同步] 开始处理发现的{}个工具Bean", discoveredTools.size());
for (Map.Entry<String, ToolBeanInfo> entry : discoveredTools.entrySet()) {
String beanName = entry.getKey();
ToolBeanInfo beanInfo = entry.getValue();
String toolName = beanInfo.getDerivedToolName();
try {
Tool existingTool = databaseTools.get(toolName);
if (existingTool != null) {
// 情况1: Bean存在且数据库已有 → 更新beanName
if (!beanName.equals(existingTool.getBeanName())) {
log.info("[工具同步] 工具'{}': 更新beanName '{}' -> '{}'",
toolName, existingTool.getBeanName(), beanName);
existingTool.setBeanName(beanName);
toolRepository.updateById(existingTool);
updated++;
summaryLog.add("✓ 工具'" + toolName + "' beanName已更新为'" + beanName + "'");
} else {
log.debug("[工具同步] 工具'{}': beanName已是'{}'", toolName, beanName);
skipped++;
summaryLog.add("- 工具'" + toolName + "' 配置已正确");
}
} else {
// 情况2: Bean存在但数据库无记录 → 创建新的tool记录
Tool newTool = createToolFromBeanInfo(beanInfo);
toolRepository.insert(newTool);
created++;
log.info("[工具同步] 工具'{}': 创建新的tool记录,beanName为'{}'", toolName, beanName);
summaryLog.add("+ 工具'" + toolName + "' 已创建新记录");
}
} catch (Exception e) {
log.error("[工具同步] 处理工具Bean'{}' 时出错: {}", toolName, e.getMessage(), e);
summaryLog.add("✗ 工具'" + toolName + "' 处理失败: " + e.getMessage());
}
}
// 第二部分:检查数据库中的工具是否在Spring容器中存在
log.info("[工具同步] 开始检查数据库中未匹配的工具");
for (Map.Entry<String, Tool> entry : databaseTools.entrySet()) {
String toolName = entry.getKey();
Tool dbTool = entry.getValue();
// 检查这个tool是否对应某个已发现的Bean
boolean found = discoveredTools.values().stream()
.anyMatch(beanInfo -> toolName.equalsIgnoreCase(beanInfo.getDerivedToolName()));
if (!found) {
// 没有对应的Bean,但有beanName配置,验证这个beanName是否有效
if (dbTool.getBeanName() != null && !dbTool.getBeanName().isEmpty()) {
try {
Object bean = applicationContext.getBean(dbTool.getBeanName());
log.debug("[工具同步] 工具'{}': beanName'{}' 存在但未被发现(可能是特殊配置)",
toolName, dbTool.getBeanName());
skipped++;
} catch (Exception e) {
log.warn("[工具同步] 工具'{}': beanName'{}' 无效,Bean不存在",
toolName, dbTool.getBeanName());
summaryLog.add("⚠ 工具'" + toolName + "' beanName'" + dbTool.getBeanName() + "' 无效");
warnings++;
}
} else {
log.warn("[工具同步] 工具'{}': 数据库记录存在但无对应Bean", toolName);
summaryLog.add("⚠ 工具'" + toolName + "' 无对应Bean");
warnings++;
}
}
}
// 输出同步总结
log.info("========== [工具同步] 完成总结 ==========");
log.info("新建: {} 个,更新: {} 个,跳过: {} 个,警告: {} 个", created, updated, skipped, warnings);
if (!summaryLog.isEmpty()) {
log.info("[工具同步] 详细信息:");
summaryLog.forEach(msg -> log.info(" {}", msg));
}
}
/**
* 从Bean信息创建Tool对象
*
* @param beanInfo Bean信息
* @return 新创建的Tool对象
*/
private Tool createToolFromBeanInfo(ToolBeanInfo beanInfo) {
Tool tool = new Tool();
// 基本信息
tool.setName(beanInfo.getDerivedToolName());
tool.setBeanName(beanInfo.getBeanName());
tool.setDisplayName(beanInfo.getSimpleClassName());
// 从类名推导分类和描述
tool.setCategory("system");
tool.setDescription("Auto-discovered tool from Bean: " + beanInfo.getFullClassName());
// 默认状态
tool.setStatus("active");
return tool;
}
/**
* 工具Bean信息容器类
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ToolBeanInfo {
/** Spring Bean名称 */
private String beanName;
/** 类名(不含包名) */
private String simpleClassName;
/** 完整类名(含包名) */
private String fullClassName;
/** 包名 */
private String packageName;
/** Bean的Class对象 */
private Class<?> beanClass;
/** Bean实例 */
private Object instance;
/** 推导的工具名称 */
private String derivedToolName;
}
/**
* 手动验证并更新工具的beanName(用于调试和运维)
*
* @param toolId 工具ID
* @param beanName 待验证的bean名称
* @return 验证结果
*/
public boolean validateAndUpdateBeanName(String toolId, String beanName) {
try {
log.info("[手动验证] 验证工具{}的beanName'{}'", toolId, beanName);
// 1. 验证bean是否存在
Object bean = applicationContext.getBean(beanName);
if (bean == null) {
log.warn("[手动验证] Bean'{}' 不存在或为null", beanName);
return false;
}
// 2. 更新Tool的beanName
Tool tool = toolRepository.selectById(toolId);
if (tool == null) {
log.warn("[手动验证] 工具{}不存在", toolId);
return false;
}
tool.setBeanName(beanName);
toolRepository.updateById(tool);
log.info("[手动验证] 工具{}的beanName已更新为'{}'", toolId, beanName);
return true;
} catch (Exception e) {
log.error("[手动验证] 验证beanName时发生错误", e);
return false;
}
}
}
package pangea.hiagent.tool;
import java.lang.annotation.*;
/**
* 工具参数注解
* 用于标记工具类中的配置参数
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ToolParam {
/**
* 参数名称
*/
String name() default "";
/**
* 参数描述
*/
String description() default "";
/**
* 参数默认值
*/
String defaultValue() default "";
/**
* 参数类型
*/
String type() default "string";
/**
* 是否必填
*/
boolean required() default false;
/**
* 参数分组
*/
String group() default "default";
}
\ No newline at end of file
package pangea.hiagent.tool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import pangea.hiagent.web.service.ToolConfigService;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
/**
* 工具参数处理器
* 用于处理工具类中的@ToolParam注解,将数据库中的参数值注入到工具类字段
*/
@Slf4j
@Component
@Scope(ConfigurableBeanFactory.SCOPE_SINGLETON)
public class ToolParamProcessor implements BeanPostProcessor {
private final ToolConfigService toolConfigService;
// 构造函数注入
public ToolParamProcessor(ToolConfigService toolConfigService) {
this.toolConfigService = toolConfigService;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
// 检查Bean是否为工具类(位于tools包下,且带有@Component注解)
Class<?> beanClass = bean.getClass();
String packageName = beanClass.getPackage().getName();
if (packageName.contains("pangea.hiagent.tools") && beanClass.isAnnotationPresent(Component.class)) {
log.debug("处理工具类参数,Bean名称:{}", beanName);
injectParams(bean);
}
return bean;
}
/**
* 注入参数值到工具类字段
* @param bean 工具类实例
*/
private void injectParams(Object bean) {
Class<?> beanClass = bean.getClass();
String toolName = beanClass.getSimpleName();
// 获取所有字段,包括父类字段
List<Field> fields = getAllFields(beanClass);
for (Field field : fields) {
if (field.isAnnotationPresent(ToolParam.class)) {
ToolParam annotation = field.getAnnotation(ToolParam.class);
String paramName = annotation.name().isEmpty() ? field.getName() : annotation.name();
// 从数据库获取参数值,如果不存在则使用默认值
String paramValue = toolConfigService.getParamValue(toolName, paramName);
if (paramValue == null) {
paramValue = annotation.defaultValue();
log.debug("参数值不存在,使用默认值,工具名称:{},参数名称:{},默认值:{}",
toolName, paramName, paramValue);
}
// 设置字段值
field.setAccessible(true);
try {
// 根据字段类型转换参数值
injectFieldValue(bean, field, paramValue);
log.debug("参数值注入成功,工具名称:{},参数名称:{},字段类型:{},值:{}",
toolName, paramName, field.getType().getName(), paramValue);
} catch (Exception e) {
log.error("参数值注入失败,工具名称:{},参数名称:{},字段类型:{},值:{}",
toolName, paramName, field.getType().getName(), paramValue, e);
}
}
}
}
/**
* 递归获取所有字段,包括父类字段
* @param clazz 类对象
* @return 字段列表
*/
private List<Field> getAllFields(Class<?> clazz) {
List<Field> fields = Arrays.asList(clazz.getDeclaredFields());
Class<?> superClass = clazz.getSuperclass();
if (superClass != null && !superClass.equals(Object.class)) {
fields.addAll(getAllFields(superClass));
}
return fields;
}
/**
* 根据字段类型注入参数值
* @param bean 工具类实例
* @param field 字段对象
* @param paramValue 参数值字符串
* @throws IllegalAccessException 访问权限异常
*/
private void injectFieldValue(Object bean, Field field, String paramValue) throws IllegalAccessException {
Class<?> fieldType = field.getType();
if (fieldType == String.class) {
field.set(bean, paramValue);
} else if (fieldType == int.class || fieldType == Integer.class) {
field.set(bean, Integer.parseInt(paramValue));
} else if (fieldType == long.class || fieldType == Long.class) {
field.set(bean, Long.parseLong(paramValue));
} else if (fieldType == boolean.class || fieldType == Boolean.class) {
field.set(bean, Boolean.parseBoolean(paramValue));
} else if (fieldType == double.class || fieldType == Double.class) {
field.set(bean, Double.parseDouble(paramValue));
} else if (fieldType == float.class || fieldType == Float.class) {
field.set(bean, Float.parseFloat(paramValue));
} else if (fieldType == short.class || fieldType == Short.class) {
field.set(bean, Short.parseShort(paramValue));
} else if (fieldType == byte.class || fieldType == Byte.class) {
field.set(bean, Byte.parseByte(paramValue));
} else if (fieldType == char.class || fieldType == Character.class) {
field.set(bean, paramValue.charAt(0));
} else {
// 对于其他类型,直接设置为null
field.set(bean, null);
log.warn("不支持的字段类型,工具名称:{},参数名称:{},字段类型:{}",
bean.getClass().getSimpleName(), field.getName(), fieldType.getName());
}
}
}
\ No newline at end of file
......@@ -3,7 +3,7 @@ package pangea.hiagent.tool.impl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import pangea.hiagent.tool.ToolParam;
/**
* 图表生成工具
......@@ -13,35 +13,11 @@ import pangea.hiagent.tool.ToolParam;
@Component
public class ChartGenerationTool {
@ToolParam(
name = "maxDataPoints",
description = "最大数据点数量限制",
defaultValue = "100",
type = "integer",
required = true,
group = "chart"
)
private Integer maxDataPoints;
@ToolParam(
name = "percentageDecimalPlaces",
description = "百分比显示的小数位数",
defaultValue = "2",
type = "integer",
required = true,
group = "chart"
)
private Integer percentageDecimalPlaces;
@ToolParam(
name = "defaultSeriesName",
description = "默认数据系列名称",
defaultValue = "数据",
type = "string",
required = true,
group = "chart"
)
private String defaultSeriesName;
private Integer maxDataPoints = 100;
private Integer percentageDecimalPlaces = 2;
private String defaultSeriesName = "数据";
/**
* 生成柱状图
......
......@@ -3,7 +3,7 @@ package pangea.hiagent.tool.impl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.ai.tool.annotation.Tool;
import pangea.hiagent.tool.ToolParam;
import java.time.LocalDateTime;
import java.time.LocalDate;
......@@ -18,34 +18,10 @@ import java.time.format.DateTimeFormatter;
@Component
public class DateTimeTools {
@ToolParam(
name = "dateTimeFormat",
description = "日期时间格式",
defaultValue = "yyyy-MM-dd HH:mm:ss",
type = "string",
required = true,
group = "datetime"
)
private String dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
@ToolParam(
name = "dateFormat",
description = "日期格式",
defaultValue = "yyyy-MM-dd",
type = "string",
required = true,
group = "datetime"
)
private String dateFormat = "yyyy-MM-dd";
@ToolParam(
name = "timeFormat",
description = "时间格式",
defaultValue = "HH:mm:ss",
type = "string",
required = true,
group = "datetime"
)
private String timeFormat = "HH:mm:ss";
@Tool(description = "获取当前日期和时间,返回格式为 'yyyy-MM-dd HH:mm:ss'")
......
......@@ -10,7 +10,7 @@ import jakarta.mail.search.ReceivedDateTerm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import pangea.hiagent.tool.ToolParam;
import java.io.File;
import java.util.*;
......@@ -23,45 +23,13 @@ import java.util.*;
@Component
public class EmailTools {
@ToolParam(
name = "defaultPop3Port",
description = "默认POP3服务器端口",
defaultValue = "995",
type = "integer",
required = true,
group = "email"
)
private Integer defaultPop3Port;
@ToolParam(
name = "defaultAttachmentPath",
description = "默认附件保存路径",
defaultValue = "attachments",
type = "string",
required = true,
group = "email"
)
private String defaultAttachmentPath;
@ToolParam(
name = "pop3SslEnable",
description = "是否启用POP3 SSL",
defaultValue = "true",
type = "boolean",
required = true,
group = "email"
)
private Boolean pop3SslEnable;
@ToolParam(
name = "pop3SocketFactoryClass",
description = "POP3 SSL套接字工厂类",
defaultValue = "javax.net.ssl.SSLSocketFactory",
type = "string",
required = true,
group = "email"
)
private String pop3SocketFactoryClass;
private Integer defaultPop3Port = 995;
private String defaultAttachmentPath = "attachments";
private Boolean pop3SslEnable = true;
private String pop3SocketFactoryClass = "javax.net.ssl.SSLSocketFactory";
// 邮件请求参数类
@JsonClassDescription("邮件操作请求参数")
......
......@@ -3,7 +3,7 @@ package pangea.hiagent.tool.impl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.ai.tool.annotation.Tool;
import pangea.hiagent.tool.ToolParam;
import java.io.File;
import java.io.IOException;
import java.nio.charset.Charset;
......@@ -24,37 +24,13 @@ import java.util.UUID;
public class FileProcessingTools {
// 支持的文本文件扩展名
@ToolParam(
name = "textFileExtensions",
description = "支持的文本文件扩展名,逗号分隔",
defaultValue = ".txt,.md,.java,.html,.htm,.css,.js,.json,.xml,.yaml,.yml,.properties,.sql,.py,.cpp,.c,.h,.cs,.php,.rb,.go,.rs,.swift,.kt,.scala,.sh,.bat,.cmd,.ps1,.log,.csv,.ts,.jsx,.tsx,.vue,.scss,.sass,.less",
type = "string",
required = true,
group = "file"
)
private String textFileExtensions;
private String textFileExtensions = ".txt,.md,.java,.html,.htm,.css,.js,.json,.xml,.yaml,.yml,.properties,.sql,.py,.cpp,.c,.h,.cs,.php,.rb,.go,.rs,.swift,.kt,.scala,.sh,.bat,.cmd,.ps1,.log,.csv,.ts,.jsx,.tsx,.vue,.scss,.sass,.less";
// 支持的图片文件扩展名
@ToolParam(
name = "imageFileExtensions",
description = "支持的图片文件扩展名,逗号分隔",
defaultValue = ".jpg,.jpeg,.png,.gif,.bmp,.svg,.webp,.ico",
type = "string",
required = true,
group = "file"
)
private String imageFileExtensions;
private String imageFileExtensions = ".jpg,.jpeg,.png,.gif,.bmp,.svg,.webp,.ico";
// 默认文件存储目录
@ToolParam(
name = "defaultStorageDir",
description = "默认文件存储目录",
defaultValue = "storage",
type = "string",
required = true,
group = "file"
)
private String defaultStorageDir;
private String defaultStorageDir = "storage";
// 转换为列表的辅助方法
private List<String> getTextFileExtensions() {
......
# 文件处理工具使用说明
## 功能概述
FileProcessingTools 是一个功能丰富的文件处理工具类,专门设计用于处理各种文本格式文件。该工具支持读取、写入、追加内容到文件,并提供文件信息查询功能。
支持的文件格式包括但不限于:
- 文本文件:`.txt`
- 标记语言文件:`.md`
- 编程语言文件:`.java`, `.html`, `.htm`, `.css`, `.js`, `.json`, `.xml`, `.yaml`, `.yml`, `.py`, `.cpp`, `.c`, `.h`, `.cs`, `.php`, `.rb`, `.go`, `.rs`, `.swift`, `.kt`, `.scala`
- 脚本文件:`.sh`, `.bat`, `.cmd`, `.ps1`
- 其他文本格式:`.properties`, `.sql`, `.log`, `.csv`, `.ts`, `.jsx`, `.tsx`, `.vue`, `.scss`, `.sass`, `.less`
## 功能列表
### 1. readFile(String filePath)
读取文本文件内容
**参数:**
- `filePath`: 文件路径(支持相对路径)
**返回值:**
- 成功时返回文件内容
- 失败时返回错误信息
**示例:**
```java
@Autowired
private FileProcessingTools fileTools;
String content = fileTools.readFile("/path/to/file.txt");
// 或使用相对路径
String content = fileTools.readFile("relative/path/to/file.txt");
```
### 2. readFileWithEncoding(String filePath, String encoding)
读取文本文件内容,支持指定字符编码
**参数:**
- `filePath`: 文件路径(支持相对路径)
- `encoding`: 字符编码(如 "UTF-8", "GBK" 等)
**返回值:**
- 成功时返回文件内容
- 失败时返回错误信息
**示例:**
```java
String content = fileTools.readFileWithEncoding("/path/to/file.txt", "UTF-8");
```
### 3. writeFile(String filePath, String content)
写入内容到文本文件
**参数:**
- `filePath`: 文件路径(支持相对路径,如果为空或null则自动生成随机文件名)
- `content`: 要写入的内容
**返回值:**
- 成功时返回"文件写入成功,文件路径: [完整文件路径]"
- 失败时返回错误信息
**示例:**
```java
// 指定文件名
String result = fileTools.writeFile("/path/to/file.txt", "Hello, World!");
// 使用相对路径
String result = fileTools.writeFile("relative/path/to/file.txt", "Hello, World!");
// 自动生成随机文件名
String result = fileTools.writeFile("", "Hello, World!");
```
### 4. writeFileWithEncoding(String filePath, String content, String encoding, boolean append)
写入内容到文本文件,支持指定字符编码和追加模式
**参数:**
- `filePath`: 文件路径(支持相对路径,如果为空或null则自动生成随机文件名)
- `content`: 要写入的内容
- `encoding`: 字符编码
- `append`: 是否追加到文件末尾(true为追加,false为覆盖)
**返回值:**
- 成功时返回"文件写入成功,文件路径: [完整文件路径]"
- 失败时返回错误信息
**示例:**
```java
// 覆盖写入
String result = fileTools.writeFileWithEncoding("/path/to/file.txt", "New content", "UTF-8", false);
// 追加写入
String result = fileTools.writeFileWithEncoding("/path/to/file.txt", "Additional content", "UTF-8", true);
// 自动生成随机文件名并写入
String result = fileTools.writeFileWithEncoding("", "Content with random filename", "UTF-8", false);
```
### 5. appendToFile(String filePath, String content)
追加内容到文本文件末尾
**参数:**
- `filePath`: 文件路径(支持相对路径,如果为空或null则自动生成随机文件名)
- `content`: 要追加的内容
**返回值:**
- 成功时返回"文件写入成功,文件路径: [完整文件路径]"
- 失败时返回错误信息
**示例:**
```java
String result = fileTools.appendToFile("/path/to/file.txt", "Appended content");
// 或使用相对路径
String result = fileTools.appendToFile("relative/path/to/file.txt", "Appended content");
// 或自动生成随机文件名
String result = fileTools.appendToFile("", "Appended content with random filename");
```
### 6. getFileSize(String filePath)
获取文件大小
**参数:**
- `filePath`: 文件路径(支持相对路径)
**返回值:**
- 成功时返回文件大小信息
- 失败时返回错误信息
**示例:**
```java
String sizeInfo = fileTools.getFileSize("/path/to/file.txt");
// 或使用相对路径
String sizeInfo = fileTools.getFileSize("relative/path/to/file.txt");
```
### 7. fileExists(String filePath)
检查文件是否存在
**参数:**
- `filePath`: 文件路径(支持相对路径)
**返回值:**
- 文件存在返回true
- 文件不存在返回false
**示例:**
```java
boolean exists = fileTools.fileExists("/path/to/file.txt");
// 或使用相对路径
boolean exists = fileTools.fileExists("relative/path/to/file.txt");
```
### 8. getFileInfo(String filePath)
获取文件详细信息
**参数:**
- `filePath`: 文件路径(支持相对路径)
**返回值:**
- 成功时返回文件详细信息(包括路径、大小、是否为文本文件、最后修改时间)
- 失败时返回错误信息
**示例:**
```java
String fileInfo = fileTools.getFileInfo("/path/to/file.txt");
// 或使用相对路径
String fileInfo = fileTools.getFileInfo("relative/path/to/file.txt");
```
### 9. generateRandomFileName(String extension)
生成随机文件名并返回完整路径
**参数:**
- `extension`: 文件扩展名(如 ".txt", "md" 等,如果不带点会自动添加)
**返回值:**
- 成功时返回完整文件路径
- 失败时返回错误信息
**示例:**
```java
String randomFilePath = fileTools.generateRandomFileName(".txt");
// 或不带点的扩展名
String randomFilePath = fileTools.generateRandomFileName("md");
```
## 使用注意事项
1. **字符编码**:默认使用UTF-8编码,可根据需要指定其他编码格式
2. **文件类型限制**:只能处理预定义的文本文件类型,非文本文件会被拒绝处理
3. **目录自动创建**:写入文件时会自动创建不存在的目录
4. **错误处理**:所有操作都有完善的错误处理和日志记录
5. **文件大小**:适合处理中小型文本文件,大文件处理可能影响性能
6. **路径支持**:支持相对路径,默认相对于当前工作目录
7. **随机文件名**:当filePath为空或null时,会自动生成随机文件名并存储在"storage"目录下
8. **扩展名推断**:当使用随机文件名时,会根据内容自动推断合适的文件扩展名
## 错误处理
工具类提供了完善的错误处理机制:
- 文件不存在时返回明确的错误信息
- 文件路径为空时自动生成随机文件名而不是报错
- IO异常时记录详细日志并返回友好的错误信息
- 编码错误时使用默认UTF-8编码并记录警告日志
## 性能优化
1. **内存使用**:使用NIO.2 API进行文件读写,提高效率
2. **字符编码**:自动检测和处理字符编码,确保内容正确性
3. **日志记录**:详细的日志记录便于问题排查和性能监控
4. **路径处理**:智能处理相对路径和绝对路径
5. **文件名生成**:使用UUID生成唯一的随机文件名,避免冲突
## 示例用法
```java
@Autowired
private FileProcessingTools fileTools;
// 读取文件
String content = fileTools.readFile("data/input.txt");
// 写入文件(自动生成随机文件名)
String writeResult = fileTools.writeFile("", "Hello, World!");
System.out.println(writeResult); // 输出文件路径
// 追加内容到文件
fileTools.appendToFile("logs/app.log", "New log entry\n");
// 获取文件信息
String fileInfo = fileTools.getFileInfo("config/settings.json");
```
\ No newline at end of file
package pangea.hiagent.tool.impl;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState;
import com.microsoft.playwright.options.WaitUntilState;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.File;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import pangea.hiagent.web.service.ToolConfigService;
import pangea.hiagent.workpanel.playwright.PlaywrightManager;
/**
* 海信LBPM流程审批工具类
* 专门负责LBPM流程审批功能,需要先通过HisenseSsoLoginTool登录
* 该工具专注于流程审批操作,不处理登录逻辑
*/
@Slf4j
@Component
public class HisenseLbpmApprovalTool {
// SSO登录页面URL
private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/";
// 注入Playwright管理器
@Autowired
private PlaywrightManager playwrightManager;
@Autowired
private ToolConfigService toolConfigService;
// 存储目录路径
private static final String STORAGE_DIR = "storage";
/**
* 从数据库获取SSO用户名
*
* @return SSO用户名
*/
public String getSsoUsername() {
log.debug("从数据库获取SSO用户名");
return toolConfigService.getParamValue("hisenseSsoLogin", "ssoUsername");
}
/**
* 工具方法:处理海信请假审批
*
* @param userId 用户ID,用于区分不同用户的会话
* @param approvalUrl 请假审批页面URL
* @param approvalOpinion 审批意见
* @return 处理结果
*/
@Tool(description = "处理海信请假审批、自驾车审批、调休审批,需要先使用HisenseSsoLoginTool登录,提供用户ID以区分会话")
public String processHisenseLeaveApproval(String approvalUrl, String approvalOpinion) {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理海信请假审批,URL: {}", ssoUsername, approvalUrl);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalUrl == null || approvalUrl.isEmpty()) {
String errorMsg = "审批URL不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalOpinion == null || approvalOpinion.isEmpty()) {
String errorMsg = "审批意见不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 获取用户专用的浏览器上下文
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
// 创建新页面
page = userContext.newPage();
// 访问审批页面
log.info("正在访问审批页面: {}", approvalUrl);
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg);
return errorMsg;
}
// 等待页面完全加载完成
page.waitForLoadState(LoadState.NETWORKIDLE);
// 等待关键元素加载完成,确保页面完全就绪
try {
page.waitForSelector("input[type='radio'][name*='oprGroup'], input[type='radio'][name*='fdNotifyLevel']",
new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(5000));
} catch (com.microsoft.playwright.TimeoutError e) {
log.warn("关键审批元素未在预期时间内加载完成,继续执行审批操作");
}
// 执行审批操作
performApprovalOperation(page, approvalOpinion);
// 截图并保存
takeScreenshotAndSave(page, "lbpm_approval_success_" + ssoUsername);
long endTime = System.currentTimeMillis();
log.info("请假审批处理完成,耗时: {} ms", endTime - startTime);
return "请假审批处理成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "请假审批处理失败: " + e.getMessage();
log.error("请假审批处理失败,耗时: {} ms", endTime - startTime, e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
try {
takeScreenshotAndSave(page, "lbpm_approval_fail_" + ssoUsername);
} catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
}
}
return errorMsg;
} finally {
// 不立即关闭页面,让服务器完成审批处理
// 保留页面、上下文和浏览器实例供后续操作使用
// 仅在发生异常时才关闭页面
if (page != null) {
try {
// 可以选择保留页面不关闭,或者等待一段时间后关闭
// 目前保持原逻辑,但实际使用中可能需要根据业务需求调整
log.debug("保留页面实例以等待服务器完成审批处理");
} catch (Exception e) {
log.warn("处理页面实例时发生异常: {}", e.getMessage());
}
}
}
}
/**
* 工具方法:获取海信业务系统的网页内容(自动处理SSO认证)
*
* @param businessSystemUrl 海信业务系统页面URL
* @return 页面内容(HTML文本)
*/
@Tool(description = "获取海信LBPM业务系统的网页内容,需要先使用HisenseSsoLoginTool登录")
public String getHisenseLbpmBusinessSystemContent(String businessSystemUrl) {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 获取海信业务系统内容,URL: {}", ssoUsername, businessSystemUrl);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg);
return errorMsg;
}
if (businessSystemUrl == null || businessSystemUrl.isEmpty()) {
String errorMsg = "业务系统URL不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 获取用户专用的浏览器上下文
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
// 创建新页面
page = userContext.newPage();
// 访问业务系统页面
log.info("正在访问业务系统页面: {}", businessSystemUrl);
page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg);
return errorMsg;
}
// 提取页面内容
String content = page.locator("body").innerText();
long endTime = System.currentTimeMillis();
log.info("成功获取业务系统页面内容,耗时: {} ms", endTime - startTime);
// 检查是否包含错误信息
if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) {
log.warn("检测到页面中可能存在JavaScript错误,但这不会影响主要功能");
}
return content;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
log.error("获取海信业务系统内容失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
} finally {
// 释放页面资源,但保留浏览器上下文供后续使用
if (page != null) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
}
}
}
/**
* 执行审批操作
*
* @param page 当前页面对象
* @param approvalOpinion 审批意见
* @throws Exception 审批过程中的异常
*/
private void performApprovalOperation(Page page, String approvalOpinion) throws Exception {
log.info("开始执行审批操作");
try {
// 定位审批操作单选框 - 尝试新的选择器
String operationRadioSelector = "input[type='radio'][name='sysWfBusinessForm.fdNotifyLevel'][value='1']";
log.debug("正在定位审批操作单选框: {}", operationRadioSelector);
Locator operationRadio = page.locator(operationRadioSelector);
if (operationRadio.count() > 0) {
operationRadio.click();
log.debug("审批操作单选框选择完成 (使用新选择器)");
} else {
// 如果新选择器未找到元素,尝试原选择器
operationRadioSelector = "input[type='radio'][alerttext=''][key='operationType'][name='oprGroup'][value='handler_pass:通过']";
log.debug("新选择器未找到元素,尝试原选择器: {}", operationRadioSelector);
operationRadio = page.locator(operationRadioSelector);
if (operationRadio.count() > 0) {
operationRadio.click();
log.debug("审批操作单选框选择完成 (使用原选择器)");
} else {
throw new RuntimeException("未找到审批操作单选框");
}
}
// 定位审批意见输入框并填入内容 - 尝试新的选择器
String opinionTextareaSelector = "textarea[name='fdUsageContent'][class='process_review_content'][key='auditNode'][subject='处理意见']";
log.debug("正在定位审批意见输入框: {}", opinionTextareaSelector);
Locator opinionTextarea = page.locator(opinionTextareaSelector);
if (opinionTextarea.count() > 0) {
opinionTextarea.fill(approvalOpinion);
log.debug("审批意见输入完成 (使用新选择器)");
} else {
// 如果新选择器未找到元素,尝试原选择器
opinionTextareaSelector = "textarea[name='fdUsageContent'][class='process_review_content'][key='auditNode']";
log.debug("新选择器未找到元素,尝试原选择器: {}", opinionTextareaSelector);
opinionTextarea = page.locator(opinionTextareaSelector);
if (opinionTextarea.count() > 0) {
opinionTextarea.fill(approvalOpinion);
log.debug("审批意见输入完成 (使用原选择器)");
} else {
throw new RuntimeException("未找到审批意见输入框");
}
}
// 定位并点击提交按钮 - 尝试新的选择器
String submitButtonSelector = "input[id='process_review_button'][class='process_review_button'][type='button'][value='提交']";
log.debug("正在定位提交按钮: {}", submitButtonSelector);
Locator submitButton = page.locator(submitButtonSelector);
if (submitButton.count() > 0) {
submitButton.click();
log.info("提交按钮点击完成 (使用新选择器)");
} else {
// 如果新选择器未找到元素,尝试原选择器
submitButtonSelector = "input[id='process_review_button'][class='process_review_button'][type='button'][value='提交']";
log.debug("新选择器未找到元素,尝试原选择器: {}", submitButtonSelector);
submitButton = page.locator(submitButtonSelector);
if (submitButton.count() > 0) {
submitButton.click();
log.info("提交按钮点击完成 (使用原选择器)");
} else {
throw new RuntimeException("未找到提交按钮");
}
}
// 等待提交完成
page.waitForLoadState(LoadState.NETWORKIDLE);
// 提交后等待服务器处理完成审批,检查是否有成功提示或页面跳转
try {
// 等待可能的成功消息提示
page.waitForSelector("text=您的操作已成功", new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(10000));
log.info("检测到审批提交成功提示");
} catch (com.microsoft.playwright.TimeoutError e) {
log.info("在预期时间内未检测到提交成功提示,继续等待");
} catch (Exception e) {
log.warn("等待成功提示时发生异常,继续执行: {}", e.getMessage());
}
// 额外等待一段时间确保服务器处理完成
page.waitForTimeout(5000); // 等待5秒让服务器完成处理
log.info("审批操作执行完成,已等待服务器响应");
} catch (Exception e) {
log.error("审批操作过程中发生异常", e);
throw new RuntimeException("审批操作失败: " + e.getMessage(), e);
}
}
/**
* 截图并保存到存储目录
*
* @param page 当前页面对象
* @param fileName 文件名前缀
*/
private void takeScreenshotAndSave(Page page, String fileName) {
try {
// 确保存储目录存在
File storageDir = new File(STORAGE_DIR);
if (!storageDir.exists()) {
storageDir.mkdirs();
}
// 生成带时间戳的文件名
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
String fullFileName = String.format("%s_%s.png", fileName, timestamp);
String filePath = Paths.get(STORAGE_DIR, fullFileName).toString();
// 截图并保存
page.screenshot(new Page.ScreenshotOptions().setPath(Paths.get(filePath)));
log.info("截图已保存至: {}", filePath);
} catch (Exception e) {
log.error("截图保存失败: {}", e.getMessage(), e);
}
}
/**
* 工具方法:自动查找并处理所有待审批的请假流程
*
* @param approvalOpinion 审批意见
* @return 处理结果
*/
@Tool(description = "自动查找所有待审批的请假流程的网址,需要先使用HisenseSsoLoginTool登录")
public List<String> processAllPendingLeaveApprovals() {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理所有待审批的请假流程", ssoUsername);
long startTime = System.currentTimeMillis();
int processedCount = 0;
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg);
return List.of(errorMsg);
}
Page page = null;
try {
// 获取用户专用的浏览器上下文
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
// 创建新页面
page = userContext.newPage();
// 访问待审批列表页面
String approvalListUrl = "https://lbpm.hisense.com/km/review/?categoryId=1843775ea85be87f9756e2540e5b20b0&nodeType=CATEGORY#j_path=%2FlistAll&mydoc=all&cri.q=docStatus%3A20%3BfdTemplate%3A1843775ea85be87f9756e2540e5b20b0";
log.info("正在访问待审批列表页面: {}", approvalListUrl);
page.navigate(approvalListUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg);
return List.of(errorMsg);
}
// 等待页面完全加载完成
page.waitForLoadState(LoadState.NETWORKIDLE);
// 等待待审批项目加载完成
try {
page.waitForSelector("span.com_subject",
new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(10000));
} catch (com.microsoft.playwright.TimeoutError e) {
log.info("在预期时间内未找到待审批项目,可能没有待审批的项目");
return List.of("没有找到待审批的项目");
}
ArrayList<String> urls= new ArrayList<>();
// 查找所有待审批项目行,这些行包含kmss_fdid属性
Locator approvalRows = page.locator("tr[kmss_fdid]");
int itemCount = approvalRows.count();
if (itemCount == 0) {
log.info("没有更多待审批项目,处理完成");
return List.of("没有更多待审批项目,处理完成");
}
log.info("找到 {} 个待审批项目", itemCount);
// 遍历所有待审批项目行,提取kmss_fdid属性值并构建审批URL
for (int i = 0; i < itemCount; i++) {
Locator currentApprovalRow = approvalRows.nth(i);
// 获取kmss_fdid属性值
String kmssFdid = currentApprovalRow.getAttribute("kmss_fdid");
if (kmssFdid != null && !kmssFdid.isEmpty()) {
// 构建完整的审批URL
String approvalUrl = "https://lbpm.hisense.com/km/review/km_review_main/kmReviewMain.do?method=view&fdId=" + kmssFdid;
// 获取审批项目文本(从span.com_subject中获取)
Locator approvalSubject = currentApprovalRow.locator("span.com_subject");
String approvalText = approvalSubject.count() > 0 ? approvalSubject.textContent() : "未知审批项目";
log.info("获取到待审批项目: {},链接: {}", approvalText, approvalUrl);
urls.add(approvalUrl);
}
}
long endTime = System.currentTimeMillis();
String resultMessage = String.format("待审批处理完成,共 %d 个项目,耗时: %d ms", processedCount, endTime - startTime);
log.info(resultMessage);
return urls;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "处理待审批项目失败: " + e.getMessage();
log.error("处理待审批项目失败,耗时: {} ms", endTime - startTime, e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
try {
takeScreenshotAndSave(page, "lbpm_pending_approval_fail_" + ssoUsername);
} catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
}
}
return List.of(errorMsg);
} finally {
// 不立即关闭页面,让服务器完成审批处理
// 保留页面、上下文和浏览器实例供后续操作使用
// 仅在发生异常时才关闭页面
if (page != null) {
try {
log.debug("保留页面实例以等待服务器完成审批处理");
} catch (Exception e) {
log.warn("处理页面实例时发生异常: {}", e.getMessage());
}
}
}
}
}
package pangea.hiagent.tool.impl;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState;
import com.microsoft.playwright.options.WaitUntilState;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.File;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import pangea.hiagent.web.service.ToolConfigService;
import pangea.hiagent.workpanel.playwright.PlaywrightManager;
/**
* 海信绩效系统流程审批工具类
* 专门负责海信绩效管理系统(HIPMS)的审批功能,需要先通过HisenseSsoLoginTool登录
* 该工具专注于流程审批操作,不处理登录逻辑
*/
@Slf4j
@Component
public class HisensePerformanceApprovalTool {
// SSO登录页面URL
private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/";
// 绩效管理系统待审批页面URL
private static final String PERFORMANCE_PENDING_URL = "https://hipms.hisense.com/PBC/PBCsubordinateTaskBook";
// 注入Playwright管理器
@Autowired
private PlaywrightManager playwrightManager;
@Autowired
private ToolConfigService toolConfigService;
// 存储目录路径
private static final String STORAGE_DIR = "storage";
/**
* 从数据库获取SSO用户名
*
* @return SSO用户名
*/
public String getSsoUsername() {
log.debug("从数据库获取SSO用户名");
return toolConfigService.getParamValue("hisenseSsoLogin", "ssoUsername");
}
/**
* 工具方法:自动查找所有待审批的绩效流程的网址
*
* @return 待审批流程的URL列表
*/
@Tool(description = "自动查找所有待审批的绩效流程的网址,需要先使用HisenseSsoLoginTool登录")
public List<String> checkHisensePerformancePendingTasks() {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 查找所有待审批的绩效流程", ssoUsername);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg);
return List.of(errorMsg);
}
Page page = null;
try {
// 获取用户专用的浏览器上下文
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
// 创建新页面
page = userContext.newPage();
// 访问待审批列表页面
log.info("正在访问绩效系统待审批页面: {}", PERFORMANCE_PENDING_URL);
page.navigate(PERFORMANCE_PENDING_URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg);
return List.of(errorMsg);
}
// 等待页面完全加载完成
page.waitForLoadState(LoadState.NETWORKIDLE);
// 等待按钮加载完成
try {
page.waitForSelector("button[data-v-991781fe] span",
new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(10000));
} catch (com.microsoft.playwright.TimeoutError e) {
log.info("在预期时间内未找到待审批项目,可能没有待审批的流程");
return List.of("没有找到待审批的流程");
}
ArrayList<String> urls = new ArrayList<>();
// 查找所有"审核"按钮(第一种)
Locator auditButtons = page.locator("button[data-v-991781fe][type='button']:has(span:text('审核'))");
int auditButtonCount = auditButtons.count();
log.info("找到 {} 个'审核'按钮", auditButtonCount);
// 查找所有"复评"按钮(第二种)
Locator reviewButtons = page.locator("button[data-v-991781fe][type='button']:has(span:text('复评'))");
int reviewButtonCount = reviewButtons.count();
log.info("找到 {} 个'复评'按钮", reviewButtonCount);
// 处理"审核"按钮,获取跳转地址
for (int i = 0; i < auditButtonCount; i++) {
try {
Locator auditButton = auditButtons.nth(i);
// 尝试获取按钮的href属性或从父元素获取
String href = auditButton.getAttribute("href");
if (href != null && !href.isEmpty()) {
log.info("获取到审核流程URL: {}", href);
urls.add(href);
} else {
// 如果按钮没有href属性,点击按钮后获取新页面URL
log.debug("审核按钮无href属性,将通过点击获取URL");
// 监听新的页面打开事件
Page newPage = page.context().waitForPage(() -> {
auditButton.click();
});
String newPageUrl = newPage.url();
log.info("获取到审核流程URL (通过点击): {}", newPageUrl);
urls.add(newPageUrl);
// 关闭新页面,返回原页面
newPage.close();
page.bringToFront();
}
} catch (Exception e) {
log.warn("处理审核按钮时发生异常: {}", e.getMessage());
}
}
// 处理"复评"按钮,获取跳转地址
for (int i = 0; i < reviewButtonCount; i++) {
try {
Locator reviewButton = reviewButtons.nth(i);
// 尝试获取按钮的href属性或从父元素获取
String href = reviewButton.getAttribute("href");
if (href != null && !href.isEmpty()) {
log.info("获取到复评流程URL: {}", href);
urls.add(href);
} else {
// 如果按钮没有href属性,点击按钮后获取新页面URL
log.debug("复评按钮无href属性,将通过点击获取URL");
// 监听新的页面打开事件
Page newPage = page.context().waitForPage(() -> {
reviewButton.click();
});
String newPageUrl = newPage.url();
log.info("获取到复评流程URL (通过点击): {}", newPageUrl);
urls.add(newPageUrl);
// 关闭新页面,返回原页面
newPage.close();
page.bringToFront();
}
} catch (Exception e) {
log.warn("处理复评按钮时发生异常: {}", e.getMessage());
}
}
long endTime = System.currentTimeMillis();
log.info("待审批流程查找完成,共 {} 个流程,耗时: {} ms", urls.size(), endTime - startTime);
if (urls.isEmpty()) {
return List.of("没有找到待审批的流程");
}
return urls;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "查找待审批流程失败: " + e.getMessage();
log.error("查找待审批流程失败,耗时: {} ms", endTime - startTime, e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
try {
takeScreenshotAndSave(page, "performance_pending_fail_" + ssoUsername);
} catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
}
}
return List.of(errorMsg);
} finally {
// 不立即关闭页面,让服务器完成处理
// 保留页面、上下文和浏览器实例供后续操作使用
if (page != null) {
try {
log.debug("保留页面实例供后续使用");
} catch (Exception e) {
log.warn("处理页面实例时发生异常: {}", e.getMessage());
}
}
}
}
/**
* 工具方法:获取海信绩效系统的审批页面内容
*
* @param approvalUrl 审批页面URL
* @return 页面内容(HTML文本)
*/
@Tool(description = "获取海信绩效系统的审批页面内容,需要先使用HisenseSsoLoginTool登录")
public String getHisensePerformancePageContent(String approvalUrl) {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 获取绩效审批页面内容,URL: {}", ssoUsername, approvalUrl);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalUrl == null || approvalUrl.isEmpty()) {
String errorMsg = "审批页面URL不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 获取用户专用的浏览器上下文
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
// 创建新页面
page = userContext.newPage();
// 访问审批页面
log.info("正在访问审批页面: {}", approvalUrl);
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg);
return errorMsg;
}
// 等待页面完全加载完成
page.waitForLoadState(LoadState.NETWORKIDLE);
// 提取页面内容
String content = page.locator("body").innerText();
long endTime = System.currentTimeMillis();
log.info("成功获取绩效审批页面内容,耗时: {} ms", endTime - startTime);
return content;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "获取绩效审批页面内容失败: " + e.getMessage();
log.error("获取绩效审批页面内容失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
} finally {
// 释放页面资源,但保留浏览器上下文供后续使用
if (page != null) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
}
}
}
/**
* 工具方法:处理绩效系统单个审批流程
*
* @param approvalUrl 审批页面URL
* @param isApproved 是否通过审批(true为通过,false为驳回)
* @param approvalOpinion 审批意见
* @return 处理结果
*/
@Tool(description = "处理绩效系统单个审批流程,需要先使用HisenseSsoLoginTool登录")
public String performSinglePerformanceApproval(String approvalUrl, boolean isApproved, String approvalOpinion) {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理绩效审批,URL: {}, 是否通过: {}", ssoUsername, approvalUrl, isApproved);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalUrl == null || approvalUrl.isEmpty()) {
String errorMsg = "审批页面URL不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalOpinion == null || approvalOpinion.isEmpty()) {
String errorMsg = "审批意见不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 获取用户专用的浏览器上下文
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
// 创建新页面
page = userContext.newPage();
// 访问审批页面
log.info("正在访问审批页面: {}", approvalUrl);
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg);
return errorMsg;
}
// 等待页面完全加载完成
page.waitForLoadState(LoadState.NETWORKIDLE);
// 等待关键元素加载完成,确保页面完全就绪
try {
page.waitForSelector("input[name='radioGroup'][type='radio'][class='ant-radio-input']",
new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(5000));
} catch (com.microsoft.playwright.TimeoutError e) {
log.warn("关键审批元素未在预期时间内加载完成,继续执行审批操作");
}
// 执行审批操作
performPerformanceApprovalOperation(page, isApproved, approvalOpinion);
// 截图并保存
takeScreenshotAndSave(page, "performance_approval_success_" + ssoUsername);
long endTime = System.currentTimeMillis();
log.info("绩效审批处理完成,耗时: {} ms", endTime - startTime);
return "绩效审批处理成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "绩效审批处理失败: " + e.getMessage();
log.error("绩效审批处理失败,耗时: {} ms", endTime - startTime, e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
try {
takeScreenshotAndSave(page, "performance_approval_fail_" + ssoUsername);
} catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
}
}
return errorMsg;
} finally {
// 不立即关闭页面,让服务器完成审批处理
// 保留页面、上下文和浏览器实例供后续操作使用
if (page != null) {
try {
log.debug("保留页面实例以等待服务器完成审批处理");
} catch (Exception e) {
log.warn("处理页面实例时发生异常: {}", e.getMessage());
}
}
}
}
/**
* 执行绩效审批操作
*
* @param page 当前页面对象
* @param isApproved 是否通过审批(true为通过,false为驳回)
* @param approvalOpinion 审批意见
* @throws Exception 审批过程中的异常
*/
private void performPerformanceApprovalOperation(Page page, boolean isApproved, String approvalOpinion) throws Exception {
log.info("开始执行绩效审批操作,审批结果: {}", isApproved ? "通过" : "驳回");
try {
// 获取当前页面URL作为参考
String originalUrl = page.url();
log.debug("审批前页面URL: {}", originalUrl);
// 根据页面元素动态确定radioValue
String radioValue;
if (isApproved) {
// 如果isApproved为真,检查页面上是否存在特定的span元素
Locator pendingReviewSpan = page.locator("span[data-v-3734a0eb][class='ant-tag ant-tag-orange']:has-text('待复评')");
Locator pendingAuditSpan = page.locator("span[data-v-3734a0eb][class='ant-tag ant-tag-orange']:has-text('待审核')");
if (pendingReviewSpan.count() > 0) {
radioValue = "0"; // 待复评时radioValue = 0
log.debug("检测到'待复评'标签,设置radioValue为0");
} else if (pendingAuditSpan.count() > 0) {
radioValue = "1"; // 待审核时radioValue = 1
log.debug("检测到'待审核'标签,设置radioValue为1");
} else {
// 如果没有找到特定标签,默认使用1
radioValue = "1";
log.debug("未检测到特定标签,使用默认radioValue为1");
}
} else {
// 如果isApproved为假(驳回),使用4
radioValue = "4";
}
String radioSelector = "input[name='radioGroup'][type='radio'][class='ant-radio-input'][value='" + radioValue + "']";
log.debug("正在定位审批结果单选框: {}", radioSelector);
Locator radioButton = page.locator(radioSelector);
if (radioButton.count() > 0) {
radioButton.click();
log.info("审批结果单选框选择完成: {}", isApproved ? "通过" : "驳回");
} else {
throw new RuntimeException("未找到审批结果单选框");
}
// 定位审批意见输入框并填入内容
String opinionTextareaSelector = "textarea[class='ant-input'][style*='width']";
log.debug("正在定位审批意见输入框: {}", opinionTextareaSelector);
Locator opinionTextarea = page.locator(opinionTextareaSelector);
if (opinionTextarea.count() > 0) {
opinionTextarea.fill(approvalOpinion);
log.debug("审批意见输入完成");
} else {
log.warn("未找到审批意见输入框,尝试使用备选选择器");
opinionTextareaSelector = "textarea[placeholder=''][class='ant-input']";
opinionTextarea = page.locator(opinionTextareaSelector);
if (opinionTextarea.count() > 0) {
opinionTextarea.fill(approvalOpinion);
log.debug("审批意见输入完成 (使用备选选择器)");
} else {
throw new RuntimeException("未找到审批意见输入框");
}
}
// 定位并点击提交按钮
String submitButtonSelector = "button[data-v-deb7b464][type='button'].ant-btn.ant-btn-primary";
log.debug("正在定位提交按钮: {}", submitButtonSelector);
Locator submitButton = page.locator(submitButtonSelector);
if (submitButton.count() > 0) {
submitButton.click();
log.info("提交按钮点击完成");
} else {
throw new RuntimeException("未找到提交按钮");
}
// 等待页面导航完成(只要跳转离开当前审批页就算完成)
try {
log.debug("等待页面导航完成...");
// 等待URL变化(表示已离开当前审批页面)
page.waitForURL(url -> !url.equals(originalUrl),
new Page.WaitForURLOptions().setTimeout(10000));
log.info("审批操作执行完成,页面已跳转离开当前审批页");
} catch (com.microsoft.playwright.TimeoutError e) {
log.warn("等待页面跳转超时,但审批操作可能已成功提交: {}", e.getMessage());
} catch (Exception e) {
log.warn("等待页面导航时发生异常,但审批操作可能已成功提交: {}", e.getMessage());
}
// 记录跳转后的页面URL
String currentUrl = page.url();
log.info("审批后当前页面URL: {}", currentUrl);
} catch (Exception e) {
log.error("审批操作过程中发生异常", e);
throw new RuntimeException("审批操作失败: " + e.getMessage(), e);
}
}
/**
* 截图并保存到存储目录
*
* @param page 当前页面对象
* @param fileName 文件名前缀
*/
private void takeScreenshotAndSave(Page page, String fileName) {
try {
// 确保存储目录存在
File storageDir = new File(STORAGE_DIR);
if (!storageDir.exists()) {
storageDir.mkdirs();
}
// 生成带时间戳的文件名
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
String fullFileName = String.format("%s_%s.png", fileName, timestamp);
String filePath = Paths.get(STORAGE_DIR, fullFileName).toString();
// 截图并保存
page.screenshot(new Page.ScreenshotOptions().setPath(Paths.get(filePath)));
log.info("截图已保存至: {}", filePath);
} catch (Exception e) {
log.error("截图保存失败: {}", e.getMessage(), e);
}
}
}
package pangea.hiagent.tool.impl;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState;
import com.microsoft.playwright.options.WaitUntilState;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.annotation.PreDestroy;
import java.io.File;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import pangea.hiagent.workpanel.playwright.PlaywrightManager;
/**
* 海信SSO认证工具类
* 用于访问需要SSO认证的海信业务系统,自动完成登录并提取页面内容
* 海信SSO认证工具类(已弃用)
*
* 此类的功能已被拆分为两个独立的工具类:
* 1. HisenseSsoLoginTool - 专门负责海信SSO登录功能
* 2. HisenseLbpmApprovalTool - 专门负责LBPM流程审批功能
*
* 保留此类是为了向后兼容,但不再包含实际功能实现。
*/
@Slf4j
@Component
public class HisenseSsoAuthTool {
// SSO登录页面URL
private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/";
// 用户名输入框选择器
private static final String USERNAME_INPUT_SELECTOR = "input[placeholder='账号名/海信邮箱/手机号']";
// 密码输入框选择器
private static final String PASSWORD_INPUT_SELECTOR = "input[placeholder='密码'][type='password']";
// 登录按钮选择器
private static final String LOGIN_BUTTON_SELECTOR = "#login-button";
// 注入Playwright管理器
@Autowired
private PlaywrightManager playwrightManager;
// 浏览器实例(从Playwright管理器获取)
private Browser browser;
// 共享的浏览器上下文,用于保持登录状态
private BrowserContext sharedContext;
// 上次登录时间
private long lastLoginTime = 0;
// 登录状态有效期(毫秒),设置为30分钟
private static final long LOGIN_VALIDITY_PERIOD = 30 * 60 * 1000;
// SSO用户名(从配置文件读取)
@Value("${hisense.sso.username:}")
private String ssoUsername;
// SSO密码(从配置文件读取)
@Value("${hisense.sso.password:}")
private String ssoPassword;
// 存储目录路径
private static final String STORAGE_DIR = "storage";
/**
* 延迟初始化浏览器实例引用和共享上下文
*/
private void initializeIfNeeded() {
if (browser == null || sharedContext == null) {
try {
log.info("正在初始化海信SSO认证工具的Playwright...");
// 从Playwright管理器获取共享的浏览器实例
this.browser = playwrightManager.getBrowser();
// 初始化共享上下文
this.sharedContext = browser.newContext();
log.info("海信SSO认证工具的Playwright初始化成功");
} catch (Exception e) {
log.error("海信SSO认证工具的Playwright初始化失败: ", e);
}
}
}
// 移除@PostConstruct注解以避免在启动时初始化
/*
@PostConstruct
public void initialize() {
try {
log.info("正在初始化海信SSO认证工具的Playwright...");
// 从Playwright管理器获取共享的浏览器实例
this.browser = playwrightManager.getBrowser();
// 初始化共享上下文
this.sharedContext = browser.newContext();
log.info("海信SSO认证工具的Playwright初始化成功");
} catch (Exception e) {
log.error("海信SSO认证工具的Playwright初始化失败: ", e);
}
}
*/
/**
* 销毁Playwright资源
*/
@PreDestroy
public void destroy() {
try {
if (sharedContext != null) {
sharedContext.close();
log.info("海信SSO认证工具的共享浏览器上下文已关闭");
}
} catch (Exception e) {
log.error("海信SSO认证工具的Playwright资源释放失败: ", e);
}
}
/**
* 工具方法:获取海信业务系统的网页内容(自动处理SSO认证)
*
* @param businessSystemUrl 海信业务系统页面URL
* @return 页面内容(HTML文本)
*/
@Tool(description = "获取海信业务系统的网页内容(自动处理SSO认证)")
public String getHisenseBusinessSystemContent(String businessSystemUrl) {
initializeIfNeeded();
log.info("开始获取海信业务系统内容,URL: {}", businessSystemUrl);
// 校验SSO凭证是否配置
if (ssoUsername == null || ssoUsername.isEmpty() || ssoPassword == null || ssoPassword.isEmpty()) {
String errorMsg = "SSO用户名或密码未配置,海信SSO工具不可用";
log.warn(errorMsg);
return errorMsg;
}
long startTime = System.currentTimeMillis();
// 参数校验
if (businessSystemUrl == null || businessSystemUrl.isEmpty()) {
String errorMsg = "业务系统URL不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 检查是否已有有效的登录会话
boolean sessionValid = isSessionLoggedIn() && validateSession(businessSystemUrl);
if (sessionValid) {
log.info("检测到有效会话,直接使用共享上下文");
page = sharedContext.newPage();
} else {
log.info("未检测到有效会话,使用共享上下文并重新登录");
page = sharedContext.newPage();
// 访问业务系统页面
log.info("正在访问业务系统页面: {}", businessSystemUrl);
page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
log.info("检测到SSO登录页面,开始自动登录...");
// 执行SSO登录
performLoginAndUpdateStatus(page);
// 等待登录完成并重定向回业务系统
page.waitForURL(businessSystemUrl, new Page.WaitForURLOptions().setTimeout(10000));
log.info("登录成功,已重定向回业务系统页面");
} else {
// 即使没有跳转到登录页面,也更新登录时间
lastLoginTime = System.currentTimeMillis();
log.info("直接访问业务系统页面成功,无需SSO登录,更新会话时间");
}
}
// 如果页面尚未导航到业务系统URL,则导航到该URL
if (!page.url().equals(businessSystemUrl) && !page.url().startsWith(businessSystemUrl)) {
log.info("正在访问业务系统页面: {}", businessSystemUrl);
page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
}
// 提取页面内容
String content = page.locator("body").innerText();
long endTime = System.currentTimeMillis();
log.info("成功获取业务系统页面内容,耗时: {} ms", endTime - startTime);
// 检查是否包含错误信息
if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) {
log.warn("检测到页面中可能存在JavaScript错误,但这不会影响主要功能");
}
return content;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
log.error("获取海信业务系统内容失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
} finally {
// 释放页面资源
if (page != null) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
}
}
}
/**
* 工具方法:处理海信请假审批
*
* @param approvalUrl 请假审批页面URL
* @param approvalOpinion 审批意见
* @return 处理结果
*/
@Tool(description = "处理海信请假审批、自驾车审批、调休审批")
public String processHisenseLeaveApproval(String approvalUrl, String approvalOpinion) {
initializeIfNeeded();
log.info("开始处理海信请假审批,URL: {}", approvalUrl);
// 校验SSO凭证是否配置
if (ssoUsername == null || ssoUsername.isEmpty() || ssoPassword == null || ssoPassword.isEmpty()) {
String errorMsg = "SSO用户名或密码未配置,海信SSO工具不可用";
log.warn(errorMsg);
return errorMsg;
}
long startTime = System.currentTimeMillis();
// 参数校验
if (approvalUrl == null || approvalUrl.isEmpty()) {
String errorMsg = "审批URL不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalOpinion == null || approvalOpinion.isEmpty()) {
String errorMsg = "审批意见不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 检查是否已有有效的登录会话
boolean sessionValid = isSessionLoggedIn() && validateSession(approvalUrl);
if (sessionValid) {
log.info("检测到有效会话,直接使用共享上下文");
page = sharedContext.newPage();
} else {
log.info("未检测到有效会话,使用共享上下文并重新登录");
page = sharedContext.newPage();
// 访问审批页面
log.info("正在访问审批页面: {}", approvalUrl);
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
log.info("检测到SSO登录页面,开始自动登录...");
// 执行SSO登录
performLoginAndUpdateStatus(page);
// 等待登录完成并重定向回审批页面
page.waitForURL(approvalUrl, new Page.WaitForURLOptions().setTimeout(10000));
log.info("登录成功,已重定向回审批页面");
} else {
// 即使没有跳转到登录页面,也更新登录时间
lastLoginTime = System.currentTimeMillis();
log.info("直接访问审批页面成功,无需SSO登录,更新会话时间");
}
}
// 如果页面尚未导航到审批URL,则导航到该URL
if (!page.url().equals(approvalUrl) && !page.url().startsWith(approvalUrl)) {
log.info("正在访问审批页面: {}", approvalUrl);
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
}
// 执行审批操作
performApprovalOperation(page, approvalOpinion);
// 截图并保存
takeScreenshotAndSave(page, "leave_approval_success");
long endTime = System.currentTimeMillis();
log.info("请假审批处理完成,耗时: {} ms", endTime - startTime);
return "请假审批处理成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "请假审批处理失败: " + e.getMessage();
log.error("请假审批处理失败,耗时: {} ms", endTime - startTime, e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
try {
takeScreenshotAndSave(page, "leave_approval_fail");
} catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
}
}
return errorMsg;
}
// 注意:这里不再释放页面资源,以保持会话状态供后续使用
/*finally {
// 释放页面资源
if (page != null) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
}
}*/
public HisenseSsoAuthTool() {
log.warn("HisenseSsoAuthTool 已被弃用,请使用 HisenseSsoLoginTool 和 HisenseLbpmApprovalTool 代替");
}
/**
* 工具方法:海信SSO登录工具,用于登录海信SSO系统
*
* @param username 用户名
* @param password 密码
* @return 登录结果
*/
@Tool(description = "海信SSO登录工具,用于登录海信SSO系统")
public String hisenseSsoLogin(String username, String password) {
initializeIfNeeded();
log.info("开始执行海信SSO登录,用户名: {}", username);
long startTime = System.currentTimeMillis();
// 参数校验
if (username == null || username.isEmpty()) {
String errorMsg = "用户名不能为空";
log.error(errorMsg);
return errorMsg;
}
if (password == null || password.isEmpty()) {
String errorMsg = "密码不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 访问SSO登录页面
log.info("正在访问SSO登录页面: {}", SSO_LOGIN_URL);
page = sharedContext.newPage();
page.navigate(SSO_LOGIN_URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 执行SSO登录
performLoginAndUpdateStatus(page);
// 等待登录完成并重定向回业务系统
page.waitForURL(SSO_LOGIN_URL, new Page.WaitForURLOptions().setTimeout(10000));
log.info("登录成功,已重定向回SSO登录页面");
long endTime = System.currentTimeMillis();
log.info("海信SSO登录完成,耗时: {} ms", endTime - startTime);
return "海信SSO登录成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "海信SSO登录失败: " + e.getMessage();
log.error("海信SSO登录失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
} finally {
// 释放页面资源
if (page != null) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
}
}
}
/**
* 工具方法:海信SSO登出工具,用于退出海信SSO系统
*
* @return 登出结果
*/
@Tool(description = "海信SSO登出工具,用于退出海信SSO系统")
public String hisenseSsoLogout() {
initializeIfNeeded();
log.info("开始执行海信SSO登出");
long startTime = System.currentTimeMillis();
try {
// 关闭共享上下文
if (sharedContext != null) {
sharedContext.close();
log.info("共享上下文已关闭");
}
// 重置登录时间
lastLoginTime = 0;
log.info("登录时间已重置");
long endTime = System.currentTimeMillis();
log.info("海信SSO登出完成,耗时: {} ms", endTime - startTime);
return "海信SSO登出成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "海信SSO登出失败: " + e.getMessage();
log.error("海信SSO登出失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
}
}
/**
* 工具方法:检查海信SSO登录状态
*/
@Tool(description = "检查海信SSO登录状态")
public String checkHisenseSsoLoginStatus() {
initializeIfNeeded();
log.info("开始检查海信SSO登录状态");
long startTime = System.currentTimeMillis();
try {
boolean isLoggedIn = isSessionLoggedIn();
long endTime = System.currentTimeMillis();
log.info("海信SSO登录状态检查完成,耗时: {} ms", endTime - startTime);
return isLoggedIn ? "已登录" : "未登录";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "海信SSO登录状态检查失败: " + e.getMessage();
log.error("海信SSO登录状态检查失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
}
}
/**
* 检查当前会话是否已登录
*
* @return true表示已登录且会话有效,false表示未登录或会话已过期
*/
private boolean isSessionLoggedIn() {
// 检查是否存在共享上下文
if (sharedContext == null) {
return false;
}
// 检查登录是否过期
long currentTime = System.currentTimeMillis();
if (currentTime - lastLoginTime > LOGIN_VALIDITY_PERIOD) {
log.debug("会话已过期,上次登录时间: {},当前时间: {}", lastLoginTime, currentTime);
return false;
}
return true;
}
/**
* 验证当前会话是否仍然有效
* 通过访问一个需要登录的页面来验证会话状态
*
* @param testUrl 用于验证会话的测试页面URL
* @return true表示会话有效,false表示会话无效
*/
private boolean validateSession(String testUrl) {
if (!isSessionLoggedIn()) {
return false;
}
try {
Page page = sharedContext.newPage();
try {
page.navigate(testUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
String currentUrl = page.url();
// 如果重定向到了登录页面,说明会话已失效
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
log.debug("会话验证失败,已重定向到登录页面");
return false;
}
log.debug("会话验证成功,当前页面URL: {}", currentUrl);
return true;
} finally {
page.close();
}
} catch (Exception e) {
log.warn("会话验证过程中发生异常: {}", e.getMessage());
return false;
}
}
/**
* 执行登录并更新登录状态
*
* @param page 当前页面对象
* @throws Exception 登录过程中的异常
*/
private void performLoginAndUpdateStatus(Page page) throws Exception {
log.info("开始执行SSO登录流程");
try {
// 填入用户名
log.debug("正在定位用户名输入框: {}", USERNAME_INPUT_SELECTOR);
Locator usernameInput = page.locator(USERNAME_INPUT_SELECTOR);
if (usernameInput.count() == 0) {
throw new RuntimeException("未找到用户名输入框");
}
usernameInput.fill(ssoUsername);
log.debug("用户名输入完成");
// 填入密码
log.debug("正在定位密码输入框: {}", PASSWORD_INPUT_SELECTOR);
Locator passwordInput = page.locator(PASSWORD_INPUT_SELECTOR);
if (passwordInput.count() == 0) {
throw new RuntimeException("未找到密码输入框");
}
passwordInput.fill(ssoPassword);
log.debug("密码输入完成");
// 点击登录按钮
log.debug("正在定位登录按钮: {}", LOGIN_BUTTON_SELECTOR);
Locator loginButton = page.locator(LOGIN_BUTTON_SELECTOR);
if (loginButton.count() == 0) {
throw new RuntimeException("未找到登录按钮");
}
loginButton.click();
log.info("登录按钮点击完成,等待登录响应");
// 等待页面开始跳转(表示登录请求已发送)
page.waitForLoadState(LoadState.NETWORKIDLE);
// 更新登录时间
lastLoginTime = System.currentTimeMillis();
log.info("SSO登录成功,登录时间已更新");
} catch (Exception e) {
log.error("SSO登录过程中发生异常", e);
throw new RuntimeException("SSO登录失败: " + e.getMessage(), e);
}
}
/**
* 执行审批操作
*
* @param page 当前页面对象
* @param approvalOpinion 审批意见
* @throws Exception 审批过程中的异常
*/
private void performApprovalOperation(Page page, String approvalOpinion) throws Exception {
log.info("开始执行审批操作");
try {
// 定位审批操作单选框
String operationRadioSelector = "input[type='radio'][alerttext=''][key='operationType'][name='oprGroup'][value='handler_pass:通过']";
log.debug("正在定位审批操作单选框: {}", operationRadioSelector);
Locator operationRadio = page.locator(operationRadioSelector);
if (operationRadio.count() == 0) {
throw new RuntimeException("未找到审批操作单选框");
}
operationRadio.click();
log.debug("审批操作单选框选择完成");
// 定位审批意见输入框并填入内容
String opinionTextareaSelector = "textarea[name='fdUsageContent'][class='process_review_content'][key='auditNode']";
log.debug("正在定位审批意见输入框: {}", opinionTextareaSelector);
Locator opinionTextarea = page.locator(opinionTextareaSelector);
if (opinionTextarea.count() == 0) {
throw new RuntimeException("未找到审批意见输入框");
}
opinionTextarea.fill(approvalOpinion);
log.debug("审批意见输入完成");
// 定位并点击提交按钮
String submitButtonSelector = "input[id='process_review_button'][class='process_review_button'][type='button'][value='提交']";
log.debug("正在定位提交按钮: {}", submitButtonSelector);
Locator submitButton = page.locator(submitButtonSelector);
if (submitButton.count() == 0) {
throw new RuntimeException("未找到提交按钮");
}
submitButton.click();
log.info("提交按钮点击完成");
// 等待提交完成
page.waitForLoadState(LoadState.NETWORKIDLE);
log.info("审批操作执行完成");
} catch (Exception e) {
log.error("审批操作过程中发生异常", e);
throw new RuntimeException("审批操作失败: " + e.getMessage(), e);
}
}
/**
* 截图并保存到存储目录
*
* @param page 当前页面对象
* @param fileName 文件名前缀
*/
private void takeScreenshotAndSave(Page page, String fileName) {
try {
// 确保存储目录存在
File storageDir = new File(STORAGE_DIR);
if (!storageDir.exists()) {
storageDir.mkdirs();
}
// 生成带时间戳的文件名
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
String fullFileName = String.format("%s_%s.png", fileName, timestamp);
String filePath = Paths.get(STORAGE_DIR, fullFileName).toString();
// 截图并保存
page.screenshot(new Page.ScreenshotOptions().setPath(Paths.get(filePath)));
log.info("截图已保存至: {}", filePath);
} catch (Exception e) {
log.error("截图保存失败: {}", e.getMessage(), e);
}
}
// 此类不再包含任何功能实现
// 所有功能已迁移到 HisenseSsoLoginTool 和 HisenseLbpmApprovalTool
}
\ No newline at end of file
package pangea.hiagent.tool.impl;
import com.microsoft.playwright.*;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.microsoft.playwright.options.LoadState;
import com.microsoft.playwright.options.WaitUntilState;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.stringtemplate.v4.compiler.CodeGenerator.primary_return;
import jakarta.annotation.PreDestroy;
import java.io.File;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import pangea.hiagent.web.service.ToolConfigService;
import pangea.hiagent.workpanel.playwright.PlaywrightManager;
/**
* 海信SSO认证工具类
* 用于访问需要SSO认证的海信业务系统,自动完成登录并提取页面内容
*/
@Slf4j
@Component
public class HisenseSsoLoginTool {
// SSO登录页面URL
private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/";
private static final String SSO_PROFILE_URL = "https://sso.hisense.com/selfcare/?#/profile";
private static final String SSO_MFA_URL = "https://sso.hisense.com/login/mfaLogin.html";
// 用户名输入框选择器
private static final String USERNAME_INPUT_SELECTOR = "input[placeholder='账号名/海信邮箱/手机号']";
// 密码输入框选择器
private static final String PASSWORD_INPUT_SELECTOR = "input[placeholder='密码'][type='password']";
// 登录按钮选择器
private static final String LOGIN_BUTTON_SELECTOR = "#login-button";
// 注入Playwright管理器
@Autowired
private PlaywrightManager playwrightManager;
// 上次登录时间
private long lastLoginTime = 0;
@Autowired
private ToolConfigService toolConfigService;
// MFA 会话信息容器
private static class MfaSession {
Page page;
BrowserContext context;
long lastAccessTime;
MfaSession(Page page, BrowserContext context) {
this.page = page;
this.context = context;
this.lastAccessTime = System.currentTimeMillis();
}
void updateAccessTime() {
this.lastAccessTime = System.currentTimeMillis();
}
boolean isExpired(long timeoutMillis) {
return System.currentTimeMillis() - lastAccessTime > timeoutMillis;
}
}
// MFA 会话缓存(用户名 -> MFA会话)
private final ConcurrentMap<String, MfaSession> mfaSessions = new ConcurrentHashMap<>();
// MFA 会话超时时间(15分钟),短于BrowserContext超时时间以主动清理
private static final long MFA_SESSION_TIMEOUT = 15 * 60 * 1000;
// 登录状态有效期(毫秒),设置为30分钟
private static final long LOGIN_VALIDITY_PERIOD = 30 * 60 * 1000;
private String userName;
private String password;
public String getUserName() {
userName = toolConfigService.getParamValue("hisenseSsoLogin", "ssoUsername");
return userName;
}
private String getPassword() {
password = toolConfigService.getParamValue("hisenseSsoLogin", "ssoPassword");
return password;
}
private BrowserContext getUSerContext() {
return playwrightManager.getUserContext(getUserName());
}
// 存储目录路径
private static final String STORAGE_DIR = "storage";
/**
* 延迟初始化浏览器实例引用和共享上下文
*/
// private void initializeIfNeeded() {
// if (browser == null || sharedContext == null) {
// try {
// log.info("正在初始化海信SSO认证工具的Playwright...");
// // 从Playwright管理器获取共享的浏览器实例
// this.browser = playwrightManager.getBrowser();
// // 初始化共享上下文
// this.sharedContext = browser.newContext();
// log.info("海信SSO认证工具的Playwright初始化成功");
// } catch (Exception e) {
// log.error("海信SSO认证工具的Playwright初始化失败: ", e);
// }
// }
// }
// 移除@PostConstruct注解以避免在启动时初始化
/*
* @PostConstruct
* public void initialize() {
* try {
* log.info("正在初始化海信SSO认证工具的Playwright...");
* // 从Playwright管理器获取共享的浏览器实例
* this.browser = playwrightManager.getBrowser();
* // 初始化共享上下文
* this.sharedContext = browser.newContext();
* log.info("海信SSO认证工具的Playwright初始化成功");
* } catch (Exception e) {
* log.error("海信SSO认证工具的Playwright初始化失败: ", e);
* }
* }
*/
/**
* 销毁Playwright资源
*/
@PreDestroy
public void destroy() {
try {
// 清空MFA会话缓存
mfaSessions.clear();
log.info("海信SSO认证工具的MFA会话缓存已清空");
// 注意:不在这里关闭BrowserContext,由PlaywrightManager统一管理生命周期
// 避免在MFA验证进行中被意外关闭
} catch (Exception e) {
log.error("海信SSO认证工具的资源释放失败: ", e);
}
}
/**
* 获取和更新 MFA 会话
* 同时检查会话是否过期,并在访问时更新最后访问时间
*
* @param username 用户名
* @return MFA会话,如果会话已过期或不存在则返回null
*/
private MfaSession getMfaSessionAndUpdateTime(String username) {
MfaSession session = mfaSessions.get(username);
if (session == null) {
return null;
}
// 检查会话是否过期
if (session.isExpired(MFA_SESSION_TIMEOUT)) {
log.warn("MFA会话已过期,用户: {}", username);
mfaSessions.remove(username);
return null;
}
// 更新最后访问时间(保活)
session.updateAccessTime();
return session;
}
/**
* 清理过期的MFA会话
*/
private void cleanupExpiredMfaSessions() {
mfaSessions.entrySet().removeIf(entry -> {
if (entry.getValue().isExpired(MFA_SESSION_TIMEOUT)) {
log.info("清理过期的MFA会话: {}", entry.getKey());
return true;
}
return false;
});
}
/**
* 工具方法:获取海信业务系统的网页内容(自动处理SSO认证)
*
* @param businessSystemUrl 海信业务系统页面URL
* @return 页面内容(HTML文本)
*/
@Tool(description = "获取任意海信业务系统的网页内容(自动处理SSO认证)")
public String getHisenseBusinessSystemContent(
@JsonPropertyDescription("海信业务系统的页面URL") String businessSystemUrl) {
// initializeIfNeeded();
log.info("开始获取海信业务系统内容,URL: {}", businessSystemUrl);
String ssoUsername = getUserName();
String ssoPassword = getPassword();
// 校验SSO凭证是否配置
if (ssoUsername == null || ssoUsername.isEmpty() || ssoPassword == null || ssoPassword.isEmpty()) {
String errorMsg = "SSO用户名或密码未配置,海信SSO工具不可用";
log.warn(errorMsg);
return errorMsg;
}
long startTime = System.currentTimeMillis();
// 参数校验
if (businessSystemUrl == null || businessSystemUrl.isEmpty()) {
String errorMsg = "业务系统URL不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 检查是否已有有效的登录会话
boolean sessionValid = isSessionLoggedIn() && validateSession(businessSystemUrl);
if (sessionValid) {
log.info("检测到有效会话,直接使用共享上下文");
page = getUSerContext().newPage();
} else {
log.info("未检测到有效会话,使用共享上下文并重新登录");
page = getUSerContext().newPage();
// 访问业务系统页面
log.info("正在访问业务系统页面: {}", businessSystemUrl);
page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
log.info("检测到SSO登录页面,开始自动登录...");
// 执行SSO登录
performLoginAndUpdateStatus(page);
// 等待登录完成并重定向回业务系统
page.waitForURL(businessSystemUrl, new Page.WaitForURLOptions().setTimeout(30000));
log.info("登录成功,已重定向回业务系统页面");
} else {
// 即使没有跳转到登录页面,也更新登录时间
lastLoginTime = System.currentTimeMillis();
log.info("直接访问业务系统页面成功,无需SSO登录,更新会话时间");
}
}
// 如果页面尚未导航到业务系统URL,则导航到该URL
if (!page.url().equals(businessSystemUrl) && !page.url().startsWith(businessSystemUrl)) {
log.info("正在访问业务系统页面: {}", businessSystemUrl);
page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
}
// 提取页面内容
String content = page.locator("body").innerText();
long endTime = System.currentTimeMillis();
log.info("成功获取业务系统页面内容,耗时: {} ms", endTime - startTime);
// 检查是否包含错误信息
if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) {
log.warn("检测到页面中可能存在JavaScript错误,但这不会影响主要功能");
}
return content;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
log.error("获取海信业务系统内容失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
} finally {
// 释放页面资源
if (page != null) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
}
}
}
/**
* 工具方法:海信SSO登录工具,用于登录海信SSO系统
*
* @param username 用户名
* @param password 密码
* @return 登录结果
*/
@Tool(description = "海信SSO登录工具,用于登录海信SSO系统")
public String hisenseSsoLogin() {
String username = getUserName();
String password = getPassword();
// 校验SSO凭证是否配置
if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
String errorMsg = "SSO用户名或密码未配置,海信SSO工具不可用";
log.warn(errorMsg);
return errorMsg;
}
log.info("开始执行海信SSO登录,用户名: {}", username);
long startTime = System.currentTimeMillis();
// 参数校验
if (username == null || username.isEmpty()) {
String errorMsg = "用户名不能为空";
log.error(errorMsg);
return errorMsg;
}
if (password == null || password.isEmpty()) {
String errorMsg = "密码不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 访问SSO登录页面
log.info("正在访问SSO登录页面: {}", SSO_LOGIN_URL);
page = getUSerContext().newPage();
page.navigate(SSO_LOGIN_URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
if (page.url().equals(SSO_MFA_URL)) {
log.info("检测到MFA页面,自动发送验证码...");
// 执行MFA登录,传递Context以保证生命周期管理
return sendVerificationCode(username, page, getUSerContext());
}
if (!SSO_LOGIN_URL.equals(page.url())) {
return "海信SSO在之前已登录成功";
}
// 执行SSO登录
String loginResult = performLoginAndUpdateStatus(page);
if(!loginResult.equals("SSO登录成功")){
return loginResult;
}
// 等待登录完成并重定向回业务系统
page.waitForURL(SSO_PROFILE_URL, new Page.WaitForURLOptions().setTimeout(30000));
log.info("登录成功,已重定向回SSO配置页面");
long endTime = System.currentTimeMillis();
log.info("海信SSO登录完成,耗时: {} ms", endTime - startTime);
return "海信SSO登录成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "海信SSO登录失败: " + e.getMessage();
log.error("海信SSO登录失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
} finally {
// 释放页面资源
if (page != null && !page.url().equals(SSO_MFA_URL)) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
}
}
}
private String sendVerificationCode(String username, Page page, BrowserContext context) {
try {
// 最初检查页面有效性
if (!isPageValid(page)) {
String errorMsg = "发送验证码时页面已关闭";
log.error(errorMsg);
return errorMsg;
}
// 确定SSO_MFA_URL网页加载完成
try {
page.waitForLoadState(LoadState.NETWORKIDLE);
} catch (Exception e) {
log.warn("等待页面加载时发生异常(可能页面已关闭),尝试继续: {}", e.getMessage());
}
// 点击获取验证码按钮
if (!isPageValid(page)) {
String errorMsg = "在操作前页面已关闭";
log.error(errorMsg);
return errorMsg;
}
Locator getCodeButton = page.locator("div.get-code-btn[hk-ripple][hk-ripple-color]");
if (getCodeButton.count() > 0) {
getCodeButton.click();
log.info("已点击获取验证码按钮");
} else {
String errorMsg = "未找到获取验证码按钮";
log.warn(errorMsg);
throw new RuntimeException(errorMsg);
}
// 确定页面上出现提示文字
Locator smsSentTip = page.locator("#sms-sent-tip");
try {
page.waitForSelector("#sms-sent-tip", new Page.WaitForSelectorOptions()
.setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(10000));
String tipText = smsSentTip.textContent();
if (tipText != null && tipText.contains("短信验证码已发送")) {
log.info("验证码发送成功: {}", tipText);
// 保存 MFA 会话(包括 Page 和 Context),便于后续验证使用
MfaSession session = new MfaSession(page, context);
mfaSessions.put(username, session);
return "已发送验证码,请查看短信";
} else {
String errorMsg = "验证码发送提示信息不符合预期: " + tipText;
log.warn(errorMsg);
throw new RuntimeException(errorMsg);
}
} catch (Exception e) {
log.error("等待验证码发送提示失败: ", e);
throw new RuntimeException("未能确认验证码发送成功: " + e.getMessage());
}
} catch (com.microsoft.playwright.impl.TargetClosedError e) {
// 专门处理TargetClosedError
log.error("验证码发送过程中BrowserContext已关闭,错误: {}", e.getMessage());
mfaSessions.remove(username);
return "验证码发送失败:BrowserContext已关闭,请重试";
} catch (Exception e) {
log.error("验证码发送过程发生异常,错误类型: {},详情: ", e.getClass().getName(), e);
mfaSessions.remove(username);
throw e;
}
}
/**
* 检查页面是否仍然有效
*
* @param page 要检查的页面
* @return 如果页面有效则返回true
*/
private boolean isPageValid(Page page) {
if (page == null) {
return false;
}
try {
// 尝试访问页面属性来检查它是否仍然有效
page.url();
return true;
} catch (Exception e) {
log.debug("页面已关闭或无效: {}", e.getMessage());
return false;
}
}
/**
* 检查BrowserContext是否仍然有效
*
* @param context 要检查的BrowserContext
* @return 如果Context有效则返回true
*/
private boolean isContextValid(BrowserContext context) {
if (context == null) {
return false;
}
try {
// 尝试访问Context属性来检查它是否仍然有效
context.pages();
return true;
} catch (Exception e) {
log.debug("BrowserContext已关闭或无效: {}", e.getMessage());
return false;
}
}
/**
* 工具方法:处理MFA验证码验证,完成海信SSO登录
*
* @param verificationCode 验证码
* @return 验证结果
*/
@Tool(description = "处理MFA验证码验证,完成海信SSO登录")
public String handleMfaVerification(
@JsonPropertyDescription("短信验证码") String verificationCode) {
log.info("开始处理MFA验证码验证");
String username = getUserName();
// 参数校验
if (verificationCode == null || verificationCode.isEmpty()) {
String errorMsg = "验证码不能为空";
log.error(errorMsg);
return errorMsg;
}
long startTime = System.currentTimeMillis();
// 清理过期的MFA会话
cleanupExpiredMfaSessions();
// 获取当前用户的MFA会话并更新访问时间
MfaSession mfaSession = getMfaSessionAndUpdateTime(username);
if (mfaSession == null) {
String errorMsg = "未找到当前用户的MFA验证会话,请先触发验证码发送。如果30分钟内未使用验证码,会话将自动过期。";
log.error(errorMsg);
return errorMsg;
}
Page mfaPage = mfaSession.page;
BrowserContext context = mfaSession.context;
// 检查MFA页面和Context是否仍然有效,如果已关闭则返回错误
if (!isPageValid(mfaPage) || !isContextValid(context)) {
String errorMsg = "MFA验证页面或Context已关闭,BrowserContext可能已被释放,请重新触发验证码发送流程";
log.error(errorMsg);
mfaSessions.remove(username);
return errorMsg;
}
try {
// 等待页面加载完成,添加异常处理
try {
mfaPage.waitForLoadState(LoadState.NETWORKIDLE);
} catch (Exception e) {
log.warn("等待页面加载时发生异常(可能页面已关闭),尝试继续: {}", e.getMessage());
}
// 查找验证码输入框并填入验证码
// 在访问元素前再次检查页面有效性
if (!isPageValid(mfaPage) || !isContextValid(context)) {
String errorMsg = "MFA验证页面或Context在操作过程中被关闭";
log.error(errorMsg);
mfaSessions.remove(username);
return errorMsg;
}
Locator verificationInput = mfaPage.locator("input[placeholder='请输入短信验证码'][name='']");
if (verificationInput.count() == 0) {
String errorMsg = "未找到验证码输入框";
log.error(errorMsg);
return errorMsg;
}
verificationInput.fill(verificationCode);
log.info("验证码已填入输入框");
// 再次检查页面有效性
if (!isPageValid(mfaPage) || !isContextValid(context)) {
String errorMsg = "验证码填入后页面或Context已关闭";
log.error(errorMsg);
mfaSessions.remove(username);
return errorMsg;
}
// 点击登录按钮提交验证码
Locator loginButton = mfaPage.locator("button#login-button.para-btn.para-btn-login[hk-ripple='']");
if (loginButton.count() == 0) {
String errorMsg = "未找到登录按钮";
log.error(errorMsg);
return errorMsg;
}
loginButton.click();
log.info("已点击登录按钮提交验证码");
// 等待页面跳转,确认登录结果
try {
// 等待页面不再是MFA页面(即跳转到其他页面)
mfaPage.waitForURL(url -> !url.equals(SSO_MFA_URL), new Page.WaitForURLOptions().setTimeout(30000));
String currentUrl = mfaPage.url();
log.info("MFA验证成功,已跳转到: {}", currentUrl);
// 从缓存中移除会话,因为登录已完成
mfaSessions.remove(username);
// 更新登录时间
lastLoginTime = System.currentTimeMillis();
long endTime = System.currentTimeMillis();
log.info("MFA验证完成,耗时: {} ms", endTime - startTime);
return "MFA验证成功,登录完成";
} catch (Exception urlException) {
// 检查是否仍然是MFA页面,表示登录失败
String currentUrl = mfaPage.url();
if (currentUrl.equals(SSO_MFA_URL)) {
String errorMsg = "MFA验证失败,验证码可能错误或已过期,请重试";
log.error(errorMsg);
return errorMsg;
} else {
// 页面已跳转,说明登录成功
log.info("MFA验证成功,已跳转到: {}", currentUrl);
// 从缓存中移除会话
mfaSessions.remove(username);
// 更新登录时间
lastLoginTime = System.currentTimeMillis();
long endTime = System.currentTimeMillis();
log.info("MFA验证完成,耗时: {} ms", endTime - startTime);
return "MFA验证成功,登录完成";
}
}
} catch (com.microsoft.playwright.impl.TargetClosedError e) {
// 专门处理TargetClosedError
long endTime = System.currentTimeMillis();
String errorMsg = "MFA验证时BrowserContext已关闭,请重新触发验证码发送流程";
log.error("MFA验证失败 - TargetClosedError,耗时: {} ms,完整错误堆栈: ", endTime - startTime, e);
mfaSessions.remove(username);
return errorMsg;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "MFA验证过程发生异常: " + e.getMessage();
log.error("MFA验证失败,耗时: {} ms,错误类型: {},完整错误堆栈: ", endTime - startTime, e.getClass().getName(), e);
mfaSessions.remove(username);
return errorMsg;
}
}
/**
* 工具方法:海信SSO登出工具,用于退出海信SSO系统
*
* @return 登出结果
*/
@Tool(description = "海信SSO登出工具,用于退出海信SSO系统")
public String hisenseSsoLogout() {
// initializeIfNeeded();
log.info("开始执行海信SSO登出");
long startTime = System.currentTimeMillis();
try {
// 关闭共享上下文
if (getUSerContext() != null) {
getUSerContext().close();
log.info("共享上下文已关闭");
}
// 重置登录时间
lastLoginTime = 0;
log.info("登录时间已重置");
long endTime = System.currentTimeMillis();
log.info("海信SSO登出完成,耗时: {} ms", endTime - startTime);
return "海信SSO登出成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "海信SSO登出失败: " + e.getMessage();
log.error("海信SSO登出失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
}
}
/**
* 工具方法:检查海信SSO登录状态
*/
@Tool(description = "检查海信SSO登录状态")
public String checkHisenseSsoLoginStatus() {
// initializeIfNeeded();
log.info("开始检查海信SSO登录状态");
long startTime = System.currentTimeMillis();
try {
boolean isLoggedIn = isSessionLoggedIn();
long endTime = System.currentTimeMillis();
log.info("海信SSO登录状态检查完成:{},耗时: {} ms", isLoggedIn, endTime - startTime);
return isLoggedIn ? "已登录" : "未登录";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "海信SSO登录状态检查失败: " + e.getMessage();
log.error("海信SSO登录状态检查失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
}
}
/**
* 检查当前会话是否已登录
*
* @return true表示已登录且会话有效,false表示未登录或会话已过期
*/
private boolean isSessionLoggedIn() {
// 检查是否存在共享上下文
if (getUSerContext() == null) {
return false;
}
// 检查登录是否过期
long currentTime = System.currentTimeMillis();
if (currentTime - lastLoginTime > LOGIN_VALIDITY_PERIOD) {
log.debug("会话已过期,上次登录时间: {},当前时间: {}", lastLoginTime, currentTime);
return false;
}
return true;
}
/**
* 验证当前会话是否仍然有效
* 通过访问一个需要登录的页面来验证会话状态
*
* @param testUrl 用于验证会话的测试页面URL
* @return true表示会话有效,false表示会话无效
*/
private boolean validateSession(String testUrl) {
if (!isSessionLoggedIn()) {
return false;
}
try {
Page page = getUSerContext().newPage();
try {
page.navigate(testUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
String currentUrl = page.url();
// 如果重定向到了登录页面,说明会话已失效
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
log.debug("会话验证失败,已重定向到登录页面");
return false;
}
log.debug("会话验证成功,当前页面URL: {}", currentUrl);
return true;
} finally {
page.close();
}
} catch (Exception e) {
log.warn("会话验证过程中发生异常: {}", e.getMessage());
return false;
}
}
/**
* 执行登录并更新登录状态
*
* @param page 当前页面对象
* @throws Exception 登录过程中的异常
*/
private String performLoginAndUpdateStatus(Page page) throws Exception {
log.info("开始执行SSO登录流程");
String ssoUsername = getUserName();
String ssoPassword = getPassword();
try {
// 填入用户名
log.debug("正在定位用户名输入框: {}", USERNAME_INPUT_SELECTOR);
Locator usernameInput = page.locator(USERNAME_INPUT_SELECTOR);
if (usernameInput.count() == 0) {
throw new RuntimeException("未找到用户名输入框");
}
usernameInput.fill(ssoUsername);
log.debug("用户名输入完成");
// 填入密码
log.debug("正在定位密码输入框: {}", PASSWORD_INPUT_SELECTOR);
Locator passwordInput = page.locator(PASSWORD_INPUT_SELECTOR);
if (passwordInput.count() == 0) {
throw new RuntimeException("未找到密码输入框");
}
passwordInput.fill(ssoPassword);
log.debug("密码输入完成");
// 点击登录按钮
log.debug("正在定位登录按钮: {}", LOGIN_BUTTON_SELECTOR);
Locator loginButton = page.locator(LOGIN_BUTTON_SELECTOR);
if (loginButton.count() == 0) {
throw new RuntimeException("未找到登录按钮");
}
loginButton.click();
log.info("登录按钮点击完成,等待登录响应");
// 等待页面开始跳转(表示登录请求已发送)
page.waitForLoadState(LoadState.NETWORKIDLE);
if (page.url().equals(SSO_MFA_URL)) {
log.info("检测到MFA页面,自动发送验证码...");
// 执行MFA登录,传递Context以保证生命周期管理
String result = sendVerificationCode(ssoUsername, page, getUSerContext());
log.info("已发送验证码,请查看短信");
return result;
}
// 更新登录时间
lastLoginTime = System.currentTimeMillis();
log.info("SSO登录成功,登录时间已更新");
return "SSO登录成功";
} catch (Exception e) {
log.error("SSO登录过程中发生异常", e);
throw new RuntimeException("SSO登录失败: " + e.getMessage(), e);
}
}
/**
* 截图并保存到存储目录
*
* @param page 当前页面对象
* @param fileName 文件名前缀
*/
private void takeScreenshotAndSave(Page page, String fileName) {
try {
// 确保存储目录存在
File storageDir = new File(STORAGE_DIR);
if (!storageDir.exists()) {
storageDir.mkdirs();
}
// 生成带时间戳的文件名
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
String fullFileName = String.format("%s_%s.png", fileName, timestamp);
String filePath = Paths.get(STORAGE_DIR, fullFileName).toString();
// 截图并保存
page.screenshot(new Page.ScreenshotOptions().setPath(Paths.get(filePath)));
log.info("截图已保存至: {}", filePath);
} catch (Exception e) {
log.error("截图保存失败: {}", e.getMessage(), e);
}
}
}
......@@ -262,7 +262,21 @@ public class PlaywrightWebTools {
return executeWithPage(url, page -> {
// 获取所有a标签的href属性
Object result = page.locator("a").evaluateAll("elements => elements.map(el => el.href)");
List<String> links = (List<String>) result;
// 安全地进行类型转换
List<?> rawList;
if (result instanceof List) {
rawList = (List<?>) result;
} else {
log.warn("预期返回List类型,但实际返回: {}", result != null ? result.getClass().getName() : "null");
return "获取链接失败:返回类型错误";
}
// 安全地转换为List<String>
List<String> links = rawList.stream()
.map(item -> item != null ? item.toString() : "")
.filter(str -> !str.isEmpty())
.toList();
return links.isEmpty() ? "未找到任何链接" : String.join(", ", links);
});
}
......
......@@ -5,8 +5,11 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.agent.service.AgentChatService;
import pangea.hiagent.agent.service.AgentValidationService;
import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.model.Agent;
import pangea.hiagent.web.dto.ChatRequest;
import pangea.hiagent.web.service.AgentService;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
......@@ -21,9 +24,11 @@ import jakarta.validation.constraints.NotBlank;
public class AgentChatController {
private final AgentChatService agentChatService;
private final AgentService agentService;
public AgentChatController(AgentChatService agentChatService) {
public AgentChatController(AgentChatService agentChatService, AgentService agentService) {
this.agentChatService = agentChatService;
this.agentService = agentService;
}
/**
......@@ -41,13 +46,27 @@ public class AgentChatController {
HttpServletResponse response) {
log.info("接收到流式对话请求,AgentId: {}", agentId);
// 检查用户权限
// 在主线程中完成权限检查,避免在异步线程中触发Spring Security异常
String userId = UserUtils.getCurrentUserId();
if (userId == null) {
log.warn("用户未认证,无法执行Agent对话");
throw new org.springframework.security.access.AccessDeniedException("用户未认证");
}
// 验证Agent存在性和权限
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
log.warn("Agent不存在: {}", agentId);
throw new IllegalArgumentException("Agent不存在");
}
// 检查权限
if (!agent.getOwner().equals(userId) && !UserUtils.isAdminUser(userId)) {
log.warn("用户 {} 无权限访问Agent: {}", userId, agentId);
throw new org.springframework.security.access.AccessDeniedException("无权限访问该Agent");
}
// 权限验证通过,调用异步处理
return agentChatService.handleChatStream(agentId, chatRequest, response);
}
}
\ No newline at end of file
......@@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RestController;
import pangea.hiagent.document.KnowledgeBaseInitializationService;
import pangea.hiagent.web.dto.ApiResponse;
import pangea.hiagent.tool.ToolBeanNameInitializer;
/**
* 系统管理控制器
......@@ -23,31 +23,12 @@ import pangea.hiagent.tool.ToolBeanNameInitializer;
@Tag(name = "系统管理", description = "系统管理相关API")
public class SystemAdminController {
@Autowired
private ToolBeanNameInitializer toolBeanNameInitializer;
@Autowired
private KnowledgeBaseInitializationService knowledgeBaseInitializationService;
/**
* 手动触发工具Bean名称初始化
*
* @return 操作结果
*/
@PostMapping("/initialize-tool-beans")
@Operation(summary = "初始化工具Bean", description = "手动触发工具Bean名称初始化任务")
public ResponseEntity<ApiResponse<Void>> initializeToolBeans() {
try {
log.info("收到手动触发工具Bean初始化请求");
toolBeanNameInitializer.initializeToolBeanNamesManually();
log.info("工具Bean初始化完成");
return ResponseEntity.ok(ApiResponse.success(null, "工具Bean初始化完成"));
} catch (Exception e) {
log.error("工具Bean初始化失败", e);
return ResponseEntity.internalServerError()
.body(ApiResponse.error(500, "工具Bean初始化失败: " + e.getMessage()));
}
}
/**
* 手动触发知识库初始化
......
package pangea.hiagent.web.controller;
// package pangea.hiagent.web.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
// import lombok.extern.slf4j.Slf4j;
// import org.springframework.web.bind.annotation.*;
// import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.agent.sse.UserSseService;
import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.workpanel.event.EventService;
// import pangea.hiagent.agent.sse.UserSseService;
// import pangea.hiagent.common.utils.UserUtils;
// import pangea.hiagent.workpanel.event.EventService;
/**
* 时间轴事件控制器
* 提供ReAct过程的实时事件推送功能
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/agent")
public class TimelineEventController {
// /**
// * 时间轴事件控制器
// * 提供ReAct过程的实时事件推送功能
// */
// @Slf4j
// @RestController
// @RequestMapping("/api/v1/agent")
// public class TimelineEventController {
private final UserSseService workPanelSseService;
public TimelineEventController(UserSseService workPanelSseService, EventService eventService) {
this.workPanelSseService = workPanelSseService;
}
// private final UserSseService workPanelSseService;
// public TimelineEventController(UserSseService workPanelSseService, EventService eventService) {
// this.workPanelSseService = workPanelSseService;
// }
/**
* 订阅时间轴事件
* 支持 SSE (Server-Sent Events) 格式的实时事件推送
*
* @return SSE emitter
*/
@GetMapping("/timeline-events")
public SseEmitter subscribeTimelineEvents() {
log.info("开始处理时间轴事件订阅请求");
// /**
// * 订阅时间轴事件
// * 支持 SSE (Server-Sent Events) 格式的实时事件推送
// *
// * @return SSE emitter
// */
// @GetMapping("/timeline-events")
// public SseEmitter subscribeTimelineEvents() {
// log.info("开始处理时间轴事件订阅请求");
// 获取当前认证用户ID
String userId = UserUtils.getCurrentUserId();
if (userId == null) {
log.warn("用户未认证,无法创建时间轴事件订阅");
throw new org.springframework.security.access.AccessDeniedException("用户未认证");
}
log.info("开始为用户 {} 创建SSE连接", userId);
// // 获取当前认证用户ID
// String userId = UserUtils.getCurrentUserId();
// if (userId == null) {
// log.warn("用户未认证,无法创建时间轴事件订阅");
// throw new org.springframework.security.access.AccessDeniedException("用户未认证");
// }
// log.info("开始为用户 {} 创建SSE连接", userId);
// 创建并注册SSE连接
return workPanelSseService.createAndRegisterConnection(userId);
}
}
\ No newline at end of file
// // 创建并注册SSE连接
// return workPanelSseService.createAndRegisterConnection(userId);
// }
// }
\ No newline at end of file
package pangea.hiagent.web.service;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import pangea.hiagent.model.ToolConfig;
import java.util.List;
......@@ -12,59 +15,69 @@ import java.util.Map;
public interface ToolConfigService {
/**
* 根据工具名称获取参数配置
* 根据工具名称获取参数配置(带缓存)
* @param toolName 工具名称
* @return 参数配置键值对
*/
@Cacheable(value = "toolConfigByToolName", key = "#toolName")
Map<String, String> getToolParams(String toolName);
/**
* 根据工具名称和参数名称获取参数值
* 根据工具名称和参数名称获取参数值(带缓存)
* @param toolName 工具名称
* @param paramName 参数名称
* @return 参数值
*/
@Cacheable(value = "toolConfig", key = "#toolName + '_' + #paramName")
String getParamValue(String toolName, String paramName);
/**
* 保存参数值
* 保存参数值(自动清除缓存)
* @param toolName 工具名称
* @param paramName 参数名称
* @param paramValue 参数值
*/
@CacheEvict(value = "toolConfig", key = "#toolName + '_' + #paramName")
void saveParamValue(String toolName, String paramName, String paramValue);
/**
* 获取所有工具配置
* 获取所有工具配置(带缓存)
* @return 工具配置列表
*/
@Cacheable(value = "allToolConfigs", key = "'all'")
List<ToolConfig> getAllToolConfigs();
/**
* 根据工具名称和参数名称获取工具配置
* 根据工具名称和参数名称获取工具配置(带缓存)
* @param toolName 工具名称
* @param paramName 参数名称
* @return 工具配置对象
*/
@Cacheable(value = "toolConfig", key = "#toolName + '_' + #paramName")
ToolConfig getToolConfig(String toolName, String paramName);
/**
* 保存工具配置
* 保存工具配置(自动清除相关缓存)
* @param toolConfig 工具配置对象
* @return 保存后的工具配置对象
*/
@CacheEvict(value = {"toolConfig", "toolConfigByToolName", "toolConfigsByToolName"},
key = "#toolConfig.toolName + '_' + #toolConfig.paramName")
ToolConfig saveToolConfig(ToolConfig toolConfig);
/**
* 删除工具配置
* 删除工具配置(自动清除相关缓存)
* @param id 配置ID
*/
@CacheEvict(value = {"toolConfig", "toolConfigByToolName", "toolConfigsByToolName"},
allEntries = true) // 删除配置时清除所有缓存,因为不知道具体工具名
void deleteToolConfig(String id);
/**
* 根据工具名称获取工具配置列表
* 根据工具名称获取工具配置列表(带缓存)
* @param toolName 工具名称
* @return 工具配置列表
*/
@Cacheable(value = "toolConfigsByToolName", key = "#toolName")
List<ToolConfig> getToolConfigsByToolName(String toolName);
}
\ No newline at end of file
......@@ -8,6 +8,7 @@ import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.LoadState;
import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.common.utils.AsyncUserContextDecorator;
/**
* 指令处理器
......@@ -167,7 +168,8 @@ public class CommandProcessor {
page.navigate(param);
// 异步处理页面加载完成后的DOM发送,避免阻塞WebSocket
java.util.concurrent.CompletableFuture.runAsync(() -> {
// 使用AsyncUserContextDecorator包装以传播用户上下文
java.util.concurrent.CompletableFuture.runAsync(AsyncUserContextDecorator.wrapWithContext(() -> {
try {
// 等待页面加载状态:DOMCONTENTLOADED确保DOM可用
log.debug("等待页面DOM加载: {}", param);
......@@ -186,7 +188,7 @@ public class CommandProcessor {
// 重要:必须将错误发送给前端
messageSender.sendErrorToClients(errorMsg);
}
});
}));
} catch (Exception e) {
String errorMsg = "导航命令执行失败:" + e.getMessage();
log.error(errorMsg, e);
......@@ -260,7 +262,7 @@ public class CommandProcessor {
// 等待元素可见,最多等待10秒
locator.waitFor(new Locator.WaitForOptions()
.setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE)
.setTimeout(10000));
.setTimeout(30000));
// 执行hover操作
locator.hover();
......
......@@ -4,6 +4,7 @@ import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState;
import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.workpanel.playwright.PlaywrightManager;
import pangea.hiagent.common.utils.AsyncUserContextDecorator;
import java.util.concurrent.ConcurrentMap;
......@@ -202,7 +203,8 @@ public class DomSyncService {
page.onFrameNavigated(frame -> {
incrementCounter("navigations");
// 异步处理导航完成后的DOM发送,避免阻塞
java.util.concurrent.CompletableFuture.runAsync(() -> {
// 使用AsyncUserContextDecorator包装以传播用户上下文
java.util.concurrent.CompletableFuture.runAsync(AsyncUserContextDecorator.wrapWithContext(() -> {
try {
// 使用更宽松的等待条件,避免NETWORKIDLE可能出现的问题
page.waitForLoadState(LoadState.DOMCONTENTLOADED);
......@@ -220,7 +222,7 @@ public class DomSyncService {
incrementCounter("errors");
messageSender.sendErrorToClients(errorMsg);
}
});
}));
});
// 5. 监听页面错误事件
page.onPageError(error -> {
......
......@@ -355,12 +355,12 @@ public class EventService {
Map<String, Object> data = tokenEventDataBuilder.createOptimizedTokenEventData(token);
if (data != null) {
log.debug("准备发送token事件: token长度={}", token != null ? token.length() : 0);
// log.debug("准备发送token事件: token长度={}", token != null ? token.length() : 0);
// 发送事件
emitter.send(SseEmitter.event().name("token").data(data));
log.debug("token事件发送成功");
// log.debug("token事件发送成功");
} else {
log.warn("构建token事件数据失败,无法发送事件");
}
......
......@@ -3,9 +3,7 @@ package pangea.hiagent.workpanel.playwright;
import com.microsoft.playwright.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.context.annotation.Lazy;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.concurrent.*;
......@@ -14,8 +12,7 @@ import java.util.concurrent.*;
* 负责统一管理Playwright实例和用户隔离的BrowserContext
*/
@Slf4j
@Component
@Lazy
@Component // Spring默认单例模式
public class PlaywrightManagerImpl implements PlaywrightManager {
// 共享的Playwright实例
......@@ -34,7 +31,12 @@ public class PlaywrightManagerImpl implements PlaywrightManager {
private static final long CONTEXT_TIMEOUT = 30 * 60 * 1000;
// 清理任务调度器
private ScheduledExecutorService cleanupScheduler;
private final ScheduledExecutorService cleanupScheduler =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "PlaywrightCleanupScheduler");
t.setDaemon(true); // 设置为守护线程
return t;
});
// 标记是否已经初始化
private volatile boolean initialized = false;
......@@ -64,11 +66,8 @@ public class PlaywrightManagerImpl implements PlaywrightManager {
"--disable-gpu",
"--remote-allow-origins=*")));
// 初始化清理任务调度器
this.cleanupScheduler = Executors.newSingleThreadScheduledExecutor();
// 每5分钟检查一次超时的用户上下文
this.cleanupScheduler.scheduleAtFixedRate(this::cleanupExpiredContexts,
cleanupScheduler.scheduleAtFixedRate(this::cleanupExpiredContexts,
5, 5, TimeUnit.MINUTES);
this.initialized = true;
......@@ -112,8 +111,8 @@ public class PlaywrightManagerImpl implements PlaywrightManager {
public BrowserContext getUserContext(String userId) {
lazyInitialize();
Browser.NewContextOptions options = new Browser.NewContextOptions()
.setViewportSize(1344, 2992) // 设置视口大小,与前端一致;手机型号:Google Pixel 9 Pro XL
.setUserAgent("Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro XL Build/UP2A.250105.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36"); // 设置用户代理
.setViewportSize(1920, 1080) // 设置视口大小为全高清分辨率,适用于Windows 11桌面环境
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"); // 设置用户代理为Windows 11 Chrome浏览器
return getUserContext(userId, options);
}
......@@ -132,7 +131,7 @@ public class PlaywrightManagerImpl implements PlaywrightManager {
BrowserContext context = userContexts.get(userId);
// 如果上下文不存在或已关闭,则创建新的
if (context == null || context.pages().isEmpty()) {
if (context == null || isContextClosed(context)) {
try {
log.debug("为用户 {} 创建新的浏览器上下文", userId);
context = browser.newContext(options);
......@@ -166,6 +165,24 @@ public class PlaywrightManagerImpl implements PlaywrightManager {
}
}
/**
* 检查BrowserContext是否已关闭
*
* @param context 要检查的BrowserContext
* @return 如果上下文已关闭则返回true,否则返回false
*/
private boolean isContextClosed(BrowserContext context) {
try {
// 尝试访问上下文的页面列表来检查它是否仍然有效
// 如果上下文已关闭,这将抛出异常
context.pages();
return false;
} catch (Exception e) {
// 如果发生异常,说明上下文可能已关闭
return true;
}
}
/**
* 清理过期的用户上下文
*/
......@@ -192,12 +209,10 @@ public class PlaywrightManagerImpl implements PlaywrightManager {
try {
// 关闭清理任务调度器
if (cleanupScheduler != null) {
cleanupScheduler.shutdown();
if (!cleanupScheduler.awaitTermination(5, TimeUnit.SECONDS)) {
cleanupScheduler.shutdownNow();
}
}
} catch (Exception e) {
log.warn("关闭清理任务调度器时发生异常", e);
}
......
......@@ -2,7 +2,7 @@
spring:
# 开发环境数据源配置
datasource:
url: jdbc:h2:mem:hiagent_dev;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
url: jdbc:h2:file:./data/hiagent_dev_db;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password: sa
......@@ -10,12 +10,19 @@ spring:
# 开发环境JPA配置
jpa:
hibernate:
ddl-auto: create-drop
ddl-auto: none # 开发环境:使用SQL脚本初始化表结构,避免初始化冲突
show-sql: true
properties:
hibernate:
format_sql: true
# SQL初始化配置 - 开发环境使用SQL脚本初始化数据
sql:
init:
schema-locations: classpath:schema.sql
data-locations: classpath:data.sql
mode: embedded # 总是执行创建表和数据脚本、但不会重複插入(MERGE控制)
# 开启H2控制台
h2:
console:
......@@ -31,20 +38,22 @@ spring:
# 开发环境详细日志配置
logging:
level:
root: INFO
root: WARN
pangea.hiagent: DEBUG
pangea.hiagent.websocket: TRACE
pangea.hiagent.websocket: DEBUG
pangea.hiagent.service: DEBUG
pangea.hiagent.controller: DEBUG
pangea.hiagent.tools: DEBUG
org.springframework: INFO
org.springframework.web: INFO
org.springframework.security: INFO
org.springframework.web.socket: INFO
org.springframework.web.socket.handler: INFO
org.springframework.web.socket.messaging: INFO
org.hibernate.SQL: INFO
org.hibernate.type.descriptor.sql.BasicBinder: INFO
org.springframework: WARN
org.springframework.web: WARN
org.springframework.security: WARN
org.springframework.web.socket: WARN
org.springframework.web.socket.handler: WARN
org.springframework.web.socket.messaging: WARN
org.hibernate.SQL: WARN
org.hibernate.type.descriptor.sql.BasicBinder: WARN
org.springframework.jdbc.datasource.init: DEBUG
org.springframework.boot.sql.init: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{userId:-N/A}] - %msg%n"
......
......@@ -27,18 +27,16 @@ spring:
exclude:
- org.springframework.ai.autoconfigure.vectorstore.milvus.MilvusVectorStoreAutoConfiguration
# SQL初始化配置
# SQL初始化配置 - 生产环境不使用SQL脚本初始化
sql:
init:
schema-locations: classpath:schema.sql
data-locations: classpath:data.sql
mode: always # 可以改为"never"以禁用SQL初始化
mode: never # 生产环境禁用SQL脚本自动初始化
# JPA/Hibernate配置
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: update # 改为update以避免每次都重建表结构
ddl-auto: create # 生产环境仅验证表结构,不修改数据库
show-sql: false
properties:
hibernate:
......@@ -129,7 +127,7 @@ logging:
pangea.hiagent: INFO
org.springframework: WARN
org.springframework.security: WARN
org.springframework.boot: INFO
org.springframework.boot: WARN
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
......
......@@ -17,24 +17,10 @@ MERGE INTO agent (id, name, description, status, default_model, owner, system_pr
('agent-2', '技术支持', '提供技术支持服务的AI助手', 'active', 'openai-default', 'user-001', '你是一个技术专家,请帮助用户解决技术问题。', 1, 15, 1, 'technical-support-kb', 5, 0.8, 50, 0, 0, '', '', 1),
('agent-3', '数据分析员', '专业的数据分析AI助手', 'active', 'deepseek-default', 'user-001', '你是一个数据分析专家,擅长处理和分析各种数据。', 0, 15, 1, 'data-analysis-kb', 5, 0.8, 50, 0, 0, '', '', 1),
('agent-4', '内容创作助手', '帮助撰写各类文案的AI助手', 'active', 'hisense-default', 'user-001', '你是一个创意写作专家,能够帮助用户创作各种类型的文案。', 0, 15, 1, 'content-creation-kb', 5, 0.8, 50, 0, 0, '', '', 1),
('agent-5', '学习导师', '个性化学习指导AI助手', 'active', 'hisense-default', 'user-001', '你是一个教育专家,能够根据用户需求提供个性化的学习建议。', 1, 15, 1, 'learning-mentor-kb', 5, 0.8, 50, 0, 0, '', '', 1);
('agent-5', '学习导师', '个性化学习指导AI助手', 'active', 'hisense-default', 'user-001', '你是一个教育专家,能够根据用户需求提供个性化的学习建议。', 1, 15, 1, 'learning-mentor-kb', 5, 0.8, 50, 0, 0, '', '', 1),
('agent-6', '海信流程审批助手', '专业的海信业务流程审批AI助手,支持SSO登录和各种审批操作', 'active', 'hisense-default', 'user-001', '你是一个海信业务流程审批助手,可以帮助用户处理海信SSO登录和各类审批操作,包括请假审批、自驾车审批、调休审批等。', 1, 15, 0, '', 5, 0.8, 50, 0, 0, '', '', 1);
-- 插入Agent和Tool的关联关系
MERGE INTO agent_tool_relation (id, agent_id, tool_id) VALUES
('relation-5', 'agent-2', 'tool-2'),
('relation-6', 'agent-2', 'tool-5'),
('relation-7', 'agent-2', 'tool-6'),
('relation-8', 'agent-3', 'tool-2'),
('relation-9', 'agent-3', 'tool-7'),
('relation-10', 'agent-3', 'tool-8'),
('relation-12', 'agent-4', 'tool-9'),
('relation-13', 'agent-4', 'tool-10'),
('relation-15', 'agent-5', 'tool-11'),
('relation-17', 'agent-2', 'tool-3'),
('relation-18', 'agent-4', 'tool-3'),
('relation-19', 'agent-5', 'tool-3');
-- 插入默认工具数据
-- 插入默认工具数据 (必须在agent_tool_relation之前插入)
MERGE INTO tool (id, name, display_name, description, category, status, bean_name, owner, timeout, http_method, parameters, return_type, return_schema, implementation, api_endpoint, headers, auth_type, auth_config) VALUES
('tool-1', 'search', '搜索工具', '进行网络搜索查询', 'API', 'active', 'searchTool', 'user-001', 30000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-2', 'calculator', '计算器', '进行数学计算', 'FUNCTION', 'active', 'calculatorTools', 'user-001', 5000, 'POST', '{}', 'number', '{}', '', '', '{}', '', '{}'),
......@@ -57,7 +43,29 @@ MERGE INTO tool (id, name, display_name, description, category, status, bean_nam
('tool-19', 'refundProcessing', '退款处理工具', '用于处理客户退款申请', 'FUNCTION', 'active', 'refundProcessingTool', 'user-001', 10000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-20', 'storageFileAccess', '存储文件访问工具', '提供访问服务器后端 storage 目录下文件的功能', 'FUNCTION', 'active', 'storageFileAccessTool', 'user-001', 10000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-21', 'stringProcessing', '字符串处理工具', '提供字符串处理和转换功能', 'FUNCTION', 'active', 'stringProcessingTools', 'user-001', 5000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-22', 'webPageAccess', '网页访问工具', '提供根据网站名称或URL地址访问网页并在工作面板中预览的功能', 'FUNCTION', 'active', 'webPageAccessTools', 'user-001', 30000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}');
('tool-22', 'webPageAccess', '网页访问工具', '提供根据网站名称或URL地址访问网页并在工作面板中预览的功能', 'FUNCTION', 'active', 'webPageAccessTools', 'user-001', 30000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-71', 'hisenseSsoLogin', '海信SSO登录工具', '用于登录海信SSO系统,需要提供用户ID以区分会话', 'FUNCTION', 'active', 'hisenseSsoLoginTool', 'user-001', 60000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-72', 'hisenseLbpmApproval', '海信LBPM流程审批工具', '处理海信请假审批、自驾车审批、调休审批,需要先使用HisenseSsoLoginTool登录,提供用户ID以区分会话', 'FUNCTION', 'active', 'hisenseLbpmApprovalTool', 'user-001', 60000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-73', 'hisensePerformanceApproval', '海信绩效系统审批工具', '处理海信绩效系统审批,检查海信SSO是否处于登录状态,自动审批所有待处理流程', 'FUNCTION', 'active', 'hisensePerformanceApprovalTool', 'user-001', 60000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}');
-- Agent和Tool的关联关系 (必须在tool数据插入完成后执行)
MERGE INTO agent_tool_relation (id, agent_id, tool_id) VALUES
('relation-5', 'agent-2', 'tool-2'),
('relation-6', 'agent-2', 'tool-5'),
('relation-7', 'agent-2', 'tool-6'),
('relation-8', 'agent-3', 'tool-2'),
('relation-9', 'agent-3', 'tool-7'),
('relation-10', 'agent-3', 'tool-8'),
('relation-12', 'agent-4', 'tool-9'),
('relation-13', 'agent-4', 'tool-10'),
('relation-15', 'agent-5', 'tool-11'),
('relation-17', 'agent-2', 'tool-3'),
('relation-18', 'agent-4', 'tool-3'),
('relation-19', 'agent-5', 'tool-3'),
('relation-20', 'agent-6', 'tool-4'),
('relation-21', 'agent-6', 'tool-71'),
('relation-22', 'agent-6', 'tool-72'),
('relation-23', 'agent-6', 'tool-73');
-- 插入默认工具配置数据
MERGE INTO tool_configs (id, tool_name, param_name, param_value, description, default_value, type, required, group_name) VALUES
......@@ -69,4 +77,6 @@ MERGE INTO tool_configs (id, tool_name, param_name, param_value, description, de
('config-6', 'emailTools', 'pop3SocketFactoryClass', 'javax.net.ssl.SSLSocketFactory', 'POP3 SSL套接字工厂类', 'javax.net.ssl.SSLSocketFactory', 'string', 1, 'email'),
('config-7', 'fileProcessing', 'textFileExtensions', '.txt,.md,.java,.html,.htm,.css,.js,.json,.xml,.yaml,.yml,.properties,.sql,.py,.cpp,.c,.h,.cs,.php,.rb,.go,.rs,.swift,.kt,.scala,.sh,.bat,.cmd,.ps1,.log,.csv,.ts,.jsx,.tsx,.vue,.scss,.sass,.less', '支持的文本文件扩展名,逗号分隔', '.txt,.md,.java,.html,.htm,.css,.js,.json,.xml,.yaml,.yml,.properties,.sql,.py,.cpp,.c,.h,.cs,.php,.rb,.go,.rs,.swift,.kt,.scala,.sh,.bat,.cmd,.ps1,.log,.csv,.ts,.jsx,.tsx,.vue,.scss,.sass,.less', 'string', 1, 'file'),
('config-8', 'fileProcessing', 'imageFileExtensions', '.jpg,.jpeg,.png,.gif,.bmp,.svg,.webp,.ico', '支持的图片文件扩展名,逗号分隔', '.jpg,.jpeg,.png,.gif,.bmp,.svg,.webp,.ico', 'string', 1, 'file'),
('config-9', 'fileProcessing', 'defaultStorageDir', 'storage', '默认文件存储目录', 'storage', 'string', 1, 'file');
\ No newline at end of file
('config-9', 'fileProcessing', 'defaultStorageDir', 'storage', '默认文件存储目录', 'storage', 'string', 1, 'file'),
('config-10', 'hisenseSsoLogin', 'ssoUsername', '', '海信SSO登录用户名', '', 'string', 1, 'auth'),
('config-11', 'hisenseSsoLogin', 'ssoPassword', '', '海信SSO登录密码', '', 'string', 1, 'auth');
\ No newline at end of file
package pangea.hiagent.service;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
import pangea.hiagent.web.service.ToolConfigService;
import pangea.hiagent.model.ToolConfig;
@SpringBootTest
public class ToolConfigServiceCacheTest {
@Autowired
private ToolConfigService toolConfigService;
@Autowired
private CacheManager cacheManager;
@Test
public void testToolConfigCacheFunctionality() {
String toolName = "HisenseSsoLoginTool";
String usernameParam = "ssoUsername";
String passwordParam = "ssoPassword";
String testUsername = "testUser123";
String testPassword = "testPassword123";
// 清理可能存在的旧数据
try {
toolConfigService.saveParamValue(toolName, usernameParam, testUsername);
toolConfigService.saveParamValue(toolName, passwordParam, testPassword);
} catch (Exception e) {
System.out.println("清理数据时出现异常,可能是首次运行: " + e.getMessage());
}
// 第一次获取参数值(这将触发缓存)
String firstUsername = toolConfigService.getParamValue(toolName, usernameParam);
String firstPassword = toolConfigService.getParamValue(toolName, passwordParam);
// 验证获取到的值
assertNotNull(firstUsername, "用户名不应为null");
assertNotNull(firstPassword, "密码不应为null");
// 更新参数值
String updatedUsername = testUsername + "_updated";
String updatedPassword = testPassword + "_updated";
toolConfigService.saveParamValue(toolName, usernameParam, updatedUsername);
toolConfigService.saveParamValue(toolName, passwordParam, updatedPassword);
// 再次获取参数值(应该返回更新后的值,因为缓存已被清除)
String secondUsername = toolConfigService.getParamValue(toolName, usernameParam);
String secondPassword = toolConfigService.getParamValue(toolName, passwordParam);
// 验证更新后的值
assertEquals(updatedUsername, secondUsername, "用户名应该被更新");
assertEquals(updatedPassword, secondPassword, "密码应该被更新");
System.out.println("ToolConfigService缓存功能测试通过!");
}
}
\ No newline at end of file
......@@ -502,6 +502,13 @@ const processSSELine = async (
// 根据事件类型处理数据
switch (eventType) {
case "heartbeat":
// 收到心跳事件,重置超时计时器
resetStreamTimeout();
// 心跳事件本身不处理,只用于保活连接
console.debug("[心跳] 收到心跳事件,连接保活");
return false;
case "token":
// 重置超时计时器,接收到token说明连接还活跃
resetStreamTimeout();
......@@ -737,13 +744,19 @@ const sendMessage = async () => {
const decoder = new TextDecoder();
let buffer = "";
let isStreamComplete = false; // 标记流是否已完成
const STREAM_TIMEOUT = 60000; // 60秒无流式消息则为超时
const HEARTBEAT_TIMEOUT = 60000; // 60秒无心跳则为超时
const HEARTBEAT_CHECK_INTERVAL = 5000; // 每5秒检查一次心跳
let lastHeartbeatTime = Date.now(); // 记录最后一次心跳时间
// 设置超时检查
// 设置超时检查 - 改进为心跳保活机制
const resetStreamTimeout = () => {
clearStreamTimeout();
lastHeartbeatTime = Date.now(); // 更新最后心跳时间
streamTimeoutTimer = setTimeout(() => {
if (!isStreamComplete) {
// 检查是否在指定时间内收到过心跳或数据
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatTime;
if (timeSinceLastHeartbeat >= HEARTBEAT_TIMEOUT) {
isStreamComplete = true;
reader.cancel();
messages.value[aiMessageIndex].isStreaming = false;
......@@ -755,8 +768,12 @@ const sendMessage = async () => {
messages.value[aiMessageIndex].content =
"[错误] 流式输出超时,请重试";
messages.value[aiMessageIndex].hasError = true;
} else {
// 如果还没超时,继续检查
resetStreamTimeout();
}
}
}, STREAM_TIMEOUT);
}, HEARTBEAT_CHECK_INTERVAL);
};
resetStreamTimeout();
......
......@@ -107,6 +107,6 @@ REM 设置更多调试参数
set JAVA_OPTS=-Dfile.encoding=UTF-8 -Dspring.profiles.active=dev -Dlogging.level.root=DEBUG -Dlogging.level.pangea.hiagent=TRACE -Dlogging.level.org.springframework.web=DEBUG -Dlogging.level.org.springframework.security=DEBUG -Dlogging.level.org.springframework.web.socket=DEBUG -Dlogging.level.org.projectlombok=DEBUG
echo [INFO] 启动Spring Boot应用...
call mvn spring-boot:run -Dspring-boot.run.arguments="--spring.jpa.hibernate.ddl-auto=create-drop --logging.level.root=DEBUG --logging.level.pangea.hiagent=TRACE --logging.level.org.springframework.web=DEBUG --logging.level.org.springframework.security=DEBUG --logging.level.org.springframework.web.socket=DEBUG --logging.level.org.projectlombok=DEBUG"
call mvn spring-boot:run -Dspring-boot.run.arguments="--spring-boot.run.profiles=dev"
pause
\ No newline at end of file
================================================================================
AI 无法获取实时时间信息 - 修复要点速查表
================================================================================
【问题症状】
用户:「现在几点了?」
AI回复:「我无法直接获取实时时间信息,因为我的知识库没有实时更新功能...」
而不是调用时间工具获取实时时间
================================================================================
【根本原因 - 4 个关键问题】
================================================================================
1. ❌ 系统提示词不足 (最严重)
位置:DefaultReactExecutor.java, 第31-39行
问题:
- 没有明确列举可用工具(时间工具)
- 没有告诉 AI 何时调用工具("现在几点"→ 必须调用时间工具)
- AI 不知道有这个能力,所以拒绝了
2. ❌ DateTimeTools 字段未初始化
位置:DateTimeTools.java, 第28和38行
问题:
- private String dateTimeFormat; // ← 值为 null
- @ToolParam 的 defaultValue 只是文档,不会自动初始化字段
- 调用工具时会 NPE 错误
3. ❌ 工具加载逻辑缺陷
位置:DefaultReactExecutor.java, 第639-654行
问题:
- Agent 为 null 时,返回空工具列表
- 用户无法使用任何工具
4. ❌ 日志输出不足
问题:
- 日志等级为 debug,生产环境看不到
- 难以排查为什么工具没被调用
================================================================================
【修复方案 - 实施清单】
================================================================================
✅ 修复1:增强 DEFAULT_SYSTEM_PROMPT
文件:DefaultReactExecutor.java (第31-47行)
内容:
- 明确列举4个时间工具(getCurrentDateTime/Date/Time/Millis)
- 使用"YOU MUST"强制AI调用工具
- 列举常见询问表述(含中文"现在几点了?")
- 说明返回格式
代码示例:
"IMPORTANT: You have the following tools available:\n" +
"1. getCurrentDateTime - 获取当前日期和时间,格式为 'yyyy-MM-dd HH:mm:ss'\n" +
"...\n" +
"- When a user asks 'what time is it?', '现在几点了?', etc., YOU MUST use getCurrentDateTime\n"
✅ 修复2:初始化 DateTimeTools 字段
文件:DateTimeTools.java (第20-60行)
内容:
- private String dateTimeFormat = "yyyy-MM-dd HH:mm:ss"; // ← 改这里!
- private String dateFormat = "yyyy-MM-dd"; // ← 改这里!
- private String timeFormat = "HH:mm:ss"; // ← 新增
- 添加异常处理和 null 检查
- 添加两个新方法:getCurrentTime() 和 getCurrentTimeMillis()
- 改用 log.info() 提升日志级别
✅ 修复3:优化 getAgentTools() 方法
文件:DefaultReactExecutor.java (第639-687行)
内容:
- Agent == null 时,返回 [dateTimeTools] 而不是空列表
- 异常时使用回退策略,确保至少有时间工具
- 添加详细的日志和 null 检查
✅ 修复4:增强日志输出
内容:
- 日志级别 debug → info
- 添加清晰的分隔符和标记
- 打印工具加载过程的每个步骤
- 异常时输出完整堆栈跟踪
================================================================================
【验证清单】
================================================================================
编译检查:
mvn clean compile -DskipTests
✅ 通过(无编译错误)
运行测试(待执行):
[ ] 测试场景1:询问"现在几点了"→ 应该返回实时时间
[ ] 测试场景2:询问"今天几号"→ 应该返回日期
[ ] 测试场景3:询问"时间戳"→ 应该返回毫秒数
[ ] 测试场景4:Agent为null时→ 应该仍能调用时间工具
日志检查:
[ ] 查看 getAgentTools() 的详细日志
[ ] 确认时间工具被正确加载
[ ] 确认异常时有回退机制
================================================================================
【修改文件清单】
================================================================================
1. backend/src/main/java/pangea/hiagent/tool/impl/DateTimeTools.java
- 初始化字段:dateTimeFormat, dateFormat, timeFormat
- 添加方法:getCurrentTime(), getCurrentTimeMillis()
- 增强异常处理
2. backend/src/main/java/pangea/hiagent/agent/react/DefaultReactExecutor.java
- 改进 DEFAULT_SYSTEM_PROMPT(第31-47行)
- 优化 getAgentTools() 方法(第639-687行)
================================================================================
【预期效果】
================================================================================
修复前:
用户:「现在几点了?」
AI:「我无法直接获取实时时间信息...」😞
修复后:
用户:「现在几点了?」
AI:「当前时间是 2024-12-24 14:30:45」或「现在是下午 2 点 30 分」✅
================================================================================
【关键要点(务必理解)】
================================================================================
1. 问题不是工具不存在,而是 AI 不知道何时调用
→ 解决方案:明确告诉 AI 在看到"时间"相关问题时必须调用工具
2. @ToolParam 的 defaultValue 只是文档注解,不会初始化字段
→ 解决方案:在字段声明时直接赋值初始化
3. 日志等级很重要,debug 级别在生产环境看不到
→ 解决方案:改用 info 等级,便于排查问题
4. 错误处理要有回退机制,不能让一个工具失败导致其他工具也不可用
→ 解决方案:异常时返回至少时间工具可用
================================================================================
【后续改进建议】
================================================================================
短期:
1. 为其他工具(Calculator, Weather等)也增强提示词
2. 建立统一的系统提示词模板
中期:
1. 自动从 @Tool 注解生成提示词(避免手工维护)
2. 建立工具调用错误追踪机制
长期:
1. 实现工具调用的完整可观测性(OpenTelemetry)
2. 性能监控和告警
================================================================================
修复完成:2024-12-24 | 验证状态:待测试 | 优先级:P1(高)
================================================================================
# AI 无法获取实时时间信息问题 - 完整分析与修复报告
**问题描述**:用户询问"现在几点了?"时,AI 回复"我无法直接获取实时时间信息,因为我的知识库没有实时更新功能",而不是调用时间查询工具获取实时时间。
---
## 一、根本原因分析
### 问题涉及的 4 个层面
#### **问题 1:提示词缺陷(最严重)** 🔴
**文件**`DefaultReactExecutor.java` (第 31-39 行)
**症状**
```java
private static final String DEFAULT_SYSTEM_PROMPT =
"You are a helpful AI assistant with access to tools. " +
"When a user asks a question that requires external information or computation, " +
"think about which tool would be most appropriate and use it. " +
...
```
**根本原因**
-**没有列举具体可用工具**:AI 不知道有哪些工具可用
-**没有明确的调用时机**:没有告诉 AI "当用户询问时间时,必须调用时间工具"
-**没有参数说明**:即使 AI 想调用工具,也不知道参数格式
-**缺乏强制指令**:使用了"think about"(建议),而不是"YOU MUST"(强制)
**为什么 AI 拒绝调用工具**
- AI 看到提示词没有提及"时间工具",认为自己没有这个能力
- AI 误认为应该从自己的知识库回答(而知识库是静态的,无法提供实时时间)
- 结果 AI 主动拒绝:*"我的知识库没有实时更新功能"*
---
#### **问题 2:DateTimeTools 字段未初始化** 🔴
**文件**`DateTimeTools.java` (第 28 和 38 行)
**症状**
```java
@ToolParam(defaultValue = "yyyy-MM-dd HH:mm:ss", ...)
private String dateTimeFormat; // ← 字段为 null!
// 后续使用时:
LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateTimeFormat)) // NPE!
```
**根本原因**
- `@ToolParam` 注解的 `defaultValue` 只是元数据描述,**不会自动初始化字段**
- 字段 `dateTimeFormat` 在运行时值为 `null`
- 调用工具时会发生 `NullPointerException`
**影响**
- 即使 AI 决定调用时间工具,工具执行也会失败
- 错误被吞掉,用户看不到具体原因
---
#### **问题 3:工具加载逻辑缺陷** 🟡
**文件**`DefaultReactExecutor.java` (第 639-654 行)
**症状**
```java
private List<Object> getAgentTools(Agent agent) {
if (agent == null) {
log.debug("Agent为空,返回空工具列表");
return new ArrayList<>(); // ← 当 agent 为 null 时,不添加任何工具!
}
...
tools.add(dateTimeTools); // 添加时间工具
}
```
**问题**
-`Agent == null` 时,直接返回**空工具列表**,不包括 `dateTimeTools`
- 虽然后续有添加 `dateTimeTools` 的逻辑,但仅在 `Agent != null` 时执行
- 如果某个聊天流程中 Agent 为 null,用户将**无法使用任何工具**
---
#### **问题 4:日志输出不足** 🟡
**文件**:多处使用 `log.debug()` 和简洁的日志
**问题**
- 使用 `debug` 日志级别,生产环境默认 `info` 级别看不到
- 没有详细的工具调用链路跟踪,难以排查问题
- 工具执行失败时,没有详细的错误堆栈信息
---
## 二、修复方案
### 修复 1:增强系统提示词 ✅
**修改文件**`DefaultReactExecutor.java` (第 31-47 行)
**新的提示词**(英文版 + 中文提示):
```java
private static final String DEFAULT_SYSTEM_PROMPT =
"You are a helpful AI assistant with access to tools. \n\n" +
"IMPORTANT: You have the following tools available:\n" +
"1. getCurrentDateTime - 获取当前日期和时间,格式为 'yyyy-MM-dd HH:mm:ss'\n" +
"2. getCurrentDate - 获取当前日期,格式为 'yyyy-MM-dd'\n" +
"3. getCurrentTime - 获取当前时间,格式为 'HH:mm:ss'\n" +
"4. getCurrentTimeMillis - 获取当前时间戳(毫秒数)\n\n" +
"TOOL CALLING RULES:\n" +
"- When a user asks 'what time is it?', 'what's the current time?', 'what's the time?', '现在几点了?', etc., YOU MUST use getCurrentDateTime or getCurrentTime tool\n" +
"- When a user asks about the current date, use getCurrentDate tool\n" +
"- When other questions require external information, think about which tool would be most appropriate and use it\n" +
"- Always provide your final response directly to the user in natural language\n" +
"- Do not output special format markers - just think internally, use tools as needed, and respond naturally to the user\n" +
"- Tool results are automatically available to you after execution\n\n" +
"RESPONSE FORMAT:\n" +
"Simply answer the user's question using the tool results. Always be helpful and direct.";
```
**改进点**
-**明确列举工具**:使用编号列表,清晰指出有 4 个时间工具
-**强制指令**:使用 "YOU MUST",明确告诉 AI 必须调用工具
-**具体触发条件**:列举常见的时间询问表述(包括中文)
-**参数格式**:明确说明每个工具返回的格式
---
### 修复 2:初始化 DateTimeTools 字段 ✅
**修改文件**`DateTimeTools.java`
**主要改变**
```java
// Before: 字段为 null
private String dateTimeFormat;
// After: 使用默认值初始化
private String dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
```
**具体改动**
1. ✅ 初始化 `dateTimeFormat``"yyyy-MM-dd HH:mm:ss"`
2. ✅ 初始化 `dateFormat``"yyyy-MM-dd"`
3. ✅ 添加新字段 `timeFormat``"HH:mm:ss"`
4. ✅ 添加 `getCurrentTime()` 方法(只返回时间部分)
5. ✅ 添加 `getCurrentTimeMillis()` 方法(返回时间戳)
6. ✅ 每个方法添加 null 检查和异常处理
7. ✅ 改用 `log.info()` 提升日志级别
**新增的方法**
```java
@Tool(description = "获取当前时间,返回格式为 'HH:mm:ss'")
public String getCurrentTime() { ... }
@Tool(description = "获取当前时间戳(毫秒),返回自1970年1月1日00:00:00 UTC以来的毫秒数")
public String getCurrentTimeMillis() { ... }
```
**错误处理**
```java
try {
if (dateTimeFormat == null || dateTimeFormat.trim().isEmpty()) {
dateTimeFormat = "yyyy-MM-dd HH:mm:ss"; // 容错回退
}
String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateTimeFormat));
log.info("【时间工具】获取当前日期时间: {}", dateTime);
return dateTime;
} catch (Exception e) {
log.error("获取当前日期时间时发生错误: {}", e.getMessage(), e);
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
```
---
### 修复 3:优化工具加载逻辑 ✅
**修改文件**`DefaultReactExecutor.java` (第 639-687 行)
**关键改变**
```java
private List<Object> getAgentTools(Agent agent) {
// 当 agent == null 时,也要返回时间工具
if (agent == null) {
List<Object> defaultTools = new ArrayList<>();
defaultTools.add(dateTimeTools);
log.info("Agent is null, using default datetime tools...");
return defaultTools; // ← 改为返回时间工具列表
}
try {
List<Object> tools = agentToolManager.getAvailableToolInstances(agent);
// 必须添加时间工具
if (dateTimeTools != null) {
if (!tools.contains(dateTimeTools)) {
tools.add(dateTimeTools);
}
} else {
log.error("DateTimeTools Bean is null, failed to inject");
}
return tools;
} catch (Exception e) {
// 发生错误时,至少返回时间工具
List<Object> fallbackTools = new ArrayList<>();
fallbackTools.add(dateTimeTools);
log.warn("[FALLBACK] Returning only datetime tools due to error...");
return fallbackTools;
}
}
```
**改进点**
- ✅ Agent 为 null 时,返回默认时间工具,而不是空列表
- ✅ 添加 null 检查和异常捕获
- ✅ 异常时使用回退策略,确保至少有时间工具可用
---
### 修复 4:增强日志输出 ✅
**日志级别提升**
- `log.debug()``log.info()``log.error()`
- 便于在生产环境中追踪问题
**日志内容增强**
```java
log.info("======== Start loading tools for Agent '{}' ========", agent.getName());
log.info("[Tool Manager] Retrieved {} tool instances from Agent", tools.size());
log.info("[DateTime Tool] Successfully added DateTimeTools to tool list");
// 打印每个工具的详细信息
for (int i = 0; i < tools.size(); i++) {
Object tool = tools.get(i);
String toolClassName = tool.getClass().getSimpleName();
log.debug(" [Tool #{}] Class name: {}", i + 1, toolClassName);
}
```
**改进点**
- ✅ 使用分隔符清晰显示工具加载过程
- ✅ 每个关键步骤都有日志
- ✅ 异常时输出完整堆栈跟踪
- ✅ 便于排查"为什么某个工具没有被加载"的问题
---
## 三、测试验证清单
### ✅ 代码编译检查
```bash
mvn clean compile -DskipTests
# 结果:通过,无编译错误
```
### ✅ 功能验证项目
**测试场景 1:询问现在几点了**
```
用户: "现在几点了?"
预期: AI 调用 getCurrentDateTime 工具,返回当前日期和时间,如 "2024-12-24 14:30:45"
实际: [待测试]
```
**测试场景 2:询问今天日期**
```
用户: "今天几号?"
预期: AI 调用 getCurrentDate 工具,返回当前日期,如 "2024-12-24"
实际: [待测试]
```
**测试场景 3:获取时间戳**
```
用户: "给我当前时间戳"
预期: AI 调用 getCurrentTimeMillis 工具,返回毫秒数
实际: [待测试]
```
**测试场景 4:Agent 为 null 时**
```
条件: 不传入 Agent 对象或 Agent 为 null
预期: 仍然能够调用时间工具
实际: [待测试]
```
---
## 四、修复前后对比
### Before(修复前):
```
用户: "现在几点了?"
AI回复: "我无法直接获取实时时间信息,因为我的知识库没有实时更新功能。不过,您可以通过以下方式轻松查看当前时间:
手机或电脑:通常屏幕右上角或右下角会显示时间。
智能设备:可以问语音助手(如Siri、小爱同学等)。
浏览器搜索:在搜索引擎中直接搜索"现在几点"即可。"
```
### After(修复后):
```
用户: "现在几点了?"
AI回复: "当前时间是 2024-12-24 14:30:45"
"现在是下午 2 点 30 分 45 秒"
```
---
## 五、影响范围分析
### 直接影响文件
1.`DefaultReactExecutor.java` - 修复了 2 处(提示词 + 工具加载逻辑)
2.`DateTimeTools.java` - 修复了字段初始化 + 添加新方法
### 间接影响
- 所有使用 `DefaultReactExecutor` 执行 ReAct 流程的模块
- 所有依赖时间查询功能的 Agent
- 所有聊天场景(包括流式和非流式)
### 向后兼容性
- ✅ 完全向后兼容
- ✅ 没有修改任何公共 API 签名
- ✅ 没有修改数据库schema
- ✅ 没有修改配置文件格式
---
## 六、后续建议
### 短期(立即)
1. ✅ 部署上述代码修复
2. ✅ 进行场景测试验证
3. ✅ 监控生产环境日志
### 中期(1-2 周)
1. 为其他工具类(如 Calculator、Weather 等)也增强提示词和日志
2. 创建统一的系统提示词模板,避免重复代码
3. 建立工具调用错误追踪机制
### 长期(1 个月)
1. 建立自动化工具元数据生成机制,从 `@Tool` 注解自动生成提示词
2. 实现工具调用的完整可观测性(OpenTelemetry)
3. 建立工具调用性能监控和告警
---
## 七、附录:修改文件清单
### 修改文件 1:DateTimeTools.java
**路径**`backend/src/main/java/pangea/hiagent/tool/impl/DateTimeTools.java`
**改动摘要**
- 字段初始化
- 添加新方法
- 增强异常处理
- 提升日志级别
### 修改文件 2:DefaultReactExecutor.java
**路径**`backend/src/main/java/pangea/hiagent/agent/react/DefaultReactExecutor.java`
**改动摘要**
- 增强 `DEFAULT_SYSTEM_PROMPT`
- 优化 `getAgentTools()` 方法
- 增强日志输出
---
**修复完成日期**:2024-12-24
**修复工程师**:系统诊断
**验证状态**:等待测试验证
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment