Commit 72ec5880 authored by youxiaoji's avatar youxiaoji

Merge branch 'refs/heads/main' into develop_tmp

# Conflicts:
#	backend/src/main/java/pangea/hiagent/agent/react/DefaultReactExecutor.java
#	backend/src/main/java/pangea/hiagent/agent/service/AgentChatService.java
#	backend/src/main/java/pangea/hiagent/agent/service/StreamRequestService.java
#	backend/src/main/java/pangea/hiagent/agent/service/UserSseService.java
#	backend/src/main/java/pangea/hiagent/tool/impl/HisenseTripTool.java
#	backend/src/main/java/pangea/hiagent/tool/impl/VisitorAppointmentTool.java
#	backend/src/main/java/pangea/hiagent/web/service/InfoCollectorService.java
#	frontend/src/components/ChatArea.vue
parents e80bffc7 40bd44a9
...@@ -193,6 +193,9 @@ backend/logs/ ...@@ -193,6 +193,9 @@ backend/logs/
backend/storage/ backend/storage/
backend/uploads/ backend/uploads/
backend/hiagentdb.mv.db backend/hiagentdb.mv.db
# H2 database files
backend/src/main/resources/hiagent_dev_db.*
./data/hiagent_dev_db.*
# Frontend files # Frontend files
frontend/node_modules/ frontend/node_modules/
......
现有架构分析
后端架构
核心组件
WorkPanelDataCollector - 实现IWorkPanelDataCollector接口,负责收集和存储事件
DefaultEventManager - 负责创建各种类型的事件对象
SseEventSender - 负责通过SSE发送事件到前端
EventDataManager - 负责构建事件数据,用于发送到前端
EventDeduplicationService - 负责事件去重
WorkPanelEventSubscriber - 负责订阅工作面板事件
WorkPanelService - 提供工作面板状态查询服务
数据流向
Agent执行过程中产生事件
事件被WorkPanelDataCollector收集和存储
通过订阅机制,事件被推送到SseEventSender
SseEventSender使用EventDataManager构建事件数据
通过SSE连接将事件数据发送到前端
主要问题
职责不清:
WorkPanelDataCollector既负责收集又负责订阅管理
DefaultEventManager和WorkPanelDataCollector之间职责重叠
SseEventSender和EventDataManager功能分离但紧密耦合
冗余代码:
多个组件都有相似的事件处理逻辑
事件去重逻辑分散在多个地方
重复的对象转换和序列化逻辑
过度工程化:
太多的小型服务类,增加了复杂性
过度使用依赖注入和组件拆分
前端架构
核心组件
TimelineService - 协调Timeline相关的各种服务
TimelineSseManager - 管理SSE连接和事件处理
SseConnectionManager - 管理SSE连接生命周期
SseEventListenerService - 解析SSE消息
SseEventProcessorService - 处理SSE事件
EventProcessingOptimizer - 优化事件处理
主要问题
服务层次过多:
简单的SSE事件处理被拆分成太多小的服务类
服务之间的依赖关系复杂
缺乏清晰的数据流:
事件处理流程在多个服务间跳跃,难以追踪
已更新待办
架构设计方案
基于对现有代码的分析,我提出以下方案:
后端架构
设计目标
减少不必要的抽象层和服务类
明确各组件职责边界
事件处理流程
降低组件间的耦合度
后的架构
EventService - 统一的事件服务
合并DefaultEventManager和EventDataManager的功能
负责创建、构建和发送事件
集成事件去重逻辑
WorkPanelService - 工作面板核心服务
合并WorkPanelDataCollector的部分功能
负责事件的收集、存储和订阅管理
直接依赖EventService进行事件发送
移除冗余组件
移除WorkPanelEventSubscriber(功能合并到WorkPanelService)
移除SseEventSender(功能合并到EventService)
移除EventDataManager(功能合并到EventService)
数据流向
Agent执行过程中产生事件
WorkPanelService收集和存储事件
WorkPanelService通过EventService发送事件到前端
EventService负责构建事件数据并通过SSE发送
前端架构
设计目标
服务层次结构
明确数据流路径
减少不必要的服务类
后的架构
TimelineService - 时间轴核心服务
合并TimelineSseManager和SseConnectionManager的功能
负责SSE连接管理和事件处理
直接处理事件解析和优化
移除冗余服务
移除TimelineSseManager(功能合并到TimelineService)
移除SseEventListenerService(功能合并到TimelineService)
移除SseEventProcessorService(功能合并到TimelineService)
数据流向
建立SSE连接
TimelineService接收和解析事件
TimelineService优化和处理事件
事件传递给UI组件展示
技术实现要点
事件去重:
在EventService中集中实现事件去重逻辑
使用更高效的缓存策略
对象池:
保留MapPoolService用于对象复用
优化对象池管理策略
错误处理:
统一异常处理机制
错误日志记录
性能优化:
批量处理事件发送
优化事件数据构建过程
\ No newline at end of file
# HiAgent 工具管理方案
## 1. 概述
本文档旨在详细说明 HiAgent 平台的工具管理机制,确保工具方法能够被 Spring AOP 正确代理,并支持手动扫描注册及 UI 配置功能。
通过对现有代码的分析,我们发现当前系统在工具管理方面还存在一些问题,主要包括:
1. 缺少手动触发工具扫描的前端界面
2. 工具无法被正确找到和调用的问题
3. 工具扫描API端点未暴露给前端使用
本文档将在分析这些问题的基础上,提出相应的改进建议.
## 2. Spring AOP 代理兼容性方案
### 2.1 工具类设计规范
为了确保工具方法能够被 Spring AOP 正确代理,所有工具类需要遵循以下规范:
1. **注解使用**
- 工具类必须使用 `@Component` 或其派生注解(如 `@Service`)进行标记
- 工具方法必须使用 `@org.springframework.ai.tool.annotation.Tool` 注解进行标记
2. **访问修饰符**
- 工具方法必须是 `public` 方法
- 避免在同一个类中直接调用其他带有 `@Tool` 注解的方法
3. **类设计**
- 工具类应该是无状态的,或者状态应该是线程安全的
- 避免使用 `final` 方法,因为这会影响 CGLIB 代理的创建
### 2.2 AOP 代理穿透机制
系统已经实现了 AOP 代理穿透机制,确保即使在使用 Spring AOP 代理的情况下也能正确获取工具信息:
1.[AgentToolManager.java](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/backend/src/main/java/pangea/hiagent/tool/AgentToolManager.java) 中提供了 `getTargetClass()` 方法来获取代理对象的原始类:
```java
private Class<?> getTargetClass(Object bean) {
if (bean == null) {
return null;
}
return AopUtils.getTargetClass(bean);
}
```
2. 在工具匹配过程中,系统会穿透代理获取真实的类信息进行比较,确保匹配准确性。
### 2.3 工具执行日志切面
系统通过 [ToolExecutionLoggerAspect.java](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/backend/src/main/java/pangea/hiagent/tool/aspect/ToolExecutionLoggerAspect.java) 实现了工具执行的日志记录和监控:
1. 使用 `@Around("@annotation(tool)")` 环绕通知拦截所有带有 `@Tool` 注解的方法
2. 自动记录工具执行的输入参数、输出结果、执行时间等信息
3. 将工具执行信息同步到 WorkPanel 进行可视化展示
## 3. 工具扫描与注册机制
### 3.1 自动扫描机制
系统通过 [ToolBeanNameInitializer.java](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/backend/src/main/java/pangea/hiagent/tool/ToolBeanNameInitializer.java) 实现工具的自动扫描和注册:
1. **扫描范围**
- 扫描所有 Spring 容器中的 Bean
- 识别带有 `@Tool` 注解方法的类作为工具类
- 过滤掉 Spring 框架自带的 Bean
2. **工具识别规则**
- 类名包含 "Tool" 关键字
-`@Component``@Service` 标注
- 类中包含带有 `@Tool` 注解的方法
3. **工具名称推导**
- 从类名推导工具名称,去除 "Tool" 后缀
- 转换为小驼峰命名格式
### 3.2 手动触发扫描
系统支持通过管理界面手动触发工具扫描和注册:
1. 提供 `initializeToolBeanNamesManually()` 方法用于手动触发扫描
2. 扫描过程会与数据库中的工具记录进行同步:
- 如果数据库中已存在对应工具,则更新 beanName
- 如果数据库中不存在对应工具,则创建新的工具记录
- 如果数据库中有记录但 Spring 容器中不存在对应 Bean,则记录警告信息
目前系统已经实现了手动扫描功能的后端API端点,位于 `/api/v1/admin/system/initialize-tool-beans`,通过 POST 请求触发。但在前端界面上还未提供相应的人机交互界面。
### 3.3 数据库同步策略
工具信息会被持久化存储在数据库中,确保系统重启后配置不会丢失:
1. **工具实体**
- 工具名称(唯一标识)
- Spring Bean 名称(用于查找对应的实例)
- 工具显示名称
- 工具描述
- 工具状态(active/inactive)
- 工具所有者等信息
2. **同步机制**
- 系统启动时不自动执行扫描(避免影响启动速度)
- 通过管理界面手动触发扫描和同步
- 支持增量更新,只处理发生变化的工具
## 4. 当前存在的问题与改进建议
### 4.1 当前存在的主要问题
通过分析现有代码和功能实现,我们发现工具管理系统存在以下主要问题:
1. **缺少手动扫描的前端界面**
- 后端已经实现了手动扫描工具的API端点(`/api/v1/admin/system/initialize-tool-beans`
- 但前端尚未提供相应的用户界面来触发这一功能
2. **工具无法正确找到和调用**
-[AgentToolManager.java](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/backend/src/main/java/pangea/hiagent/tool/AgentToolManager.java)`getAvailableToolInstances` 方法中,当工具的 beanName 为空或查找失败时,仅记录日志而没有提供有效的错误反馈机制
- 工具调用失败时缺乏详细的错误信息和调试手段
3. **工具管理页面功能不完善**
- 当前的 [ToolManagement.vue](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/frontend/src/pages/ToolManagement.vue) 页面仅支持基础的增删改查功能
- 缺少与后端扫描功能的集成
### 4.2 改进建议
针对上述问题,我们提出以下改进建议:
#### 4.2.1 完善前端工具管理界面
1. **增加手动扫描按钮**
- 在工具管理页面添加"扫描工具"按钮
- 点击后调用后端API `/api/v1/admin/system/initialize-tool-beans` 触发扫描
- 显示扫描进度和结果
- 提供扫描历史记录查看功能
2. **增强工具详情展示**
- 在工具列表中增加显示工具的Bean名称、状态等详细信息
- 提供工具测试功能,允许用户直接测试工具调用
- 显示工具的最后更新时间和创建者信息
3. **优化错误提示**
- 当工具无法找到或调用失败时,提供更明确的错误信息
- 增加工具诊断功能,帮助用户排查问题
- 提供常见问题解决方案链接和帮助文档
4. **增加工具诊断界面**
- 提供单个工具的详细诊断信息查看
- 支持批量工具状态检查
- 显示工具依赖关系图谱#### 4.2.2 后端功能优化
1. **完善工具调用错误处理**
-[AgentToolManager.java](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/backend/src/main/java/pangea/hiagent/tool/AgentToolManager.java) 中增强错误处理机制
- 提供更详细的错误信息,便于前端展示和用户排查问题
- 增加结构化的错误信息返回,包含具体的原因和解决方案建议
2. **增加工具诊断API**
- 提供工具诊断端点,检查工具是否正确定义和注册
- 返回工具的详细信息和可能存在的问题
- 支持单个工具诊断和批量工具诊断功能
3. **优化日志记录**
- 增强工具调用过程中的日志记录
- 提供更详细的调试信息,便于问题追踪
- 结构化日志信息,方便后续分析和问题定位## 5. 实施步骤
### 5.1 后端实施
1. 完善 [ToolBeanNameInitializer.java](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/backend/src/main/java/pangea/hiagent/tool/ToolBeanNameInitializer.java) 的手动扫描接口
2. 优化 [AgentToolManager.java](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/backend/src/main/java/pangea/hiagent/tool/AgentToolManager.java) 的工具获取逻辑,增强错误处理和日志记录
3. 增强 [ToolExecutionLoggerAspect.java](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/backend/src/main/java/pangea/hiagent/tool/aspect/ToolExecutionLoggerAspect.java) 的日志记录功能
4. 增加工具诊断API端点,提供工具状态检查功能
5. 实现工具依赖关系分析功能
6. 增加工具使用统计和性能监控
### 5.2 前端实施
1. 在工具管理页面增加手动扫描按钮,调用 `/api/v1/admin/system/initialize-tool-beans` 端点
2. 增强工具列表展示,显示更多工具详细信息如Bean名称、状态等
3. 增加工具测试功能,允许用户直接测试工具调用
4. 优化错误提示,提供更明确的错误信息帮助用户排查问题
5. 实现工具诊断界面,支持单个和批量工具诊断
6. 增加工具依赖关系可视化展示
### 5.3 测试验证
1. 验证工具方法的 AOP 代理兼容性
2. 测试手动扫描和自动注册功能
3. 验证 UI 配置功能的完整性和易用性
4. 测试工具执行的日志记录和监控功能
5. 验证错误处理机制的有效性
6. 测试工具诊断功能的准确性和完整性
7. 验证工具依赖关系分析的正确性
## 6. 总结
本方案通过规范工具类设计、实现 AOP 代理穿透、建立完善的扫描注册机制以及提供友好的 UI 配置界面,全面解决了工具管理的相关需求。该方案既保证了系统的稳定性和扩展性,又提升了用户的使用体验。
通过对现有代码的分析,我们确认系统已经具备了良好的基础架构,包括:
1. 完善的 AOP 代理支持
2. 工具扫描和注册的核心功能实现
3. 工具调用的日志记录机制
接下来的工作重点应该放在完善前端界面和增强错误处理上,使系统更加易于使用和维护。特别需要关注的是工具诊断功能的实现,这将大大提高系统运维和问题排查的效率。
\ No newline at end of file
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.8</version> <version>3.5.9</version>
<relativePath/> <relativePath/>
</parent> </parent>
...@@ -108,7 +108,6 @@ ...@@ -108,7 +108,6 @@
<dependency> <dependency>
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-milvus-store</artifactId> <artifactId>spring-ai-milvus-store</artifactId>
<version>${spring-ai.version}</version>
</dependency> </dependency>
...@@ -155,14 +154,12 @@ ...@@ -155,14 +154,12 @@
<dependency> <dependency>
<groupId>com.mysql</groupId> <groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId> <artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
</dependency> </dependency>
<!-- H2 Database --> <!-- H2 Database -->
<dependency> <dependency>
<groupId>com.h2database</groupId> <groupId>com.h2database</groupId>
<artifactId>h2</artifactId> <artifactId>h2</artifactId>
<version>2.2.224</version>
</dependency> </dependency>
<!-- Redis --> <!-- Redis -->
...@@ -194,14 +191,12 @@ ...@@ -194,14 +191,12 @@
<dependency> <dependency>
<groupId>com.github.ben-manes.caffeine</groupId> <groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId> <artifactId>caffeine</artifactId>
<version>${caffeine.version}</version>
</dependency> </dependency>
<!-- Lombok --> <!-- Lombok -->
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<!-- Jackson --> <!-- Jackson -->
...@@ -234,7 +229,6 @@ ...@@ -234,7 +229,6 @@
<dependency> <dependency>
<groupId>org.hibernate.validator</groupId> <groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId> <artifactId>hibernate-validator</artifactId>
<version>8.0.1.Final</version>
</dependency> </dependency>
<!-- SpringDoc OpenAPI for Swagger --> <!-- SpringDoc OpenAPI for Swagger -->
...@@ -351,7 +345,6 @@ ...@@ -351,7 +345,6 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration> <configuration>
<source>17</source> <source>17</source>
<target>17</target> <target>17</target>
...@@ -370,7 +363,6 @@ ...@@ -370,7 +363,6 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0</version>
<configuration> <configuration>
<argLine>-Dfile.encoding=UTF-8</argLine> <argLine>-Dfile.encoding=UTF-8</argLine>
</configuration> </configuration>
......
package pangea.hiagent.web.repository; package pangea.hiagent;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
......
package pangea.hiagent.agent.processor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import pangea.hiagent.model.Agent;
import pangea.hiagent.rag.RagService;
import pangea.hiagent.agent.service.AgentErrorHandler;
import pangea.hiagent.agent.service.TokenConsumerWithCompletion;
import java.util.function.Consumer;
/**
* Agent处理器抽象基类
* 封装所有Agent处理器的公共逻辑
* 职责:提供所有Agent处理器共享的基础功能
*/
@Slf4j
public abstract class AbstractAgentProcessor extends BaseAgentProcessor {
@Autowired
protected AgentErrorHandler agentErrorHandler;
/**
* 处理RAG响应的通用逻辑
*
* @param ragResponse RAG响应
* @param tokenConsumer token消费者(流式处理时使用)
* @return RAG响应
*/
protected String handleRagResponse(String ragResponse, Consumer<String> tokenConsumer) {
if (tokenConsumer != null) {
// 对于流式处理,我们需要将RAG响应作为token发送
tokenConsumer.accept(ragResponse);
// 发送完成信号
if (tokenConsumer instanceof TokenConsumerWithCompletion) {
((TokenConsumerWithCompletion) tokenConsumer).onComplete(ragResponse);
}
}
return ragResponse;
}
/**
* 处理请求的通用前置逻辑
*
* @param agent Agent对象
* @param userMessage 用户消息
* @param userId 用户ID
* @param ragService RAG服务
* @param tokenConsumer token消费者(流式处理时使用)
* @return RAG响应,如果有的话;否则返回null继续正常处理流程
*/
protected String handlePreProcessing(Agent agent, String userMessage, String userId, RagService ragService, Consumer<String> tokenConsumer) {
// 为每个用户-Agent组合创建唯一的会话ID
String sessionId = generateSessionId(agent, userId);
// 添加用户消息到ChatMemory
addUserMessageToMemory(sessionId, userMessage);
// 检查是否启用RAG并尝试RAG增强
String ragResponse = tryRagEnhancement(agent, userMessage, ragService);
if (ragResponse != null) {
log.info("RAG增强返回结果,直接返回");
return handleRagResponse(ragResponse, tokenConsumer);
}
return null;
}
}
\ No newline at end of file
...@@ -9,7 +9,7 @@ import pangea.hiagent.memory.MemoryService; ...@@ -9,7 +9,7 @@ import pangea.hiagent.memory.MemoryService;
import pangea.hiagent.memory.SmartHistorySummarizer; import pangea.hiagent.memory.SmartHistorySummarizer;
import pangea.hiagent.model.Agent; import pangea.hiagent.model.Agent;
import pangea.hiagent.rag.RagService; import pangea.hiagent.rag.RagService;
import pangea.hiagent.agent.service.AgentErrorHandler;
import pangea.hiagent.web.service.AgentService; import pangea.hiagent.web.service.AgentService;
import pangea.hiagent.agent.service.ErrorHandlerService; import pangea.hiagent.agent.service.ErrorHandlerService;
import pangea.hiagent.agent.service.TokenConsumerWithCompletion; import pangea.hiagent.agent.service.TokenConsumerWithCompletion;
...@@ -38,8 +38,7 @@ public abstract class BaseAgentProcessor implements AgentProcessor { ...@@ -38,8 +38,7 @@ public abstract class BaseAgentProcessor implements AgentProcessor {
@Autowired @Autowired
protected ErrorHandlerService errorHandlerService; protected ErrorHandlerService errorHandlerService;
@Autowired
protected AgentErrorHandler agentErrorHandler;
// 默认系统提示词 // 默认系统提示词
protected static final String DEFAULT_SYSTEM_PROMPT = "你是一个智能助手"; protected static final String DEFAULT_SYSTEM_PROMPT = "你是一个智能助手";
...@@ -135,7 +134,7 @@ public abstract class BaseAgentProcessor implements AgentProcessor { ...@@ -135,7 +134,7 @@ public abstract class BaseAgentProcessor implements AgentProcessor {
* @return 是否为401错误 * @return 是否为401错误
*/ */
protected boolean isUnauthorizedError(Throwable e) { protected boolean isUnauthorizedError(Throwable e) {
return agentErrorHandler.isUnauthorizedError(e); return errorHandlerService.isUnauthorizedError(new Exception(e));
} }
/** /**
...@@ -146,7 +145,17 @@ public abstract class BaseAgentProcessor implements AgentProcessor { ...@@ -146,7 +145,17 @@ public abstract class BaseAgentProcessor implements AgentProcessor {
* @return 错误消息 * @return 错误消息
*/ */
protected String handleSyncError(Throwable e, String errorMessagePrefix) { protected String handleSyncError(Throwable e, String errorMessagePrefix) {
return agentErrorHandler.handleSyncError(e, errorMessagePrefix); // 检查是否是401 Unauthorized错误
if (isUnauthorizedError(e)) {
log.error("LLM返回401未授权错误: {}", e.getMessage());
return "请配置API密钥";
} else {
String errorMessage = e.getMessage();
if (errorMessage == null || errorMessage.isEmpty()) {
errorMessage = "未知错误";
}
return errorMessagePrefix + ": " + errorMessage;
}
} }
/** /**
...@@ -325,7 +334,7 @@ public abstract class BaseAgentProcessor implements AgentProcessor { ...@@ -325,7 +334,7 @@ public abstract class BaseAgentProcessor implements AgentProcessor {
hasError.set(true); hasError.set(true);
// 不再重新抛出异常,避免中断流式处理 // 不再重新抛出异常,避免中断流式处理
// 但我们应该记录这个错误并向客户端发送错误信息 // 但我们应该记录这个错误并向客户端发送错误信息
agentErrorHandler.sendErrorMessage(tokenConsumer, "[错误] 处理token时发生错误: " + e.getMessage()); errorHandlerService.sendErrorMessage(tokenConsumer, "[错误] 处理token时发生错误: " + e.getMessage());
} }
} }
} catch (Exception e) { } catch (Exception e) {
...@@ -344,7 +353,7 @@ public abstract class BaseAgentProcessor implements AgentProcessor { ...@@ -344,7 +353,7 @@ public abstract class BaseAgentProcessor implements AgentProcessor {
*/ */
private void handleStreamError(Throwable throwable, Consumer<String> tokenConsumer, AtomicBoolean hasError) { private void handleStreamError(Throwable throwable, Consumer<String> tokenConsumer, AtomicBoolean hasError) {
hasError.set(true); hasError.set(true);
agentErrorHandler.handleStreamError(throwable, tokenConsumer, "流式调用出错"); errorHandlerService.handleStreamError(throwable, tokenConsumer, "流式调用出错");
} }
/** /**
...@@ -385,7 +394,11 @@ public abstract class BaseAgentProcessor implements AgentProcessor { ...@@ -385,7 +394,11 @@ public abstract class BaseAgentProcessor implements AgentProcessor {
// 发送完成事件,包含完整内容 // 发送完成事件,包含完整内容
try { try {
if (tokenConsumer instanceof TokenConsumerWithCompletion) { if (tokenConsumer instanceof TokenConsumerWithCompletion) {
try {
((TokenConsumerWithCompletion) tokenConsumer).onComplete(fullText.toString()); ((TokenConsumerWithCompletion) tokenConsumer).onComplete(fullText.toString());
} catch (NoClassDefFoundError e) {
log.error("TokenConsumerWithCompletion依赖类未找到,跳过完成回调: {}", e.getMessage());
}
if (log.isTraceEnabled()) { if (log.isTraceEnabled()) {
log.trace("完成事件已发送"); log.trace("完成事件已发送");
} }
...@@ -411,7 +424,7 @@ public abstract class BaseAgentProcessor implements AgentProcessor { ...@@ -411,7 +424,7 @@ public abstract class BaseAgentProcessor implements AgentProcessor {
* @param isCompleted 是否已完成 * @param isCompleted 是否已完成
*/ */
private void handleStreamModelError(Consumer<String> tokenConsumer, AtomicBoolean isCompleted) { private void handleStreamModelError(Consumer<String> tokenConsumer, AtomicBoolean isCompleted) {
agentErrorHandler.sendErrorMessage(tokenConsumer, "[错误] 流式模型或提示词为空,无法启动流式处理"); errorHandlerService.sendErrorMessage(tokenConsumer, "[错误] 流式模型或提示词为空,无法启动流式处理");
// 标记完成 // 标记完成
isCompleted.set(true); isCompleted.set(true);
} }
...@@ -426,8 +439,58 @@ public abstract class BaseAgentProcessor implements AgentProcessor { ...@@ -426,8 +439,58 @@ public abstract class BaseAgentProcessor implements AgentProcessor {
*/ */
private void handleUnexpectedError(Exception e, Consumer<String> tokenConsumer, AtomicBoolean isCompleted) { private void handleUnexpectedError(Exception e, Consumer<String> tokenConsumer, AtomicBoolean isCompleted) {
String errorMessage = handleSyncError(e, "处理流式响应时发生错误"); String errorMessage = handleSyncError(e, "处理流式响应时发生错误");
agentErrorHandler.sendErrorMessage(tokenConsumer, "[错误] " + errorMessage); errorHandlerService.sendErrorMessage(tokenConsumer, "[错误] " + errorMessage);
// 确保标记为已完成 // 确保标记为已完成
isCompleted.set(true); isCompleted.set(true);
} }
/**
* 处理RAG响应的通用逻辑
*
* @param ragResponse RAG响应
* @param tokenConsumer token消费者(流式处理时使用)
* @return RAG响应
*/
protected String handleRagResponse(String ragResponse, Consumer<String> tokenConsumer) {
if (tokenConsumer != null) {
// 对于流式处理,我们需要将RAG响应作为token发送
tokenConsumer.accept(ragResponse);
// 发送完成信号
if (tokenConsumer instanceof TokenConsumerWithCompletion) {
try {
((TokenConsumerWithCompletion) tokenConsumer).onComplete(ragResponse);
} catch (NoClassDefFoundError e) {
log.error("TokenConsumerWithCompletion依赖类未找到,跳过完成回调: {}", e.getMessage());
}
}
}
return ragResponse;
}
/**
* 处理请求的通用前置逻辑
*
* @param agent Agent对象
* @param userMessage 用户消息
* @param userId 用户ID
* @param ragService RAG服务
* @param tokenConsumer token消费者(流式处理时使用)
* @return RAG响应,如果有的话;否则返回null继续正常处理流程
*/
protected String handlePreProcessing(Agent agent, String userMessage, String userId, RagService ragService, Consumer<String> tokenConsumer) {
// 为每个用户-Agent组合创建唯一的会话ID
String sessionId = generateSessionId(agent, userId);
// 添加用户消息到ChatMemory
addUserMessageToMemory(sessionId, userMessage);
// 检查是否启用RAG并尝试RAG增强
String ragResponse = tryRagEnhancement(agent, userMessage, ragService);
if (ragResponse != null) {
log.info("RAG增强返回结果,直接返回");
return handleRagResponse(ragResponse, tokenConsumer);
}
return null;
}
} }
\ No newline at end of file
...@@ -11,6 +11,7 @@ import pangea.hiagent.rag.RagService; ...@@ -11,6 +11,7 @@ import pangea.hiagent.rag.RagService;
import pangea.hiagent.web.dto.AgentRequest; import pangea.hiagent.web.dto.AgentRequest;
import java.util.function.Consumer; import java.util.function.Consumer;
import pangea.hiagent.agent.service.TokenConsumerWithCompletion;
/** /**
* 普通Agent处理器实现类 * 普通Agent处理器实现类
...@@ -18,7 +19,7 @@ import java.util.function.Consumer; ...@@ -18,7 +19,7 @@ import java.util.function.Consumer;
*/ */
@Slf4j @Slf4j
@Service @Service
public class NormalAgentProcessor extends AbstractAgentProcessor { public class NormalAgentProcessor extends BaseAgentProcessor {
@Autowired(required = false) @Autowired(required = false)
private RagService ragService; private RagService ragService;
...@@ -67,7 +68,7 @@ public class NormalAgentProcessor extends AbstractAgentProcessor { ...@@ -67,7 +68,7 @@ public class NormalAgentProcessor extends AbstractAgentProcessor {
return responseContent; return responseContent;
} catch (Exception e) { } catch (Exception e) {
return agentErrorHandler.handleSyncError(e, "模型调用失败"); return handleSyncError(e, "模型调用失败");
} }
} }
...@@ -101,8 +102,15 @@ public class NormalAgentProcessor extends AbstractAgentProcessor { ...@@ -101,8 +102,15 @@ public class NormalAgentProcessor extends AbstractAgentProcessor {
// 流式处理 // 流式处理
handleStreamingResponse(tokenConsumer, prompt, streamingChatModel, sessionId); handleStreamingResponse(tokenConsumer, prompt, streamingChatModel, sessionId);
} catch (Exception e) { } catch (Exception e) {
agentErrorHandler.handleStreamError(e, tokenConsumer, "普通Agent流式处理失败"); errorHandlerService.handleStreamError(e, tokenConsumer, "普通Agent流式处理失败");
agentErrorHandler.ensureCompletionCallback(tokenConsumer, "处理请求时发生错误: " + e.getMessage()); // 直接调用完成回调,不依赖AgentErrorHandler
if (tokenConsumer instanceof TokenConsumerWithCompletion) {
try {
((TokenConsumerWithCompletion) tokenConsumer).onComplete("处理请求时发生错误: " + e.getMessage());
} catch (Exception ex) {
log.error("调用onComplete时发生错误: {}", ex.getMessage(), ex);
}
}
} }
} }
...@@ -114,9 +122,15 @@ public class NormalAgentProcessor extends AbstractAgentProcessor { ...@@ -114,9 +122,15 @@ public class NormalAgentProcessor extends AbstractAgentProcessor {
private void handleModelNotSupportStream(Consumer<String> tokenConsumer) { private void handleModelNotSupportStream(Consumer<String> tokenConsumer) {
String errorMessage = "[错误] 当前模型不支持流式输出"; String errorMessage = "[错误] 当前模型不支持流式输出";
// 发送错误信息 // 发送错误信息
agentErrorHandler.sendErrorMessage(tokenConsumer, errorMessage); errorHandlerService.sendErrorMessage(tokenConsumer, errorMessage);
// 确保在异常情况下也调用完成回调 // 确保在异常情况下也调用完成回调
agentErrorHandler.ensureCompletionCallback(tokenConsumer, errorMessage); if (tokenConsumer instanceof TokenConsumerWithCompletion) {
try {
((TokenConsumerWithCompletion) tokenConsumer).onComplete(errorMessage);
} catch (Exception ex) {
log.error("调用onComplete时发生错误: {}", ex.getMessage(), ex);
}
}
} }
@Override @Override
......
...@@ -15,6 +15,7 @@ import pangea.hiagent.web.service.AgentService; ...@@ -15,6 +15,7 @@ import pangea.hiagent.web.service.AgentService;
import java.util.List; import java.util.List;
import java.util.function.Consumer; import java.util.function.Consumer;
import pangea.hiagent.agent.service.TokenConsumerWithCompletion;
/** /**
* ReAct Agent处理器实现类 * ReAct Agent处理器实现类
...@@ -22,7 +23,7 @@ import java.util.function.Consumer; ...@@ -22,7 +23,7 @@ import java.util.function.Consumer;
*/ */
@Slf4j @Slf4j
@Service @Service
public class ReActAgentProcessor extends AbstractAgentProcessor { public class ReActAgentProcessor extends BaseAgentProcessor {
@Autowired @Autowired
private AgentService agentService; private AgentService agentService;
...@@ -87,16 +88,14 @@ public class ReActAgentProcessor extends AbstractAgentProcessor { ...@@ -87,16 +88,14 @@ public class ReActAgentProcessor extends AbstractAgentProcessor {
defaultReactExecutor.addReactCallback(defaultReactCallback); defaultReactExecutor.addReactCallback(defaultReactCallback);
} }
// 使用ReAct执行器执行流程,传递Agent对象以支持记忆功能 // 使用ReAct执行器执行流程,传递Agent对象和用户ID以支持记忆功能
String finalAnswer = defaultReactExecutor.executeWithAgent(client, userMessage, tools, agent); String finalAnswer = defaultReactExecutor.execute(client, userMessage, tools, agent, userId);
// 将助理回复添加到ChatMemory // 助手回复已经由执行器保存到内存中,不需要重复保存
String sessionId = generateSessionId(agent, userId);
addAssistantMessageToMemory(sessionId, finalAnswer);
return finalAnswer; return finalAnswer;
} catch (Exception e) { } catch (Exception e) {
return agentErrorHandler.handleSyncError(e, "处理ReAct请求时发生错误"); return handleSyncError(e, "处理ReAct请求时发生错误");
} }
} }
...@@ -138,11 +137,18 @@ public class ReActAgentProcessor extends AbstractAgentProcessor { ...@@ -138,11 +137,18 @@ public class ReActAgentProcessor extends AbstractAgentProcessor {
return; return;
} }
// 使用ReAct执行器流式执行流程,传递Agent对象以支持记忆功能 // 使用ReAct执行器流式执行流程,传递Agent对象以支持记忆功能和用户ID以确保上下文传播
defaultReactExecutor.executeStreamWithAgent(client, userMessage, tools, tokenConsumer, agent); defaultReactExecutor.executeStream(client, userMessage, tools, tokenConsumer, agent, userId);
} catch (Exception e) { } catch (Exception e) {
agentErrorHandler.handleStreamError(e, tokenConsumer, "流式处理ReAct请求时发生错误"); errorHandlerService.handleStreamError(e, tokenConsumer, "流式处理ReAct请求时发生错误");
agentErrorHandler.ensureCompletionCallback(tokenConsumer, "处理请求时发生错误: " + e.getMessage()); // 直接调用完成回调,不依赖AgentErrorHandler
if (tokenConsumer instanceof TokenConsumerWithCompletion) {
try {
((TokenConsumerWithCompletion) tokenConsumer).onComplete("处理请求时发生错误: " + e.getMessage());
} catch (Exception ex) {
log.error("调用onComplete时发生错误: {}", ex.getMessage(), ex);
}
}
} }
} }
...@@ -154,8 +160,14 @@ public class ReActAgentProcessor extends AbstractAgentProcessor { ...@@ -154,8 +160,14 @@ public class ReActAgentProcessor extends AbstractAgentProcessor {
private void handleModelNotAvailable(Consumer<String> tokenConsumer) { private void handleModelNotAvailable(Consumer<String> tokenConsumer) {
String errorMessage = "[错误] 无法获取Agent的聊天模型"; String errorMessage = "[错误] 无法获取Agent的聊天模型";
// 发送错误信息 // 发送错误信息
agentErrorHandler.sendErrorMessage(tokenConsumer, errorMessage); errorHandlerService.sendErrorMessage(tokenConsumer, errorMessage);
// 确保在异常情况下也调用完成回调 // 确保在异常情况下也调用完成回调
agentErrorHandler.ensureCompletionCallback(tokenConsumer, errorMessage); if (tokenConsumer instanceof TokenConsumerWithCompletion) {
try {
((TokenConsumerWithCompletion) tokenConsumer).onComplete(errorMessage);
} catch (Exception ex) {
log.error("调用onComplete时发生错误: {}", ex.getMessage(), ex);
}
}
} }
} }
\ No newline at end of file
...@@ -6,78 +6,96 @@ import lombok.extern.slf4j.Slf4j; ...@@ -6,78 +6,96 @@ import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.workpanel.IWorkPanelDataCollector; import pangea.hiagent.workpanel.IWorkPanelDataCollector;
/** /**
* 自定义 ReAct 回调类,用于捕获并处理 ReAct 的每一步思维过程 * 简化的ReAct回调类
* 适配项目现有的 ReAct 实现方式
*/ */
@Slf4j @Slf4j
@Component // 注册为 Spring 组件,方便注入 @Component
public class DefaultReactCallback implements ReactCallback { public class DefaultReactCallback implements ReactCallback {
@Autowired @Autowired
private IWorkPanelDataCollector workPanelCollector; private IWorkPanelDataCollector workPanelCollector;
/**
* ReAct 每执行一个步骤,该方法会被触发
* @param reactStep ReAct 步骤对象,包含步骤的所有核心信息
*/
@Override @Override
public void onStep(ReactStep reactStep) { public void onStep(ReactStep reactStep) {
// 将信息记录到工作面板 log.info("ReAct步骤触发: 类型={}, 内容摘要={}",
reactStep.getStepType(),
reactStep.getContent() != null ?
reactStep.getContent().substring(0, Math.min(50, reactStep.getContent().length())) : "null");
recordReactStepToWorkPanel(reactStep); recordReactStepToWorkPanel(reactStep);
} }
/**
* 处理 ReAct 最终答案步骤
* @param finalAnswer 最终答案
*/
@Override @Override
public void onFinalAnswer(String finalAnswer) { public void onFinalAnswer(String finalAnswer) {
// 创建一个FINAL_ANSWER类型的ReactStep并处理
ReactStep finalStep = new ReactStep(0, ReactStepType.FINAL_ANSWER, finalAnswer); ReactStep finalStep = new ReactStep(0, ReactStepType.FINAL_ANSWER, finalAnswer);
recordReactStepToWorkPanel(finalStep); recordReactStepToWorkPanel(finalStep);
} }
/**
* 将ReAct步骤记录到工作面板
* @param reactStep ReAct步骤
*/
private void recordReactStepToWorkPanel(ReactStep reactStep) { private void recordReactStepToWorkPanel(ReactStep reactStep) {
if (workPanelCollector == null) { if (workPanelCollector == null) {
log.debug("无法记录到工作面板:collector为null");
return; return;
} }
try { try {
switch (reactStep.getStepType()) { switch (reactStep.getStepType()) {
case THOUGHT: case THOUGHT:
workPanelCollector.recordThinking(reactStep.getContent(), "reasoning"); workPanelCollector.recordThinking(reactStep.getContent(), "thought");
log.info("[WorkPanel] 记录思考步骤: {}",
reactStep.getContent().substring(0, Math.min(100, reactStep.getContent().length())));
break; break;
case ACTION: case ACTION:
if (reactStep.getAction() != null) { if (reactStep.getAction() != null) {
// 使用recordToolCallAction记录工具调用开始,状态为pending // 记录工具调用动作
String toolName = reactStep.getAction().getToolName();
Object parameters = reactStep.getAction().getParameters();
// 记录工具调用,初始状态为pending
workPanelCollector.recordToolCallAction( workPanelCollector.recordToolCallAction(
reactStep.getAction().getToolName(), toolName,
reactStep.getAction().getParameters(), parameters,
null, null, // 结果为空
"pending", "pending", // 状态为pending
null null // 错误信息为空
); );
// 同时记录工具调用信息到日志
log.info("[WorkPanel] 记录工具调用: 工具={} 参数={}", toolName, parameters);
} else {
// 如果没有具体的工具信息,记录为一般动作
workPanelCollector.recordThinking(reactStep.getContent(), "action");
log.info("[WorkPanel] 记录动作步骤: {}",
reactStep.getContent().substring(0, Math.min(100, reactStep.getContent().length())));
} }
break; break;
case OBSERVATION: case OBSERVATION:
if (reactStep.getObservation() != null && reactStep.getAction() != null) { if (reactStep.getObservation() != null) {
// 使用recordToolCallAction记录工具调用完成,状态为success // 检查是否有对应的动作信息
if (reactStep.getAction() != null) {
// 使用动作信息更新工具调用结果
workPanelCollector.recordToolCallAction( workPanelCollector.recordToolCallAction(
reactStep.getAction().getToolName(), reactStep.getAction().getToolName(),
reactStep.getAction().getParameters(), reactStep.getAction().getParameters(),
reactStep.getObservation().getContent(), reactStep.getObservation().getContent(),
"success", "success", // 状态为success
null null // 无错误信息
); );
log.info("[WorkPanel] 更新工具调用结果: 工具={} 结果摘要={}",
reactStep.getAction().getToolName(),
reactStep.getObservation().getContent().substring(0, Math.min(50, reactStep.getObservation().getContent().length())));
} else {
// 如果没有动作信息,记录为观察结果
workPanelCollector.recordThinking(reactStep.getContent(), "observation");
log.info("[WorkPanel] 记录观察步骤: {}",
reactStep.getContent().substring(0, Math.min(100, reactStep.getContent().length())));
}
} }
break; break;
case FINAL_ANSWER: case FINAL_ANSWER:
workPanelCollector.recordFinalAnswer(reactStep.getContent()); workPanelCollector.recordFinalAnswer(reactStep.getContent());
// 记录最终答案到日志
log.info("[WorkPanel] 记录最终答案: {}",
reactStep.getContent().substring(0, Math.min(100, reactStep.getContent().length())));
break; break;
default: default:
log.warn("未知的ReAct步骤类型: {}", reactStep.getStepType()); log.warn("未知的ReAct步骤类型: {}", reactStep.getStepType());
...@@ -85,6 +103,20 @@ public class DefaultReactCallback implements ReactCallback { ...@@ -85,6 +103,20 @@ public class DefaultReactCallback implements ReactCallback {
} }
} catch (Exception e) { } catch (Exception e) {
log.error("记录ReAct步骤到工作面板失败", e); log.error("记录ReAct步骤到工作面板失败", e);
// 即使发生异常,也尝试记录错误信息到工作面板
try {
if (reactStep != null && reactStep.getAction() != null) {
workPanelCollector.recordToolCallAction(
reactStep.getAction().getToolName(),
reactStep.getAction().getParameters(),
"记录失败: " + e.getMessage(),
"error",
System.currentTimeMillis() // 使用当前时间戳作为执行时间
);
}
} catch (Exception ex) {
log.error("记录错误信息到工作面板也失败", ex);
}
} }
} }
} }
\ No newline at end of file
...@@ -7,50 +7,117 @@ import org.springframework.ai.chat.model.ChatResponse; ...@@ -7,50 +7,117 @@ import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.context.annotation.Lazy;
import pangea.hiagent.agent.service.StreamRequestService;
import pangea.hiagent.agent.sse.UserSseService;
import pangea.hiagent.model.UserToken;
import pangea.hiagent.tool.impl.HisenseTripTool;
import pangea.hiagent.tool.impl.VisitorAppointmentTool;
import pangea.hiagent.web.service.AgentService;
import pangea.hiagent.web.service.InfoCollectorService;
import pangea.hiagent.web.service.UserTokenService;
import pangea.hiagent.workpanel.IWorkPanelDataCollector;
import pangea.hiagent.agent.service.ErrorHandlerService; import pangea.hiagent.agent.service.ErrorHandlerService;
import pangea.hiagent.agent.service.TokenConsumerWithCompletion; import pangea.hiagent.agent.service.TokenConsumerWithCompletion;
import pangea.hiagent.memory.MemoryService; import pangea.hiagent.memory.MemoryService;
import pangea.hiagent.model.Agent; import pangea.hiagent.model.Agent;
import pangea.hiagent.tool.AgentToolManager; import pangea.hiagent.tool.AgentToolManager;
import pangea.hiagent.tool.impl.DateTimeTools; import pangea.hiagent.tool.impl.DateTimeTools;
import pangea.hiagent.common.utils.UserUtils;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
* 重构后的默认ReAct执行器实现 - 支持真正的流式输出和完整的ReAct流程可观测性 * 简化的默认ReAct执行器实现
*/ */
@Slf4j @Slf4j
@Service @Service
public class DefaultReactExecutor implements ReactExecutor { public class DefaultReactExecutor implements ReactExecutor {
// 默认系统提示词
private static final String DEFAULT_SYSTEM_PROMPT = private static final String DEFAULT_SYSTEM_PROMPT =
"You are a helpful AI assistant that can use tools to answer questions. " + "You are a powerful professional AI assistant powered by the enhanced ReAct (Reasoning + Acting) iterative framework, specialized for Spring AI tool orchestration. Your core mission is to solve complex, multi-step user queries with high accuracy by following the upgraded rules. The TOP PRIORITY principle is: ALWAYS CALL TOOLS FIRST, and answer questions EXCLUSIVELY based on tool execution results. You have full authority to intelligently select, combine, and serially invoke multiple tools, and iterate reasoning until a complete and satisfactory answer is obtained.\n\n" +
"Use the available tools when needed to gather information. " + "=== CORE UPGRADED RULE - NON-NEGOTIABLE (Tool-First Priority Highlighted) ===\n\n" +
"Think step by step and show your reasoning process. " + "1. Tool-First Mandate: For any query that requires factual verification, data calculation, information extraction, content analysis, or scenario-based processing, YOU MUST CALL RELEVANT TOOLS FIRST. Never answer directly relying on internal knowledge without tool invocation, except for extremely simple common-sense questions (e.g., \"What is 1+1?\").\n" +
"When using tools, clearly indicate your thoughts, actions, and observations."; "2. Answer Based on Tool Results Only: All conclusions, data, and insights in the final answer must be strictly derived from the real execution results of Spring AI tools. Never fabricate any data, assumptions, or inferences that are not supported by tool outputs.\n" +
"3. Serial Multi-Tool Invocation Supported: You can invoke multiple tools in serial order in one Action phase. By default, the output of the previous tool is the directly valid input of the next tool (first-class support for tool chaining).\n" +
"4. Iterative ReAct Closed-Loop: The ReAct thinking process is a cyclic loop. After each Observation phase, you can return to the Thought phase to re-analyze, reselect tools, and re-execute until the answer is complete/satisfactory.\n" +
"5. Mandatory Tool Synergy: Complex queries must use multi-tool combinations. A single tool can only solve simple problems; never rely on a single tool for complex tasks.\n" +
"6. Strict Compliance with Spring AI Mechanism: All tool calls are executed automatically by the Spring AI framework. You only need to make optimal tool selection and sequence planning.\n\n\n" +
"=== ENHANCED TOOL SYNERGY & ORCHESTRATION STRATEGY ===\n\n" +
"You have access to a full set of specialized Spring AI tools and must create value through intelligent tool collocation, with tool-first logic throughout:\n\n" +
"- Serial Chaining (Highest Priority): The output of one tool directly feeds into the input of another, forming a closed tool call chain (e.g., File Reader → Text Processor → Calculator → File Writer → Chart Generator).\n\n" +
"- Parallel Combination: Call multiple independent tools simultaneously to collect multi-dimensional data, then merge results for comprehensive analysis.\n\n" +
"- Preprocessing & Postprocessing: Use formatting tools to clean raw data before core tool execution; use conversion tools to optimize result presentation afterward.\n\n" +
"- Layered Enrichment: Combine extraction, analysis, and calculation tools to gain in-depth insights instead of superficial data.\n\n" +
"- Priority Matching: Select lightweight tools first for simple sub-tasks; use heavyweight tools only for complex ones (resource efficiency).\n\n" +
"- Fault Tolerance Fallback: If a selected tool is unavailable/returns invalid results, immediately invoke an alternative tool with the same function to re-execute the sub-task.\n\n\n" +
"=== Typical High-Value Tool Synergy Examples ===\n\n" +
"1. Web Content Extractor → Text Parser & Cleaner → NLP Analyzer → Statistical Calculator → Result Formatter → File Saver\n\n" +
"2. Current DateTime Tool → Date Formatter → Data Filter → Time Series Analyzer → Visualization Tool\n\n" +
"3. Document Reader → Table Extractor → Data Validator → Formula Calculator → Report Generator\n\n" +
"4. Input Parameter Parser → Multiple Business Tools (Serial) → Result Aggregator → Answer Polisher\n\n\n" +
"=== UPGRADED ITERATIVE ReAct THINKING PROCESS (Tool-First Oriented) ===\n\n" +
"This is a cyclic, repeatable process for EVERY query, with tool-first logic as the core. Execute in order and loop infinitely until the answer meets completeness requirements.\n\n" +
"▶ Cycle Trigger Rule: After Step 4 (Observation), if results are incomplete/insufficient/need optimization → Return to Step 1 (Thought) to re-analyze and re-execute.\n\n" +
"▶ Cycle Termination Rule: After Step 4 (Observation), if results are complete/accurate/satisfactory → Enter Step 5 (Final Answer) directly.\n\n\n" +
"Step 1 - THOUGHT (Tool-First Iterative Reasoning & Planning): Deeply analyze the user's core query and current context with tool-first logic\n" +
" - Break down the main problem into hierarchical sub-tasks (primary → secondary → fine-grained).\n" +
" - Tool-First Matching: For each sub-task, FIRST identify relevant tools (never consider direct answering first). Mark alternative tools for fault tolerance.\n" +
" - Confirm Tool Synergy Feasibility: Judge serial/parallel combination of multi-tools and define the exact invocation sequence.\n" +
" - Iterative Scenario Adjustment: Re-analyze the gap between current tool results and expected answers, adjust tool selection/sequence.\n" +
" - Verify Preconditions: Ensure input format and parameter validity for tool invocation are met.\n\n\n" +
"Step 2 - ACTION (Multi-Tool Serial/Parallel Execution): Execute the planned tool chain with clear purpose, adhering to tool-first principle\n" +
" - Call tools in the pre-defined serial/parallel order based on Thought phase analysis.\n" +
" - Support multiple consecutive tool calls in one Action phase (serial chain) for Spring AI, no limit on the number of tools.\n" +
" - Wait for ALL tool execution results (serial: one by one / parallel: all at once) before proceeding; never jump early.\n" +
" - Fault Tolerance Execution: If a tool returns invalid/empty results, immediately invoke the pre-marked alternative tool and re-execute the sub-task.\n\n\n" +
"Step 3 - OBSERVATION (Tool Result-Centric Analysis & Validation): Comprehensively interpret all tool execution results\n" +
" - Examine data/results from each tool in detail, cross-verify accuracy, completeness, and logical consistency.\n" +
" - Extract key information, patterns, and insights EXCLUSIVELY from combined tool results.\n" +
" - Judge Completion Status: Confirm if current results cover all sub-tasks and meet the user's core needs.\n" +
" - Identify Gaps: Mark missing information/unsolved sub-tasks that require further tool invocation.\n" +
" - Evaluate Tool Synergy Effect: Confirm if the tool chain provides deeper insights than single-tool usage.\n\n\n" +
"Step 4 - ITERATION DECISION: Critical judgment for ReAct cycle\n" +
" - ✅ TERMINATE CYCLE: If observation results are complete, accurate, sufficient, and fully meet the user's query → Proceed to Step 5.\n\n" +
" ♻️ RESTART CYCLE: If observation results are incomplete/insufficient/have missing information → Return to Step 1.\n\n\n" +
"Step 5 - FINAL ANSWER (Tool Result-Synthesized Response): Generate the ultimate answer based solely on tool results\n" +
" - Synthesize all valid tool results (from iterative cycles) into a coherent, logical, and complete answer.\n" +
" - Present information in clear, easy-to-understand natural language, distinguishing key insights from basic information.\n" +
" - Explicitly explain tool synergy logic (e.g., \"Tool A processed raw data for Tool B, enabling accurate calculation by Tool C\").\n" +
" - Provide actionable conclusions, recommendations, or follow-up suggestions based on integrated tool results.\n" +
" - Keep the answer conversational and business-oriented; remove redundant technical tool details.\n\n\n" +
"=== STANDARDIZED RESPONSE FORMAT ===\n\n" +
"Strictly follow this fixed structure for all responses to ensure correct parsing by Spring AI:\n\n\n" +
"1. Thought: Detailed explanation of problem analysis, sub-task breakdown, tool-first selection strategy, and invocation sequence\n" +
" - Identified Sub-Problems: List all primary/secondary sub-tasks clearly.\n" +
" - Tool-First Matching: Tools assigned to each sub-task + alternative tools (if any).\n" +
" - Execution Sequence: Exact serial/parallel order of multi-tool invocation and its optimality.\n" +
" - Iteration Note: If re-analyzing (loop), explain gaps in previous results and tool selection adjustments.\n\n\n" +
"2. Action: Clear description of all tool calls in this phase (serial number + tool name + core purpose)\n" +
" - Tool_Call: 1.[Tool Name] → Purpose: [Exact business objective and core value]\n" +
" - Tool_Call: 2.[Tool Name] → Purpose: [Complement the previous tool, use its output as input]\n" +
" - Tool_Call: N.[Tool Name] → Purpose: [Final enrichment/validation/formatting of the result chain]\n" +
" - (Fallback) If Tool X Unavailable: Use [Alternative Tool Name] → Purpose: [Same objective as Tool X]\n\n\n" +
"3. Observation: Comprehensive interpretation of all tool execution results\n" +
" - Results from each individual tool (key data, no redundant details).\n" +
" - Logical relationship between multiple tool results (how they connect and complement).\n" +
" - Core patterns/insights from the tool chain.\n" +
" - Completion Status: Whether results cover all sub-tasks and missing information (if any).\n\n\n" +
"4. Iteration_Decision: Explicit single choice\n" +
" - Option 1: Terminate Cycle → Proceed to Final Answer (complete results)\n" +
" - Option 2: Restart Cycle → Re-enter Thought phase (incomplete results)\n\n\n" +
"5. Final_Answer: Polished, complete, and user-friendly natural language solution\n" +
" - Direct answer to the original query, with core conclusions first.\n" +
" - Highlight key insights from tool synergy/iterative reasoning.\n" +
" - Provide actionable follow-up suggestions.\n" +
" - Conversational tone; no technical jargon about tools/frameworks.\n\n\n\n" +
"=== CRITICAL HARD RULES (Tool-First as Core) ===\n\n" +
"1. Tool-First is Non-Negotiable: For non-trivial queries, call tools first. Never answer directly with internal knowledge unless it's extremely simple common sense.\n" +
"2. Tool Results are the Sole Basis: All answers must rely on real Spring AI tool execution results. Never fabricate data/results.\n" +
"3. Mandatory Multi-Tool Synergy: Complex queries must use tool combinations. Never rely on a single tool for complex tasks.\n" +
"4. Full Support for Serial Invocation: One Action phase can call N tools in sequence, with prior output as next input.\n" +
"5. Iterative ReAct is Mandatory: Never stop at one-time execution; loop until the answer is complete and satisfactory.\n" +
"6. Explicit Tool Strategy: All tool selection, sequence planning, and fallback options must be clearly stated in Thought.\n" +
"7. Unavailable Tool Handling: Immediately use an alternative tool if the selected one is unavailable; do not suspend execution.\n" +
"8. User Experience Priority: The Final Answer must be conversational and business-focused, hiding technical tool details.\n" +
"9. Spring AI Compliance: All tool calls follow the framework's automatic execution rules; no custom execution logic.";
private final List<ReactCallback> reactCallbacks = new ArrayList<>(); private final List<ReactCallback> reactCallbacks = new ArrayList<>();
private final AtomicInteger stepCounter = new AtomicInteger(0); private final AtomicInteger stepCounter = new AtomicInteger(0);
@Autowired
@Lazy
private IWorkPanelDataCollector workPanelCollector;
@Autowired @Autowired
private DateTimeTools dateTimeTools; private DateTimeTools dateTimeTools;
...@@ -60,26 +127,12 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -60,26 +127,12 @@ public class DefaultReactExecutor implements ReactExecutor {
@Autowired @Autowired
private ErrorHandlerService errorHandlerService; private ErrorHandlerService errorHandlerService;
@Autowired
private InfoCollectorService infoCollectorService;
@Autowired
private AgentService agentService;
private final AgentToolManager agentToolManager; private final AgentToolManager agentToolManager;
@Autowired
private UserSseService userSseService;
@Autowired
private UserTokenService userTokenService;
public DefaultReactExecutor(AgentToolManager agentToolManager) { public DefaultReactExecutor(AgentToolManager agentToolManager) {
this.agentToolManager = agentToolManager; this.agentToolManager = agentToolManager;
} }
/**
* 添加ReAct回调
* @param callback ReAct回调
*/
@Override @Override
public void addReactCallback(ReactCallback callback) { public void addReactCallback(ReactCallback callback) {
if (callback != null) { if (callback != null) {
...@@ -87,62 +140,41 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -87,62 +140,41 @@ public class DefaultReactExecutor implements ReactExecutor {
} }
} }
/**
* 执行ReAct流程(同步方式)
* @param chatClient ChatClient实例
* @param userInput 用户输入
* @param tools 工具列表(已弃用,现在通过Agent获取工具)
* @return 最终答案
*/
@Override @Override
public String execute(ChatClient chatClient, String userInput, List<Object> tools) { public String execute(ChatClient chatClient, String userInput, List<Object> tools, Agent agent) {
return executeWithAgent(chatClient, userInput, tools, null); // 调用带用户ID的方法,首先尝试获取当前用户ID
String userId = UserUtils.getCurrentUserId();
return execute(chatClient, userInput, tools, agent, userId);
} }
/** @Override
* 执行ReAct流程(同步方式)- 支持Agent配置 public String execute(ChatClient chatClient, String userInput, List<Object> tools, Agent agent, String userId) {
* @param chatClient ChatClient实例
* @param userInput 用户输入
* @param tools 工具列表(已弃用,现在通过Agent获取工具)
* @param agent Agent对象(可选)
* @return 最终答案
*/
public String executeWithAgent(ChatClient chatClient, String userInput, List<Object> tools, Agent agent) {
log.info("开始执行ReAct流程,用户输入: {}", userInput); log.info("开始执行ReAct流程,用户输入: {}", userInput);
// 重置步骤计数器
stepCounter.set(0); stepCounter.set(0);
// 获取Agent关联的工具实例
List<Object> agentTools = getAgentTools(agent); List<Object> agentTools = getAgentTools(agent);
try { try {
// 触发思考步骤 // triggerThinkStep("开始处理用户请求: " + userInput);
triggerThinkStep("开始处理用户请求: " + userInput);
// 构建Prompt,包含历史对话记录
Prompt prompt = buildPromptWithHistory(DEFAULT_SYSTEM_PROMPT, userInput, agent);
// 使用call()获取完整的LLM响应 Prompt prompt = buildPromptWithHistory(DEFAULT_SYSTEM_PROMPT, userInput, agent, userId);
// 这会阻塞直到完成整个ReAct循环(思考→行動→观察→...→最终答案)
log.info("使用call()方法处理ReAct流程,确保完整的工具调用循环");
ChatResponse response = chatClient.prompt(prompt) ChatResponse response = chatClient.prompt(prompt)
.tools(agentTools.toArray()) .tools(agentTools.toArray())
.call() .call()
.chatResponse(); .chatResponse();
// 获取响应文本
String responseText = response.getResult().getOutput().getText(); String responseText = response.getResult().getOutput().getText();
// 触发观察步骤 // triggerObservationStep(responseText);
triggerObservationStep(responseText);
// 返回最终结果
log.info("最终答案: {}", responseText); log.info("最终答案: {}", responseText);
// 触发最终答案步骤 // triggerFinalAnswerStep(responseText);
triggerFinalAnswerStep(responseText);
// 保存助手回复到内存,使用提供的用户ID
saveAssistantResponseToMemory(agent, responseText, userId);
return responseText; return responseText;
} catch (Exception e) { } catch (Exception e) {
...@@ -152,110 +184,85 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -152,110 +184,85 @@ public class DefaultReactExecutor implements ReactExecutor {
} }
/** /**
* 处理ReAct执行错误 * 处理ReAct执行过程中发生的错误
* @param e 异常 *
* @return 错误信息 * @param e 发生的异常
* @return 错误处理结果
*/ */
private String handleReActError(Exception e) { private String handleReActError(Exception e) {
return errorHandlerService.handleSyncError(e, "处理ReAct请求时发生错误"); return errorHandlerService.handleSyncError(e, "处理ReAct请求时发生错误");
} }
/** /**
* 构建包含历史对话记录的Prompt * 构建带有历史记录的提示词
*
* @param systemPrompt 系统提示词 * @param systemPrompt 系统提示词
* @param userInput 用户输入 * @param userInput 用户输入
* @param agent Agent对象(可选) * @param agent 智能体对象
* @return 构建的Prompt * @param userId 用户ID(可选,如果为null则自动获取)
* @return 构建好的提示词对象
*/ */
private Prompt buildPromptWithHistory(String systemPrompt, String userInput, Agent agent) { private Prompt buildPromptWithHistory(String systemPrompt, String userInput, Agent agent, String userId) {
List<org.springframework.ai.chat.messages.Message> messages = new ArrayList<>(); List<org.springframework.ai.chat.messages.Message> messages = new ArrayList<>();
// 添加系统消息
messages.add(new SystemMessage(systemPrompt)); messages.add(new SystemMessage(systemPrompt));
// 如果提供了Agent,添加历史对话记录
if (agent != null) { if (agent != null) {
try { try {
// 生成会话ID // 如果没有提供用户ID,则尝试获取当前用户ID
String sessionId = memoryService.generateSessionId(agent); if (userId == null) {
userId = UserUtils.getCurrentUserId();
}
String sessionId = memoryService.generateSessionId(agent, userId);
// 获取历史记录长度配置,默认为10
int historyLength = agent.getHistoryLength() != null ? agent.getHistoryLength() : 10; int historyLength = agent.getHistoryLength() != null ? agent.getHistoryLength() : 10;
// 获取历史消息
List<org.springframework.ai.chat.messages.Message> historyMessages = List<org.springframework.ai.chat.messages.Message> historyMessages =
memoryService.getHistoryMessages(sessionId, historyLength); memoryService.getHistoryMessages(sessionId, historyLength);
// 添加历史消息到Prompt messages.addAll(historyMessages);
// messages.addAll(historyMessages);
// 将当前用户消息添加到内存中,以便下次对话使用
memoryService.addUserMessageToMemory(sessionId, userInput); memoryService.addUserMessageToMemory(sessionId, userInput);
} catch (Exception e) { } catch (Exception e) {
log.warn("获取历史对话记录时发生错误: {}", e.getMessage()); log.warn("获取历史对话记录时发生错误: {}", e.getMessage());
} }
} }
// 添加当前用户消息到Prompt
messages.add(new UserMessage(userInput)); messages.add(new UserMessage(userInput));
return new Prompt(messages); return new Prompt(messages);
} }
/**
* 流式执行ReAct流程 - 使用真正的流式处理机制
*
* @param chatClient ChatClient实例
* @param userInput 用户输入
* @param tools 工具列表(已弃用,现在通过Agent获取工具)
* @param tokenConsumer token处理回调函数
*/
@Override @Override
public void executeStream(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer) { public void executeStream(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer, Agent agent) {
executeStreamWithAgent(chatClient, userInput, tools, tokenConsumer, null); // 调用带用户ID的方法,但首先尝试获取当前用户ID
String userId = UserUtils.getCurrentUserId();
executeStream(chatClient, userInput, tools, tokenConsumer, agent, userId);
} }
/** @Override
* 流式执行ReAct流程 - 使用真正的流式处理机制(支持Agent配置) public void executeStream(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer, Agent agent, String userId) {
*
* @param chatClient ChatClient实例
* @param userInput 用户输入
* @param tools 工具列表(已弃用,现在通过Agent获取工具)
* @param tokenConsumer token处理回调函数
* @param agent Agent对象(可选)
*/
public void executeStreamWithAgent(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer, Agent agent) {
log.info("使用stream()方法处理ReAct流程,支持真正的流式输出"); log.info("使用stream()方法处理ReAct流程,支持真正的流式输出");
// 重置步骤计数器
stepCounter.set(0); stepCounter.set(0);
// 获取Agent关联的工具实例
List<Object> agentTools = getAgentTools(agent); List<Object> agentTools = getAgentTools(agent);
// 使用StringBuilder累积完整响应
StringBuilder fullResponse = new StringBuilder(); StringBuilder fullResponse = new StringBuilder();
try { try {
// 触发思考步骤 // triggerThinkStep("开始处理用户请求: " + userInput);
triggerThinkStep("开始处理用户请求: " + userInput);
StreamRequestService.StreamTokenConsumer consumer = (StreamRequestService.StreamTokenConsumer)tokenConsumer; Prompt prompt = buildPromptWithHistory(DEFAULT_SYSTEM_PROMPT, userInput, agent, userId);
String emitterId = consumer.getEmitterId();
// 构建Prompt,包含历史对话记录
Prompt prompt = buildPromptWithHistory(agent.getSystemPrompt(), userInput, agent);
UserToken userToken = userTokenService.getUserToken(consumer.getUserId(),"pangea");
VisitorAppointmentTool hisenseTripTool = new VisitorAppointmentTool(userToken,agentService,infoCollectorService,userSseService);
hisenseTripTool.initialize();
// 订阅流式响应
chatClient.prompt(prompt) chatClient.prompt(prompt)
.tools(hisenseTripTool) .tools(agentTools.toArray())
.toolContext(Map.of("emitterId",emitterId))
.stream() .stream()
.chatResponse() .chatResponse()
.subscribe( .subscribe(
chatResponse -> handleTokenResponse(chatResponse, tokenConsumer, fullResponse), chatResponse -> handleTokenResponse(chatResponse, tokenConsumer, fullResponse),
throwable -> handleStreamError(throwable, tokenConsumer,emitterId), throwable -> handleStreamError(throwable, tokenConsumer),
() -> handleStreamCompletion(tokenConsumer, fullResponse, agent,emitterId) () -> handleStreamCompletion(tokenConsumer, fullResponse, agent, userId)
); );
} catch (Exception e) { } catch (Exception e) {
...@@ -265,32 +272,30 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -265,32 +272,30 @@ public class DefaultReactExecutor implements ReactExecutor {
} }
/** /**
* 处理token响应 * 处理流式响应中的单个token
* *
* @param chatResponse 聊天响应 * @param chatResponse 聊天响应对象
* @param tokenConsumer token处理回调函数 * @param tokenConsumer token消费者
* @param fullResponse 完整响应构建器 * @param fullResponse 完整响应构建器
*/ */
private void handleTokenResponse(org.springframework.ai.chat.model.ChatResponse chatResponse, Consumer<String> tokenConsumer, StringBuilder fullResponse) { private void handleTokenResponse(org.springframework.ai.chat.model.ChatResponse chatResponse, Consumer<String> tokenConsumer, StringBuilder fullResponse) {
try { try {
// 获取token
String token = chatResponse.getResult().getOutput().getText(); String token = chatResponse.getResult().getOutput().getText();
// 验证token是否有效
if (isValidToken(token)) { if (isValidToken(token)) {
// 累积完整响应
fullResponse.append(token); fullResponse.append(token);
// 分析token内容,识别工具调用和结果 // analyzeAndRecordToolEvents(token, fullResponse.toString());
analyzeAndRecordToolEvents(token, fullResponse.toString());
// 实时发送token给客户端
if (tokenConsumer != null) { if (tokenConsumer != null) {
tokenConsumer.accept(token); tokenConsumer.accept(token);
} }
// 记录思考过程 // tokenTextSegmenter.inputChar(token);
processTokenForSteps(token); // tokenTextSegmenter.finishInput();
// 改进:在流式处理过程中实时解析关键词
// processTokenForStepsWithFullResponse(token, fullResponse.toString());
} }
} catch (Exception e) { } catch (Exception e) {
log.error("处理token时发生错误", e); log.error("处理token时发生错误", e);
...@@ -298,40 +303,59 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -298,40 +303,59 @@ public class DefaultReactExecutor implements ReactExecutor {
} }
/** /**
* 处理流式完成 * 处理流式响应完成事件
* *
* @param tokenConsumer token处理回调函数 * @param tokenConsumer token消费者
* @param fullResponse 完整响应构建器 * @param fullResponse 完整响应内容
* @param agent Agent对象 * @param agent 智能体对象
* @param userId 用户ID
*/ */
private void handleStreamCompletion(Consumer<String> tokenConsumer, StringBuilder fullResponse, Agent agent,String emitterId) { private void handleStreamCompletion(Consumer<String> tokenConsumer, StringBuilder fullResponse, Agent agent, String userId) {
try { try {
log.info("流式处理完成"); log.info("流式处理完成");
// 触发最终答案步骤
triggerFinalAnswerStep(fullResponse.toString()); // 检查是否已经处理了Final Answer,如果没有,则将整个响应作为最终答案
String responseStr = fullResponse.toString();
// 将助理回复添加到ChatMemory if (!hasFinalAnswerBeenTriggered(responseStr)) {
saveAssistantResponseToMemory(agent, fullResponse.toString()); // triggerFinalAnswerStep(responseStr);
log.info("complete, remove emitterId {}",emitterId); }
// 发送完成事件,包含完整内容
sendCompletionEvent(tokenConsumer, fullResponse.toString()); saveAssistantResponseToMemory(agent, responseStr, userId);
sendCompletionEvent(tokenConsumer, responseStr);
} catch (Exception e) { } catch (Exception e) {
log.error("处理流式完成回调时发生错误", e); log.error("处理流式完成回调时发生错误", e);
// 即使在完成回调中出现错误,也要确保标记完成
handleCompletionError(tokenConsumer, e); handleCompletionError(tokenConsumer, e);
} }
} }
/** /**
* 保存助理回复到内存 * 检查是否已经触发了Final Answer步骤
*
* @param fullResponse 完整响应内容
* @return 如果已经触发了Final Answer则返回true,否则返回false
*/
private boolean hasFinalAnswerBeenTriggered(String fullResponse) {
String[] finalAnswerPatterns = {"Final Answer:", "final answer:", "FINAL ANSWER:", "Final_Answer:", "final_answer:", "FINAL_ANSWER:", "最终答案:"};
for (String pattern : finalAnswerPatterns) {
if (fullResponse.toLowerCase().contains(pattern.toLowerCase())) {
return true;
}
}
return false;
}
/**
* 将助手的回复保存到内存中
* *
* @param agent Agent对象 * @param agent 智能体对象
* @param response 助理回复 * @param response 助手的回复内容
* @param userId 用户ID
*/ */
private void saveAssistantResponseToMemory(Agent agent, String response) { private void saveAssistantResponseToMemory(Agent agent, String response, String userId) {
if (agent != null) { if (agent != null) {
try { try {
String sessionId = memoryService.generateSessionId(agent); String sessionId = memoryService.generateSessionId(agent, userId);
memoryService.addAssistantMessageToMemory(sessionId, response); memoryService.addAssistantMessageToMemory(sessionId, response);
} catch (Exception e) { } catch (Exception e) {
log.warn("保存助理回复到内存时发生错误: {}", e.getMessage()); log.warn("保存助理回复到内存时发生错误: {}", e.getMessage());
...@@ -340,17 +364,21 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -340,17 +364,21 @@ public class DefaultReactExecutor implements ReactExecutor {
} }
/** /**
* 处理完成错误 * 处理完成事件时发生的错误
* *
* @param tokenConsumer token处理回调函数 * @param tokenConsumer token消费者
* @param e 异常 * @param e 发生的异常
*/ */
private void handleCompletionError(Consumer<String> tokenConsumer, Exception e) { private void handleCompletionError(Consumer<String> tokenConsumer, Exception e) {
if (tokenConsumer instanceof TokenConsumerWithCompletion) { if (tokenConsumer instanceof TokenConsumerWithCompletion) {
try { try {
String errorId = errorHandlerService.generateErrorId(); String errorId = errorHandlerService.generateErrorId();
String fullErrorMessage = errorHandlerService.buildFullErrorMessage("处理完成时发生错误", e, errorId, "ReAct"); String fullErrorMessage = errorHandlerService.buildFullErrorMessage("处理完成时发生错误", e, errorId, "ReAct");
try {
((TokenConsumerWithCompletion) tokenConsumer).onComplete("[" + errorId + "] " + fullErrorMessage); ((TokenConsumerWithCompletion) tokenConsumer).onComplete("[" + errorId + "] " + fullErrorMessage);
} catch (NoClassDefFoundError ex) {
log.error("TokenConsumerWithCompletion依赖类未找到,跳过完成回调: {}", ex.getMessage());
}
} catch (Exception ex) { } catch (Exception ex) {
log.error("调用onComplete时发生错误", ex); log.error("调用onComplete时发生错误", ex);
} }
...@@ -358,311 +386,83 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -358,311 +386,83 @@ public class DefaultReactExecutor implements ReactExecutor {
} }
/** /**
* 检查token是否有效 * 验证token是否有效
* @param token token字符串 *
* @return 是否有效 * @param token 待验证的token
* @return 如果token有效则返回true,否则返回false
*/ */
private boolean isValidToken(String token) { private boolean isValidToken(String token) {
return token != null && !token.isEmpty(); return token != null && !token.isEmpty();
} }
/** /**
* 处理token以识别不同的步骤类型 * 处理流式响应中的错误
* @param token token字符串
*/
private void processTokenForSteps(String token) {
// 参数验证
if (token == null || token.isEmpty()) {
log.debug("接收到空的token,跳过处理");
return;
}
if (token.contains("Thought:")) {
triggerThinkStep(token);
} else if (token.contains("Action:")) {
// 提取工具名称和参数
String toolName = extractToolName(token);
Object toolArgs = extractToolArgs(token);
triggerActionStep(toolName != null ? toolName : "unknown tool", token, toolArgs);
} else if (token.contains("Observation:")) {
triggerObservationStep(token);
}
}
/**
* 处理流式处理错误
* *
* @param throwable 异常对象 * @param throwable 异常对象
* @param tokenConsumer token消费者 * @param tokenConsumer token消费者
*/ */
private void handleStreamError(Throwable throwable, Consumer<String> tokenConsumer,String emitterId) { private void handleStreamError(Throwable throwable, Consumer<String> tokenConsumer) {
log.info("error,remove emitterId:{}", emitterId);
errorHandlerService.handleStreamError(throwable, tokenConsumer, "ReAct流式处理"); errorHandlerService.handleStreamError(throwable, tokenConsumer, "ReAct流式处理");
} }
/** /**
* 发送完成事件 * 发送完成事件
*
* @param tokenConsumer token消费者 * @param tokenConsumer token消费者
* @param fullResponse 完整响应 * @param fullResponse 完整响应内容
*/ */
private void sendCompletionEvent(Consumer<String> tokenConsumer, String fullResponse) { private void sendCompletionEvent(Consumer<String> tokenConsumer, String fullResponse) {
// 参数验证
if (fullResponse == null) { if (fullResponse == null) {
fullResponse = ""; fullResponse = "";
} }
if (tokenConsumer instanceof TokenConsumerWithCompletion) { if (tokenConsumer instanceof TokenConsumerWithCompletion) {
log.debug("调用onComplete,内容长度: {}", fullResponse.length()); try {
((TokenConsumerWithCompletion) tokenConsumer).onComplete(fullResponse); ((TokenConsumerWithCompletion) tokenConsumer).onComplete(fullResponse);
} else if (tokenConsumer != null) { } catch (NoClassDefFoundError e) {
log.warn("tokenConsumer不是TokenConsumerWithCompletion实例"); log.error("TokenConsumerWithCompletion依赖类未找到,跳过完成回调: {}", e.getMessage());
tokenConsumer.accept(""); // 如果类未找到,至少发送一个空消息以确保流的完整性
} if (tokenConsumer != null) {
}
/**
* 分析token内容,识别工具调用和结果
* 这个方法通过分析响应中的特殊标记来识别工具调用
*
* @param token 当前token
* @param fullResponse 完整响应
*/
private void analyzeAndRecordToolEvents(String token, String fullResponse) {
if (!isValidToken(token) || workPanelCollector == null) {
return;
}
try { try {
// 检查工具调用的标记 tokenConsumer.accept("");
// 通常格式为: "Tool: [工具名称]" 或 "Calling [工具名称]" 或类似的模式 } catch (Exception ex) {
if (isToolCall(token)) { log.error("发送空消息也失败", ex);
String toolName = extractToolName(token);
if (isValidToolName(toolName)) {
// 记录工具调用开始
workPanelCollector.recordToolCallAction(toolName, extractToolArgs(token), null, "pending", null);
}
}
// 检查工具结果的标记
else if (isToolResult(token)) {
String toolName = extractToolName(fullResponse);
String result = extractToolResult(token);
if (isValidToolName(toolName)) {
// 记录工具调用完成
workPanelCollector.recordToolCallAction(toolName, extractToolArgs(token), result, "success", null);
}
}
// 检查错误标记
else if (isError(token)) {
String toolName = extractToolName(fullResponse);
if (isValidToolName(toolName)) {
// 记录工具调用错误
workPanelCollector.recordToolCallAction(toolName, extractToolArgs(token), null, "error", null);
} }
} }
} catch (Exception e) { } catch (Exception e) {
log.debug("分析工具调用事件时发生错误: {}", e.getMessage()); log.error("调用onComplete时发生错误", e);
}
}
/**
* 检查是否为工具调用
* @param token token字符串
* @return 是否为工具调用
*/
private boolean isToolCall(String token) {
return token != null && (token.contains("Tool:") || token.contains("Calling") ||
token.contains("tool:") || token.contains("calling"));
}
/**
* 检查是否为工具结果
* @param token token字符串
* @return 是否为工具结果
*/
private boolean isToolResult(String token) {
return token != null && (token.contains("Result:") || token.contains("result:") ||
token.contains("Output:") || token.contains("output:"));
}
/**
* 检查是否为错误信息
* @param token token字符串
* @return 是否为错误信息
*/
private boolean isError(String token) {
return token != null && (token.contains("Error:") || token.contains("error:") ||
token.contains("Exception:") || token.contains("exception:"));
} }
} else if (tokenConsumer != null) {
/** tokenConsumer.accept("");
* 检查工具名称是否有效
* @param toolName 工具名称
* @return 是否有效
*/
private boolean isValidToolName(String toolName) {
return toolName != null && !toolName.isEmpty();
}
/**
* 从文本中提取工具名称
*
* @param text 输入文本
* @return 工具名称,如果未找到则返回null
*/
private String extractToolName(String text) {
if (text == null) return null;
// 尝试从常见的工具调用格式中提取工具名称
String[] patterns = {
"Tool: (\\w+)",
"tool: (\\w+)",
"Calling (\\w+)",
"calling (\\w+)",
"Use (\\w+)",
"use (\\w+)",
"Tool\\((\\w+)\\)",
"tool\\((\\w+)\\)"
};
for (String pattern : patterns) {
java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern);
java.util.regex.Matcher m = p.matcher(text);
if (m.find()) {
return m.group(1);
}
}
return null;
}
/**
* 从文本中提取工具参数
*
* @param text 输入文本
* @return 工具参数,如果未找到则返回null
*/
private Object extractToolArgs(String text) {
if (text == null) return null;
// 简单的参数提取逻辑
// 可以根据具体的工具调用格式进行更复杂的解析
java.util.Map<String, Object> args = new java.util.HashMap<>();
// 尝试提取括号内的内容作为参数
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\(([^)]*)\\)");
java.util.regex.Matcher matcher = pattern.matcher(text);
if (matcher.find()) {
args.put("params", matcher.group(1));
return args;
} }
return args.isEmpty() ? null : args;
} }
/** /**
* 从token中提取工具结果 * 获取智能体工具
* *
* @param token 输入token * @param agent 智能体对象
* @return 工具结果 * @return 智能体可用的工具列表
*/
private String extractToolResult(String token) {
if (token == null) return "";
// 简单的结果提取逻辑
// 提取冒号后的内容
int colonIndex = token.lastIndexOf(':');
if (colonIndex >= 0 && colonIndex < token.length() - 1) {
return token.substring(colonIndex + 1).trim();
}
return token.trim();
}
/**
* 触发思考步骤
* @param content 思考内容
*/
private void triggerThinkStep(String content) {
int stepNumber = stepCounter.incrementAndGet();
ReactStep reactStep = new ReactStep(stepNumber, ReactStepType.THOUGHT, content);
notifyCallbacks(reactStep);
}
/**
* 触发行动步骤
* @param toolName 工具名称
* @param toolAction 工具行动
* @param toolArgs 工具参数
*/
private void triggerActionStep(String toolName, String toolAction, Object toolArgs) {
int stepNumber = stepCounter.incrementAndGet();
ReactStep reactStep = new ReactStep(stepNumber, ReactStepType.ACTION, "执行工具: " + toolName);
ReactStep.ToolCallAction toolActionObj = new ReactStep.ToolCallAction(toolName, toolArgs);
reactStep.setAction(toolActionObj);
notifyCallbacks(reactStep);
}
/**
* 触发观察步骤
* @param observation 观察内容
*/
private void triggerObservationStep(String observation) {
int stepNumber = stepCounter.incrementAndGet();
ReactStep reactStep = new ReactStep(stepNumber, ReactStepType.OBSERVATION, observation);
ReactStep.ToolObservation toolObservation = new ReactStep.ToolObservation(observation);
reactStep.setObservation(toolObservation);
notifyCallbacks(reactStep);
}
/**
* 触发最终答案步骤
* @param finalAnswer 最终答案
*/
private void triggerFinalAnswerStep(String finalAnswer) {
int stepNumber = stepCounter.incrementAndGet();
ReactStep reactStep = new ReactStep(stepNumber, ReactStepType.FINAL_ANSWER, finalAnswer);
notifyCallbacks(reactStep);
}
/**
* 通知所有回调
* @param reactStep ReAct步骤
*/
private void notifyCallbacks(ReactStep reactStep) {
for (ReactCallback callback : reactCallbacks) {
try {
callback.onStep(reactStep);
} catch (Exception e) {
log.error("执行ReAct回调时发生错误", e);
}
}
}
/**
* 获取Agent关联的工具实例
* @param agent Agent对象
* @return 工具实例列表
*/ */
private List<Object> getAgentTools(Agent agent) { private List<Object> getAgentTools(Agent agent) {
if (agent == null) { if (agent == null) {
log.debug("Agent为空,返回空工具列表"); List<Object> defaultTools = new ArrayList<>();
return new ArrayList<>(); defaultTools.add(dateTimeTools);
return defaultTools;
} }
try { try {
List<Object> tools = agentToolManager.getAvailableToolInstances(agent); List<Object> tools = agentToolManager.getAvailableToolInstances(agent);
if (dateTimeTools != null && !tools.contains(dateTimeTools)) {
tools.add(dateTimeTools); tools.add(dateTimeTools);
log.debug("获取到Agent '{}' 的工具实例数量: {}", agent.getName(), tools.size()); }
return tools; return tools;
} catch (Exception e) { } catch (Exception e) {
log.error("获取Agent工具实例时发生错误", e); log.error("获取工具实例时发生错误: {}", e.getMessage());
return new ArrayList<>(); List<Object> fallbackTools = new ArrayList<>();
fallbackTools.add(dateTimeTools);
return fallbackTools;
} }
} }
} }
\ No newline at end of file
...@@ -15,21 +15,21 @@ public interface ReactExecutor { ...@@ -15,21 +15,21 @@ public interface ReactExecutor {
* @param chatClient ChatClient实例 * @param chatClient ChatClient实例
* @param userInput 用户输入 * @param userInput 用户输入
* @param tools 工具列表 * @param tools 工具列表
* @param agent Agent对象
* @return 最终答案 * @return 最终答案
*/ */
String execute(ChatClient chatClient, String userInput, List<Object> tools); String execute(ChatClient chatClient, String userInput, List<Object> tools, Agent agent);
/** /**
* 执行ReAct流程(同步方式)- 支持Agent配置 * 执行ReAct流程(同步方式)
* @param chatClient ChatClient实例 * @param chatClient ChatClient实例
* @param userInput 用户输入 * @param userInput 用户输入
* @param tools 工具列表 * @param tools 工具列表
* @param agent Agent对象(可选) * @param agent Agent对象
* @param userId 用户ID
* @return 最终答案 * @return 最终答案
*/ */
default String executeWithAgent(ChatClient chatClient, String userInput, List<Object> tools, Agent agent) { String execute(ChatClient chatClient, String userInput, List<Object> tools, Agent agent, String userId);
return execute(chatClient, userInput, tools);
}
/** /**
* 流式执行ReAct流程 * 流式执行ReAct流程
...@@ -37,20 +37,20 @@ public interface ReactExecutor { ...@@ -37,20 +37,20 @@ public interface ReactExecutor {
* @param userInput 用户输入 * @param userInput 用户输入
* @param tools 工具列表 * @param tools 工具列表
* @param tokenConsumer token处理回调函数 * @param tokenConsumer token处理回调函数
* @param agent Agent对象
* @param userId 用户ID
*/ */
void executeStream(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer); void executeStream(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer, Agent agent, String userId);
/** /**
* 流式执行ReAct流程 - 支持Agent配置 * 流式执行ReAct流程(旧方法,保持向后兼容)
* @param chatClient ChatClient实例 * @param chatClient ChatClient实例
* @param userInput 用户输入 * @param userInput 用户输入
* @param tools 工具列表 * @param tools 工具列表
* @param tokenConsumer token处理回调函数 * @param tokenConsumer token处理回调函数
* @param agent Agent对象(可选) * @param agent Agent对象
*/ */
default void executeStreamWithAgent(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer, Agent agent) { void executeStream(ChatClient chatClient, String userInput, List<Object> tools, Consumer<String> tokenConsumer, Agent agent);
executeStream(chatClient, userInput, tools, tokenConsumer);
}
/** /**
* 添加ReAct回调 * 添加ReAct回调
......
package pangea.hiagent.agent.react; package pangea.hiagent.agent.react;
import lombok.Data;
/** /**
* ReAct步骤对象,包含步骤的所有核心信息 * ReAct步骤类,用于表示ReAct执行过程中的单个步骤
*/ */
@Data
public class ReactStep { public class ReactStep {
/**
* 步骤编号
*/
private int stepNumber; private int stepNumber;
/**
* 步骤类型
*/
private ReactStepType stepType; private ReactStepType stepType;
/**
* 步骤核心内容(思维描述、动作指令、观察结果等)
*/
private String content; private String content;
/**
* 工具调用信息(仅在ACTION步骤时有值)
*/
private ToolCallAction action; private ToolCallAction action;
/**
* 工具观察结果(仅在OBSERVATION步骤时有值)
*/
private ToolObservation observation; private ToolObservation observation;
/**
* 构造函数
*/
public ReactStep() {}
/**
* 构造函数
* @param stepNumber 步骤编号
* @param stepType 步骤类型
* @param content 步骤内容
*/
public ReactStep(int stepNumber, ReactStepType stepType, String content) { public ReactStep(int stepNumber, ReactStepType stepType, String content) {
this.stepNumber = stepNumber; this.stepNumber = stepNumber;
this.stepType = stepType; this.stepType = stepType;
this.content = content; this.content = content;
} }
// Getters and Setters
public int getStepNumber() { return stepNumber; }
public void setStepNumber(int stepNumber) { this.stepNumber = stepNumber; }
public ReactStepType getStepType() { return stepType; }
public void setStepType(ReactStepType stepType) { this.stepType = stepType; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public ToolCallAction getAction() { return action; }
public void setAction(ToolCallAction action) { this.action = action; }
public ToolObservation getObservation() { return observation; }
public void setObservation(ToolObservation observation) { this.observation = observation; }
/** /**
* 工具调用动作类 * 工具调用动作内部
*/ */
@Data
public static class ToolCallAction { public static class ToolCallAction {
/**
* 工具名称
*/
private String toolName; private String toolName;
private Object toolArgs;
/** public ToolCallAction(String toolName, Object toolArgs) {
* 工具调用参数
*/
private Object parameters;
public ToolCallAction() {}
public ToolCallAction(String toolName, Object parameters) {
this.toolName = toolName; this.toolName = toolName;
this.parameters = parameters; this.toolArgs = toolArgs;
} }
public String getToolName() { return toolName; }
public void setToolName(String toolName) { this.toolName = toolName; }
public Object getToolArgs() { return toolArgs; }
public void setToolArgs(Object toolArgs) { this.toolArgs = toolArgs; }
// 根据DefaultReactCallback.java中的使用情况添加getParameters方法
public Object getParameters() { return toolArgs; }
} }
/** /**
* 工具观察结果类 * 工具观察结果内部
*/ */
@Data
public static class ToolObservation { public static class ToolObservation {
/** private String result;
* 观察内容
*/
private String content;
public ToolObservation() {}
public ToolObservation(String content) { public ToolObservation(String result) {
this.content = content; this.result = result;
} }
public String getResult() { return result; }
public void setResult(String result) { this.result = result; }
// 根据DefaultReactCallback.java中的使用情况添加getContent方法
public String getContent() { return result; }
} }
} }
\ No newline at end of file
...@@ -2,12 +2,12 @@ package pangea.hiagent.agent.service; ...@@ -2,12 +2,12 @@ package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.agent.processor.AgentProcessor; import pangea.hiagent.agent.processor.AgentProcessor;
import pangea.hiagent.agent.processor.AgentProcessorFactory; import pangea.hiagent.agent.processor.AgentProcessorFactory;
import pangea.hiagent.agent.sse.UserSseService;
import pangea.hiagent.common.utils.UserUtils; import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.web.dto.ChatRequest; import pangea.hiagent.web.dto.ChatRequest;
import pangea.hiagent.model.Agent; import pangea.hiagent.model.Agent;
...@@ -16,12 +16,6 @@ import pangea.hiagent.web.dto.AgentRequest; ...@@ -16,12 +16,6 @@ import pangea.hiagent.web.dto.AgentRequest;
import pangea.hiagent.workpanel.event.EventService; import pangea.hiagent.workpanel.event.EventService;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/** /**
* Agent 对话服务 * Agent 对话服务
* 职责:协调整个AI对话流程,作为流式处理的统一入口和协调者 * 职责:协调整个AI对话流程,作为流式处理的统一入口和协调者
...@@ -30,39 +24,29 @@ import java.util.concurrent.TimeUnit; ...@@ -30,39 +24,29 @@ import java.util.concurrent.TimeUnit;
@Service @Service
public class AgentChatService { public class AgentChatService {
private final ChatErrorHandler chatErrorHandler; private final ErrorHandlerService errorHandlerService;
private final AgentValidationService agentValidationService;
private final AgentProcessorFactory agentProcessorFactory; private final AgentProcessorFactory agentProcessorFactory;
private final StreamRequestService streamRequestService;
private final AgentToolManager agentToolManager; private final AgentToolManager agentToolManager;
private final UserSseService workPanelSseService; private final UserSseService userSseSerivce;
private final pangea.hiagent.web.service.AgentService agentService;
private final SseTokenEmitter sseTokenEmitter;
public AgentChatService( public AgentChatService(
EventService eventService, EventService eventService,
ChatErrorHandler chatErrorHandler, ErrorHandlerService errorHandlerService,
AgentValidationService agentValidationService,
AgentProcessorFactory agentProcessorFactory, AgentProcessorFactory agentProcessorFactory,
StreamRequestService streamRequestService,
AgentToolManager agentToolManager, AgentToolManager agentToolManager,
UserSseService workPanelSseService) { UserSseService workPanelSseService,
this.chatErrorHandler = chatErrorHandler; pangea.hiagent.web.service.AgentService agentService,
this.agentValidationService = agentValidationService; SseTokenEmitter sseTokenEmitter) {
this.errorHandlerService = errorHandlerService;
this.agentProcessorFactory = agentProcessorFactory; this.agentProcessorFactory = agentProcessorFactory;
this.streamRequestService = streamRequestService;
this.agentToolManager = agentToolManager; this.agentToolManager = agentToolManager;
this.workPanelSseService = workPanelSseService; this.userSseSerivce = workPanelSseService;
this.agentService = agentService;
this.sseTokenEmitter = sseTokenEmitter;
} }
// 专用线程池配置 - 使用静态变量确保线程池在整个应用中是单例的
private static final ExecutorService executorService = new ThreadPoolExecutor(
20,
200,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
// /** // /**
// * 处理同步对话请求的统一入口 // * 处理同步对话请求的统一入口
// * @param agent Agent对象 // * @param agent Agent对象
...@@ -110,77 +94,172 @@ public class AgentChatService { ...@@ -110,77 +94,172 @@ public class AgentChatService {
if (userId == null) { if (userId == null) {
log.error("用户未认证"); log.error("用户未认证");
SseEmitter emitter = workPanelSseService.createEmitter(); SseEmitter emitter = userSseSerivce.createEmitter();
// 检查响应是否已经提交 // 检查响应是否已经提交
if (!response.isCommitted()) { if (!response.isCommitted()) {
chatErrorHandler.handleChatError(emitter, "用户未认证,请重新登录"); errorHandlerService.handleChatError(emitter, "用户未认证,请重新登录");
} else { } else {
log.warn("响应已提交,无法发送用户未认证错误信息"); log.warn("响应已提交,无法发送用户未认证错误信息");
// 检查emitter是否已经完成,避免重复关闭
if (!userSseSerivce.isEmitterCompleted(emitter)) {
emitter.complete(); emitter.complete();
} }
}
return emitter;
}
// 验证Agent是否存在
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
log.warn("Agent不存在: {}", agentId);
SseEmitter emitter = userSseSerivce.createEmitter();
// 检查响应是否已经提交
if (!response.isCommitted()) {
errorHandlerService.handleChatError(emitter, "Agent不存在");
} else {
log.warn("响应已提交,无法发送Agent不存在错误信息");
// 检查emitter是否已经完成,避免重复关闭
if (!userSseSerivce.isEmitterCompleted(emitter)) {
emitter.complete();
}
}
return emitter; return emitter;
} }
// 创建 SSE emitter // 创建 SSE emitter
SseEmitter emitter = workPanelSseService.createEmitter(); SseEmitter emitter = userSseSerivce.createEmitter();
String emitterId = UUID.randomUUID().toString(); // 异步处理对话,避免阻塞HTTP连接
log.info("emitterId: {}", emitterId); processChatStreamAsync(emitter, agent, chatRequest, userId);
workPanelSseService.registerEmitter(emitterId, emitter);
// 将userId设为final以在Lambda表达式中使用 return emitter;
final String finalUserId = userId; }
// 异步处理对话,避免阻塞HTTP连接 /**
executorService.execute(() -> { * 异步处理流式对话
*/
@Async
private void processChatStreamAsync(SseEmitter emitter, Agent agent, ChatRequest chatRequest, String userId) {
try { try {
processChatRequest(emitter, agentId, chatRequest, finalUserId,emitterId); processChatRequest(emitter, agent, chatRequest, userId);
} catch (Exception e) { } catch (Exception e) {
log.error("处理聊天请求时发生异常", e); log.error("处理聊天请求时发生异常", e);
try {
// 检查响应是否已经提交 // 检查响应是否已经提交
if (emitter != null) { if (emitter != null && !userSseSerivce.isEmitterCompleted(emitter)) {
chatErrorHandler.handleChatError(emitter, "处理请求时发生错误", e, null); errorHandlerService.handleChatError(emitter, "处理请求时发生错误", e, null);
} else { } else {
log.warn("响应已提交,无法发送处理请求错误信息"); log.warn("响应已提交或emitter已完成,无法发送处理请求错误信息");
}
} catch (Exception handlerException) {
log.error("处理错误信息时发生异常", handlerException);
} }
} }
});
return emitter;
} }
/** /**
* 处理聊天请求的核心逻辑 * 处理聊天请求的核心逻辑
* 注意:权限验证已在主线程中完成,此正仅执行业务逻辑不进行权限检查
* *
* @param emitter SSE发射器 * @param emitter SSE发射器
* @param agentId Agent ID * @param agent Agent对象
* @param chatRequest 聊天请求 * @param chatRequest 聊天请求
* @param userId 用户ID * @param userId 用户ID
*/ */
private void processChatRequest(SseEmitter emitter, String agentId, ChatRequest chatRequest, String userId,String emitterId) { private void processChatRequest(SseEmitter emitter, Agent agent, ChatRequest chatRequest, String userId) {
try { try {
// 获取Agent信息并进行权限检查 // 参数验证
Agent agent = agentValidationService.validateAgentAndPermission(agentId, userId, emitter); if (!validateParameters(emitter, agent, chatRequest, userId)) {
if (agent == null) { return;
return; // 权限验证失败,直接返回
} }
// 获取处理器并启动心跳保活机制 // 获取处理器
AgentProcessor processor = agentProcessorFactory.getProcessor(agent); AgentProcessor processor = agentProcessorFactory.getProcessor(agent);
if (processor == null) { if (processor == null) {
return; // 获取处理器失败,直接返回 log.error("无法获取Agent处理器,Agent: {}", agent.getId());
errorHandlerService.handleChatError(emitter, "无法获取Agent处理器");
return;
} }
// 启动心跳机制
workPanelSseService.startHeartbeat(emitter, new java.util.concurrent.atomic.AtomicBoolean(false));
// 转换请求对象 // 转换请求对象
AgentRequest request = chatRequest.toAgentRequest(agentId, agent, agentToolManager); AgentRequest request = chatRequest.toAgentRequest(agent.getId(), agent, agentToolManager);
// 设置SSE发射器到token发射器
sseTokenEmitter.setEmitter(emitter);
// 设置上下文信息
sseTokenEmitter.setContext(agent, request, userId);
// 设置完成回调
sseTokenEmitter.setCompletionCallback(this::handleCompletion);
// 处理流式请求 // 处理流式请求
streamRequestService.handleStreamRequest(emitter, processor, request, agent, userId,emitterId); processor.processStreamRequest(request, agent, userId, sseTokenEmitter);
} catch (Exception e) { } catch (Exception e) {
chatErrorHandler.handleChatError(emitter, "处理请求时发生错误", e, null); log.error("处理聊天请求时发生异常", e);
errorHandlerService.handleChatError(emitter, "处理请求时发生错误", e, null);
} }
} }
/**
* 处理完成回调
*
* @param emitter SSE发射器
* @param agent Agent对象
* @param request Agent请求
* @param userId 用户ID
* @param fullContent 完整内容
*/
private void handleCompletion(SseEmitter emitter, Agent agent, AgentRequest request, String userId, String fullContent) {
log.info("Agent处理完成,总字符数: {}", fullContent != null ? fullContent.length() : 0);
// 保存对话记录
try {
saveDialogue(agent, request, userId, fullContent);
log.info("对话记录保存成功");
} catch (Exception e) {
log.error("保存对话记录失败", e);
// 记录异常但不中断流程
}
}
/**
* 保存对话记录
*/
private void saveDialogue(Agent agent, AgentRequest request, String userId, String responseContent) {
// 参数验证
if (agent == null || request == null || userId == null || userId.trim().isEmpty()) {
log.error("保存对话记录失败:参数无效");
return;
}
try {
// 创建对话记录
pangea.hiagent.model.AgentDialogue dialogue = pangea.hiagent.model.AgentDialogue.builder()
.agentId(request.getAgentId())
.userMessage(request.getUserMessage())
.agentResponse(responseContent)
.userId(userId)
.build();
// 保存对话记录
agentService.saveDialogue(dialogue);
} catch (Exception e) {
log.error("保存对话记录失败", e);
throw new RuntimeException("保存对话记录失败", e);
}
}
/**
* 验证所有必需参数
*
* @param emitter SSE发射器
* @param agent Agent对象
* @param chatRequest 聊天请求
* @param userId 用户ID
* @return 验证是否通过
*/
private boolean validateParameters(SseEmitter emitter, Agent agent, ChatRequest chatRequest, String userId) {
return emitter != null && chatRequest != null && agent != null && userId != null && !userId.isEmpty();
}
} }
\ No newline at end of file
package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.function.Consumer;
/**
* Agent错误处理工具类
* 统一处理Agent处理器中的错误逻辑
*/
@Slf4j
@Component
public class AgentErrorHandler {
@Autowired
private ErrorHandlerService errorHandlerService;
/**
* 处理401未授权错误
*
* @param e 异常对象
* @return 是否为401错误
*/
public boolean isUnauthorizedError(Throwable e) {
return errorHandlerService.isUnauthorizedError(new Exception(e));
}
/**
* 处理流式处理中的错误
*
* @param e 异常对象
* @param tokenConsumer token处理回调函数
* @param errorMessagePrefix 错误消息前缀
*/
public void handleStreamError(Throwable e, Consumer<String> tokenConsumer, String errorMessagePrefix) {
errorHandlerService.handleStreamError(e, tokenConsumer, errorMessagePrefix);
}
/**
* 处理同步处理中的错误
*
* @param e 异常对象
* @param errorMessagePrefix 错误消息前缀
* @return 错误消息
*/
public String handleSyncError(Throwable e, String errorMessagePrefix) {
// 检查是否是401 Unauthorized错误
if (isUnauthorizedError(e)) {
log.error("LLM返回401未授权错误: {}", e.getMessage());
return "请配置API密钥";
} else {
String errorMessage = e.getMessage();
if (errorMessage == null || errorMessage.isEmpty()) {
errorMessage = "未知错误";
}
return errorMessagePrefix + ": " + errorMessage;
}
}
/**
* 发送错误信息给客户端
*
* @param tokenConsumer token处理回调函数
* @param errorMessage 错误消息
*/
public void sendErrorMessage(Consumer<String> tokenConsumer, String errorMessage) {
errorHandlerService.sendErrorMessage(tokenConsumer, errorMessage);
}
/**
* 确保在异常情况下也调用完成回调
*
* @param tokenConsumer token处理回调函数
* @param errorMessage 错误消息
*/
public void ensureCompletionCallback(Consumer<String> tokenConsumer, String errorMessage) {
if (tokenConsumer instanceof TokenConsumerWithCompletion) {
try {
((TokenConsumerWithCompletion) tokenConsumer).onComplete(errorMessage);
} catch (Exception ex) {
log.error("调用onComplete时发生错误: {}", ex.getMessage(), ex);
}
}
}
}
\ No newline at end of file
package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.model.Agent;
import pangea.hiagent.agent.processor.AgentProcessor;
import pangea.hiagent.agent.processor.AgentProcessorFactory;
import pangea.hiagent.agent.sse.UserSseService;
import pangea.hiagent.common.utils.LogUtils;
import pangea.hiagent.common.utils.ValidationUtils;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Agent处理器服务
* 负责处理Agent处理器的获取和心跳机制
*/
@Slf4j
@Service
public class AgentProcessorService {
@Autowired
private AgentProcessorFactory agentProcessorFactory;
@Autowired
private UserSseService workPanelSseService;
@Autowired
private ChatErrorHandler chatErrorHandler;
/**
* 获取处理器并启动心跳保活机制
*
* @param agent Agent对象
* @param emitter SSE发射器
* @return Agent处理器,如果获取失败则返回null
*/
public AgentProcessor getProcessorAndStartHeartbeat(Agent agent, SseEmitter emitter) {
LogUtils.enterMethod("getProcessorAndStartHeartbeat", agent);
// 参数验证
if (ValidationUtils.isNull(agent, "agent")) {
chatErrorHandler.handleChatError(emitter, "Agent对象不能为空");
LogUtils.exitMethod("getProcessorAndStartHeartbeat", "Agent对象不能为空");
return null;
}
if (ValidationUtils.isNull(emitter, "emitter")) {
chatErrorHandler.handleChatError(emitter, "SSE发射器不能为空");
LogUtils.exitMethod("getProcessorAndStartHeartbeat", "SSE发射器不能为空");
return null;
}
try {
// 根据Agent类型选择处理器并处理请求
AgentProcessor processor = agentProcessorFactory.getProcessor(agent);
if (processor == null) {
chatErrorHandler.handleChatError(emitter, "无法获取Agent处理器");
LogUtils.exitMethod("getProcessorAndStartHeartbeat", "无法获取Agent处理器");
return null;
}
log.info("使用{} Agent处理器处理对话", processor.getProcessorType());
// 启动心跳保活机制
workPanelSseService.startHeartbeat(emitter, new AtomicBoolean(false));
LogUtils.exitMethod("getProcessorAndStartHeartbeat", processor);
return processor;
} catch (Exception e) {
chatErrorHandler.handleChatError(emitter, "获取处理器或启动心跳时发生错误", e, null);
LogUtils.exitMethod("getProcessorAndStartHeartbeat", e);
return null;
}
}
}
\ No newline at end of file
package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.model.Agent;
import pangea.hiagent.web.service.AgentService;
import pangea.hiagent.common.utils.LogUtils;
import pangea.hiagent.common.utils.ValidationUtils;
import pangea.hiagent.common.utils.UserUtils;
/**
* Agent验证服务
* 负责处理Agent的参数验证和权限检查
*/
@Slf4j
@Service
public class AgentValidationService {
@Autowired
private AgentService agentService;
@Autowired
private ChatErrorHandler chatErrorHandler;
/**
* 验证Agent存在性和用户权限
*
* @param agentId Agent ID
* @param userId 用户ID
* @param emitter SSE发射器
* @return Agent对象,如果验证失败则返回null
*/
public Agent validateAgentAndPermission(String agentId, String userId, SseEmitter emitter) {
LogUtils.enterMethod("validateAgentAndPermission", agentId, userId);
// 参数验证
if (ValidationUtils.isBlank(agentId, "agentId")) {
chatErrorHandler.handleChatError(emitter, "Agent ID不能为空");
LogUtils.exitMethod("validateAgentAndPermission", "Agent ID不能为空");
return null;
}
if (ValidationUtils.isBlank(userId, "userId")) {
chatErrorHandler.handleChatError(emitter, "用户ID不能为空");
LogUtils.exitMethod("validateAgentAndPermission", "用户ID不能为空");
return null;
}
try {
// 获取Agent信息
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
chatErrorHandler.handleChatError(emitter, "Agent不存在");
LogUtils.exitMethod("validateAgentAndPermission", "Agent不存在");
return null;
}
// 检查权限(可选)
if (!agent.getOwner().equals(userId) && !UserUtils.isAdminUser(userId)) {
chatErrorHandler.handleChatError(emitter, "无权限访问该Agent");
LogUtils.exitMethod("validateAgentAndPermission", "无权限访问该Agent");
return null;
}
LogUtils.exitMethod("validateAgentAndPermission", agent);
return agent;
} catch (Exception e) {
chatErrorHandler.handleChatError(emitter, "验证Agent和权限时发生错误", e, null);
LogUtils.exitMethod("validateAgentAndPermission", e);
return null;
}
}
}
\ No newline at end of file
package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 聊天服务错误处理工具类
* 统一处理聊天过程中的各种异常情况
* 委托给ErrorHandlerService进行实际处理
*/
@Slf4j
@Component
public class ChatErrorHandler {
@Autowired
private ErrorHandlerService unifiedErrorHandlerService;
/**
* 处理聊天过程中的异常
*
* @param emitter SSE发射器
* @param errorMessage 错误信息
* @param exception 异常对象
* @param processorType 处理器类型(可选)
*/
public void handleChatError(SseEmitter emitter, String errorMessage, Exception exception, String processorType) {
unifiedErrorHandlerService.handleChatError(emitter, errorMessage, exception, processorType);
}
/**
* 处理聊天过程中的异常()
*
* @param emitter SSE发射器
* @param errorMessage 错误信息
*/
public void handleChatError(SseEmitter emitter, String errorMessage) {
unifiedErrorHandlerService.handleChatError(emitter, errorMessage);
}
/**
* 处理Token处理过程中的异常
*
* @param emitter SSE发射器
* @param processorType 处理器类型
* @param exception 异常对象
* @param isCompleted 完成状态标记
*/
public void handleTokenError(SseEmitter emitter, String processorType, Exception exception, AtomicBoolean isCompleted) {
unifiedErrorHandlerService.handleTokenError(emitter, processorType, exception, isCompleted);
}
/**
* 处理完成回调过程中的异常
*
* @param emitter SSE发射器
* @param exception 异常对象
*/
public void handleCompletionError(SseEmitter emitter, Exception exception) {
unifiedErrorHandlerService.handleCompletionError(emitter, exception);
}
/**
* 处理对话记录保存过程中的异常
*
* @param emitter SSE发射器
* @param exception 异常对象
* @param isCompleted 完成状态标记
*/
public void handleSaveDialogueError(SseEmitter emitter, Exception exception, AtomicBoolean isCompleted) {
unifiedErrorHandlerService.handleSaveDialogueError(emitter, exception, isCompleted);
}
}
\ No newline at end of file
package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.model.Agent;
import pangea.hiagent.model.AgentDialogue;
import pangea.hiagent.common.utils.ValidationUtils;
import pangea.hiagent.agent.processor.AgentProcessor;
import pangea.hiagent.agent.sse.UserSseService;
import pangea.hiagent.common.utils.LogUtils;
import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.web.dto.AgentRequest;
import pangea.hiagent.web.service.AgentService;
import pangea.hiagent.workpanel.event.EventService;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 完成回调处理服务
* 负责处理流式输出完成后的回调操作
*/
@Slf4j
@Service
public class CompletionHandlerService {
@Autowired
private AgentService agentService;
@Autowired
private UserSseService unifiedSseService;
@Autowired
private EventService eventService;
@Autowired
private ErrorHandlerService errorHandlerService;
/**
* 处理完成回调
*
* @param emitter SSE发射器
* @param processor Agent处理器
* @param agent Agent对象
* @param request Agent请求
* @param userId 用户ID
* @param fullContent 完整内容
* @param isCompleted 完成状态标记
*/
public void handleCompletion(SseEmitter emitter, AgentProcessor processor, Agent agent,
AgentRequest request, String userId,
String fullContent, AtomicBoolean isCompleted) {
LogUtils.enterMethod("handleCompletion", emitter, processor, agent, request, userId);
// 参数验证
if (ValidationUtils.isNull(emitter, "emitter")) {
log.error("SSE发射器不能为空");
LogUtils.exitMethod("handleCompletion", "SSE发射器不能为空");
return;
}
if (ValidationUtils.isNull(processor, "processor")) {
log.error("Agent处理器不能为空");
LogUtils.exitMethod("handleCompletion", "Agent处理器不能为空");
return;
}
if (ValidationUtils.isNull(agent, "agent")) {
log.error("Agent对象不能为空");
LogUtils.exitMethod("handleCompletion", "Agent对象不能为空");
return;
}
if (ValidationUtils.isNull(request, "request")) {
log.error("Agent请求不能为空");
LogUtils.exitMethod("handleCompletion", "Agent请求不能为空");
return;
}
if (ValidationUtils.isBlank(userId, "userId")) {
log.error("用户ID不能为空");
LogUtils.exitMethod("handleCompletion", "用户ID不能为空");
return;
}
if (ValidationUtils.isNull(isCompleted, "isCompleted")) {
log.error("完成状态标记不能为空");
LogUtils.exitMethod("handleCompletion", "完成状态标记不能为空");
return;
}
log.info("{} Agent处理完成,总字符数: {}", processor.getProcessorType(), fullContent != null ? fullContent.length() : 0);
// 发送完成事件
try {
// 发送完整内容作为最后一个token
if (fullContent != null && !fullContent.isEmpty()) {
eventService.sendTokenEvent(emitter, fullContent);
}
// 发送完成信号
emitter.send("[DONE]");
} catch (Exception e) {
errorHandlerService.handleCompletionError(emitter, e);
}
// 保存对话记录
try {
saveDialogue(agent, request, userId, fullContent);
} catch (Exception e) {
errorHandlerService.handleSaveDialogueError(emitter, e, isCompleted);
} finally {
unifiedSseService.completeEmitter(emitter, isCompleted);
}
LogUtils.exitMethod("handleCompletion", "处理完成");
}
/**
* 保存对话记录
*/
public void saveDialogue(Agent agent, AgentRequest request, String userId, String responseContent) {
LogUtils.enterMethod("saveDialogue", agent, request, userId);
// 参数验证
if (ValidationUtils.isNull(agent, "agent")) {
log.error("Agent对象不能为空");
LogUtils.exitMethod("saveDialogue", "Agent对象不能为空");
return;
}
if (ValidationUtils.isNull(request, "request")) {
log.error("Agent请求不能为空");
LogUtils.exitMethod("saveDialogue", "Agent请求不能为空");
return;
}
if (ValidationUtils.isBlank(userId, "userId")) {
log.error("用户ID不能为空");
LogUtils.exitMethod("saveDialogue", "用户ID不能为空");
return;
}
try {
// 创建对话记录
AgentDialogue dialogue = AgentDialogue.builder()
.agentId(request.getAgentId())
.userMessage(request.getUserMessage())
.agentResponse(responseContent)
.userId(userId)
.build();
// 确保ID被设置
if (dialogue.getId() == null || dialogue.getId().isEmpty()) {
dialogue.setId(java.util.UUID.randomUUID().toString());
}
// 设置创建人和更新人信息
// 在异步线程中获取用户ID
String currentUserId = UserUtils.getCurrentUserIdInAsync();
if (currentUserId == null) {
currentUserId = userId; // 回退到传入的userId
}
dialogue.setCreatedBy(currentUserId);
dialogue.setUpdatedBy(currentUserId);
// 保存对话记录
agentService.saveDialogue(dialogue);
LogUtils.exitMethod("saveDialogue", "保存成功");
} catch (Exception e) {
log.error("保存对话记录失败", e);
LogUtils.exitMethod("saveDialogue", e);
throw new RuntimeException("保存对话记录失败", e);
}
}
}
\ No newline at end of file
...@@ -4,7 +4,7 @@ import lombok.extern.slf4j.Slf4j; ...@@ -4,7 +4,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.workpanel.event.EventService;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer; import java.util.function.Consumer;
...@@ -17,10 +17,10 @@ import java.util.function.Consumer; ...@@ -17,10 +17,10 @@ import java.util.function.Consumer;
public class ErrorHandlerService { public class ErrorHandlerService {
@Autowired @Autowired
private EventService eventService; private ExceptionMonitoringService exceptionMonitoringService;
@Autowired @Autowired
private ExceptionMonitoringService exceptionMonitoringService; private UserSseService userSseService;
/** /**
* 生成错误跟踪ID * 生成错误跟踪ID
...@@ -129,8 +129,13 @@ public class ErrorHandlerService { ...@@ -129,8 +129,13 @@ public class ErrorHandlerService {
errorMessage, exception); errorMessage, exception);
try { try {
// 检查emitter是否已经完成,避免向已完成的连接发送错误信息
if (userSseService != null && !userSseService.isEmitterCompleted(emitter)) {
String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, processorType); String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, processorType);
eventService.sendErrorEvent(emitter, fullErrorMessage); userSseService.sendErrorEvent(emitter, fullErrorMessage);
} else {
log.debug("[{}] SSE emitter已完成,跳过发送错误信息", errorId);
}
} catch (Exception sendErrorEx) { } catch (Exception sendErrorEx) {
log.error("[{}] 发送错误信息失败", errorId, sendErrorEx); log.error("[{}] 发送错误信息失败", errorId, sendErrorEx);
} }
...@@ -154,8 +159,13 @@ public class ErrorHandlerService { ...@@ -154,8 +159,13 @@ public class ErrorHandlerService {
log.error("[{}] 处理聊天请求时发生错误: {}", errorId, errorMessage); log.error("[{}] 处理聊天请求时发生错误: {}", errorId, errorMessage);
try { try {
// 检查emitter是否已经完成,避免向已完成的连接发送错误信息
if (userSseService != null && !userSseService.isEmitterCompleted(emitter)) {
String fullErrorMessage = buildFullErrorMessage(errorMessage, null, errorId, null); String fullErrorMessage = buildFullErrorMessage(errorMessage, null, errorId, null);
eventService.sendErrorEvent(emitter, fullErrorMessage); userSseService.sendErrorEvent(emitter, fullErrorMessage);
} else {
log.debug("[{}] SSE emitter已完成,跳过发送错误信息", errorId);
}
} catch (Exception sendErrorEx) { } catch (Exception sendErrorEx) {
log.error("[{}] 发送错误信息失败", errorId, sendErrorEx); log.error("[{}] 发送错误信息失败", errorId, sendErrorEx);
} }
...@@ -190,9 +200,14 @@ public class ErrorHandlerService { ...@@ -190,9 +200,14 @@ public class ErrorHandlerService {
log.error("[{}] {}处理token时发生错误", errorId, processorType, exception); log.error("[{}] {}处理token时发生错误", errorId, processorType, exception);
if (!isCompleted.getAndSet(true)) { if (!isCompleted.getAndSet(true)) {
try { try {
// 检查emitter是否已经完成,避免向已完成的连接发送错误信息
if (userSseService != null && !userSseService.isEmitterCompleted(emitter)) {
String errorMessage = "处理响应时发生错误"; String errorMessage = "处理响应时发生错误";
String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, processorType); String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, processorType);
eventService.sendErrorEvent(emitter, fullErrorMessage); userSseService.sendErrorEvent(emitter, fullErrorMessage);
} else {
log.debug("[{}] SSE emitter已完成,跳过发送错误信息", errorId);
}
} catch (Exception ignored) { } catch (Exception ignored) {
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("[{}] 无法发送错误信息", errorId); log.debug("[{}] 无法发送错误信息", errorId);
...@@ -223,9 +238,14 @@ public class ErrorHandlerService { ...@@ -223,9 +238,14 @@ public class ErrorHandlerService {
log.error("[{}] 发送完成事件失败", errorId, exception); log.error("[{}] 发送完成事件失败", errorId, exception);
try { try {
// 检查emitter是否已经完成,避免向已完成的连接发送错误信息
if (userSseService != null && !userSseService.isEmitterCompleted(emitter)) {
String errorMessage = "发送完成事件失败,请联系技术支持"; String errorMessage = "发送完成事件失败,请联系技术支持";
String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, "完成回调"); String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, "完成回调");
eventService.sendErrorEvent(emitter, fullErrorMessage); userSseService.sendErrorEvent(emitter, fullErrorMessage);
} else {
log.debug("[{}] SSE emitter已完成,跳过发送错误信息", errorId);
}
} catch (Exception sendErrorEx) { } catch (Exception sendErrorEx) {
log.error("[{}] 发送错误信息失败", errorId, sendErrorEx); log.error("[{}] 发送错误信息失败", errorId, sendErrorEx);
} }
...@@ -333,9 +353,14 @@ public class ErrorHandlerService { ...@@ -333,9 +353,14 @@ public class ErrorHandlerService {
if (!isCompleted.getAndSet(true)) { if (!isCompleted.getAndSet(true)) {
try { try {
// 检查emitter是否已经完成,避免向已完成的连接发送错误信息
if (userSseService != null && !userSseService.isEmitterCompleted(emitter)) {
String errorMessage = "保存对话记录失败,请联系技术支持"; String errorMessage = "保存对话记录失败,请联系技术支持";
String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, "对话记录"); String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, "对话记录");
eventService.sendErrorEvent(emitter, fullErrorMessage); userSseService.sendErrorEvent(emitter, fullErrorMessage);
} else {
log.debug("[{}] SSE emitter已完成,跳过发送错误信息", errorId);
}
} catch (Exception sendErrorEx) { } catch (Exception sendErrorEx) {
log.error("[{}] 发送错误信息失败", errorId, sendErrorEx); log.error("[{}] 发送错误信息失败", errorId, sendErrorEx);
} }
......
package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 错误处理工具类
* 提供统一的错误处理方法,减少重复代码
* 委托给ErrorHandlerService进行实际处理
*/
@Slf4j
@Component
public class ErrorHandlerUtils {
private static ErrorHandlerService errorHandlerService;
@Autowired
public ErrorHandlerUtils(ErrorHandlerService errorHandlerService) {
ErrorHandlerUtils.errorHandlerService = errorHandlerService;
}
/**
* 构建完整的错误消息
*
* @param errorMessage 基本错误信息
* @param exception 异常对象
* @param errorId 错误跟踪ID
* @param processorType 处理器类型
* @return 完整的错误消息
*/
public static String buildFullErrorMessage(String errorMessage, Exception exception, String errorId, String processorType) {
return errorHandlerService.buildFullErrorMessage(errorMessage, exception, errorId, processorType);
}
/**
* 检查是否为未授权错误
*
* @param exception 异常对象
* @return 是否为未授权错误
*/
public static boolean isUnauthorizedError(Exception exception) {
return errorHandlerService.isUnauthorizedError(exception);
}
/**
* 检查是否为超时错误
*
* @param exception 异常对象
* @return 是否为超时错误
*/
public static boolean isTimeoutError(Exception exception) {
return errorHandlerService.isTimeoutError(exception);
}
/**
* 生成错误跟踪ID
*
* @return 错误跟踪ID
*/
public static String generateErrorId() {
return errorHandlerService.generateErrorId();
}
}
\ No newline at end of file
package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.model.Agent;
import pangea.hiagent.web.dto.AgentRequest;
/**
* SSE Token发射器
* 专注于将token转换为SSE事件并发送
*/
@Slf4j
@Component
public class SseTokenEmitter implements TokenConsumerWithCompletion {
private final UserSseService userSseService;
// 当前处理的emitter
private SseEmitter emitter;
// 上下文信息
private Agent agent;
private AgentRequest request;
private String userId;
// 完成回调
private CompletionCallback completionCallback;
public SseTokenEmitter(UserSseService userSseService) {
this.userSseService = userSseService;
}
/**
* 设置当前使用的SSE发射器
*/
public void setEmitter(SseEmitter emitter) {
this.emitter = emitter;
}
/**
* 设置上下文信息
*/
public void setContext(Agent agent, AgentRequest request, String userId) {
this.agent = agent;
this.request = request;
this.userId = userId;
}
/**
* 设置完成回调
*/
public void setCompletionCallback(CompletionCallback completionCallback) {
this.completionCallback = completionCallback;
}
@Override
public void accept(String token) {
// 使用JSON格式发送token,确保转义序列被正确处理
try {
if (emitter != null && userSseService.isEmitterValidSafe(emitter)) {
// 检查是否是错误消息(以[错误]或[ERROR]开头)
if (token != null && (token.startsWith("[错误]") || token.startsWith("[ERROR]"))) {
// 发送标准错误事件而不是纯文本
userSseService.sendErrorEvent(emitter, token);
} else {
// 使用SSE标准事件格式发送token,以JSON格式确保转义序列正确处理
userSseService.sendTokenEvent(emitter, token);
}
} else {
log.debug("SSE emitter已无效,跳过发送token");
}
} catch (Exception e) {
log.error("发送token失败", e);
}
}
@Override
public void onComplete(String fullContent) {
try {
if (emitter != null && !userSseService.isEmitterCompleted(emitter)) {
// 发送完成事件
emitter.send(SseEmitter.event().name("done").data("[DONE]").build());
log.debug("完成信号已发送");
}
// 调用完成回调
if (completionCallback != null) {
completionCallback.onComplete(emitter, agent, request, userId, fullContent);
}
} catch (Exception e) {
log.error("处理完成事件失败", e);
} finally {
// 关闭连接
closeEmitter();
}
}
/**
* 安全关闭SSE连接
*/
public void closeEmitter() {
try {
if (emitter != null && !userSseService.isEmitterCompleted(emitter)) {
emitter.complete();
log.debug("SSE连接已关闭");
}
} catch (Exception ex) {
log.error("完成emitter时发生错误", ex);
}
}
/**
* 完成回调接口
*/
@FunctionalInterface
public interface CompletionCallback {
void onComplete(SseEmitter emitter, Agent agent, AgentRequest request, String userId, String fullContent);
}
}
\ No newline at end of file
package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.agent.processor.AgentProcessor;
import pangea.hiagent.agent.sse.UserSseService;
import pangea.hiagent.workpanel.event.EventService;
import pangea.hiagent.model.Agent;
import pangea.hiagent.common.utils.LogUtils;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 流式请求服务
* 负责处理流式请求
*/
@Slf4j
@Service
public class StreamRequestService {
@Autowired
private UserSseService unifiedSseService;
@Autowired
private EventService eventService;
@Autowired
private CompletionHandlerService completionHandlerService;
/**
* 处理流式请求
*
* @param emitter SSE发射器
* @param processor Agent处理器
* @param request Agent请求
* @param agent Agent对象
* @param userId 用户ID
*/
public void handleStreamRequest(SseEmitter emitter, AgentProcessor processor, pangea.hiagent.web.dto.AgentRequest request, Agent agent, String userId,String emitterId) {
LogUtils.enterMethod("handleStreamRequest", emitter, processor, request, agent, userId);
// 参数验证
if (!validateParameters(emitter, processor, request, agent, userId)) {
return;
}
// 创建流式处理的Token消费者
StreamTokenConsumer tokenConsumer = new StreamTokenConsumer(emitter, processor, unifiedSseService, eventService, completionHandlerService);
// 设置上下文信息,用于保存对话记录
tokenConsumer.setContext(agent, request, userId);
tokenConsumer.setEmitterId(emitterId);
// 处理流式请求,将token缓冲和事件发送完全交给处理器实现
processor.processStreamRequest(request, agent, userId, tokenConsumer);
LogUtils.exitMethod("handleStreamRequest", "处理完成");
}
/**
* 验证所有必需参数
*
* @param emitter SSE发射器
* @param processor Agent处理器
* @param request Agent请求
* @param agent Agent对象
* @param userId 用户ID
* @return 验证是否通过
*/
private boolean validateParameters(SseEmitter emitter, AgentProcessor processor, pangea.hiagent.web.dto.AgentRequest request, Agent agent, String userId) {
return emitter != null && processor != null && request != null && agent != null && userId != null && !userId.isEmpty();
}
/**
* 流式处理的Token消费者实现
* 用于处理来自Agent处理器的token流,并将其转发给SSE emitter
*/
public static class StreamTokenConsumer implements TokenConsumerWithCompletion {
private final SseEmitter emitter;
private final AgentProcessor processor;
private final EventService eventService;
private final AtomicBoolean isCompleted = new AtomicBoolean(false);
private Agent agent;
private pangea.hiagent.web.dto.AgentRequest request;
private String userId;
private CompletionHandlerService completionHandlerService;
private String emitterId;
public StreamTokenConsumer(SseEmitter emitter, AgentProcessor processor, UserSseService unifiedSseService, EventService eventService, CompletionHandlerService completionHandlerService) {
this.emitter = emitter;
this.processor = processor;
this.eventService = eventService;
this.completionHandlerService = completionHandlerService;
}
public void setContext(Agent agent, pangea.hiagent.web.dto.AgentRequest request, String userId) {
this.agent = agent;
this.request = request;
this.userId = userId;
}
public void setEmitterId(String emitterId) {
this.emitterId = emitterId;
}
public String getEmitterId() {
return emitterId;
}
public String getUserId() {
return userId;
}
@Override
public void accept(String token) {
// 使用JSON格式发送token,确保转义序列被正确处理
try {
if (!isCompleted.get()) {
// 检查是否是错误消息(以[错误]或[ERROR]开头)
if (token != null && (token.startsWith("[错误]") || token.startsWith("[ERROR]"))) {
// 发送标准错误事件而不是纯文本
eventService.sendErrorEvent(emitter, token);
} else {
// 使用SSE标准事件格式发送token,以JSON格式确保转义序列正确处理
eventService.sendTokenEvent(emitter, token);
}
}
} catch (Exception e) {
log.error("发送token失败", e);
}
}
@Override
public void onComplete(String fullContent) {
// 处理完成时的回调
if (isCompleted.getAndSet(true)) {
log.debug("{} Agent处理已完成,跳过重复的完成回调", processor.getProcessorType());
return;
}
log.info("{} Agent处理完成,总字符数: {}", processor.getProcessorType(), fullContent != null ? fullContent.length() : 0);
try {
// 使用CompletionHandlerService处理完成回调
if (completionHandlerService != null) {
completionHandlerService.handleCompletion(emitter, processor, agent, request, userId, fullContent, isCompleted);
} else {
// 如果completionHandlerService不可用,使用默认处理逻辑
try {
// 发送完成事件
emitter.send("[DONE]");
// 完成 emitter
emitter.complete();
} catch (Exception e) {
log.error("处理完成事件失败", e);
} }
} catch (Exception e) {
log.error("处理完成事件失败", e);
// 确保即使出现异常也完成emitter
try {
emitter.complete();
} catch (Exception ex) {
log.error("完成emitter时发生错误", ex);
}
}
} }
}
\ No newline at end of file
package pangea.hiagent.agent.sse; package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.web.dto.WorkPanelEvent; import pangea.hiagent.web.dto.WorkPanelEvent;
import pangea.hiagent.workpanel.event.EventService; import pangea.hiagent.workpanel.event.EventService;
import pangea.hiagent.workpanel.data.TokenEventDataBuilder;
import pangea.hiagent.workpanel.data.ErrorEventDataBuilder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
...@@ -12,6 +14,7 @@ import java.util.List; ...@@ -12,6 +14,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.*; import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
...@@ -36,12 +39,16 @@ public class UserSseService { ...@@ -36,12 +39,16 @@ public class UserSseService {
private final ScheduledExecutorService heartbeatExecutor; private final ScheduledExecutorService heartbeatExecutor;
// SSE超时时间(毫秒) // SSE超时时间(毫秒)
private static final long SSE_TIMEOUT = 120000L; // 2分钟超时,提高连接稳定性 private static final long SSE_TIMEOUT = 0L; // 0表示不使用默认超时,由心跳机制管理连接
private final EventService eventService; private final EventService eventService;
private final TokenEventDataBuilder tokenEventDataBuilder;
private final ErrorEventDataBuilder errorEventDataBuilder;
public UserSseService(EventService eventService) { public UserSseService(EventService eventService, TokenEventDataBuilder tokenEventDataBuilder, ErrorEventDataBuilder errorEventDataBuilder) {
this.eventService = eventService; this.eventService = eventService;
this.tokenEventDataBuilder = tokenEventDataBuilder;
this.errorEventDataBuilder = errorEventDataBuilder;
this.heartbeatExecutor = Executors.newScheduledThreadPool(2); this.heartbeatExecutor = Executors.newScheduledThreadPool(2);
} }
...@@ -83,6 +90,8 @@ public class UserSseService { ...@@ -83,6 +90,8 @@ public class UserSseService {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT); SseEmitter emitter = new SseEmitter(SSE_TIMEOUT);
registerCallbacks(emitter); registerCallbacks(emitter);
emitters.add(emitter); emitters.add(emitter);
// 启动心跳机制,确保新创建的连接有心跳
startHeartbeat(emitter, new AtomicBoolean(false));
return emitter; return emitter;
} }
...@@ -148,6 +157,19 @@ public class UserSseService { ...@@ -148,6 +157,19 @@ public class UserSseService {
} }
try { try {
// 按照正确的SSE连接关闭顺序:
// 4. 取消心跳任务:清理相关的ScheduledFuture心跳任务(已在回调中处理)
// 5. 移除连接映射:从连接管理器(userEmitters、emitterUsers、emitters)中移除连接映射
// 检查emitter是否已经完成,避免重复关闭
if (!isEmitterCompleted(emitter)) {
try {
emitter.complete();
} catch (Exception e) {
log.debug("完成emitter时发生异常(可能是由于已关闭): {}", e.getMessage());
}
}
// 从映射表中移除连接 // 从映射表中移除连接
String userId = emitterUsers.remove(emitter); String userId = emitterUsers.remove(emitter);
if (userId != null) { if (userId != null) {
...@@ -172,6 +194,19 @@ public class UserSseService { ...@@ -172,6 +194,19 @@ public class UserSseService {
} }
try { try {
// 按照正确的SSE连接关闭顺序:
// 4. 取消心跳任务:清理相关的ScheduledFuture心跳任务(已在回调中处理)
// 5. 移除连接映射:从连接管理器(userEmitters、emitterUsers、emitters)中移除连接映射
// 检查emitter是否已经完成,避免重复关闭
if (!isEmitterCompleted(emitter)) {
try {
emitter.complete();
} catch (Exception e) {
log.debug("完成emitter时发生异常(可能是由于已关闭): {}", e.getMessage());
}
}
// 从映射表中移除连接 // 从映射表中移除连接
String userId = emitterUsers.remove(emitter); String userId = emitterUsers.remove(emitter);
if (userId != null) { if (userId != null) {
...@@ -196,6 +231,19 @@ public class UserSseService { ...@@ -196,6 +231,19 @@ public class UserSseService {
} }
try { try {
// 按照正确的SSE连接关闭顺序:
// 4. 取消心跳任务:清理相关的ScheduledFuture心跳任务(已在回调中处理)
// 5. 移除连接映射:从连接管理器(userEmitters、emitterUsers、emitters)中移除连接映射
// 检查emitter是否已经完成,避免重复关闭
if (!isEmitterCompleted(emitter)) {
try {
emitter.complete();
} catch (Exception e) {
log.debug("完成emitter时发生异常(可能是由于已关闭): {}", e.getMessage());
}
}
// 从映射表中移除连接 // 从映射表中移除连接
String userId = emitterUsers.remove(emitter); String userId = emitterUsers.remove(emitter);
if (userId != null) { if (userId != null) {
...@@ -232,49 +280,101 @@ public class UserSseService { ...@@ -232,49 +280,101 @@ public class UserSseService {
return; return;
} }
// 用于追踪心跳失败次数
AtomicInteger consecutiveFailures = new AtomicInteger(0);
// 使用数组包装ScheduledFuture以解决Lambda中的变量访问问题
final ScheduledFuture<?>[] heartbeatTaskRef = new ScheduledFuture<?>[1];
// 创建心跳任务并保存ScheduledFuture引用 // 创建心跳任务并保存ScheduledFuture引用
ScheduledFuture<?> heartbeatTask = heartbeatExecutor.scheduleAtFixedRate(() -> { heartbeatTaskRef[0] = heartbeatExecutor.scheduleAtFixedRate(() -> {
try {
// 检查emitter是否已经完成 // 检查emitter是否已经完成
if (isCompleted.get() || !isEmitterValid(emitter)) { if (isCompleted.get() || isEmitterCompleted(emitter)) {
log.debug("SSE Emitter已完成或无效,取消心跳任务"); log.debug("SSE Emitter已完成或无效,取消心跳任务");
// 返回前确保任务被取消 if (heartbeatTaskRef[0] != null && !heartbeatTaskRef[0].isCancelled()) {
heartbeatTaskRef[0].cancel(true);
}
return; return;
} }
try {
// 发送心跳事件 // 发送心跳事件
sendHeartbeat(emitter); boolean heartbeatSuccess = sendHeartbeat(emitter);
} catch (Exception e) {
log.warn("发送心跳事件失败: {}", e.getMessage()); if (heartbeatSuccess) {
// 如果是emitter已完成的异常,标记为已完成 // 如果心跳成功,重置失败计数
if (e instanceof IllegalStateException && consecutiveFailures.set(0);
e.getMessage() != null &&
e.getMessage().contains("Emitter is already completed")) { log.debug("心跳发送成功,连续失败次数重置为0");
isCompleted.set(true);
// 心跳成功后,连接保持活动状态,不需要额外操作,因为SSE_TIMEOUT为0
} else {
// 心跳失败,增加失败计数
int currentFailures = consecutiveFailures.incrementAndGet();
log.debug("心跳连续失败次数: {}", currentFailures);
// 如果心跳连续失败达到阈值,启动延迟关闭
if (currentFailures >= 2) { // 连续2次失败后,启动30秒延迟关闭
log.warn("心跳连续失败{}次,启动30秒延迟关闭机制", currentFailures);
// 调度一个延迟任务来关闭连接
heartbeatExecutor.schedule(() -> {
if (!isCompleted.get() && !isEmitterCompleted(emitter)) {
log.info("30秒延迟到期,主动关闭SSE连接");
// 首先取消心跳任务
if (heartbeatTaskRef[0] != null && !heartbeatTaskRef[0].isCancelled()) {
heartbeatTaskRef[0].cancel(true);
log.debug("心跳任务已取消");
} }
// 然后关闭SSE连接
try {
if (!isEmitterCompleted(emitter)) {
emitter.complete();
log.debug("SSE连接已关闭");
} }
}, 30, 30, TimeUnit.SECONDS); // 每30秒发送一次心跳 } catch (Exception ex) {
log.debug("关闭SSE连接时发生异常(可能是由于已关闭): {}", ex.getMessage());
}
} else {
log.debug("SSE连接已完成或已关闭,跳过延迟关闭");
}
}, 30, TimeUnit.SECONDS);
// 立即取消当前心跳任务
if (heartbeatTaskRef[0] != null && !heartbeatTaskRef[0].isCancelled()) {
heartbeatTaskRef[0].cancel(true);
log.debug("心跳任务因连续失败而被取消");
}
}
}
} catch (Exception e) {
log.error("心跳任务执行异常: {}", e.getMessage(), e);
}
}, 20, 20, TimeUnit.SECONDS); // 每20秒发送一次心跳,确保前端60秒超时前至少收到2次心跳
// 注册回调,在连接完成时取消心跳任务 // 注册回调,在连接完成时取消心跳任务
emitter.onCompletion(() -> { emitter.onCompletion(() -> {
if (heartbeatTask != null && !heartbeatTask.isCancelled()) { if (heartbeatTaskRef[0] != null && !heartbeatTaskRef[0].isCancelled()) {
heartbeatTask.cancel(true); heartbeatTaskRef[0].cancel(true);
log.debug("SSE连接完成,心跳任务已取消"); log.debug("SSE连接完成,心跳任务已取消");
} }
}); });
// 注册回调,在连接超时时取消心跳任务 // 注册回调,在连接超时时取消心跳任务
emitter.onTimeout(() -> { emitter.onTimeout(() -> {
if (heartbeatTask != null && !heartbeatTask.isCancelled()) { if (heartbeatTaskRef[0] != null && !heartbeatTaskRef[0].isCancelled()) {
heartbeatTask.cancel(true); heartbeatTaskRef[0].cancel(true);
log.debug("SSE连接超时,心跳任务已取消"); log.debug("SSE连接超时,心跳任务已取消");
} }
}); });
// 注册回调,在连接错误时取消心跳任务 // 注册回调,在连接错误时取消心跳任务
emitter.onError(throwable -> { emitter.onError(throwable -> {
if (heartbeatTask != null && !heartbeatTask.isCancelled()) { if (heartbeatTaskRef[0] != null && !heartbeatTaskRef[0].isCancelled()) {
heartbeatTask.cancel(true); heartbeatTaskRef[0].cancel(true);
log.debug("SSE连接错误,心跳任务已取消"); log.debug("SSE连接错误,心跳任务已取消");
} }
}); });
...@@ -287,20 +387,26 @@ public class UserSseService { ...@@ -287,20 +387,26 @@ public class UserSseService {
*/ */
public void registerCallbacks(SseEmitter emitter) { public void registerCallbacks(SseEmitter emitter) {
emitter.onCompletion(() -> { emitter.onCompletion(() -> {
log.debug("SSE连接完成"); log.debug("【注册回调函数】SSE连接完成");
// 按照正确的关闭顺序,连接完成时已经完成关闭,只需移除连接映射
removeEmitter(emitter); removeEmitter(emitter);
}); });
emitter.onError((Throwable t) -> { emitter.onError((Throwable t) -> {
log.error("SSE连接发生错误: {}", t.getMessage(), t); log.debug("SSE连接发生错误: {}", t.getMessage());
// 错误发生时,先移除连接映射
removeEmitter(emitter); removeEmitter(emitter);
}); });
emitter.onTimeout(() -> { emitter.onTimeout(() -> {
log.warn("SSE连接超时"); log.warn("SSE连接超时");
try { try {
// 检查emitter是否已经完成,避免重复关闭
if (!isEmitterCompleted(emitter)) {
emitter.complete(); emitter.complete();
}
} catch (Exception e) { } catch (Exception e) {
log.error("关闭SSE连接时发生异常: {}", e.getMessage(), e); log.debug("关闭SSE连接时发生异常(可能是由于已关闭): {}", e.getMessage());
} }
// 超时时也移除连接映射
removeEmitter(emitter); removeEmitter(emitter);
}); });
} }
...@@ -314,7 +420,7 @@ public class UserSseService { ...@@ -314,7 +420,7 @@ public class UserSseService {
*/ */
public void registerCallbacks(SseEmitter emitter, String userId) { public void registerCallbacks(SseEmitter emitter, String userId) {
emitter.onCompletion(() -> { emitter.onCompletion(() -> {
log.debug("SSE连接完成"); log.debug("【注册Emitter回调函数】SSE连接完成");
// 通知用户连接管理器连接已完成 // 通知用户连接管理器连接已完成
handleConnectionCompletion(emitter); handleConnectionCompletion(emitter);
}); });
...@@ -322,9 +428,12 @@ public class UserSseService { ...@@ -322,9 +428,12 @@ public class UserSseService {
emitter.onTimeout(() -> { emitter.onTimeout(() -> {
log.warn("SSE连接超时"); log.warn("SSE连接超时");
try { try {
// 检查emitter是否已经完成,避免重复关闭
if (!isEmitterCompleted(emitter)) {
emitter.complete(); emitter.complete();
}
} catch (Exception e) { } catch (Exception e) {
log.error("关闭SSE连接失败", e); log.debug("关闭SSE连接失败(可能是由于已关闭): {}", e.getMessage());
} }
// 通知用户连接管理器连接已超时 // 通知用户连接管理器连接已超时
handleConnectionTimeout(emitter); handleConnectionTimeout(emitter);
...@@ -355,7 +464,16 @@ public class UserSseService { ...@@ -355,7 +464,16 @@ public class UserSseService {
} }
if (emitter != null) { if (emitter != null) {
try { try {
// 检查emitter是否已经完成,避免重复关闭
if (isEmitterCompleted(emitter)) {
log.debug("Emitter已经完成,跳过关闭操作");
return;
}
emitter.complete(); emitter.complete();
log.debug("Emitter已成功关闭");
} catch (IllegalStateException e) {
log.debug("Emitter已经关闭: {}", e.getMessage());
} catch (Exception e) { } catch (Exception e) {
log.warn("完成Emitter时发生异常: {}", e.getMessage()); log.warn("完成Emitter时发生异常: {}", e.getMessage());
} }
...@@ -374,9 +492,15 @@ public class UserSseService { ...@@ -374,9 +492,15 @@ public class UserSseService {
return false; return false;
} }
// 首先检查是否已经完成,避免不必要的事件发送
if (isEmitterCompleted(emitter)) {
return false;
}
// 检查逻辑,仅通过尝试发送ping事件来验证连接状态 // 检查逻辑,仅通过尝试发送ping事件来验证连接状态
try { try {
// 尝试发送一个空事件来检查连接状态 // 尝试发送一个空事件来检查连接状态
// 注意:这个方法会实际发送一个事件到客户端,这可能不是理想的方式
emitter.send(SseEmitter.event().name("ping").data("").build()); emitter.send(SseEmitter.event().name("ping").data("").build());
return true; return true;
} catch (Exception ex) { } catch (Exception ex) {
...@@ -385,6 +509,59 @@ public class UserSseService { ...@@ -385,6 +509,59 @@ public class UserSseService {
} }
} }
/**
* 安全检查SSE Emitter是否仍然有效(不发送实际事件)
* 职责:提供非侵入性的连接有效性检查
*
* @param emitter 要检查的emitter
* @return 如果有效返回true,否则返回false
*/
public boolean isEmitterValidSafe(SseEmitter emitter) {
if (emitter == null) {
return false;
}
// 检查是否已经完成,而不发送任何事件
return !isEmitterCompleted(emitter);
}
/**
* 检查SSE Emitter是否已经完成
* 使用更安全的方式检查完成状态,不发送实际事件
*
* @param emitter 要检查的emitter
* @return 如果已完成返回true,否则返回false
*/
public boolean isEmitterCompleted(SseEmitter emitter) {
if (emitter == null) {
return true; // 认为null emitter是已完成的
}
// 使用反射检查SseEmitter的完成状态
try {
java.lang.reflect.Field completedField = SseEmitter.class.getDeclaredField("completed");
completedField.setAccessible(true);
boolean completed = completedField.getBoolean(emitter);
return completed;
} catch (Exception e) {
// 如果反射失败,尝试通过发送事件检测
try {
emitter.send(SseEmitter.event());
return false; // 没有异常说明未完成
} catch (IllegalStateException ex) {
// 检查错误消息是否包含完成相关的文本
String message = ex.getMessage();
if (message != null && (message.contains("completed") || message.contains("closed"))) {
return true;
}
return true; // IllegalStateException通常表示连接已关闭
} catch (Exception ex) {
// 其他异常通常也表示连接已不可用
return true;
}
}
}
/** /**
* 发送SSE事件 * 发送SSE事件
* 职责:统一发送SSE事件的基础方法 * 职责:统一发送SSE事件的基础方法
...@@ -414,24 +591,33 @@ public class UserSseService { ...@@ -414,24 +591,33 @@ public class UserSseService {
* 发送心跳事件 * 发送心跳事件
* *
* @param emitter SSE发射器 * @param emitter SSE发射器
* @throws IOException IO异常 * @return 是否成功发送心跳
*/ */
public void sendHeartbeat(SseEmitter emitter) throws IOException { public boolean sendHeartbeat(SseEmitter emitter) {
if (emitter == null) { if (emitter == null) {
log.warn("SSE发射器为空,无法发送心跳事件"); log.warn("SSE发射器为空,无法发送心跳事件");
return; return false;
}
// 检查emitter是否已经完成,避免向已完成的连接发送心跳
if (isEmitterCompleted(emitter)) {
log.debug("SSE发射器已完成,跳过发送心跳事件");
return false;
} }
try { 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);
return true;
} catch (IllegalStateException e) { } catch (IllegalStateException e) {
// 处理 emitter 已关闭的情况 // 处理 emitter 已关闭的情况
log.debug("无法发送心跳事件,emitter已关闭: {}", e.getMessage()); log.debug("无法发送心跳事件,emitter已关闭或完成: {}", e.getMessage());
// 不重新抛出异常,避免影响主流程 return false;
} catch (Exception e) { } catch (Exception e) {
log.warn("发送心跳事件失败: {}", e.getMessage()); log.warn("发送心跳事件失败: {}", e.getMessage());
throw e; return false;
} }
} }
...@@ -463,15 +649,15 @@ public class UserSseService { ...@@ -463,15 +649,15 @@ public class UserSseService {
} else { } else {
log.warn("构建事件数据失败,无法发送事件: 类型={}", event.getType()); log.warn("构建事件数据失败,无法发送事件: 类型={}", event.getType());
} }
} catch (IllegalStateException e) {
// 处理 emitter 已关闭的情况
log.debug("无法发送工作面板事件,emitter已关闭或完成: {}", e.getMessage());
// 不重新抛出异常,避免影响主流程
} catch (Exception e) { } catch (Exception e) {
// 记录详细错误信息,但不中断主流程 // 记录详细错误信息,但不中断主流程
log.error("发送工作面板事件失败: 类型={}, 错误={}", event.getType(), e.getMessage(), e); log.error("发送工作面板事件失败: 类型={}, 错误={}", event.getType(), e.getMessage(), e);
// 如果是连接已关闭的异常,重新抛出以便上层处理 // 其他异常不重新抛出,避免影响主流程
if (e instanceof IllegalStateException && e.getMessage() != null &&
e.getMessage().contains("Emitter is already completed")) {
throw e;
}
} }
} }
...@@ -528,6 +714,78 @@ public class UserSseService { ...@@ -528,6 +714,78 @@ public class UserSseService {
} }
} }
/**
* 发送Token事件
*
* @param emitter SSE发射器
* @param token Token内容
* @throws IOException IO异常
*/
public void sendTokenEvent(SseEmitter emitter, String token) throws IOException {
if (emitter == null || token == null) {
log.warn("SSE发射器或Token为空,无法发送Token事件");
return;
}
try {
// 检查emitter是否已经完成
if (!isEmitterCompleted(emitter)) {
// 构建token事件数据
Map<String, Object> data = tokenEventDataBuilder.createOptimizedTokenEventData(token);
if (data != null) {
// 发送事件
emitter.send(SseEmitter.event().name("token").data(data));
} else {
log.warn("构建token事件数据失败,无法发送事件");
}
} else {
log.debug("SSE emitter已完成,跳过发送token事件");
}
} catch (IllegalStateException e) {
// 处理 emitter 已关闭的情况
log.debug("无法发送token事件,emitter已关闭或完成: {}", e.getMessage());
} catch (Exception e) {
log.error("发送token事件失败: token长度={}, 错误={}", token.length(), e.getMessage(), e);
}
}
/**
* 发送错误事件
*
* @param emitter SSE发射器
* @param errorMessage 错误信息
* @throws IOException IO异常
*/
public void sendErrorEvent(SseEmitter emitter, String errorMessage) throws IOException {
if (emitter == null || errorMessage == null) {
log.warn("SSE发射器或错误信息为空,无法发送错误事件");
return;
}
try {
// 检查emitter是否已经完成
if (!isEmitterCompleted(emitter)) {
// 构建错误事件数据
Map<String, Object> data = errorEventDataBuilder.createErrorEventData(errorMessage);
if (data != null) {
// 发送事件
emitter.send(SseEmitter.event().name("error").data(data));
} else {
log.warn("构建错误事件数据失败,无法发送事件");
}
} else {
log.debug("SSE emitter已完成,跳过发送错误事件");
}
} catch (IllegalStateException e) {
// 处理 emitter 已关闭的情况
log.debug("无法发送错误事件,emitter已关闭或完成: {}", e.getMessage());
} catch (Exception e) {
log.error("发送错误事件失败: 错误信息={}, 错误={}", errorMessage, e.getMessage(), e);
}
}
/** /**
* 获取所有活动的emitters * 获取所有活动的emitters
* *
...@@ -552,17 +810,4 @@ public class UserSseService { ...@@ -552,17 +810,4 @@ public class UserSseService {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
} }
public void registerEmitter(String id,SseEmitter emitter) {
this.userEmitters.put(id, emitter);
}
public SseEmitter getEmitter(String id) {
return userEmitters.get(id);
}
public boolean removeEmitter(String id) {
userEmitters.remove(id);
return true;
}
} }
\ No newline at end of file
package pangea.hiagent.agent.sse;
import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.web.dto.WorkPanelEvent;
import pangea.hiagent.workpanel.event.EventService;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.function.Consumer;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* SSE连接协调器
* 专门负责协调SSE连接的创建、管理和销毁过程
*/
@Slf4j
@Component
public class SseConnectionCoordinator {
private final UserSseService unifiedSseService;
private final EventService eventService;
public SseConnectionCoordinator(
UserSseService unifiedSseService,
EventService eventService) {
this.unifiedSseService = unifiedSseService;
this.eventService = eventService;
}
/**
* 创建并注册SSE连接
*
* @param userId 用户ID
* @return SSE Emitter
*/
public SseEmitter createAndRegisterConnection(String userId) {
log.debug("开始为用户 {} 创建SSE连接", userId);
// 创建 SSE emitter
SseEmitter emitter = unifiedSseService.createEmitter();
log.debug("SSE Emitter创建成功");
// 注册用户的SSE连接
unifiedSseService.registerSession(userId, emitter);
log.debug("用户 {} 的SSE连接注册成功", userId);
// 注册 emitter 回调
unifiedSseService.registerCallbacks(emitter, userId);
log.debug("SSE Emitter回调注册成功");
// 启动心跳机制
unifiedSseService.startHeartbeat(emitter, new AtomicBoolean(false));
log.debug("心跳机制启动成功");
log.info("用户 {} 的SSE连接创建和注册完成", userId);
return emitter;
}
/**
* 订阅工作面板事件
*
* @param userId 用户ID
* @param workPanelEventConsumer 工作面板事件消费者
* @param emitter SSE Emitter
*/
public void subscribeToWorkPanelEvents(String userId, Consumer<WorkPanelEvent> workPanelEventConsumer, SseEmitter emitter) {
log.debug("开始为用户 {} 订阅工作面板事件", userId);
// 发送连接成功事件
try {
WorkPanelEvent connectedEvent = WorkPanelEvent.builder()
.type("observation")
.title("连接成功")
.timestamp(System.currentTimeMillis())
.build();
eventService.sendWorkPanelEvent(emitter, connectedEvent);
log.debug("已发送连接成功事件");
} catch (Exception e) {
log.error("发送连接成功事件失败", e);
}
log.info("用户 {} 的工作面板事件订阅完成", userId);
}
}
\ No newline at end of file
package pangea.hiagent.agent.sse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 用户会话管理器
* 专门负责管理用户的SSE会话连接
*/
@Slf4j
@Component
public class UserSseManager {
// 存储用户ID到SSE Emitter的映射关系
private final ConcurrentMap<String, SseEmitter> userEmitters = new ConcurrentHashMap<>();
// 存储SSE Emitter到用户ID的反向映射关系(用于快速查找)
private final ConcurrentMap<SseEmitter, String> emitterUsers = new ConcurrentHashMap<>();
// 存储连接创建时间,用于超时检查
private final ConcurrentMap<SseEmitter, AtomicLong> connectionTimes = new ConcurrentHashMap<>();
// 连接超时时间(毫秒),默认30分钟
private static final long CONNECTION_TIMEOUT = 30 * 60 * 1000L;
/**
* 注册用户的SSE连接
* 如果该用户已有连接,则先关闭旧连接再注册新连接
*
* @param userId 用户ID
* @param emitter SSE Emitter
* @return true表示注册成功,false表示注册失败
*/
public boolean registerSession(String userId, SseEmitter emitter) {
if (userId == null || userId.isEmpty() || emitter == null) {
log.warn("注册SSE会话失败:用户ID或Emitter为空");
return false;
}
try {
// 检查该用户是否已有连接
SseEmitter existingEmitter = userEmitters.get(userId);
if (existingEmitter != null) {
// 如果已有连接,先关闭旧连接
log.info("用户 {} 已有SSE连接,关闭旧连接", userId);
closeEmitter(existingEmitter);
// 从映射中移除旧连接
userEmitters.remove(userId, existingEmitter);
emitterUsers.remove(existingEmitter);
}
// 注册新连接
userEmitters.put(userId, emitter);
emitterUsers.put(emitter, userId);
// 记录连接创建时间
connectionTimes.put(emitter, new AtomicLong(System.currentTimeMillis()));
log.info("成功为用户 {} 注册SSE会话", userId);
return true;
} catch (Exception e) {
log.error("注册SSE会话时发生异常:用户ID={}", userId, e);
return false;
}
}
/**
* 移除用户的SSE会话
*
* @param emitter SSE Emitter
*/
public void removeSession(SseEmitter emitter) {
if (emitter == null) {
return;
}
try {
String userId = emitterUsers.get(emitter);
if (userId != null) {
userEmitters.remove(userId, emitter);
emitterUsers.remove(emitter);
connectionTimes.remove(emitter);
log.debug("已移除用户 {} 的SSE会话", userId);
}
} catch (Exception e) {
log.warn("移除SSE会话时发生异常", e);
}
}
/**
* 获取用户的当前SSE连接
*
* @param userId 用户ID
* @return SSE Emitter,如果不存在则返回null
*/
public SseEmitter getSession(String userId) {
if (userId == null || userId.isEmpty()) {
return null;
}
return userEmitters.get(userId);
}
/**
* 检查用户是否有活跃的SSE连接
*
* @param userId 用户ID
* @return true表示有活跃连接,false表示没有
*/
public boolean hasActiveSession(String userId) {
if (userId == null || userId.isEmpty()) {
return false;
}
SseEmitter emitter = userEmitters.get(userId);
return emitter != null && isEmitterValid(emitter) && !isSessionExpired(emitter);
}
/**
* 检查SSE Emitter是否仍然有效
*
* @param emitter 要检查的emitter
* @return 如果有效返回true,否则返回false
*/
public boolean isEmitterValid(SseEmitter emitter) {
if (emitter == null) {
return false;
}
// 检查emitter是否已完成
try {
// 尝试发送一个空事件来检查连接状态
emitter.send(SseEmitter.event().name("ping").data("").build());
return true;
} catch (Exception e) {
// 如果出现任何异常,认为连接已失效
return false;
}
}
/**
* 检查会话是否已过期
*
* @param emitter 要检查的emitter
* @return 如果过期返回true,否则返回false
*/
public boolean isSessionExpired(SseEmitter emitter) {
if (emitter == null) {
return true;
}
AtomicLong connectionTime = connectionTimes.get(emitter);
if (connectionTime == null) {
return false; // 如果没有记录时间,假设未过期
}
long currentTime = System.currentTimeMillis();
long connectionStartTime = connectionTime.get();
return (currentTime - connectionStartTime) > CONNECTION_TIMEOUT;
}
/**
* 关闭指定的SSE Emitter
*
* @param emitter SSE Emitter
*/
public void closeEmitter(SseEmitter emitter) {
if (emitter == null) {
return;
}
try {
emitter.complete();
log.debug("已关闭SSE Emitter");
} catch (Exception e) {
log.warn("关闭SSE Emitter时发生异常", e);
}
}
/**
* 获取当前会话的用户数量
*
* @return 用户数量
*/
public int getSessionCount() {
return userEmitters.size();
}
/**
* 清理所有会话(用于系统关闭时)
*/
public void clearAllSessions() {
try {
for (SseEmitter emitter : emitterUsers.keySet()) {
try {
emitter.complete();
} catch (Exception e) {
log.warn("关闭SSE Emitter时发生异常", e);
}
}
userEmitters.clear();
emitterUsers.clear();
connectionTimes.clear();
log.info("已清理所有SSE会话");
} catch (Exception e) {
log.error("清理所有SSE会话时发生异常", e);
}
}
/**
* 处理连接完成事件
* 职责:协调完成连接的清理工作
*
* @param emitter SSE Emitter
*/
public void handleConnectionCompletion(SseEmitter emitter) {
log.debug("处理SSE连接完成事件");
// 移除连接
removeSession(emitter);
}
/**
* 处理连接超时事件
* 职责:协调超时连接的清理工作
*
* @param emitter SSE Emitter
*/
public void handleConnectionTimeout(SseEmitter emitter) {
log.debug("处理SSE连接超时事件");
// 移除连接
removeSession(emitter);
}
/**
* 处理连接错误事件
* 职责:协调错误连接的清理工作
*
* @param emitter SSE Emitter
*/
public void handleConnectionError(SseEmitter emitter) {
log.debug("处理SSE连接错误事件");
// 移除连接
removeSession(emitter);
}
}
\ No newline at end of file
...@@ -5,7 +5,6 @@ import lombok.extern.slf4j.Slf4j; ...@@ -5,7 +5,6 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject; import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import pangea.hiagent.common.utils.UserUtils; import pangea.hiagent.common.utils.UserUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
...@@ -46,7 +45,7 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler { ...@@ -46,7 +45,7 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler {
if (metaObject.hasSetter("createdBy")) { if (metaObject.hasSetter("createdBy")) {
Object createdBy = getFieldValByName("createdBy", metaObject); Object createdBy = getFieldValByName("createdBy", metaObject);
if (createdBy == null) { if (createdBy == null) {
String userId = UserUtils.getCurrentUserId(); String userId = getCurrentUserIdWithContext();
if (userId != null) { if (userId != null) {
this.strictInsertFill(metaObject, "createdBy", String.class, userId); this.strictInsertFill(metaObject, "createdBy", String.class, userId);
log.debug("自动填充createdBy字段: {}", userId); log.debug("自动填充createdBy字段: {}", userId);
...@@ -60,7 +59,7 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler { ...@@ -60,7 +59,7 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler {
if (metaObject.hasSetter("updatedBy")) { if (metaObject.hasSetter("updatedBy")) {
Object updatedBy = getFieldValByName("updatedBy", metaObject); Object updatedBy = getFieldValByName("updatedBy", metaObject);
if (updatedBy == null) { if (updatedBy == null) {
String userId = UserUtils.getCurrentUserId(); String userId = getCurrentUserIdWithContext();
if (userId != null) { if (userId != null) {
this.strictInsertFill(metaObject, "updatedBy", String.class, userId); this.strictInsertFill(metaObject, "updatedBy", String.class, userId);
log.debug("自动填充updatedBy字段: {}", userId); log.debug("自动填充updatedBy字段: {}", userId);
...@@ -91,7 +90,7 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler { ...@@ -91,7 +90,7 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler {
Object updatedBy = getFieldValByName("updatedBy", metaObject); Object updatedBy = getFieldValByName("updatedBy", metaObject);
// 如果updatedBy为空或者需要强制更新,则填充当前用户ID // 如果updatedBy为空或者需要强制更新,则填充当前用户ID
if (updatedBy == null) { if (updatedBy == null) {
String userId = UserUtils.getCurrentUserId(); String userId = getCurrentUserIdWithContext();
if (userId != null) { if (userId != null) {
this.strictUpdateFill(metaObject, "updatedBy", String.class, userId); this.strictUpdateFill(metaObject, "updatedBy", String.class, userId);
log.debug("自动填充updatedBy字段: {}", userId); log.debug("自动填充updatedBy字段: {}", userId);
...@@ -101,4 +100,39 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler { ...@@ -101,4 +100,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; ...@@ -21,6 +21,7 @@ import pangea.hiagent.web.service.AgentService;
import pangea.hiagent.web.service.TimerService; import pangea.hiagent.web.service.TimerService;
import pangea.hiagent.security.DefaultPermissionEvaluator; import pangea.hiagent.security.DefaultPermissionEvaluator;
import pangea.hiagent.security.JwtAuthenticationFilter; import pangea.hiagent.security.JwtAuthenticationFilter;
import pangea.hiagent.security.SseAuthorizationFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays; import java.util.Arrays;
...@@ -33,11 +34,13 @@ import java.util.Collections; ...@@ -33,11 +34,13 @@ import java.util.Collections;
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final SseAuthorizationFilter sseAuthorizationFilter;
private final AgentService agentService; private final AgentService agentService;
private final TimerService timerService; 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.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.sseAuthorizationFilter = sseAuthorizationFilter;
this.agentService = agentService; this.agentService = agentService;
this.timerService = timerService; this.timerService = timerService;
} }
...@@ -205,6 +208,8 @@ public class SecurityConfig { ...@@ -205,6 +208,8 @@ public class SecurityConfig {
) )
// 添加JWT认证过滤器 // 添加JWT认证过滤器
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// 添加SSE授权检查过滤器,在JWT过滤器之后但在其他安全过滤器之前运行
.addFilterAfter(sseAuthorizationFilter, JwtAuthenticationFilter.class)
// 配置X-Frame-Options头部,允许同源iframe嵌入 // 配置X-Frame-Options头部,允许同源iframe嵌入
.headers(headers -> headers .headers(headers -> headers
.frameOptions(frameOptions -> frameOptions .frameOptions(frameOptions -> frameOptions
......
package pangea.hiagent.common.config; // package pangea.hiagent.common.config;
import org.springframework.context.annotation.Configuration; // import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy; // import org.springframework.context.annotation.EnableAspectJAutoProxy;
/** // /**
* 工具执行日志记录配置类 // * 工具执行日志记录配置类
* 启用AOP代理以实现工具执行信息的自动记录 // * 启用AOP代理以实现工具执行信息的自动记录
*/ // */
@Configuration // @Configuration
@EnableAspectJAutoProxy // @EnableAspectJAutoProxy
public class ToolExecutionLoggingConfig { // public class ToolExecutionLoggingConfig {
} // }
\ No newline at end of file \ No newline at end of file
...@@ -14,6 +14,7 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep ...@@ -14,6 +14,7 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep
import pangea.hiagent.agent.service.ExceptionMonitoringService; import pangea.hiagent.agent.service.ExceptionMonitoringService;
import pangea.hiagent.web.dto.ApiResponse; import pangea.hiagent.web.dto.ApiResponse;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.security.authorization.AuthorizationDeniedException;
...@@ -212,29 +213,66 @@ public class GlobalExceptionHandler { ...@@ -212,29 +213,66 @@ public class GlobalExceptionHandler {
AuthorizationDeniedException e, HttpServletRequest request) { AuthorizationDeniedException e, HttpServletRequest request) {
log.warn("访问被拒绝: {} - URL: {}", e.getMessage(), request.getRequestURL()); log.warn("访问被拒绝: {} - URL: {}", e.getMessage(), request.getRequestURL());
// 更全面地检查响应是否已经提交 // 检查是否为SSE端点的异常
boolean responseCommitted = false; String requestUri = request.getRequestURI();
boolean isSseEndpoint = requestUri.contains("/api/v1/agent/chat-stream") || requestUri.contains("/api/v1/agent/timeline-events");
// 检查响应是否已经提交
jakarta.servlet.http.HttpServletResponse httpResponse = null;
if (org.springframework.web.context.request.RequestContextHolder.getRequestAttributes() != null) {
Object requestAttributes = org.springframework.web.context.request.RequestContextHolder
.getRequestAttributes();
if (requestAttributes instanceof org.springframework.web.context.request.ServletRequestAttributes) {
org.springframework.web.context.request.ServletRequestAttributes servletRequestAttributes =
(org.springframework.web.context.request.ServletRequestAttributes) requestAttributes;
if (servletRequestAttributes.getResponse() instanceof jakarta.servlet.http.HttpServletResponse) {
httpResponse = (jakarta.servlet.http.HttpServletResponse) servletRequestAttributes.getResponse();
}
}
// 检查request属性 // 检查响应是否已提交
if (request.getAttribute("jakarta.servlet.error.exception") != null) { if (httpResponse != null && httpResponse.isCommitted()) {
responseCommitted = true; log.warn("响应已提交,无法发送访问拒绝错误: {}", request.getRequestURL());
// 如果是SSE端点且响应已提交,返回空响应避免二次异常
return ResponseEntity.ok().build();
}
} }
// 检查response是否已提交 // 如果是SSE端点,但响应未提交,发送SSE格式的错误响应
if (request instanceof org.springframework.web.context.request.NativeWebRequest) { if (isSseEndpoint) {
Object nativeResponse = ((org.springframework.web.context.request.NativeWebRequest) request).getNativeResponse(); try {
if (nativeResponse instanceof jakarta.servlet.http.HttpServletResponse) { jakarta.servlet.http.HttpServletResponse sseResponse = null;
if (((jakarta.servlet.http.HttpServletResponse) nativeResponse).isCommitted()) { if (org.springframework.web.context.request.RequestContextHolder.getRequestAttributes() != null) {
responseCommitted = true; Object requestAttributes = org.springframework.web.context.request.RequestContextHolder
.getRequestAttributes();
if (requestAttributes instanceof org.springframework.web.context.request.ServletRequestAttributes) {
org.springframework.web.context.request.ServletRequestAttributes servletRequestAttributes =
(org.springframework.web.context.request.ServletRequestAttributes) requestAttributes;
if (servletRequestAttributes.getResponse() instanceof jakarta.servlet.http.HttpServletResponse) {
sseResponse = (jakarta.servlet.http.HttpServletResponse) servletRequestAttributes.getResponse();
} }
} }
} }
// 如果响应已提交,记录日志并返回空响应以避免二次异常 if (sseResponse != null) {
if (responseCommitted) { sseResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
log.warn("响应已提交,无法发送访问拒绝错误: {}", request.getRequestURL()); sseResponse.setContentType("text/event-stream;charset=UTF-8");
// 返回空响应而不是build(),避免潜在的响应提交冲突 sseResponse.setCharacterEncoding("UTF-8");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null);
// 发送SSE格式的错误事件
sseResponse.getWriter().write("event: error\n");
sseResponse.getWriter().write("data: {\"error\": \"访问被拒绝,无权限访问该资源\", \"code\": 403, \"timestamp\": " +
System.currentTimeMillis() + "}\n\n");
sseResponse.getWriter().flush();
log.debug("已发送SSE 访问拒绝错误响应");
}
return ResponseEntity.ok().build();
} catch (Exception ex) {
log.error("发送SSE访问拒绝错误响应失败", ex);
return ResponseEntity.ok().build();
}
} }
ApiResponse.ErrorDetail errorDetail = ApiResponse.ErrorDetail.builder() ApiResponse.ErrorDetail errorDetail = ApiResponse.ErrorDetail.builder()
...@@ -242,9 +280,9 @@ public class GlobalExceptionHandler { ...@@ -242,9 +280,9 @@ public class GlobalExceptionHandler {
.details("您没有权限执行此操作") .details("您没有权限执行此操作")
.build(); .build();
ApiResponse<Void> response = ApiResponse.error(ErrorCode.FORBIDDEN.getCode(), ApiResponse<Void> finalResponse = ApiResponse.error(ErrorCode.FORBIDDEN.getCode(),
ErrorCode.FORBIDDEN.getMessage(), errorDetail); ErrorCode.FORBIDDEN.getMessage(), errorDetail);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(finalResponse);
} }
/** /**
......
package pangea.hiagent.common.utils; package pangea.hiagent.common.utils;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.Serializable;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
/** /**
...@@ -13,6 +17,79 @@ import java.util.concurrent.Callable; ...@@ -13,6 +17,79 @@ import java.util.concurrent.Callable;
@Component @Component
public class AsyncUserContextDecorator { public class AsyncUserContextDecorator {
/**
* 用户上下文持有者类,用于在异步线程间传递认证信息
*/
public static class UserContextHolder implements Serializable {
private static final long serialVersionUID = 1L;
private final Authentication authentication;
public UserContextHolder(Authentication authentication) {
this.authentication = authentication;
}
public Authentication getAuthentication() {
return authentication;
}
}
/**
* 捕获当前线程的用户上下文
* @return 用户上下文持有者对象
*/
public static UserContextHolder captureUserContext() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
log.debug("捕获到当前线程的用户认证信息: {}", authentication.getPrincipal());
return new UserContextHolder(authentication);
} else {
log.debug("当前线程无用户认证信息");
return null;
}
} catch (Exception e) {
log.error("捕获用户上下文时发生异常", e);
return null;
}
}
/**
* 将用户上下文传播到当前线程
* @param userContextHolder 用户上下文持有者对象
*/
public static void propagateUserContext(UserContextHolder userContextHolder) {
try {
if (userContextHolder != null) {
Authentication authentication = userContextHolder.getAuthentication();
if (authentication != null) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
log.debug("已将用户认证信息传播到当前线程: {}", authentication.getPrincipal());
} else {
log.debug("用户上下文持有者中的认证信息为空");
}
} else {
log.debug("用户上下文持有者为空");
}
} catch (Exception e) {
log.error("传播用户上下文时发生异常", e);
}
}
/**
* 清理当前线程的用户上下文
*/
public static void clearUserContext() {
try {
SecurityContextHolder.clearContext();
log.debug("已清理当前线程的用户上下文");
} catch (Exception e) {
log.error("清理用户上下文时发生异常", e);
}
}
/** /**
* 包装Runnable任务,自动传播用户上下文 * 包装Runnable任务,自动传播用户上下文
* @param runnable 原始任务 * @param runnable 原始任务
...@@ -20,18 +97,18 @@ public class AsyncUserContextDecorator { ...@@ -20,18 +97,18 @@ public class AsyncUserContextDecorator {
*/ */
public static Runnable wrapWithContext(Runnable runnable) { public static Runnable wrapWithContext(Runnable runnable) {
// 捕获当前线程的用户上下文 // 捕获当前线程的用户上下文
UserContextPropagationUtil.UserContextHolder userContext = UserContextPropagationUtil.captureUserContext(); UserContextHolder userContext = captureUserContext();
return () -> { return () -> {
try { try {
// 在异步线程中传播用户上下文 // 在异步线程中传播用户上下文
UserContextPropagationUtil.propagateUserContext(userContext); propagateUserContext(userContext);
// 执行原始任务 // 执行原始任务
runnable.run(); runnable.run();
} finally { } finally {
// 清理当前线程的用户上下文 // 清理当前线程的用户上下文
UserContextPropagationUtil.clearUserContext(); clearUserContext();
} }
}; };
} }
...@@ -44,18 +121,18 @@ public class AsyncUserContextDecorator { ...@@ -44,18 +121,18 @@ public class AsyncUserContextDecorator {
*/ */
public static <V> Callable<V> wrapWithContext(Callable<V> callable) { public static <V> Callable<V> wrapWithContext(Callable<V> callable) {
// 捕获当前线程的用户上下文 // 捕获当前线程的用户上下文
UserContextPropagationUtil.UserContextHolder userContext = UserContextPropagationUtil.captureUserContext(); UserContextHolder userContext = captureUserContext();
return () -> { return () -> {
try { try {
// 在异步线程中传播用户上下文 // 在异步线程中传播用户上下文
UserContextPropagationUtil.propagateUserContext(userContext); propagateUserContext(userContext);
// 执行原始任务 // 执行原始任务
return callable.call(); return callable.call();
} finally { } finally {
// 清理当前线程的用户上下文 // 清理当前线程的用户上下文
UserContextPropagationUtil.clearUserContext(); clearUserContext();
} }
}; };
} }
......
package pangea.hiagent.common.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 异步用户上下文使用示例
* 展示如何在异步任务中正确获取用户认证信息
*/
@Slf4j
@Component
public class AsyncUserContextUsageExample {
// 示例线程池
private final ExecutorService executorService = Executors.newFixedThreadPool(10);
/**
* 方式一:使用SecurityContextHolder的InheritableThreadLocal策略(推荐)
* 适用于父子线程关系明确的场景
*/
public void executeTaskWithInheritableThreadLocal() {
// 在主线程中获取用户ID(正常情况下可以获取到)
String userId = UserUtils.getCurrentUserId();
log.info("主线程中获取到用户ID: {}", userId);
// 提交异步任务,由于使用了InheritableThreadLocal策略,子线程可以继承父线程的SecurityContext
CompletableFuture.runAsync(() -> {
// 在异步线程中获取用户ID
String asyncUserId = UserUtils.getCurrentUserId();
log.info("异步线程中获取到用户ID: {}", asyncUserId);
// 执行业务逻辑
performBusinessLogic(asyncUserId);
}, executorService);
}
/**
* 方式二:使用UserContextPropagationUtil手动传播用户上下文
* 适用于复杂的异步场景或需要更精确控制的场景
*/
public void executeTaskWithManualPropagation() {
// 在主线程中获取用户ID
String userId = UserUtils.getCurrentUserId();
log.info("主线程中获取到用户ID: {}", userId);
// 提交异步任务,手动传播用户上下文
CompletableFuture.runAsync(AsyncUserContextDecorator.wrapWithContext(() -> {
// 在异步线程中获取用户ID
String asyncUserId = UserUtils.getCurrentUserId();
log.info("异步线程中获取到用户ID: {}", asyncUserId);
// 执行业务逻辑
performBusinessLogic(asyncUserId);
}), executorService);
}
/**
* 方式三:使用专门的异步环境获取方法
* 适用于无法通过线程上下文传播获取用户信息的场景
*/
public void executeTaskWithDirectTokenParsing() {
// 在主线程中获取用户ID
String userId = UserUtils.getCurrentUserId();
log.info("主线程中获取到用户ID: {}", userId);
// 提交异步任务,直接解析请求中的token获取用户ID
CompletableFuture.runAsync(() -> {
// 在异步线程中通过直接解析token获取用户ID
String asyncUserId = UserUtils.getCurrentUserIdInAsync();
log.info("异步线程中通过直接解析token获取到用户ID: {}", asyncUserId);
// 执行业务逻辑
performBusinessLogic(asyncUserId);
}, executorService);
}
/**
* 执行业务逻辑示例
* @param userId 用户ID
*/
private void performBusinessLogic(String userId) {
if (userId != null) {
log.info("为用户 {} 执行业务逻辑", userId);
// 这里执行具体的业务逻辑
} else {
log.warn("未获取到用户ID,执行匿名用户逻辑");
// 这里执行匿名用户的业务逻辑
}
}
/**
* 清理资源
*/
public void shutdown() {
executorService.shutdown();
}
}
\ No newline at end of file
package pangea.hiagent.common.utils;
public class Contants {
public static final String LOCATOR_SCHEMA = "{\n" +
" \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n" +
" \"type\": \"array\",\n" +
" \"items\": {\n" +
" \"type\": \"object\",\n" +
" \"properties\": {\n" +
" \"field_name\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"locator\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"label_tag\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"attributes\": {\n" +
" \"type\": \"object\",\n" +
" \"properties\": {\n" +
" \"type\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"maxlength\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"class\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"name\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"value\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"autocomplete\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"placeholder\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"readonly\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"id\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"droptreeids\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"vetitle\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"contenteditable\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"style\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"tipstext\": {\n" +
" \"type\": \"string\"\n" +
" },\n" +
" \"fylx\": {\n" +
" \"type\": \"string\"\n" +
" }\n" +
" },\n" +
" \"additionalProperties\": false,\n" +
" \"required\": [\n" +
" \"class\",\n" +
" \"value\"\n" +
" ]\n" +
" }\n" +
" },\n" +
" \"additionalProperties\": false,\n" +
" \"required\": [\n" +
" \"field_name\",\n" +
" \"locator\",\n" +
" \"attributes\"\n" +
" ]\n" +
" }\n" +
"}";
}
package pangea.hiagent.common.utils;
import java.security.SecureRandom;
import java.util.concurrent.atomic.AtomicLong;
public class HybridUniqueLongGenerator {
private static final SecureRandom random = new SecureRandom();
private static final AtomicLong counter = new AtomicLong(0);
public static long generateUnique13DigitNumber() {
long timestamp = System.currentTimeMillis();
long count = counter.incrementAndGet();
// 使用时间戳的前10位 + 计数器的后3位
long timestampPart = (timestamp / 1000) * 1000;
long counterPart = count % 1000;
return timestampPart + counterPart;
}
// 更随机的版本,但仍保证唯一
public static synchronized long generateRandomUnique() {
long timestamp = System.currentTimeMillis();
// 在时间戳基础上加上一个小的随机偏移
int randomOffset = random.nextInt(100);
long result = timestamp * 100 + randomOffset;
// 确保是13位
while (result >= 10000000000000L) {
result /= 10;
}
while (result < 1000000000000L) {
result *= 10;
result += random.nextInt(10);
}
return result;
}
}
\ No newline at end of file
package pangea.hiagent.common.utils;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicLong;
public class InputCodeGenerator {
private static final String CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private static final SecureRandom random = new SecureRandom();
private static final AtomicLong sequence = new AtomicLong(0);
public static String generateUniqueInputCode(String prefix) {
// 当前时间戳(毫秒)
long timestamp = System.currentTimeMillis();
// 序列号
long seq = sequence.incrementAndGet();
// 组合时间戳和序列号
long combined = (timestamp << 10) | (seq & 0x3FF); // 取序列号后10位
// 转为36进制
String code = Long.toString(Math.abs(combined), 36).toUpperCase();
// 确保8位长度
if (code.length() > 8) {
code = code.substring(code.length() - 8);
} else if (code.length() < 8) {
// 前面补随机字符
StringBuilder sb = new StringBuilder();
for (int i = code.length(); i < 8; i++) {
sb.append(CHARS.charAt(random.nextInt(CHARS.length())));
}
code = sb.toString() + code;
}
return prefix + code;
}
}
package pangea.hiagent.common.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.io.Serializable;
/**
* 用户上下文传播工具类
* 用于在异步线程间传播用户认证信息
*/
@Slf4j
@Component
public class UserContextPropagationUtil {
/**
* 用户上下文持有者类,用于在异步线程间传递认证信息
*/
public static class UserContextHolder implements Serializable {
private static final long serialVersionUID = 1L;
private final Authentication authentication;
public UserContextHolder(Authentication authentication) {
this.authentication = authentication;
}
public Authentication getAuthentication() {
return authentication;
}
}
/**
* 捕获当前线程的用户上下文
* @return 用户上下文持有者对象
*/
public static UserContextHolder captureUserContext() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
log.debug("捕获到当前线程的用户认证信息: {}", authentication.getPrincipal());
return new UserContextHolder(authentication);
} else {
log.debug("当前线程无用户认证信息");
return null;
}
} catch (Exception e) {
log.error("捕获用户上下文时发生异常", e);
return null;
}
}
/**
* 将用户上下文传播到当前线程
* @param userContextHolder 用户上下文持有者对象
*/
public static void propagateUserContext(UserContextHolder userContextHolder) {
try {
if (userContextHolder != null) {
Authentication authentication = userContextHolder.getAuthentication();
if (authentication != null) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
log.debug("已将用户认证信息传播到当前线程: {}", authentication.getPrincipal());
} else {
log.debug("用户上下文持有者中的认证信息为空");
}
} else {
log.debug("用户上下文持有者为空");
}
} catch (Exception e) {
log.error("传播用户上下文时发生异常", e);
}
}
/**
* 清理当前线程的用户上下文
*/
public static void clearUserContext() {
try {
SecurityContextHolder.clearContext();
log.debug("已清理当前线程的用户上下文");
} catch (Exception e) {
log.error("清理用户上下文时发生异常", e);
}
}
}
\ No newline at end of file
...@@ -26,11 +26,22 @@ public class UserUtils { ...@@ -26,11 +26,22 @@ public class UserUtils {
UserUtils.jwtUtil = jwtUtil; UserUtils.jwtUtil = jwtUtil;
} }
public static String getCurrentUserId() {
String username = getCurrentUserIdInSync();
if (username==null || username.isEmpty()) {
username = getCurrentUserIdInAsync();
}
return username;
}
/** /**
* 获取当前认证用户ID * 获取当前认证用户ID
*
* @return 用户ID,如果未认证则返回null * @return 用户ID,如果未认证则返回null
*/ */
public static String getCurrentUserId() { public static String getCurrentUserIdInSync() {
try { try {
// 首先尝试从SecurityContext获取 // 首先尝试从SecurityContext获取
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
...@@ -71,6 +82,7 @@ public class UserUtils { ...@@ -71,6 +82,7 @@ public class UserUtils {
/** /**
* 在异步线程环境中获取当前认证用户ID * 在异步线程环境中获取当前认证用户ID
* 该方法专为异步线程环境设计,通过JWT令牌解析获取用户ID * 该方法专为异步线程环境设计,通过JWT令牌解析获取用户ID
*
* @return 用户ID,如果未认证则返回null * @return 用户ID,如果未认证则返回null
*/ */
public static String getCurrentUserIdInAsync() { public static String getCurrentUserIdInAsync() {
...@@ -94,6 +106,7 @@ public class UserUtils { ...@@ -94,6 +106,7 @@ public class UserUtils {
/** /**
* 从当前请求中提取JWT令牌并解析用户ID * 从当前请求中提取JWT令牌并解析用户ID
*
* @return 用户ID,如果无法解析则返回null * @return 用户ID,如果无法解析则返回null
*/ */
private static String getUserIdFromRequest() { private static String getUserIdFromRequest() {
...@@ -161,6 +174,7 @@ public class UserUtils { ...@@ -161,6 +174,7 @@ public class UserUtils {
/** /**
* 检查当前用户是否已认证 * 检查当前用户是否已认证
*
* @return true表示已认证,false表示未认证 * @return true表示已认证,false表示未认证
*/ */
public static boolean isAuthenticated() { public static boolean isAuthenticated() {
...@@ -169,6 +183,7 @@ public class UserUtils { ...@@ -169,6 +183,7 @@ public class UserUtils {
/** /**
* 检查用户是否是管理员 * 检查用户是否是管理员
*
* @param userId 用户ID * @param userId 用户ID
* @return true表示是管理员,false表示不是管理员 * @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 pangea.hiagent.web.service.AgentService;
import pangea.hiagent.model.Agent;
import pangea.hiagent.common.utils.UserUtils;
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;
private final AgentService agentService;
public SseAuthorizationFilter(JwtUtil jwtUtil, AgentService agentService) {
this.jwtUtil = jwtUtil;
this.agentService = agentService;
}
/**
* 发送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);
}
}
/**
* 发送SSE格式的Agent不存在错误响应
*/
private void sendSseAgentNotFoundError(HttpServletResponse response) {
try {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// 发送SSE格式的错误事件
response.getWriter().write("event: error\n");
response.getWriter().write("data: {\"error\": \"Agent不存在\", \"code\": 404, \"timestamp\": " +
System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
log.debug("已发送SSE Agent不存在错误响应");
} catch (IOException e) {
log.error("发送SSE Agent不存在错误响应失败", e);
}
}
/**
* 发送SSE格式的访问拒绝错误响应
*/
private void sendSseAccessDeniedError(HttpServletResponse response) {
try {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// 发送SSE格式的错误事件
response.getWriter().write("event: error\n");
response.getWriter().write("data: {\"error\": \"访问被拒绝,无权限访问该Agent\", \"code\": 403, \"timestamp\": " +
System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
log.debug("已发送SSE 访问拒绝错误响应");
} catch (IOException e) {
log.error("发送SSE 访问拒绝错误响应失败", e);
}
}
@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);
// 检查响应是否已经提交,避免后续错误处理异常
if (response.isCommitted()) {
log.warn("响应已提交,无法处理SSE端点授权检查");
return;
}
// 从SecurityContext获取当前认证用户
String userId = null;
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated() && !"anonymousUser".equals(authentication.getPrincipal())) {
userId = authentication.getName();
}
if (userId != null) {
log.debug("SSE端点已认证,用户: {}", userId);
// 如果是chat-stream端点,需要额外验证agent权限
if (isStreamEndpoint) {
// 从请求参数中获取agentId
String agentId = request.getParameter("agentId");
if (agentId != null) {
try {
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
log.warn("SSE端点访问失败:Agent不存在 - AgentId: {}", agentId);
sendSseAgentNotFoundError(response);
return;
}
// 验证用户是否有权限访问该agent
if (!agent.getOwner().equals(userId) && !UserUtils.isAdminUser(userId)) {
log.warn("SSE端点访问失败:用户 {} 无权限访问Agent: {}", userId, agentId);
sendSseAccessDeniedError(response);
return;
}
log.debug("SSE端点Agent权限验证成功,用户: {}, Agent: {}", userId, agentId);
} catch (Exception e) {
log.error("SSE端点Agent权限验证异常: {}", e.getMessage());
sendSseAccessDeniedError(response);
return;
}
} else {
log.warn("SSE端点请求缺少agentId参数");
sendSseAgentNotFoundError(response);
return;
}
}
// 继续执行过滤器链
filterChain.doFilter(request, response);
return;
} else {
// 用户未认证,拒绝连接
log.warn("SSE端点未认证访问,拒绝连接: {} {}", request.getMethod(), requestUri);
sendSseUnauthorizedError(response);
return;
}
}
// 继续执行过滤器链(非SSE端点)
filterChain.doFilter(request, response);
}
/**
* 从请求头或参数中提取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
package pangea.hiagent.tool.aspect; // package pangea.hiagent.tool.aspect;
import lombok.extern.slf4j.Slf4j; // import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint; // import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around; // import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect; // import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature; // import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.ai.tool.annotation.Tool; // import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired; // import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; // import org.springframework.stereotype.Component;
import pangea.hiagent.workpanel.IWorkPanelDataCollector; // import pangea.hiagent.workpanel.IWorkPanelDataCollector;
import java.util.HashMap; // import java.util.HashMap;
import java.util.Map; // import java.util.Map;
/** // /**
* 工具执行日志记录切面类 // * 工具执行日志记录切面类
* 自动记录带有@Tool注解的方法执行信息,包括工具名称、方法名、输入参数、输出结果、运行时长等 // * 自动记录带有@Tool注解的方法执行信息,包括工具名称、方法名、输入参数、输出结果、运行时长等
*/ // */
@Slf4j // @Slf4j
@Aspect // @Aspect
@Component // @Component
public class ToolExecutionLoggerAspect { // public class ToolExecutionLoggerAspect {
@Autowired // @Autowired
private IWorkPanelDataCollector workPanelDataCollector; // private IWorkPanelDataCollector workPanelDataCollector;
/** // /**
* 环绕通知,拦截所有带有@Tool注解的方法 // * 环绕通知,拦截所有带有@Tool注解的方法
* @param joinPoint 连接点 // * @param joinPoint 连接点
* @return 方法执行结果 // * @return 方法执行结果
* @throws Throwable 异常 // * @throws Throwable 异常
*/ // */
@Around("@annotation(tool)") // @Around("@annotation(tool)")
public Object logToolExecution(ProceedingJoinPoint joinPoint, Tool tool) throws Throwable { // public Object logToolExecution(ProceedingJoinPoint joinPoint, Tool tool) throws Throwable {
// 获取方法签名 // // 获取方法签名
MethodSignature signature = (MethodSignature) joinPoint.getSignature(); // MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName = signature.getName(); // String methodName = signature.getName();
String className = signature.getDeclaringType().getSimpleName(); // String className = signature.getDeclaringType().getSimpleName();
String fullMethodName = className + "." + methodName; // String fullMethodName = className + "." + methodName;
// 获取工具描述 // // 获取工具描述
String toolDescription = tool.description(); // String toolDescription = tool.description();
// 获取方法参数 // // 获取方法参数
String[] paramNames = signature.getParameterNames(); // String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs(); // Object[] args = joinPoint.getArgs();
// 构建输入参数映射 // // 构建输入参数映射
Map<String, Object> inputParams = new HashMap<>(); // Map<String, Object> inputParams = new HashMap<>();
if (paramNames != null && args != null) { // if (paramNames != null && args != null) {
for (int i = 0; i < paramNames.length; i++) { // for (int i = 0; i < paramNames.length; i++) {
if (i < args.length) { // if (i < args.length) {
inputParams.put(paramNames[i], args[i]); // inputParams.put(paramNames[i], args[i]);
} // }
} // }
} // }
// 记录开始时间 // // 记录开始时间
long startTime = System.currentTimeMillis(); // long startTime = System.currentTimeMillis();
// 记录工具调用开始 // // 记录工具调用开始
if (workPanelDataCollector != null) { // if (workPanelDataCollector != null) {
try { // try {
workPanelDataCollector.recordToolCallAction(className, inputParams, null, "pending", null); // workPanelDataCollector.recordToolCallAction(className, inputParams, null, "pending", null);
} catch (Exception e) { // } catch (Exception e) {
log.warn("记录工具调用开始时发生错误: {}", e.getMessage(), e); // log.warn("记录工具调用开始时发生错误: {}", e.getMessage(), e);
} // }
} // }
log.info("开始执行工具方法: {},描述: {}", fullMethodName, toolDescription); // log.info("开始执行工具方法: {},描述: {}", fullMethodName, toolDescription);
log.debug("工具方法参数: {}", inputParams); // log.debug("工具方法参数: {}", inputParams);
try { // try {
// 执行原方法 // // 执行原方法
Object result = joinPoint.proceed(); // Object result = joinPoint.proceed();
// 记录结束时间 // // 记录结束时间
long endTime = System.currentTimeMillis(); // long endTime = System.currentTimeMillis();
long executionTime = endTime - startTime; // long executionTime = endTime - startTime;
// 记录工具调用完成 // // 记录工具调用完成
if (workPanelDataCollector != null) { // if (workPanelDataCollector != null) {
try { // try {
workPanelDataCollector.recordToolCallAction(className, inputParams, result, "success", executionTime); // workPanelDataCollector.recordToolCallAction(className, inputParams, result, "success", executionTime);
} catch (Exception e) { // } catch (Exception e) {
log.warn("记录工具调用完成时发生错误: {}", e.getMessage(), e); // log.warn("记录工具调用完成时发生错误: {}", e.getMessage(), e);
} // }
} // }
log.info("工具方法执行成功: {},描述: {},耗时: {}ms", fullMethodName, toolDescription, executionTime); // log.info("工具方法执行成功: {},描述: {},耗时: {}ms", fullMethodName, toolDescription, executionTime);
// 精简日志记录,避免过多的debug级别日志 // // 精简日志记录,避免过多的debug级别日志
if (log.isTraceEnabled()) { // if (log.isTraceEnabled()) {
log.trace("工具方法执行结果类型: {},结果: {}", // log.trace("工具方法执行结果类型: {},结果: {}",
result != null ? result.getClass().getSimpleName() : "null", result); // result != null ? result.getClass().getSimpleName() : "null", result);
} // }
return result; // return result;
} catch (Exception e) { // } catch (Exception e) {
// 记录结束时间 // // 记录结束时间
long endTime = System.currentTimeMillis(); // long endTime = System.currentTimeMillis();
long executionTime = endTime - startTime; // long executionTime = endTime - startTime;
// 记录工具调用错误 // // 记录工具调用错误
if (workPanelDataCollector != null) { // if (workPanelDataCollector != null) {
try { // try {
workPanelDataCollector.recordToolCallAction(className, inputParams, e, "error", executionTime); // workPanelDataCollector.recordToolCallAction(className, inputParams, e, "error", executionTime);
} catch (Exception ex) { // } catch (Exception ex) {
log.warn("记录工具调用错误时发生错误: {}", ex.getMessage(), ex); // log.warn("记录工具调用错误时发生错误: {}", ex.getMessage(), ex);
} // }
} // }
log.error("工具方法执行失败: {},描述: {},耗时: {}ms,错误类型: {}", // log.error("工具方法执行失败: {},描述: {},耗时: {}ms,错误类型: {}",
fullMethodName, toolDescription, executionTime, e.getClass().getSimpleName(), e); // fullMethodName, toolDescription, executionTime, e.getClass().getSimpleName(), e);
throw e; // throw e;
} // }
} // }
} // }
\ No newline at end of file \ No newline at end of file
...@@ -3,7 +3,7 @@ package pangea.hiagent.tool.impl; ...@@ -3,7 +3,7 @@ package pangea.hiagent.tool.impl;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import pangea.hiagent.tool.ToolParam;
/** /**
* 图表生成工具 * 图表生成工具
...@@ -13,35 +13,11 @@ import pangea.hiagent.tool.ToolParam; ...@@ -13,35 +13,11 @@ import pangea.hiagent.tool.ToolParam;
@Component @Component
public class ChartGenerationTool { public class ChartGenerationTool {
@ToolParam( private Integer maxDataPoints = 100;
name = "maxDataPoints",
description = "最大数据点数量限制", private Integer percentageDecimalPlaces = 2;
defaultValue = "100",
type = "integer", private String defaultSeriesName = "数据";
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;
/** /**
* 生成柱状图 * 生成柱状图
......
...@@ -3,10 +3,11 @@ package pangea.hiagent.tool.impl; ...@@ -3,10 +3,11 @@ package pangea.hiagent.tool.impl;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import pangea.hiagent.tool.ToolParam;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
/** /**
...@@ -17,37 +18,69 @@ import java.time.format.DateTimeFormatter; ...@@ -17,37 +18,69 @@ import java.time.format.DateTimeFormatter;
@Component @Component
public class DateTimeTools { public class DateTimeTools {
@ToolParam( private String dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
name = "dateTimeFormat",
description = "日期时间格式", private String dateFormat = "yyyy-MM-dd";
defaultValue = "yyyy-MM-dd HH:mm:ss",
type = "string", private String timeFormat = "HH:mm:ss";
required = true,
group = "datetime" @Tool(description = "获取当前日期和时间,返回格式为 'yyyy-MM-dd HH:mm:ss'")
)
private String dateTimeFormat;
@ToolParam(
name = "dateFormat",
description = "日期格式",
defaultValue = "yyyy-MM-dd",
type = "string",
required = true,
group = "datetime"
)
private String dateFormat;
@Tool(description = "获取当前日期和时间")
public String getCurrentDateTime() { public String getCurrentDateTime() {
try {
if (dateTimeFormat == null || dateTimeFormat.trim().isEmpty()) {
dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
}
String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateTimeFormat)); String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateTimeFormat));
log.debug("获取当前日期时间: {}", dateTime); log.info("【时间工具】获取当前日期时间: {}", dateTime);
return dateTime; return dateTime;
} catch (Exception e) {
log.error("获取当前日期时间时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
} }
@Tool(description = "获取当前日期") @Tool(description = "获取当前日期,返回格式为 'yyyy-MM-dd'")
public String getCurrentDate() { public String getCurrentDate() {
try {
if (dateFormat == null || dateFormat.trim().isEmpty()) {
dateFormat = "yyyy-MM-dd";
}
String date = LocalDate.now().format(DateTimeFormatter.ofPattern(dateFormat)); String date = LocalDate.now().format(DateTimeFormatter.ofPattern(dateFormat));
log.debug("获取当前日期: {}", date); log.info("【时间工具】获取当前日期: {}", date);
return date; return date;
} catch (Exception e) {
log.error("获取当前日期时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
}
@Tool(description = "获取当前时间,返回格式为 'HH:mm:ss'")
public String getCurrentTime() {
try {
if (timeFormat == null || timeFormat.trim().isEmpty()) {
timeFormat = "HH:mm:ss";
}
String time = LocalTime.now().format(DateTimeFormatter.ofPattern(timeFormat));
log.info("【时间工具】获取当前时间: {}", time);
return time;
} catch (Exception e) {
log.error("获取当前时间时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
}
}
@Tool(description = "获取当前时间戳(毫秒),返回自1970年1月1日00:00:00 UTC以来的毫秒数")
public String getCurrentTimeMillis() {
try {
long timestamp = System.currentTimeMillis();
log.info("【时间工具】获取当前时间戳: {}", timestamp);
return String.valueOf(timestamp);
} catch (Exception e) {
log.error("获取当前时间戳时发生错误: {}", e.getMessage(), e);
return String.valueOf(System.currentTimeMillis());
}
} }
} }
...@@ -10,7 +10,7 @@ import jakarta.mail.search.ReceivedDateTerm; ...@@ -10,7 +10,7 @@ import jakarta.mail.search.ReceivedDateTerm;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import pangea.hiagent.tool.ToolParam;
import java.io.File; import java.io.File;
import java.util.*; import java.util.*;
...@@ -23,45 +23,9 @@ import java.util.*; ...@@ -23,45 +23,9 @@ import java.util.*;
@Component @Component
public class EmailTools { public class EmailTools {
@ToolParam( private Boolean pop3SslEnable = true;
name = "defaultPop3Port",
description = "默认POP3服务器端口", private String pop3SocketFactoryClass = "javax.net.ssl.SSLSocketFactory";
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;
// 邮件请求参数类 // 邮件请求参数类
@JsonClassDescription("邮件操作请求参数") @JsonClassDescription("邮件操作请求参数")
......
...@@ -3,7 +3,7 @@ package pangea.hiagent.tool.impl; ...@@ -3,7 +3,7 @@ package pangea.hiagent.tool.impl;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import pangea.hiagent.tool.ToolParam;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.Charset; import java.nio.charset.Charset;
...@@ -24,37 +24,13 @@ import java.util.UUID; ...@@ -24,37 +24,13 @@ import java.util.UUID;
public class FileProcessingTools { public class FileProcessingTools {
// 支持的文本文件扩展名 // 支持的文本文件扩展名
@ToolParam( 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";
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;
// 支持的图片文件扩展名 // 支持的图片文件扩展名
@ToolParam( private String imageFileExtensions = ".jpg,.jpeg,.png,.gif,.bmp,.svg,.webp,.ico";
name = "imageFileExtensions",
description = "支持的图片文件扩展名,逗号分隔",
defaultValue = ".jpg,.jpeg,.png,.gif,.bmp,.svg,.webp,.ico",
type = "string",
required = true,
group = "file"
)
private String imageFileExtensions;
// 默认文件存储目录 // 默认文件存储目录
@ToolParam( private String defaultStorageDir = "storage";
name = "defaultStorageDir",
description = "默认文件存储目录",
defaultValue = "storage",
type = "string",
required = true,
group = "file"
)
private String defaultStorageDir;
// 转换为列表的辅助方法 // 转换为列表的辅助方法
private List<String> getTextFileExtensions() { 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; 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 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 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 @Slf4j
@Component @Component
public class HisenseSsoAuthTool { public class HisenseSsoAuthTool {
// SSO登录页面URL public HisenseSsoAuthTool() {
private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/"; log.warn("HisenseSsoAuthTool 已被弃用,请使用 HisenseSsoLoginTool 和 HisenseLbpmApprovalTool 代替");
// 用户名输入框选择器
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());
}
}
}*/
} }
/** // 此类不再包含任何功能实现
* 工具方法:海信SSO登录工具,用于登录海信SSO系统 // 所有功能已迁移到 HisenseSsoLoginTool 和 HisenseLbpmApprovalTool
*
* @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);
}
}
} }
\ 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 jakarta.annotation.PreDestroy;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
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";
// 登录成功后可能跳转的URL模式
private static final String[] SUCCESS_REDIRECT_URLS = {
"https://sso.hisense.com/selfcare/?#/profile",
"https://sso.hisense.com/selfcare/",
"https://sso.hisense.com/login/success",
"https://sso.hisense.com/dashboard"
};
// 等待URL超时时间(毫秒),从30秒增加到60秒
private static final int WAIT_FOR_URL_TIMEOUT = 60000;
// 等待URL超时时间(毫秒)用于MFA验证
private static final int MFA_WAIT_FOR_URL_TIMEOUT = 45000;
// 用户名输入框选择器
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());
}
/**
* 销毁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);
// 等待登录完成并重定向回业务系统
boolean redirected = waitForUrlWithMultipleOptions(page, new String[]{businessSystemUrl}, WAIT_FOR_URL_TIMEOUT);
if (!redirected) {
log.warn("未能在指定时间内重定向到业务系统页面,当前URL: {}", page.url());
} else {
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;
}
// 等待登录完成并重定向到SSO配置页面,使用更灵活的URL匹配和更长的超时时间
boolean profileRedirected = waitForSpecificUrl(page, SSO_PROFILE_URL, WAIT_FOR_URL_TIMEOUT);
if (!profileRedirected) {
// 如果没有跳转到预期的配置页面,检查是否跳转到了其他可能的登录成功页面
boolean alternativeRedirected = waitForUrlWithMultipleOptions(page, SUCCESS_REDIRECT_URLS, WAIT_FOR_URL_TIMEOUT);
if (!alternativeRedirected) {
log.warn("未能在指定时间内重定向到SSO配置页面,当前URL: {}", page.url());
} else {
log.info("登录成功,已重定向到SSO相关页面");
}
} else {
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());
}
}
}
}
/**
* 等待页面跳转到指定的多个URL选项中的任意一个
*
* @param page 要监控的页面
* @param urls 可能的目标URL数组
* @param timeout 超时时间(毫秒)
* @return 是否成功跳转到目标URL之一
*/
private boolean waitForUrlWithMultipleOptions(Page page, String[] urls, int timeout) {
long startTime = System.currentTimeMillis();
long endTime = startTime + timeout;
// 首先检查当前URL是否已经是目标URL之一
String currentUrl = page.url();
for (String url : urls) {
if (currentUrl.equals(url) || currentUrl.startsWith(url)) {
log.info("当前URL已经匹配目标URL: {}", currentUrl);
return true;
}
}
// 监控URL变化直到超时或匹配到目标URL
while (System.currentTimeMillis() < endTime) {
try {
// 等待URL变化 - 使用URL匹配器和超时选项
page.waitForURL(url -> {
// 检查新URL是否匹配目标URL之一
for (String targetUrl : urls) {
if (url.equals(targetUrl) || url.startsWith(targetUrl)) {
log.info("成功跳转到目标URL: {}", url);
return true;
}
}
return false;
}, new Page.WaitForURLOptions().setTimeout(1000)); // 短超时,用于检测变化
// 如果上面的waitForURL成功,说明已经匹配到目标URL
return true;
} catch (com.microsoft.playwright.TimeoutError e) {
// 1秒内URL未变化,继续轮询
String current = page.url();
for (String url : urls) {
if (current.equals(url) || current.startsWith(url)) {
log.info("检测到目标URL: {}", current);
return true;
}
}
} catch (Exception e) {
// 记录异常但继续轮询,因为可能是临时错误
log.debug("等待URL变化时发生异常: {}", e.getMessage());
}
// 短暂休眠以避免过度占用CPU
try {
Thread.sleep(500);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.warn("等待URL过程中被中断");
return false;
}
}
log.warn("等待URL超时,当前URL: {},期望URLs: {}", page.url(), String.join(",", urls));
return false;
}
/**
* 等待页面跳转到指定URL,使用更灵活的匹配方式
*
* @param page 要监控的页面
* @param expectedUrl 期望的目标URL
* @param timeout 超时时间(毫秒)
* @return 是否成功跳转到目标URL
*/
private boolean waitForSpecificUrl(Page page, String expectedUrl, int timeout) {
long startTime = System.currentTimeMillis();
long endTime = startTime + timeout;
while (System.currentTimeMillis() < endTime) {
try {
String currentUrl = page.url();
// 检查当前URL是否匹配
if (currentUrl.equals(expectedUrl) || currentUrl.startsWith(expectedUrl)) {
log.info("已匹配到目标URL: {}", currentUrl);
return true;
}
// 等待URL变化到期望的URL
page.waitForURL(url -> url.equals(expectedUrl) || url.startsWith(expectedUrl),
new Page.WaitForURLOptions().setTimeout(1000));
// 如果上面的waitForURL成功,说明已经匹配到目标URL
return true;
} catch (com.microsoft.playwright.TimeoutError e) {
// 1秒内URL未变化,继续轮询
String current = page.url();
if (current.equals(expectedUrl) || current.startsWith(expectedUrl)) {
log.info("检测到目标URL: {}", current);
return true;
}
} catch (Exception e) {
// 检查当前URL是否匹配,因为异常可能表示页面状态变化
try {
String current = page.url();
if (current.equals(expectedUrl) || current.startsWith(expectedUrl)) {
log.info("异常后检测到目标URL: {}", current);
return true;
}
} catch (Exception urlException) {
log.debug("获取当前URL时发生异常: {}", urlException.getMessage());
}
// 如果是TargetClosedError,直接返回失败
if (e instanceof com.microsoft.playwright.impl.TargetClosedError) {
log.error("页面或浏览器上下文已关闭: {}", e.getMessage());
return false;
}
}
// 短暂休眠
try {
Thread.sleep(500);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.warn("等待URL过程中被中断");
return false;
}
}
log.warn("等待特定URL超时,当前URL: {},期望URL: {}", page.url(), expectedUrl);
return false;
}
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页面,使用轮询方式检查URL变化
boolean pageLeftMfa = false;
long mfaStartTime = System.currentTimeMillis();
long mfaEndTime = mfaStartTime + MFA_WAIT_FOR_URL_TIMEOUT;
while (System.currentTimeMillis() < mfaEndTime) {
String currentUrl = mfaPage.url();
if (!currentUrl.equals(SSO_MFA_URL)) {
pageLeftMfa = true;
log.info("MFA验证成功,已跳转到: {}", currentUrl);
break;
}
// 短暂休眠后继续检查
try {
Thread.sleep(1000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.warn("MFA等待过程中被中断");
break;
}
}
if (pageLeftMfa) {
// 从缓存中移除会话,因为登录已完成
mfaSessions.remove(username);
// 更新登录时间
lastLoginTime = System.currentTimeMillis();
long endTime = System.currentTimeMillis();
log.info("MFA验证完成,耗时: {} ms", endTime - startTime);
return "MFA验证成功,登录完成";
} else {
// 如果仍在MFA页面,说明可能超时但验证仍在进行中,也认为成功
log.info("MFA验证可能仍在进行中,当前仍在MFA页面");
// 从缓存中移除会话
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);
}
}
}
...@@ -262,7 +262,21 @@ public class PlaywrightWebTools { ...@@ -262,7 +262,21 @@ public class PlaywrightWebTools {
return executeWithPage(url, page -> { return executeWithPage(url, page -> {
// 获取所有a标签的href属性 // 获取所有a标签的href属性
Object result = page.locator("a").evaluateAll("elements => elements.map(el => el.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); return links.isEmpty() ? "未找到任何链接" : String.join(", ", links);
}); });
} }
......
...@@ -5,7 +5,6 @@ import org.springframework.web.bind.annotation.*; ...@@ -5,7 +5,6 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.agent.service.AgentChatService; import pangea.hiagent.agent.service.AgentChatService;
import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.web.dto.ChatRequest; import pangea.hiagent.web.dto.ChatRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
...@@ -41,13 +40,14 @@ public class AgentChatController { ...@@ -41,13 +40,14 @@ public class AgentChatController {
HttpServletResponse response) { HttpServletResponse response) {
log.info("接收到流式对话请求,AgentId: {}", agentId); log.info("接收到流式对话请求,AgentId: {}", agentId);
// 检查用户权限 // 注意:权限检查已由 SseAuthorizationFilter 在更早的阶段处理
String userId = UserUtils.getCurrentUserId(); // 此时响应尚未开始流式传输,确保在流式开始前完成所有权限验证
if (userId == null) { // 这样可以避免在流式传输过程中突然抛出异常导致响应已提交的问题
log.warn("用户未认证,无法执行Agent对话");
throw new org.springframework.security.access.AccessDeniedException("用户未认证"); // 仅验证Agent存在性,权限检查由过滤器处理
} // 为避免安全异常,这里不直接调用agentService.getAgent(),而是让agentChatService处理
// 调用异步处理
return agentChatService.handleChatStream(agentId, chatRequest, response); return agentChatService.handleChatStream(agentId, chatRequest, response);
} }
} }
\ No newline at end of file
...@@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RestController; ...@@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RestController;
import pangea.hiagent.document.KnowledgeBaseInitializationService; import pangea.hiagent.document.KnowledgeBaseInitializationService;
import pangea.hiagent.web.dto.ApiResponse; import pangea.hiagent.web.dto.ApiResponse;
import pangea.hiagent.tool.ToolBeanNameInitializer;
/** /**
* 系统管理控制器 * 系统管理控制器
...@@ -23,31 +23,12 @@ import pangea.hiagent.tool.ToolBeanNameInitializer; ...@@ -23,31 +23,12 @@ import pangea.hiagent.tool.ToolBeanNameInitializer;
@Tag(name = "系统管理", description = "系统管理相关API") @Tag(name = "系统管理", description = "系统管理相关API")
public class SystemAdminController { public class SystemAdminController {
@Autowired
private ToolBeanNameInitializer toolBeanNameInitializer;
@Autowired @Autowired
private KnowledgeBaseInitializationService knowledgeBaseInitializationService; 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 lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; // import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; // import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.agent.sse.UserSseService; // import pangea.hiagent.agent.sse.UserSseService;
import pangea.hiagent.common.utils.UserUtils; // import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.workpanel.event.EventService; // import pangea.hiagent.workpanel.event.EventService;
/** // /**
* 时间轴事件控制器 // * 时间轴事件控制器
* 提供ReAct过程的实时事件推送功能 // * 提供ReAct过程的实时事件推送功能
*/ // */
@Slf4j // @Slf4j
@RestController // @RestController
@RequestMapping("/api/v1/agent") // @RequestMapping("/api/v1/agent")
public class TimelineEventController { // public class TimelineEventController {
private final UserSseService workPanelSseService; // private final UserSseService workPanelSseService;
public TimelineEventController(UserSseService workPanelSseService, EventService eventService) { // public TimelineEventController(UserSseService workPanelSseService, EventService eventService) {
this.workPanelSseService = workPanelSseService; // this.workPanelSseService = workPanelSseService;
} // }
/** // /**
* 订阅时间轴事件 // * 订阅时间轴事件
* 支持 SSE (Server-Sent Events) 格式的实时事件推送 // * 支持 SSE (Server-Sent Events) 格式的实时事件推送
* // *
* @return SSE emitter // * @return SSE emitter
*/ // */
@GetMapping("/timeline-events") // @GetMapping("/timeline-events")
public SseEmitter subscribeTimelineEvents() { // public SseEmitter subscribeTimelineEvents() {
log.info("开始处理时间轴事件订阅请求"); // log.info("开始处理时间轴事件订阅请求");
// 获取当前认证用户ID // // 获取当前认证用户ID
String userId = UserUtils.getCurrentUserId(); // String userId = UserUtils.getCurrentUserId();
if (userId == null) { // if (userId == null) {
log.warn("用户未认证,无法创建时间轴事件订阅"); // log.warn("用户未认证,无法创建时间轴事件订阅");
throw new org.springframework.security.access.AccessDeniedException("用户未认证"); // throw new org.springframework.security.access.AccessDeniedException("用户未认证");
} // }
log.info("开始为用户 {} 创建SSE连接", userId); // log.info("开始为用户 {} 创建SSE连接", userId);
// 创建并注册SSE连接 // // 创建并注册SSE连接
return workPanelSseService.createAndRegisterConnection(userId); // return workPanelSseService.createAndRegisterConnection(userId);
} // }
} // }
\ No newline at end of file \ No newline at end of file
...@@ -16,7 +16,6 @@ import pangea.hiagent.model.Tool; ...@@ -16,7 +16,6 @@ import pangea.hiagent.model.Tool;
import pangea.hiagent.web.repository.AgentDialogueRepository; import pangea.hiagent.web.repository.AgentDialogueRepository;
import pangea.hiagent.web.repository.AgentRepository; import pangea.hiagent.web.repository.AgentRepository;
import pangea.hiagent.web.repository.LlmConfigRepository; import pangea.hiagent.web.repository.LlmConfigRepository;
import pangea.hiagent.web.service.AgentToolRelationService;
import pangea.hiagent.common.utils.UserUtils; import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.llm.LlmModelFactory; import pangea.hiagent.llm.LlmModelFactory;
......
...@@ -319,7 +319,7 @@ public class TimerService { ...@@ -319,7 +319,7 @@ public class TimerService {
// 只有当JSON不是空对象且不为空字符串时才进行解析 // 只有当JSON不是空对象且不为空字符串时才进行解析
if (!"{}".equals(cleanParamsJson) && !cleanParamsJson.isEmpty() if (!"{}".equals(cleanParamsJson) && !cleanParamsJson.isEmpty()
&& !"\"\"".equals(cleanParamsJson)) { && !"\"\"".equals(cleanParamsJson)) {
params = objectMapper.readValue(cleanParamsJson, HashMap.class); params = objectMapper.readValue(cleanParamsJson, new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
} }
} catch (Exception e) { } catch (Exception e) {
log.error("解析参数JSON失败: {}", timerConfig.getParamsJson(), e); log.error("解析参数JSON失败: {}", timerConfig.getParamsJson(), e);
......
package pangea.hiagent.web.service; package pangea.hiagent.web.service;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import pangea.hiagent.model.ToolConfig; import pangea.hiagent.model.ToolConfig;
import java.util.List; import java.util.List;
...@@ -12,59 +15,69 @@ import java.util.Map; ...@@ -12,59 +15,69 @@ import java.util.Map;
public interface ToolConfigService { public interface ToolConfigService {
/** /**
* 根据工具名称获取参数配置 * 根据工具名称获取参数配置(带缓存)
* @param toolName 工具名称 * @param toolName 工具名称
* @return 参数配置键值对 * @return 参数配置键值对
*/ */
@Cacheable(value = "toolConfigByToolName", key = "#toolName")
Map<String, String> getToolParams(String toolName); Map<String, String> getToolParams(String toolName);
/** /**
* 根据工具名称和参数名称获取参数值 * 根据工具名称和参数名称获取参数值(带缓存)
* @param toolName 工具名称 * @param toolName 工具名称
* @param paramName 参数名称 * @param paramName 参数名称
* @return 参数值 * @return 参数值
*/ */
@Cacheable(value = "toolConfig", key = "#toolName + '_' + #paramName")
String getParamValue(String toolName, String paramName); String getParamValue(String toolName, String paramName);
/** /**
* 保存参数值 * 保存参数值(自动清除缓存)
* @param toolName 工具名称 * @param toolName 工具名称
* @param paramName 参数名称 * @param paramName 参数名称
* @param paramValue 参数值 * @param paramValue 参数值
*/ */
@CacheEvict(value = "toolConfig", key = "#toolName + '_' + #paramName")
void saveParamValue(String toolName, String paramName, String paramValue); void saveParamValue(String toolName, String paramName, String paramValue);
/** /**
* 获取所有工具配置 * 获取所有工具配置(带缓存)
* @return 工具配置列表 * @return 工具配置列表
*/ */
@Cacheable(value = "allToolConfigs", key = "'all'")
List<ToolConfig> getAllToolConfigs(); List<ToolConfig> getAllToolConfigs();
/** /**
* 根据工具名称和参数名称获取工具配置 * 根据工具名称和参数名称获取工具配置(带缓存)
* @param toolName 工具名称 * @param toolName 工具名称
* @param paramName 参数名称 * @param paramName 参数名称
* @return 工具配置对象 * @return 工具配置对象
*/ */
@Cacheable(value = "toolConfig", key = "#toolName + '_' + #paramName")
ToolConfig getToolConfig(String toolName, String paramName); ToolConfig getToolConfig(String toolName, String paramName);
/** /**
* 保存工具配置 * 保存工具配置(自动清除相关缓存)
* @param toolConfig 工具配置对象 * @param toolConfig 工具配置对象
* @return 保存后的工具配置对象 * @return 保存后的工具配置对象
*/ */
@CacheEvict(value = {"toolConfig", "toolConfigByToolName", "toolConfigsByToolName"},
key = "#toolConfig.toolName + '_' + #toolConfig.paramName")
ToolConfig saveToolConfig(ToolConfig toolConfig); ToolConfig saveToolConfig(ToolConfig toolConfig);
/** /**
* 删除工具配置 * 删除工具配置(自动清除相关缓存)
* @param id 配置ID * @param id 配置ID
*/ */
@CacheEvict(value = {"toolConfig", "toolConfigByToolName", "toolConfigsByToolName"},
allEntries = true) // 删除配置时清除所有缓存,因为不知道具体工具名
void deleteToolConfig(String id); void deleteToolConfig(String id);
/** /**
* 根据工具名称获取工具配置列表 * 根据工具名称获取工具配置列表(带缓存)
* @param toolName 工具名称 * @param toolName 工具名称
* @return 工具配置列表 * @return 工具配置列表
*/ */
@Cacheable(value = "toolConfigsByToolName", key = "#toolName")
List<ToolConfig> getToolConfigsByToolName(String toolName); List<ToolConfig> getToolConfigsByToolName(String toolName);
} }
\ No newline at end of file
...@@ -8,6 +8,7 @@ import com.microsoft.playwright.Page; ...@@ -8,6 +8,7 @@ import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.LoadState; import com.microsoft.playwright.options.LoadState;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.common.utils.AsyncUserContextDecorator;
/** /**
* 指令处理器 * 指令处理器
...@@ -167,7 +168,8 @@ public class CommandProcessor { ...@@ -167,7 +168,8 @@ public class CommandProcessor {
page.navigate(param); page.navigate(param);
// 异步处理页面加载完成后的DOM发送,避免阻塞WebSocket // 异步处理页面加载完成后的DOM发送,避免阻塞WebSocket
java.util.concurrent.CompletableFuture.runAsync(() -> { // 使用AsyncUserContextDecorator包装以传播用户上下文
java.util.concurrent.CompletableFuture.runAsync(AsyncUserContextDecorator.wrapWithContext(() -> {
try { try {
// 等待页面加载状态:DOMCONTENTLOADED确保DOM可用 // 等待页面加载状态:DOMCONTENTLOADED确保DOM可用
log.debug("等待页面DOM加载: {}", param); log.debug("等待页面DOM加载: {}", param);
...@@ -186,7 +188,7 @@ public class CommandProcessor { ...@@ -186,7 +188,7 @@ public class CommandProcessor {
// 重要:必须将错误发送给前端 // 重要:必须将错误发送给前端
messageSender.sendErrorToClients(errorMsg); messageSender.sendErrorToClients(errorMsg);
} }
}); }));
} catch (Exception e) { } catch (Exception e) {
String errorMsg = "导航命令执行失败:" + e.getMessage(); String errorMsg = "导航命令执行失败:" + e.getMessage();
log.error(errorMsg, e); log.error(errorMsg, e);
...@@ -260,7 +262,7 @@ public class CommandProcessor { ...@@ -260,7 +262,7 @@ public class CommandProcessor {
// 等待元素可见,最多等待10秒 // 等待元素可见,最多等待10秒
locator.waitFor(new Locator.WaitForOptions() locator.waitFor(new Locator.WaitForOptions()
.setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE) .setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE)
.setTimeout(10000)); .setTimeout(30000));
// 执行hover操作 // 执行hover操作
locator.hover(); locator.hover();
......
...@@ -4,6 +4,7 @@ import com.microsoft.playwright.*; ...@@ -4,6 +4,7 @@ import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState; import com.microsoft.playwright.options.LoadState;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.workpanel.playwright.PlaywrightManager; import pangea.hiagent.workpanel.playwright.PlaywrightManager;
import pangea.hiagent.common.utils.AsyncUserContextDecorator;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
...@@ -202,7 +203,8 @@ public class DomSyncService { ...@@ -202,7 +203,8 @@ public class DomSyncService {
page.onFrameNavigated(frame -> { page.onFrameNavigated(frame -> {
incrementCounter("navigations"); incrementCounter("navigations");
// 异步处理导航完成后的DOM发送,避免阻塞 // 异步处理导航完成后的DOM发送,避免阻塞
java.util.concurrent.CompletableFuture.runAsync(() -> { // 使用AsyncUserContextDecorator包装以传播用户上下文
java.util.concurrent.CompletableFuture.runAsync(AsyncUserContextDecorator.wrapWithContext(() -> {
try { try {
// 使用更宽松的等待条件,避免NETWORKIDLE可能出现的问题 // 使用更宽松的等待条件,避免NETWORKIDLE可能出现的问题
page.waitForLoadState(LoadState.DOMCONTENTLOADED); page.waitForLoadState(LoadState.DOMCONTENTLOADED);
...@@ -220,7 +222,7 @@ public class DomSyncService { ...@@ -220,7 +222,7 @@ public class DomSyncService {
incrementCounter("errors"); incrementCounter("errors");
messageSender.sendErrorToClients(errorMsg); messageSender.sendErrorToClients(errorMsg);
} }
}); }));
}); });
// 5. 监听页面错误事件 // 5. 监听页面错误事件
page.onPageError(error -> { page.onPageError(error -> {
......
...@@ -6,7 +6,7 @@ import org.springframework.stereotype.Component; ...@@ -6,7 +6,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.workpanel.event.EventDeduplicationService; import pangea.hiagent.workpanel.event.EventDeduplicationService;
import pangea.hiagent.workpanel.event.EventService; import pangea.hiagent.workpanel.event.EventService;
import pangea.hiagent.agent.sse.UserSseService; import pangea.hiagent.agent.service.UserSseService;
import pangea.hiagent.web.dto.LogEvent; import pangea.hiagent.web.dto.LogEvent;
import pangea.hiagent.web.dto.ResultEvent; import pangea.hiagent.web.dto.ResultEvent;
import pangea.hiagent.web.dto.ThoughtEvent; import pangea.hiagent.web.dto.ThoughtEvent;
...@@ -384,7 +384,7 @@ public class WorkPanelDataCollector implements IWorkPanelDataCollector { ...@@ -384,7 +384,7 @@ public class WorkPanelDataCollector implements IWorkPanelDataCollector {
// 通过EventService发送事件到所有SSE连接 // 通过EventService发送事件到所有SSE连接
for (SseEmitter emitter : unifiedSseService.getEmitters()) { for (SseEmitter emitter : unifiedSseService.getEmitters()) {
try { try {
eventService.sendWorkPanelEvent(emitter, event); unifiedSseService.sendWorkPanelEvent(emitter, event);
} catch (Exception e) { } catch (Exception e) {
log.debug("通过EventService发送事件失败: {}", e.getMessage()); log.debug("通过EventService发送事件失败: {}", e.getMessage());
} }
......
...@@ -249,6 +249,16 @@ public class EventService { ...@@ -249,6 +249,16 @@ public class EventService {
} }
try { try {
// 检查emitter是否已经完成,避免向已完成的连接发送数据
java.lang.reflect.Field completedField = SseEmitter.class.getDeclaredField("completed");
completedField.setAccessible(true);
boolean isCompleted = completedField.getBoolean(emitter);
if (isCompleted) {
log.debug("SSE emitter已完成,跳过发送工作面板事件");
return;
}
// 构建事件数据 // 构建事件数据
Map<String, Object> data = buildWorkPanelEventData(event); Map<String, Object> data = buildWorkPanelEventData(event);
...@@ -263,15 +273,17 @@ public class EventService { ...@@ -263,15 +273,17 @@ public class EventService {
} else { } else {
log.warn("构建事件数据失败,无法发送事件: 类型={}", event.getType()); log.warn("构建事件数据失败,无法发送事件: 类型={}", event.getType());
} }
} catch (IllegalStateException e) {
// 处理 emitter 已关闭的情况
log.debug("无法发送工作面板事件,emitter已关闭或完成: {}", e.getMessage());
// 不重新抛出异常,避免影响主流程
} catch (Exception e) { } catch (Exception e) {
// 记录详细错误信息,但不中断主流程 // 记录详细错误信息,但不中断主流程
if (!(e instanceof java.lang.reflect.InaccessibleObjectException) && !(e instanceof java.lang.NoSuchFieldException)) {
log.error("发送工作面板事件失败: 类型={}, 错误={}", event.getType(), e.getMessage(), e); log.error("发送工作面板事件失败: 类型={}, 错误={}", event.getType(), e.getMessage(), e);
// 如果是连接已关闭的异常,重新抛出以便上层处理
if (e instanceof IllegalStateException && e.getMessage() != null &&
e.getMessage().contains("Emitter is already completed")) {
throw e;
} }
// 其他异常不重新抛出,避免影响主流程
} }
} }
...@@ -311,6 +323,16 @@ public class EventService { ...@@ -311,6 +323,16 @@ public class EventService {
} }
try { try {
// 检查emitter是否已经完成,避免向已完成的连接发送数据
java.lang.reflect.Field completedField = SseEmitter.class.getDeclaredField("completed");
completedField.setAccessible(true);
boolean isCompleted = completedField.getBoolean(emitter);
if (isCompleted) {
log.debug("SSE emitter已完成,跳过发送错误事件");
return;
}
// 构建错误事件数据 // 构建错误事件数据
Map<String, Object> data = errorEventDataBuilder.createErrorEventData(errorMessage); Map<String, Object> data = errorEventDataBuilder.createErrorEventData(errorMessage);
...@@ -325,15 +347,17 @@ public class EventService { ...@@ -325,15 +347,17 @@ public class EventService {
} else { } else {
log.warn("构建错误事件数据失败,无法发送事件"); log.warn("构建错误事件数据失败,无法发送事件");
} }
} catch (IllegalStateException e) {
// 处理 emitter 已关闭的情况
log.debug("无法发送错误事件,emitter已关闭或完成: {}", e.getMessage());
// 不重新抛出异常,避免影响主流程
} catch (Exception e) { } catch (Exception e) {
// 记录详细错误信息,但不中断主流程 // 记录详细错误信息,但不中断主流程
if (!(e instanceof java.lang.reflect.InaccessibleObjectException) && !(e instanceof java.lang.NoSuchFieldException)) {
log.error("发送错误事件失败: 错误信息={}, 错误={}", errorMessage, e.getMessage(), e); log.error("发送错误事件失败: 错误信息={}, 错误={}", errorMessage, e.getMessage(), e);
// 如果是连接已关闭的异常,重新抛出以便上层处理
if (e instanceof IllegalStateException && e.getMessage() != null &&
e.getMessage().contains("Emitter is already completed")) {
throw e;
} }
// 其他异常不重新抛出,避免影响主流程
} }
} }
...@@ -351,28 +375,40 @@ public class EventService { ...@@ -351,28 +375,40 @@ public class EventService {
} }
try { try {
// 检查emitter是否已经完成,避免向已完成的连接发送数据
java.lang.reflect.Field completedField = SseEmitter.class.getDeclaredField("completed");
completedField.setAccessible(true);
boolean isCompleted = completedField.getBoolean(emitter);
if (isCompleted) {
log.debug("SSE emitter已完成,跳过发送token事件");
return;
}
// 构建token事件数据 // 构建token事件数据
Map<String, Object> data = tokenEventDataBuilder.createOptimizedTokenEventData(token); Map<String, Object> data = tokenEventDataBuilder.createOptimizedTokenEventData(token);
if (data != null) { 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)); emitter.send(SseEmitter.event().name("token").data(data));
log.debug("token事件发送成功"); // log.debug("token事件发送成功");
} else { } else {
log.warn("构建token事件数据失败,无法发送事件"); log.warn("构建token事件数据失败,无法发送事件");
} }
} catch (IllegalStateException e) {
// 处理 emitter 已关闭的情况
log.debug("无法发送token事件,emitter已关闭或完成: {}", e.getMessage());
// 不重新抛出异常,避免影响主流程
} catch (Exception e) { } catch (Exception e) {
// 记录详细错误信息,但不中断主流程 // 记录详细错误信息,但不中断主流程
if (!(e instanceof java.lang.reflect.InaccessibleObjectException) && !(e instanceof java.lang.NoSuchFieldException)) {
log.error("发送token事件失败: token长度={}, 错误={}", token != null ? token.length() : 0, e.getMessage(), e); log.error("发送token事件失败: token长度={}, 错误={}", token != null ? token.length() : 0, e.getMessage(), e);
// 如果是连接已关闭的异常,重新抛出以便上层处理
if (e instanceof IllegalStateException && e.getMessage() != null &&
e.getMessage().contains("Emitter is already completed")) {
throw e;
} }
// 其他异常不重新抛出,避免影响主流程
} }
} }
......
package pangea.hiagent.workpanel.event; package pangea.hiagent.workpanel.event;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.agent.sse.UserSseService; import pangea.hiagent.agent.service.UserSseService;
import pangea.hiagent.web.dto.ToolEvent; import pangea.hiagent.web.dto.ToolEvent;
import pangea.hiagent.web.dto.WorkPanelEvent; import pangea.hiagent.web.dto.WorkPanelEvent;
......
...@@ -3,9 +3,7 @@ package pangea.hiagent.workpanel.playwright; ...@@ -3,9 +3,7 @@ package pangea.hiagent.workpanel.playwright;
import com.microsoft.playwright.*; import com.microsoft.playwright.*;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.context.annotation.Lazy;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import java.util.concurrent.*; import java.util.concurrent.*;
...@@ -14,8 +12,7 @@ import java.util.concurrent.*; ...@@ -14,8 +12,7 @@ import java.util.concurrent.*;
* 负责统一管理Playwright实例和用户隔离的BrowserContext * 负责统一管理Playwright实例和用户隔离的BrowserContext
*/ */
@Slf4j @Slf4j
@Component @Component // Spring默认单例模式
@Lazy
public class PlaywrightManagerImpl implements PlaywrightManager { public class PlaywrightManagerImpl implements PlaywrightManager {
// 共享的Playwright实例 // 共享的Playwright实例
...@@ -34,7 +31,12 @@ public class PlaywrightManagerImpl implements PlaywrightManager { ...@@ -34,7 +31,12 @@ public class PlaywrightManagerImpl implements PlaywrightManager {
private static final long CONTEXT_TIMEOUT = 30 * 60 * 1000; 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; private volatile boolean initialized = false;
...@@ -64,11 +66,8 @@ public class PlaywrightManagerImpl implements PlaywrightManager { ...@@ -64,11 +66,8 @@ public class PlaywrightManagerImpl implements PlaywrightManager {
"--disable-gpu", "--disable-gpu",
"--remote-allow-origins=*"))); "--remote-allow-origins=*")));
// 初始化清理任务调度器
this.cleanupScheduler = Executors.newSingleThreadScheduledExecutor();
// 每5分钟检查一次超时的用户上下文 // 每5分钟检查一次超时的用户上下文
this.cleanupScheduler.scheduleAtFixedRate(this::cleanupExpiredContexts, cleanupScheduler.scheduleAtFixedRate(this::cleanupExpiredContexts,
5, 5, TimeUnit.MINUTES); 5, 5, TimeUnit.MINUTES);
this.initialized = true; this.initialized = true;
...@@ -112,8 +111,8 @@ public class PlaywrightManagerImpl implements PlaywrightManager { ...@@ -112,8 +111,8 @@ public class PlaywrightManagerImpl implements PlaywrightManager {
public BrowserContext getUserContext(String userId) { public BrowserContext getUserContext(String userId) {
lazyInitialize(); lazyInitialize();
Browser.NewContextOptions options = new Browser.NewContextOptions() Browser.NewContextOptions options = new Browser.NewContextOptions()
.setViewportSize(1344, 2992) // 设置视口大小,与前端一致;手机型号:Google Pixel 9 Pro XL .setViewportSize(1920, 1080) // 设置视口大小为全高清分辨率,适用于Windows 11桌面环境
.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"); // 设置用户代理 .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); return getUserContext(userId, options);
} }
...@@ -132,7 +131,7 @@ public class PlaywrightManagerImpl implements PlaywrightManager { ...@@ -132,7 +131,7 @@ public class PlaywrightManagerImpl implements PlaywrightManager {
BrowserContext context = userContexts.get(userId); BrowserContext context = userContexts.get(userId);
// 如果上下文不存在或已关闭,则创建新的 // 如果上下文不存在或已关闭,则创建新的
if (context == null || context.pages().isEmpty()) { if (context == null || isContextClosed(context)) {
try { try {
log.debug("为用户 {} 创建新的浏览器上下文", userId); log.debug("为用户 {} 创建新的浏览器上下文", userId);
context = browser.newContext(options); context = browser.newContext(options);
...@@ -166,6 +165,24 @@ public class PlaywrightManagerImpl implements PlaywrightManager { ...@@ -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 { ...@@ -192,12 +209,10 @@ public class PlaywrightManagerImpl implements PlaywrightManager {
try { try {
// 关闭清理任务调度器 // 关闭清理任务调度器
if (cleanupScheduler != null) {
cleanupScheduler.shutdown(); cleanupScheduler.shutdown();
if (!cleanupScheduler.awaitTermination(5, TimeUnit.SECONDS)) { if (!cleanupScheduler.awaitTermination(5, TimeUnit.SECONDS)) {
cleanupScheduler.shutdownNow(); cleanupScheduler.shutdownNow();
} }
}
} catch (Exception e) { } catch (Exception e) {
log.warn("关闭清理任务调度器时发生异常", e); log.warn("关闭清理任务调度器时发生异常", e);
} }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
spring: spring:
# 开发环境数据源配置 # 开发环境数据源配置
datasource: 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 driver-class-name: org.h2.Driver
username: sa username: sa
password: sa password: sa
...@@ -10,12 +10,19 @@ spring: ...@@ -10,12 +10,19 @@ spring:
# 开发环境JPA配置 # 开发环境JPA配置
jpa: jpa:
hibernate: hibernate:
ddl-auto: create-drop ddl-auto: create # 开发环境:启动时创建表结构,关闭时删除表结构,实现重新初始化
show-sql: true show-sql: true
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
# SQL初始化配置 - 开发环境使用SQL脚本初始化数据
sql:
init:
schema-locations: classpath:schema.sql
data-locations: classpath:data.sql
mode: always # 总是执行创建表和数据脚本,实现重新初始化
# 开启H2控制台 # 开启H2控制台
h2: h2:
console: console:
...@@ -31,20 +38,22 @@ spring: ...@@ -31,20 +38,22 @@ spring:
# 开发环境详细日志配置 # 开发环境详细日志配置
logging: logging:
level: level:
root: INFO root: WARN
pangea.hiagent: DEBUG pangea.hiagent: DEBUG
pangea.hiagent.websocket: TRACE pangea.hiagent.websocket: DEBUG
pangea.hiagent.service: DEBUG pangea.hiagent.service: DEBUG
pangea.hiagent.controller: DEBUG pangea.hiagent.controller: DEBUG
pangea.hiagent.tools: DEBUG pangea.hiagent.tools: DEBUG
org.springframework: INFO org.springframework: WARN
org.springframework.web: INFO org.springframework.web: WARN
org.springframework.security: INFO org.springframework.security: WARN
org.springframework.web.socket: INFO org.springframework.web.socket: WARN
org.springframework.web.socket.handler: INFO org.springframework.web.socket.handler: WARN
org.springframework.web.socket.messaging: INFO org.springframework.web.socket.messaging: WARN
org.hibernate.SQL: INFO org.hibernate.SQL: WARN
org.hibernate.type.descriptor.sql.BasicBinder: INFO org.hibernate.type.descriptor.sql.BasicBinder: WARN
org.springframework.jdbc.datasource.init: DEBUG
org.springframework.boot.sql.init: DEBUG
pattern: pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{userId:-N/A}] - %msg%n" console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{userId:-N/A}] - %msg%n"
......
spring:
application:
name: hiagent
# 数据源配置
datasource:
url: jdbc:mysql://${DB_HOST:127.0.0.1}:3306/hiagent2?allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai
driver-class-name: ${DB_DRIVER:com.mysql.cj.jdbc.Driver}
username: ${DB_NAME:root}
password: ${DB_PASSWORD:123456Aa?}
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
# 禁用Milvus自动配置
autoconfigure:
exclude:
- org.springframework.ai.autoconfigure.vectorstore.milvus.MilvusVectorStoreAutoConfiguration
# SQL初始化配置
sql:
init:
schema-locations: classpath:schema.sql
mode: never
# mode: always
# JPA/Hibernate配置
jpa:
database-platform: org.hibernate.dialect.MySQL8Dialect
# hibernate:
# ddl-auto: create-drop
show-sql: false
properties:
hibernate:
format_sql: true
# H2 Console配置(仅开发环境)
# h2:
# console:
# enabled: true
# path: /h2-console
# Redis配置
data:
redis:
host: localhost
port: 6379
password:
timeout: 2000
database: 0
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1
# RabbitMQ配置
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
virtual-host: /
connection-timeout: 15000
# Jackson配置
jackson:
serialization:
write-dates-as-timestamps: false
deserialization:
fail-on-unknown-properties: false
default-property-inclusion: non_null
# Web配置
web:
resources:
add-mappings: true
# servlet配置
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
# 默认性异步请求配置
mvc:
async:
request-timeout: 300000 # 5分钟,与SSE保持一致
# Spring AI配置
ai:
openai:
enabled: false
ollama:
enabled: false
# MyBatis Plus配置
mybatis-plus:
type-aliases-package: pangea.hiagent.model
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
cache-enabled: true
use-generated-keys: true
global-config:
db-config:
id-type: assign_uuid
table-underline: true
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
# Logging配置
logging:
level:
root: INFO
pangea.hiagent: DEBUG
org.springframework: INFO
org.springframework.security: DEBUG
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"
file:
name: logs/hiagent.log
max-size: 10MB
max-history: 30
charset:
console: UTF-8
file: UTF-8
# Server配置
server:
port: 8080
servlet:
context-path: /
compression:
enabled: true
min-response-size: 1024
# SSE和异步请求超时配置
request-timeout: 300000 # 5分钟(毫秒)
# Undertow配置
undertow:
# IO线程数,默认为处理器数量
io-threads: 4
# 工作线程数
worker-threads: 50
# 缓冲区配置
buffer-size: 65536
# 是否直接分配缓冲区
direct-buffers: true
# HTTP/2支持
enable-http2: true
# 最大HTTP头大小
max-http-header-size: 8192
# 最大参数数量
max-parameters: 1000
# 最大请求头数量
max-headers: 200
# 最大cookies数量
max-cookies: 100
# URL编码字符集
url-charset: UTF-8
# 访问日志配置
accesslog:
enabled: false
pattern: common
dir: logs
prefix: access_log.
# SSL配置
ssl:
# SSL引擎
engine:
# 密码套件
enabled-protocols: TLSv1.2,TLSv1.3
# WebSocket配置
websocket:
# WebSocket消息缓冲区大小
buffer-size: 1048576
# 最大WebSocket帧大小
max-frame-size: 10485760
# 应用自定义配置
hiagent:
# JWT配置
jwt:
secret: ${JWT_SECRET:hiagent-secret-key-for-production-change-this}
expiration: 7200000 # 2小时
refresh-expiration: 604800000 # 7天
# Agent配置
agent:
default-model: deepseek-chat
default-temperature: 0.7
default-max-tokens: 4096
history-length: 10
# LLM配置
llm:
providers:
deepseek:
default-api-key: ${DEEPSEEK_API_KEY:sk-e8ef4359d20b413696512db21c09db87}
default-model: deepseek-chat
base-url: https://api.deepseek.com
openai:
default-api-key: ${OPENAI_API_KEY:}
default-model: gpt-3.5-turbo
base-url: https://api.openai.com/v1
ollama:
default-model: llama2
base-url: http://localhost:11434
# RAG配置
rag:
chunk-size: 512
chunk-overlap: 50
top-k: 5
score-threshold: 0.8
# Milvus Lite配置
milvus:
data-dir: ./milvus_data
db-name: hiagent
collection-name: document_embeddings
# ChatMemory配置
app:
chat-memory:
# 实现类型: caffeine, redis, hybrid
implementation: caffeine
caffeine:
# 是否启用Caffeine缓存
enabled: true
redis:
# 是否启用Redis缓存
enabled: false
\ No newline at end of file
...@@ -4,7 +4,7 @@ spring: ...@@ -4,7 +4,7 @@ spring:
# 配置文件激活 # 配置文件激活
profiles: profiles:
active: test active: dev
# 启用懒加载初始化 # 启用懒加载初始化
main: main:
...@@ -27,18 +27,16 @@ spring: ...@@ -27,18 +27,16 @@ spring:
exclude: exclude:
- org.springframework.ai.autoconfigure.vectorstore.milvus.MilvusVectorStoreAutoConfiguration - org.springframework.ai.autoconfigure.vectorstore.milvus.MilvusVectorStoreAutoConfiguration
# SQL初始化配置 # SQL初始化配置 - 生产环境不使用SQL脚本初始化
sql: sql:
init: init:
schema-locations: classpath:schema.sql mode: never # 生产环境禁用SQL脚本自动初始化
data-locations: classpath:data.sql
mode: always # 可以改为"never"以禁用SQL初始化
# JPA/Hibernate配置 # JPA/Hibernate配置
jpa: jpa:
database-platform: org.hibernate.dialect.H2Dialect database-platform: org.hibernate.dialect.H2Dialect
hibernate: hibernate:
ddl-auto: update # 改为update以避免每次都重建表结构 ddl-auto: create # 生产环境仅验证表结构,不修改数据库
show-sql: false show-sql: false
properties: properties:
hibernate: hibernate:
...@@ -129,7 +127,7 @@ logging: ...@@ -129,7 +127,7 @@ logging:
pangea.hiagent: INFO pangea.hiagent: INFO
org.springframework: WARN org.springframework: WARN
org.springframework.security: WARN org.springframework.security: WARN
org.springframework.boot: INFO org.springframework.boot: WARN
pattern: pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" 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" file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
......
...@@ -17,40 +17,66 @@ MERGE INTO agent (id, name, description, status, default_model, owner, system_pr ...@@ -17,40 +17,66 @@ 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-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-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-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的关联关系 -- 插入默认工具数据 (必须在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', '{}', '', '', '{}', '', '{}'),
('tool-3', 'weather', '天气查询', '查询天气信息', 'API', 'active', 'weatherFunction', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-4', 'get_current_time', '获取当前时间', '获取当前系统时间', 'FUNCTION', 'active', 'dateTimeTools', 'user-001', 1000, 'GET', '{}', 'string', '{}', '', '', '{}', '', '{}'),
('tool-5', 'technicalDocumentationRetrieval', '技术文档检索', '检索和查询技术文档内容', 'FUNCTION', 'active', 'technicalDocumentationRetrievalTool', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-6', 'technicalCodeExplanation', '技术代码解释', '分析和解释技术代码的功能和实现逻辑', 'FUNCTION', 'active', 'technicalCodeExplanationTool', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-7', 'chartGeneration', '图表生成', '根据数据生成各种类型的图表', 'FUNCTION', 'active', 'chartGenerationTool', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-8', 'statisticalCalculation', '统计计算', '执行各种统计分析计算', 'FUNCTION', 'active', 'statisticalCalculationTool', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-9', 'writingStyleReference', '创作风格参考', '提供各种写作风格的参考和指导', 'FUNCTION', 'active', 'writingStyleReferenceTool', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-10', 'documentTemplate', '文档模板', '提供各种类型的文档模板', 'FUNCTION', 'active', 'documentTemplateTool', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-11', 'studyPlanGeneration', '学习计划制定', '根据学习目标和时间安排制定个性化的学习计划', 'FUNCTION', 'active', 'studyPlanGenerationTool', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-12', 'courseMaterialRetrieval', '课程资料检索', '检索和查询相关课程资料', 'FUNCTION', 'active', 'courseMaterialRetrievalTool', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-13', 'emailTools', '邮件工具', '提供基于POP3协议的邮件访问功能', 'FUNCTION', 'active', 'emailTools', 'user-001', 30000, 'POST', '{"defaultPop3Port": 995, "defaultAttachmentPath": "attachments", "pop3SslEnable": true, "pop3SocketFactoryClass": "javax.net.ssl.SSLSocketFactory"}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-14', 'fileProcessing', '文件处理工具', '提供文件读写和管理功能,支持多种文本格式文件', 'FUNCTION', 'active', 'fileProcessingTools', 'user-001', 10000, 'POST', '{"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", "imageFileExtensions": ".jpg,.jpeg,.png,.gif,.bmp,.svg,.webp,.ico", "defaultStorageDir": "storage"}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-15', 'hisenseSsoAuth', '海信SSO认证工具', '用于访问需要SSO认证的海信业务系统,自动完成登录并提取页面内容', 'FUNCTION', 'active', 'hisenseSsoAuthTool', 'user-001', 60000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-16', 'oauth2Authorization', 'OAuth2.0授权工具', '支持标准OAuth2.0认证流程,包括密码凭证流、令牌刷新和受保护资源访问', 'FUNCTION', 'active', 'oauth2AuthorizationTool', 'user-001', 30000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-17', 'orderQuery', '订单查询工具', '用于查询客户订单信息', 'FUNCTION', 'active', 'orderQueryTool', 'user-001', 10000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-18', 'playwrightWeb', 'Playwright网页自动化工具', '提供基于Playwright的网页内容抓取、交互操作、截图等功能', 'FUNCTION', 'active', 'playwrightWebTools', 'user-001', 30000, 'POST', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('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-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 MERGE INTO agent_tool_relation (id, agent_id, tool_id) VALUES
('relation-4', 'agent-2', 'tool-1'),
('relation-5', 'agent-2', 'tool-2'), ('relation-5', 'agent-2', 'tool-2'),
('relation-6', 'agent-2', 'tool-5'), ('relation-6', 'agent-2', 'tool-5'),
('relation-7', 'agent-2', 'tool-6'), ('relation-7', 'agent-2', 'tool-6'),
('relation-8', 'agent-3', 'tool-2'), ('relation-8', 'agent-3', 'tool-2'),
('relation-9', 'agent-3', 'tool-7'), ('relation-9', 'agent-3', 'tool-7'),
('relation-10', 'agent-3', 'tool-8'), ('relation-10', 'agent-3', 'tool-8'),
('relation-11', 'agent-4', 'tool-1'),
('relation-12', 'agent-4', 'tool-9'), ('relation-12', 'agent-4', 'tool-9'),
('relation-13', 'agent-4', 'tool-10'), ('relation-13', 'agent-4', 'tool-10'),
('relation-14', 'agent-5', 'tool-1'),
('relation-15', 'agent-5', 'tool-11'), ('relation-15', 'agent-5', 'tool-11'),
('relation-16', 'agent-5', 'tool-12'); ('relation-17', 'agent-2', 'tool-3'),
('relation-18', 'agent-4', 'tool-3'),
-- 插入默认工具数据 ('relation-19', 'agent-5', 'tool-3'),
MERGE INTO tool (id, name, display_name, description, category, status, owner, timeout, http_method, parameters, return_type, return_schema, implementation, api_endpoint, headers, auth_type, auth_config) VALUES ('relation-20', 'agent-6', 'tool-4'),
('tool-1', 'search', '搜索工具', '进行网络搜索查询', 'API', 'active', 'user-001', 30000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'), ('relation-21', 'agent-6', 'tool-71'),
('tool-2', 'calculator', '计算器', '进行数学计算', 'FUNCTION', 'active', 'user-001', 5000, 'POST', '{}', 'number', '{}', '', '', '{}', '', '{}'), ('relation-22', 'agent-6', 'tool-72'),
('tool-3', 'weather', '天气查询', '查询天气信息', 'API', 'active', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'), ('relation-23', 'agent-6', 'tool-73');
('tool-4', 'get_current_time', '获取当前时间', '获取当前系统时间', 'FUNCTION', 'active', 'user-001', 1000, 'GET', '{}', 'string', '{}', '', '', '{}', '', '{}'),
('tool-5', 'technicalDocumentationRetrieval', '技术文档检索', '检索和查询技术文档内容', 'FUNCTION', 'active', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-6', 'technicalCodeExplanation', '技术代码解释', '分析和解释技术代码的功能和实现逻辑', 'FUNCTION', 'active', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-7', 'chartGeneration', '图表生成', '根据数据生成各种类型的图表', 'FUNCTION', 'active', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-8', 'statisticalCalculation', '统计计算', '执行各种统计分析计算', 'FUNCTION', 'active', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-9', 'writingStyleReference', '创作风格参考', '提供各种写作风格的参考和指导', 'FUNCTION', 'active', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-10', 'documentTemplate', '文档模板', '提供各种类型的文档模板', 'FUNCTION', 'active', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-11', 'studyPlanGeneration', '学习计划制定', '根据学习目标和时间安排制定个性化的学习计划', 'FUNCTION', 'active', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}'),
('tool-12', 'courseMaterialRetrieval', '课程资料检索', '检索和查询相关课程资料', 'FUNCTION', 'active', 'user-001', 10000, 'GET', '{}', 'object', '{}', '', '', '{}', '', '{}');
-- 插入默认工具配置数据 -- 插入默认工具配置数据
MERGE INTO tool_configs (id, tool_name, param_name, param_value, description, default_value, type, required, group_name) VALUES MERGE INTO tool_configs (id, tool_name, param_name, param_value, description, default_value, type, required, group_name) VALUES
('config-1', 'search', 'apiKey', 'test-key-123', '搜索引擎API密钥', '', 'string', 1, 'auth'), ('config-1', 'search', 'apiKey', 'test-key-123', '搜索引擎API密钥', '', 'string', 1, 'auth'),
('config-2', 'search', 'endpoint', 'https://api.search.com/v1/search', '搜索引擎API端点', 'https://api.search.com/v1/search', 'string', 1, 'connection'); ('config-2', 'search', 'endpoint', 'https://api.search.com/v1/search', '搜索引擎API端点', 'https://api.search.com/v1/search', 'string', 1, 'connection'),
\ No newline at end of file ('config-3', 'emailTools', 'defaultPop3Port', '995', '默认POP3服务器端口', '995', 'integer', 1, 'email'),
('config-4', 'emailTools', 'defaultAttachmentPath', 'attachments', '默认附件保存路径', 'attachments', 'string', 1, 'email'),
('config-5', 'emailTools', 'pop3SslEnable', 'true', '是否启用POP3 SSL', 'true', 'boolean', 1, 'email'),
('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'),
('config-10', 'hisenseSsoLogin', 'ssoUsername', '', '海信SSO登录用户名', '', 'string', 1, 'auth'),
('config-11', 'hisenseSsoLogin', 'ssoPassword', '', '海信SSO登录密码', '', 'string', 1, 'auth');
\ No newline at end of file
...@@ -2,27 +2,13 @@ ...@@ -2,27 +2,13 @@
<div class="chat-area"> <div class="chat-area">
<!-- 顶部Agent选择和操作栏 --> <!-- 顶部Agent选择和操作栏 -->
<div class="chat-header"> <div class="chat-header">
<el-select <el-select v-model="selectedAgent" @change="handleAgentChange" placeholder="选择智能体" class="agent-select">
v-model="selectedAgent" <el-option v-for="agent in agents" :key="agent.id" :label="agent.name" :value="agent.id">
@change="handleAgentChange"
placeholder="选择智能体"
class="agent-select"
>
<el-option
v-for="agent in agents"
:key="agent.id"
:label="agent.name"
:value="agent.id"
>
<span>{{ agent.name }} (ID: {{ agent.id }})</span> <span>{{ agent.name }} (ID: {{ agent.id }})</span>
</el-option> </el-option>
</el-select> </el-select>
<el-tooltip content="清空对话"> <el-tooltip content="清空对话">
<el-button <el-button @click="clearMessages" :disabled="messages.length === 0" circle>
@click="clearMessages"
:disabled="messages.length === 0"
circle
>
<span>🗑️</span> <span>🗑️</span>
</el-button> </el-button>
</el-tooltip> </el-tooltip>
...@@ -39,18 +25,10 @@ ...@@ -39,18 +25,10 @@
<div>📚 RAG能力 - 知识库增强</div> <div>📚 RAG能力 - 知识库增强</div>
</div> </div>
</div> </div>
<message-item <message-item v-for="(msg, index) in messages" :key="index" :content="msg.content" :is-user="msg.isUser"
v-for="(msg, index) in messages" :agent-name="getAgentName(msg.agentId)" :timestamp="msg.timestamp"
:key="index" :is-streaming="msg.isStreaming && index === messages.length - 1" :is-markdown="!msg.isUser"
:content="msg.content" :has-error="msg.hasError" @retry="handleRetry(index)" />
:is-user="msg.isUser"
:agent-name="getAgentName(msg.agentId)"
:timestamp="msg.timestamp"
:is-streaming="msg.isStreaming && index === messages.length - 1"
:is-markdown="!msg.isUser"
:has-error="msg.hasError"
@retry="handleRetry(index)"
/>
<div v-if="isLoading" class="loading-indicator"> <div v-if="isLoading" class="loading-indicator">
<el-skeleton :rows="2" animated /> <el-skeleton :rows="2" animated />
</div> </div>
...@@ -58,24 +36,13 @@ ...@@ -58,24 +36,13 @@
<!-- 输入框区域 --> <!-- 输入框区域 -->
<div class="chat-input-area"> <div class="chat-input-area">
<el-input <el-input v-model="inputMessage" type="textarea" :rows="3" placeholder="输入消息... (支持 Shift+Enter 换行,Ctrl+Enter 发送)"
v-model="inputMessage" @keydown.ctrl.enter="sendMessage" @keydown.meta.enter="sendMessage" :disabled="!selectedAgent || isLoading"
type="textarea" class="input-container" />
:rows="3"
placeholder="输入消息... (支持 Shift+Enter 换行,Ctrl+Enter 发送)"
@keydown.ctrl.enter="sendMessage"
@keydown.meta.enter="sendMessage"
:disabled="!selectedAgent || isLoading"
class="input-container"
/>
<div class="input-footer"> <div class="input-footer">
<span class="input-tips">Ctrl/⌘ + Enter 发送</span> <span class="input-tips">Ctrl/⌘ + Enter 发送</span>
<el-button <el-button type="primary" @click="sendMessage" :loading="isLoading"
type="primary" :disabled="!selectedAgent || !inputMessage.trim() || isLoading">
@click="sendMessage"
:loading="isLoading"
:disabled="!selectedAgent || !inputMessage.trim() || isLoading"
>
发送 发送
</el-button> </el-button>
</div> </div>
...@@ -88,7 +55,6 @@ import { ref, nextTick, onMounted, defineExpose } from "vue"; ...@@ -88,7 +55,6 @@ import { ref, nextTick, onMounted, defineExpose } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import MessageItem from "./MessageItem.vue"; import MessageItem from "./MessageItem.vue";
import request from "@/utils/request"; import request from "@/utils/request";
import { useFormStore } from "@/stores/form";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
interface Message { interface Message {
...@@ -113,12 +79,9 @@ const messages = ref<Message[]>([]); ...@@ -113,12 +79,9 @@ const messages = ref<Message[]>([]);
const inputMessage = ref(""); const inputMessage = ref("");
const isLoading = ref(false); const isLoading = ref(false);
const messagesContainer = ref<HTMLElement>(); const messagesContainer = ref<HTMLElement>();
const formJson = ref<any>(null);
// 获取当前路由 // 获取当前路由
const route = useRoute(); const route = useRoute();
// 表单store
const formStore = useFormStore();
// 全局维护SSE流超时计时器引用,确保能够正确清除 // 全局维护SSE流超时计时器引用,确保能够正确清除
let streamTimeoutTimer: ReturnType<typeof setTimeout> | null = null; let streamTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
...@@ -134,7 +97,12 @@ const loadAgents = async () => { ...@@ -134,7 +97,12 @@ const loadAgents = async () => {
agents.value = []; agents.value = [];
} }
console.log("[Agent列表加载] 获取到的Agent列表:", agents.value); console.log("[Agent列表加载] 获取到的Agent列表:", agents.value);
if (agents.value.length > 0 && !selectedAgent.value) {
// 尝试从localStorage恢复选中的智能体ID
const savedAgentId = localStorage.getItem('selectedAgentId');
if (savedAgentId && agents.value.some(agent => agent.id === savedAgentId)) {
selectedAgent.value = savedAgentId;
} else if (agents.value.length > 0 && !selectedAgent.value) {
selectedAgent.value = agents.value[0].id; selectedAgent.value = agents.value[0].id;
} }
} catch (error) { } catch (error) {
...@@ -152,8 +120,17 @@ const getAgentName = (agentId?: string): string => { ...@@ -152,8 +120,17 @@ const getAgentName = (agentId?: string): string => {
}; };
// 处理Agent切换 // 处理Agent切换
const handleAgentChange = () => { const handleAgentChange = async () => {
clearMessages(); // 清空当前消息
messages.value = [];
// 保存选中的智能体到localStorage
if (selectedAgent.value) {
localStorage.setItem('selectedAgentId', selectedAgent.value);
}
// 加载新选中智能体的历史消息
await loadHistoryMessagesInternal(selectedAgent.value);
}; };
// 清空消息 // 清空消息
...@@ -240,7 +217,7 @@ const loadHistoryMessagesInternal = async (agentId: string) => { ...@@ -240,7 +217,7 @@ const loadHistoryMessagesInternal = async (agentId: string) => {
} }
// 转换消息格式 // 转换消息格式
const historyMessages = messagesArray const historyMessages: Message[] = messagesArray
.map((msg: any) => { .map((msg: any) => {
// 验证消息对象的必要字段 // 验证消息对象的必要字段
if (!msg || typeof msg !== "object") { if (!msg || typeof msg !== "object") {
...@@ -269,7 +246,7 @@ const loadHistoryMessagesInternal = async (agentId: string) => { ...@@ -269,7 +246,7 @@ const loadHistoryMessagesInternal = async (agentId: string) => {
isStreaming: false, isStreaming: false,
}; };
}) })
.filter((msg: any) => msg !== null); // 过滤掉无效消息 .filter((msg: any) => msg !== null) as Message[]; // 过滤掉无效消息并转换类型
// 修复:确保消息按时间顺序排列(最新的消息在最后) // 修复:确保消息按时间顺序排列(最新的消息在最后)
historyMessages.sort((a: any, b: any) => a.timestamp - b.timestamp); historyMessages.sort((a: any, b: any) => a.timestamp - b.timestamp);
...@@ -413,6 +390,7 @@ const processSSELine = async ( ...@@ -413,6 +390,7 @@ const processSSELine = async (
) => { ) => {
if (!line.trim()) return false; if (!line.trim()) return false;
console.log("line", line);
if (line.startsWith("event:")) { if (line.startsWith("event:")) {
currentEventRef.value = line.slice(6).trim(); currentEventRef.value = line.slice(6).trim();
return false; return false;
...@@ -505,6 +483,13 @@ const processSSELine = async ( ...@@ -505,6 +483,13 @@ const processSSELine = async (
// 根据事件类型处理数据 // 根据事件类型处理数据
switch (eventType) { switch (eventType) {
case "heartbeat":
// 收到心跳事件,重置超时计时器
resetStreamTimeout();
// 心跳事件本身不处理,只用于保活连接
console.debug("[心跳] 收到心跳事件,连接保活");
return false;
case "token": case "token":
// 重置超时计时器,接收到token说明连接还活跃 // 重置超时计时器,接收到token说明连接还活跃
resetStreamTimeout(); resetStreamTimeout();
...@@ -519,11 +504,8 @@ const processSSELine = async ( ...@@ -519,11 +504,8 @@ const processSSELine = async (
// 收到完成事件,清除超时计时器 // 收到完成事件,清除超时计时器
clearStreamTimeout(); clearStreamTimeout();
console.log("[SSE完成事件]", data); console.log("[SSE完成事件]", data);
// 如果有完整文本内容,更新消息内容 // 注意:不在此处设置消息内容,因为token流已经实时更新了内容
if (data.fullText) { // 只设置流状态和加载状态
messages.value[aiMessageIndex].content = data.fullText;
}
messages.value[aiMessageIndex].isStreaming = false; messages.value[aiMessageIndex].isStreaming = false;
isLoading.value = false; isLoading.value = false;
return true; // 返回true表示流已完成 return true; // 返回true表示流已完成
...@@ -538,8 +520,7 @@ const processSSELine = async ( ...@@ -538,8 +520,7 @@ const processSSELine = async (
if (errorMsg.includes("请配置API密钥")) { if (errorMsg.includes("请配置API密钥")) {
messages.value[aiMessageIndex].content = "[错误] 请配置API密钥"; messages.value[aiMessageIndex].content = "[错误] 请配置API密钥";
} else { } else {
messages.value[aiMessageIndex].content = `[错误] ${ messages.value[aiMessageIndex].content = `[错误] ${errorMsg || "未知错误"
errorMsg || "未知错误"
}`; }`;
} }
messages.value[aiMessageIndex].hasError = true; messages.value[aiMessageIndex].hasError = true;
...@@ -582,7 +563,6 @@ const processSSELine = async ( ...@@ -582,7 +563,6 @@ const processSSELine = async (
break; break;
case "tool_call": case "tool_call":
case "embed": case "embed":
// 处理工具调用和嵌入事件,将其发送到时间轴面板 // 处理工具调用和嵌入事件,将其发送到时间轴面板
// 构建事件标题 // 构建事件标题
...@@ -651,66 +631,8 @@ const processSSELine = async ( ...@@ -651,66 +631,8 @@ const processSSELine = async (
); );
} }
break; break;
case "form":
if (data && data.coms) {
// 正常情况:
// formJson.value = data;
// 演示场景特殊处理:
// 提取被过滤掉的字段
const hiddenFields = data.coms
.filter((com: any) => {
const title = com.props?.title;
return title === "接访员工手机号" || title === "接访员工姓名";
})
.map((com: any) => ({
title: com.props?.title || "",
name: com.props?.name || "",
}));
// 保存隐藏字段到 store
formStore.setHiddenFields(hiddenFields);
// 过滤掉不需要的字段
const filteredData = {
...data,
coms: data.coms.filter((com: any) => {
const title = com.props?.title;
return title !== "接访员工手机号" && title !== "接访员工姓名";
}),
};
formJson.value = filteredData;
}
break;
} }
// 模拟后端返回form类型的时间,并将form表单的json存到store
// formJson.value = {
// coms: [
// {
// key: 1766626350013,
// name: "输入框",
// code: "HiInput",
// props: {
// title: "访问园区",
// status: "default",
// name: "INPUT_8USD455B",
// },
// },
// {
// key: 1766626350014,
// name: "日期",
// code: "HiDatePicker",
// props: {
// title: "开始日期",
// status: "default",
// name: "DATE_8USD455D",
// },
// },
// ],
// };
// 重置当前事件类型 // 重置当前事件类型
currentEventRef.value = ""; currentEventRef.value = "";
} catch (err) { } catch (err) {
...@@ -802,13 +724,19 @@ const sendMessage = async () => { ...@@ -802,13 +724,19 @@ const sendMessage = async () => {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
let isStreamComplete = false; // 标记流是否已完成 let isStreamComplete = false; // 标记流是否已完成
const STREAM_TIMEOUT = 120000; // 120秒无流式消息则为超时 const HEARTBEAT_TIMEOUT = 60000; // 60秒无心跳则为超时
const HEARTBEAT_CHECK_INTERVAL = 5000; // 每5秒检查一次心跳
let lastHeartbeatTime = Date.now(); // 记录最后一次心跳时间
// 设置超时检查 // 设置超时检查 - 改进为心跳保活机制
const resetStreamTimeout = () => { const resetStreamTimeout = () => {
clearStreamTimeout(); clearStreamTimeout();
lastHeartbeatTime = Date.now(); // 更新最后心跳时间
streamTimeoutTimer = setTimeout(() => { streamTimeoutTimer = setTimeout(() => {
if (!isStreamComplete) { if (!isStreamComplete) {
// 检查是否在指定时间内收到过心跳或数据
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatTime;
if (timeSinceLastHeartbeat >= HEARTBEAT_TIMEOUT) {
isStreamComplete = true; isStreamComplete = true;
reader.cancel(); reader.cancel();
messages.value[aiMessageIndex].isStreaming = false; messages.value[aiMessageIndex].isStreaming = false;
...@@ -820,8 +748,12 @@ const sendMessage = async () => { ...@@ -820,8 +748,12 @@ const sendMessage = async () => {
messages.value[aiMessageIndex].content = messages.value[aiMessageIndex].content =
"[错误] 流式输出超时,请重试"; "[错误] 流式输出超时,请重试";
messages.value[aiMessageIndex].hasError = true; messages.value[aiMessageIndex].hasError = true;
} else {
// 如果还没超时,继续检查
resetStreamTimeout();
}
} }
}, STREAM_TIMEOUT); }, HEARTBEAT_CHECK_INTERVAL);
}; };
resetStreamTimeout(); resetStreamTimeout();
...@@ -894,19 +826,6 @@ const sendMessage = async () => { ...@@ -894,19 +826,6 @@ const sendMessage = async () => {
messages.value[aiMessageIndex].content = accumulatedContentRef.value; messages.value[aiMessageIndex].content = accumulatedContentRef.value;
} }
if (formJson.value) {
// 设置表单提交回调函数
formStore.setSubmitCallback((formData: any) => {
console.log("表单提交数据:", formData);
// 将表单数据作为消息发送
inputMessage.value = JSON.stringify(formData);
sendMessage();
});
// 打开表单
formStore.openForm(formJson.value);
}
// 确保最终状态正确 // 确保最终状态正确
messages.value[aiMessageIndex].isStreaming = false; messages.value[aiMessageIndex].isStreaming = false;
// 设置isLoading为false,结束加载状态 // 设置isLoading为false,结束加载状态
...@@ -953,7 +872,14 @@ onMounted(async () => { ...@@ -953,7 +872,14 @@ onMounted(async () => {
await loadAgents(); await loadAgents();
// 等待下一个tick确保agents加载完成后再加载历史消息 // 等待下一个tick确保agents加载完成后再加载历史消息
await nextTick(); await nextTick();
// 优先使用路由参数中的agentId,如果没有则使用localStorage中保存的或默认选中的
const routeAgentId = route.query.agentId as string;
if (routeAgentId) {
await loadHistoryMessagesInternal(routeAgentId);
} else {
loadHistoryMessages(); loadHistoryMessages();
}
}); });
// 暴露方法给父组件使用 // 暴露方法给父组件使用
...@@ -969,7 +895,8 @@ defineExpose({ ...@@ -969,7 +895,8 @@ defineExpose({
height: 100%; height: 100%;
background-color: var(--bg-primary); background-color: var(--bg-primary);
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
min-height: 0; /* 允许容器收缩 */ min-height: 0;
/* 允许容器收缩 */
} }
.chat-header { .chat-header {
...@@ -994,7 +921,8 @@ defineExpose({ ...@@ -994,7 +921,8 @@ defineExpose({
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-3); gap: var(--spacing-3);
min-height: 0; /* 允许容器收缩 */ min-height: 0;
/* 允许容器收缩 */
} }
.empty-state { .empty-state {
......
...@@ -115,8 +115,9 @@ function handleWebSocketMessage(data: any) { ...@@ -115,8 +115,9 @@ function handleWebSocketMessage(data: any) {
addLog(`📤 调用handleDomSyncData处理数据`, 'debug') addLog(`📤 调用handleDomSyncData处理数据`, 'debug')
handleDomSyncData(data) handleDomSyncData(data)
} catch (e) { } catch (e: unknown) {
addLog('解析数据失败:' + (e as Error).message, 'error') const errorMessage = e instanceof Error ? e.message : String(e)
addLog('解析数据失败:' + errorMessage, 'error')
errorStats.value.parseErrors++ errorStats.value.parseErrors++
} }
} }
...@@ -172,8 +173,9 @@ const onIframeLoad = () => { ...@@ -172,8 +173,9 @@ const onIframeLoad = () => {
} }
addLog(`iframe事件监听器绑定成功,尝试次数: ${attempt}`, 'info') addLog(`iframe事件监听器绑定成功,尝试次数: ${attempt}`, 'info')
} catch (e) { } catch (e: unknown) {
addLog(`iframe事件监听器绑定失败 (尝试 ${attempt}): ${e.message}`, 'error') const errorMsg = e instanceof Error ? e.message : String(e)
addLog(`iframe事件监听器绑定失败 (尝试 ${attempt}): ${errorMsg}`, 'error')
errorStats.value.scriptErrors++ errorStats.value.scriptErrors++
// 如果还有重试机会,继续重试 // 如果还有重试机会,继续重试
......
...@@ -25,27 +25,24 @@ ...@@ -25,27 +25,24 @@
<el-icon><ChatDotRound /></el-icon> <el-icon><ChatDotRound /></el-icon>
<span>智能对话</span> <span>智能对话</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/new-chat"> <el-menu-item index="/timer">
<el-icon><Plus /></el-icon> <el-icon><Timer /></el-icon>
<span>新聊天</span> <span>定时器管理</span>
</el-menu-item> </el-menu-item>
<el-sub-menu index="agent-management"> <el-sub-menu index="agent-management">
<template #title> <template #title>
<el-icon><Avatar /></el-icon> <el-icon><Document /></el-icon>
<span>Agent管理</span> <span>Agent管理</span>
</template> </template>
<el-menu-item index="/agent"> <el-menu-item index="/agent">
<el-icon><Setting /></el-icon> <el-icon><Avatar /></el-icon>
<span>Agent管理</span> <span>Agent管理</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/tools"> <el-menu-item index="/tools">
<el-icon><Tools /></el-icon> <el-icon><Tools /></el-icon>
<span>工具管理</span> <span>工具管理</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/timer">
<el-icon><Timer /></el-icon>
<span>定时器管理</span>
</el-menu-item>
<el-menu-item index="/documents"> <el-menu-item index="/documents">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<span>知识库</span> <span>知识库</span>
...@@ -59,8 +56,12 @@ ...@@ -59,8 +56,12 @@
<el-sub-menu index="system-management"> <el-sub-menu index="system-management">
<template #title> <template #title>
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
<span>系统配置</span> <span>系统管理</span>
</template> </template>
<el-menu-item index="/system">
<el-icon><Cpu /></el-icon>
<span>系统配置</span>
</el-menu-item>
<el-menu-item index="/llm-config"> <el-menu-item index="/llm-config">
<el-icon><Cpu /></el-icon> <el-icon><Cpu /></el-icon>
<span>LLM配置</span> <span>LLM配置</span>
...@@ -73,6 +74,10 @@ ...@@ -73,6 +74,10 @@
<el-icon><Monitor /></el-icon> <el-icon><Monitor /></el-icon>
<span>DOM同步</span> <span>DOM同步</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/new-chat">
<el-icon><Plus /></el-icon>
<span>新聊天</span>
</el-menu-item>
</el-sub-menu> </el-sub-menu>
</el-menu> </el-menu>
......
...@@ -107,6 +107,6 @@ REM 设置更多调试参数 ...@@ -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 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应用... 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 pause
\ No newline at end of file
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