Commit 50254708 authored by 高如斌's avatar 高如斌

Merge branch 'main' of...

Merge branch 'main' of https://gitlab-cloud.hisense.com/gavin-group/pangea-agent into feature/chat-form
parents 307f751e 901b31c3
...@@ -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/
...@@ -214,4 +217,6 @@ Thumbs.db ...@@ -214,4 +217,6 @@ Thumbs.db
.Trashes .Trashes
ehthumbs.db ehthumbs.db
Icon? Icon?
*.icon? *.icon?
\ No newline at end of file backend/data/hiagent_dev_db.trace.db
backend/data/hiagent_dev_db.mv.db
现有架构分析
后端架构
核心组件
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.workpanel.data; package pangea.hiagent.agent.data;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
...@@ -29,4 +29,4 @@ public class CompletionEventDataBuilder { ...@@ -29,4 +29,4 @@ public class CompletionEventDataBuilder {
data.put("timestamp", System.currentTimeMillis()); data.put("timestamp", System.currentTimeMillis());
return data; return data;
} }
} }
\ No newline at end of file
package pangea.hiagent.workpanel.data; package pangea.hiagent.agent.data;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
...@@ -30,4 +30,5 @@ public class ErrorEventDataBuilder { ...@@ -30,4 +30,5 @@ public class ErrorEventDataBuilder {
data.put("type", "error"); data.put("type", "error");
return data; return data;
} }
} }
\ No newline at end of file
package pangea.hiagent.workpanel.data; package pangea.hiagent.agent.data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import pangea.hiagent.common.utils.ObjectPool; import pangea.hiagent.common.utils.ObjectPool;
...@@ -58,4 +58,5 @@ public class MapPoolService { ...@@ -58,4 +58,5 @@ public class MapPoolService {
public String getMapPoolStatistics() { public String getMapPoolStatistics() {
return mapPool.getStatistics(); return mapPool.getStatistics();
} }
} }
\ No newline at end of file
package pangea.hiagent.workpanel.data; package pangea.hiagent.agent.data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
......
package pangea.hiagent.web.dto; package pangea.hiagent.agent.data;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
...@@ -34,8 +34,17 @@ public class WorkPanelEvent implements Serializable { ...@@ -34,8 +34,17 @@ public class WorkPanelEvent implements Serializable {
*/ */
private Long timestamp; private Long timestamp;
/**
* 事件内容
*/
private String content;
/** /**
* 元数据 * 元数据
*/ */
private Map<String, Object> metadata; private Map<String, Object> metadata;
/**
* 触发事件的用户ID
*/
private String userId;
} }
\ No newline at end of file
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) {
((TokenConsumerWithCompletion) tokenConsumer).onComplete(fullText.toString()); try {
((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;
...@@ -30,14 +31,16 @@ public class ReActAgentProcessor extends AbstractAgentProcessor { ...@@ -30,14 +31,16 @@ public class ReActAgentProcessor extends AbstractAgentProcessor {
@Autowired @Autowired
private RagService ragService; private RagService ragService;
@Autowired
private ReactCallback defaultReactCallback;
@Autowired @Autowired
private ReactExecutor defaultReactExecutor; private ReactExecutor defaultReactExecutor;
@Autowired @Autowired
private AgentToolManager agentToolManager; private AgentToolManager agentToolManager;
@Autowired
private ReactCallback defaultReactCallback;
@Override @Override
public String processRequest(Agent agent, AgentRequest request, String userId) { public String processRequest(Agent agent, AgentRequest request, String userId) {
...@@ -71,10 +74,6 @@ public class ReActAgentProcessor extends AbstractAgentProcessor { ...@@ -71,10 +74,6 @@ public class ReActAgentProcessor extends AbstractAgentProcessor {
// 处理请求的通用前置逻辑 // 处理请求的通用前置逻辑
String ragResponse = handlePreProcessing(agent, userMessage, userId, ragService, null); String ragResponse = handlePreProcessing(agent, userMessage, userId, ragService, null);
if (ragResponse != null) { if (ragResponse != null) {
// 触发最终答案回调
if (defaultReactCallback != null) {
defaultReactCallback.onFinalAnswer(ragResponse);
}
return ragResponse; return ragResponse;
} }
...@@ -82,21 +81,16 @@ public class ReActAgentProcessor extends AbstractAgentProcessor { ...@@ -82,21 +81,16 @@ public class ReActAgentProcessor extends AbstractAgentProcessor {
ChatClient client = ChatClient.builder(agentService.getChatModelForAgent(agent)).build(); ChatClient client = ChatClient.builder(agentService.getChatModelForAgent(agent)).build();
List<Object> tools = agentToolManager.getAvailableToolInstances(agent); List<Object> tools = agentToolManager.getAvailableToolInstances(agent);
// 添加自定义回调到ReAct执行器
if (defaultReactExecutor != null && defaultReactCallback != null) {
defaultReactExecutor.addReactCallback(defaultReactCallback);
}
// 使用ReAct执行器执行流程,传递Agent对象以支持记忆功能
String finalAnswer = defaultReactExecutor.executeWithAgent(client, userMessage, tools, agent);
// 将助理回复添加到ChatMemory // 使用ReAct执行器执行流程,传递Agent对象和用户ID以支持记忆功能
String sessionId = generateSessionId(agent, userId); String finalAnswer = defaultReactExecutor.execute(client, userMessage, tools, agent, userId);
addAssistantMessageToMemory(sessionId, finalAnswer);
// 助手回复已经由执行器保存到内存中,不需要重复保存
return finalAnswer; return finalAnswer;
} catch (Exception e) { } catch (Exception e) {
return agentErrorHandler.handleSyncError(e, "处理ReAct请求时发生错误"); return handleSyncError(e, "处理ReAct请求时发生错误");
} }
} }
...@@ -115,10 +109,6 @@ public class ReActAgentProcessor extends AbstractAgentProcessor { ...@@ -115,10 +109,6 @@ public class ReActAgentProcessor extends AbstractAgentProcessor {
// 处理请求的通用前置逻辑 // 处理请求的通用前置逻辑
String ragResponse = handlePreProcessing(agent, userMessage, userId, ragService, tokenConsumer); String ragResponse = handlePreProcessing(agent, userMessage, userId, ragService, tokenConsumer);
if (ragResponse != null) { if (ragResponse != null) {
// 触发最终答案回调
if (defaultReactCallback != null) {
defaultReactCallback.onFinalAnswer(ragResponse);
}
return; return;
} }
...@@ -138,11 +128,18 @@ public class ReActAgentProcessor extends AbstractAgentProcessor { ...@@ -138,11 +128,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 +151,14 @@ public class ReActAgentProcessor extends AbstractAgentProcessor { ...@@ -154,8 +151,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
package pangea.hiagent.agent.react; package pangea.hiagent.agent.react;
import java.io.IOException;
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 lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.workpanel.IWorkPanelDataCollector; import pangea.hiagent.agent.service.UserSseService;
import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.agent.data.WorkPanelEvent;
/** /**
* 自定义 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 UserSseService userSseService;
/**
* ReAct 每执行一个步骤,该方法会被触发
* @param reactStep ReAct 步骤对象,包含步骤的所有核心信息
*/
@Override @Override
public void onStep(ReactStep reactStep) { public void onStep(ReactStep reactStep) {
// 将信息记录到工作面板
recordReactStepToWorkPanel(reactStep); String reactStepName = reactStep.getStepType().name();
}
/**
* 处理 ReAct 最终答案步骤
* @param finalAnswer 最终答案
*/
@Override
public void onFinalAnswer(String finalAnswer) {
// 创建一个FINAL_ANSWER类型的ReactStep并处理
ReactStep finalStep = new ReactStep(0, ReactStepType.FINAL_ANSWER, finalAnswer);
recordReactStepToWorkPanel(finalStep);
}
/**
* 将ReAct步骤记录到工作面板
* @param reactStep ReAct步骤
*/
private void recordReactStepToWorkPanel(ReactStep reactStep) {
if (workPanelCollector == null) {
log.debug("无法记录到工作面板:collector为null");
return;
}
try { try {
switch (reactStep.getStepType()) { userSseService.sendWorkPanelEvent(WorkPanelEvent.builder()
case THOUGHT: .type(reactStepName)
workPanelCollector.recordThinking(reactStep.getContent(), "reasoning"); .content(reactStep.getContent())
break; .userId(UserUtils.getCurrentUserIdStatic())
case ACTION: .build());
if (reactStep.getAction() != null) { } catch (IOException e) {
// 使用recordToolCallAction记录工具调用开始,状态为pending log.error("发送ReAct步骤到WorkPanel失败: 类型={}, 内容摘要={}",
workPanelCollector.recordToolCallAction( reactStep.getStepType(),
reactStep.getAction().getToolName(), reactStep.getContent() != null
reactStep.getAction().getParameters(), ? reactStep.getContent().substring(0, Math.min(50, reactStep.getContent().length()))
null, : "null",
"pending", e);
null
);
}
break;
case OBSERVATION:
if (reactStep.getObservation() != null && reactStep.getAction() != null) {
// 使用recordToolCallAction记录工具调用完成,状态为success
workPanelCollector.recordToolCallAction(
reactStep.getAction().getToolName(),
reactStep.getAction().getParameters(),
reactStep.getObservation().getContent(),
"success",
null
);
}
break;
case FINAL_ANSWER:
workPanelCollector.recordFinalAnswer(reactStep.getContent());
break;
default:
log.warn("未知的ReAct步骤类型: {}", reactStep.getStepType());
break;
}
} catch (Exception e) {
log.error("记录ReAct步骤到工作面板失败", e);
} }
// 记录最终答案到日志
log.info("[WorkPanel] 记录{} {}", reactStepName,
reactStep.getContent().substring(0, Math.min(100, reactStep.getContent().length())));
} }
} }
\ No newline at end of file
...@@ -5,62 +5,47 @@ import org.springframework.ai.chat.client.ChatClient; ...@@ -5,62 +5,47 @@ import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.*; import org.springframework.ai.chat.messages.*;
import org.springframework.ai.chat.model.ChatResponse; 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.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.context.annotation.Lazy;
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.common.utils.UserUtils;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
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 {
// 默认系统提示词 @Value("${hiagent.react.system-prompt}")
private static final String DEFAULT_SYSTEM_PROMPT = private String defaultSystemPrompt;
"You are a helpful AI assistant that can use tools to answer questions. " +
"Use the available tools when needed to gather information. " +
"Think step by step and show your reasoning process. " +
"When using tools, clearly indicate your thoughts, actions, and observations.";
private final List<ReactCallback> reactCallbacks = new ArrayList<>(); private final List<ReactCallback> reactCallbacks = new ArrayList<>();
private final AtomicInteger stepCounter = new AtomicInteger(0);
@Autowired
@Lazy
private IWorkPanelDataCollector workPanelCollector;
@Autowired private final EventSplitter eventSplitter;
private DateTimeTools dateTimeTools;
@Autowired
private MemoryService memoryService; private MemoryService memoryService;
@Autowired
private ErrorHandlerService errorHandlerService; private ErrorHandlerService errorHandlerService;
private final AgentToolManager agentToolManager; private final AgentToolManager agentToolManager;
public DefaultReactExecutor(AgentToolManager agentToolManager) { public DefaultReactExecutor(EventSplitter eventSplitter, AgentToolManager agentToolManager ,
MemoryService memoryService, ErrorHandlerService errorHandlerService) {
this.eventSplitter = eventSplitter;
this.agentToolManager = agentToolManager; this.agentToolManager = agentToolManager;
this.memoryService = memoryService;
this.errorHandlerService = errorHandlerService;
} }
/**
* 添加ReAct回调
* @param callback ReAct回调
*/
@Override @Override
public void addReactCallback(ReactCallback callback) { public void addReactCallback(ReactCallback callback) {
if (callback != null) { if (callback != null) {
...@@ -68,62 +53,33 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -68,62 +53,33 @@ 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.getCurrentUserIdStatic();
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);
// 获取Agent关联的工具实例
List<Object> agentTools = getAgentTools(agent); List<Object> agentTools = getAgentTools(agent);
try { try {
// 触发思考步骤 Prompt prompt = buildPromptWithHistory(defaultSystemPrompt, userInput, agent, userId);
triggerThinkStep("开始处理用户请求: " + userInput);
// 构建Prompt,包含历史对话记录
Prompt prompt = buildPromptWithHistory(DEFAULT_SYSTEM_PROMPT, userInput, agent);
// 使用call()获取完整的LLM响应
// 这会阻塞直到完成整个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);
// 返回最终结果
log.info("最终答案: {}", responseText); log.info("最终答案: {}", responseText);
// 触发最终答案步骤 // 保存助手回复到内存,使用提供的用户ID
triggerFinalAnswerStep(responseText); saveAssistantResponseToMemory(agent, responseText, userId);
return responseText; return responseText;
} catch (Exception e) { } catch (Exception e) {
...@@ -133,98 +89,74 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -133,98 +89,74 @@ public class DefaultReactExecutor implements ReactExecutor {
} }
/** /**
* 处理ReAct执行错误 * 处理ReAct执行过程中发生的错误
* @param e 异常 *
* @return 错误信息 * @param e 发生的异常
* @return 错误处理结果
*/ */
private String handleReActError(Exception e) { private String handleReActError(Exception e) {
log.error("ReAct执行过程中发生错误", 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.getCurrentUserIdStatic();
}
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.getCurrentUserIdStatic();
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);
// 获取Agent关联的工具实例
List<Object> agentTools = getAgentTools(agent); List<Object> agentTools = getAgentTools(agent);
// 使用StringBuilder累积完整响应
StringBuilder fullResponse = new StringBuilder(); StringBuilder fullResponse = new StringBuilder();
try { try {
// 触发思考步骤 Prompt prompt = buildPromptWithHistory(defaultSystemPrompt, userInput, agent, userId);
triggerThinkStep("开始处理用户请求: " + userInput);
// 构建Prompt,包含历史对话记录
Prompt prompt = buildPromptWithHistory(DEFAULT_SYSTEM_PROMPT, userInput, agent);
// 订阅流式响应
chatClient.prompt(prompt) chatClient.prompt(prompt)
.tools(agentTools.toArray()) .tools(agentTools.toArray())
.stream() .stream()
...@@ -232,7 +164,7 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -232,7 +164,7 @@ public class DefaultReactExecutor implements ReactExecutor {
.subscribe( .subscribe(
chatResponse -> handleTokenResponse(chatResponse, tokenConsumer, fullResponse), chatResponse -> handleTokenResponse(chatResponse, tokenConsumer, fullResponse),
throwable -> handleStreamError(throwable, tokenConsumer), throwable -> handleStreamError(throwable, tokenConsumer),
() -> handleStreamCompletion(tokenConsumer, fullResponse, agent) () -> handleStreamCompletion(tokenConsumer, fullResponse, agent, userId)
); );
} catch (Exception e) { } catch (Exception e) {
...@@ -242,73 +174,63 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -242,73 +174,63 @@ 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());
// 实时发送token给客户端
if (tokenConsumer != null) { if (tokenConsumer != null) {
tokenConsumer.accept(token); tokenConsumer.accept(token);
} }
// 记录思考过程 eventSplitter.feedToken(token);
processTokenForSteps(token);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("处理token时发生错误", e); log.error("处理token时发生错误", e);
errorHandlerService.handleReactFlowError(e, tokenConsumer);
} }
} }
/** /**
* 处理流式完成 * 处理流式响应完成事件
* *
* @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) { private void handleStreamCompletion(Consumer<String> tokenConsumer, StringBuilder fullResponse, Agent agent, String userId) {
try { try {
log.info("流式处理完成"); log.info("流式处理完成");
// 触发最终答案步骤
triggerFinalAnswerStep(fullResponse.toString());
// 将助理回复添加到ChatMemory
saveAssistantResponseToMemory(agent, fullResponse.toString());
// 发送完成事件,包含完整内容 String responseStr = fullResponse.toString();
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);
} }
} }
/** /**
* 保存助理回复到内存 * 将助手的回复保存到内存中
* *
* @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());
...@@ -317,10 +239,10 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -317,10 +239,10 @@ 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) {
...@@ -328,6 +250,8 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -328,6 +250,8 @@ public class DefaultReactExecutor implements ReactExecutor {
String errorId = errorHandlerService.generateErrorId(); String errorId = errorHandlerService.generateErrorId();
String fullErrorMessage = errorHandlerService.buildFullErrorMessage("处理完成时发生错误", e, errorId, "ReAct"); String fullErrorMessage = errorHandlerService.buildFullErrorMessage("处理完成时发生错误", e, errorId, "ReAct");
((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);
} }
...@@ -335,39 +259,17 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -335,39 +259,17 @@ 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消费者
...@@ -378,267 +280,54 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -378,267 +280,54 @@ public class DefaultReactExecutor implements ReactExecutor {
/** /**
* 发送完成事件 * 发送完成事件
*
* @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) {
} try {
tokenConsumer.accept("");
/** } catch (Exception ex) {
* 分析token内容,识别工具调用和结果 log.error("发送空消息也失败", ex);
* 这个方法通过分析响应中的特殊标记来识别工具调用 }
*
* @param token 当前token
* @param fullResponse 完整响应
*/
private void analyzeAndRecordToolEvents(String token, String fullResponse) {
if (!isValidToken(token) || workPanelCollector == null) {
return;
}
try {
// 检查工具调用的标记
// 通常格式为: "Tool: [工具名称]" 或 "Calling [工具名称]" 或类似的模式
if (isToolCall(token)) {
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) {
log.error("调用onComplete时发生错误", e);
} }
} catch (Exception e) { } else if (tokenConsumer != null) {
log.debug("分析工具调用事件时发生错误: {}", e.getMessage()); tokenConsumer.accept("");
}
}
/**
* 检查是否为工具调用
* @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:"));
}
/**
* 检查工具名称是否有效
* @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) { private List<Object> getAgentTools(Agent agent) {
int stepNumber = stepCounter.incrementAndGet(); List<Object> tools = new ArrayList<>();
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); if (agent != null) {
}
/**
* 触发最终答案步骤
* @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 { try {
callback.onStep(reactStep); tools = agentToolManager.getAvailableToolInstances(agent);
} catch (Exception e) { } catch (Exception e) {
log.error("执行ReAct回调时发生错误", e); log.error("获取工具实例时发生错误: {}", e.getMessage());
// 发生异常时,tools 保持为空列表
} }
} }
}
/**
* 获取Agent关联的工具实例
* @param agent Agent对象
* @return 工具实例列表
*/
private List<Object> getAgentTools(Agent agent) {
if (agent == null) {
log.debug("Agent为空,返回空工具列表");
return new ArrayList<>();
}
try { return tools;
List<Object> tools = agentToolManager.getAvailableToolInstances(agent);
tools.add(dateTimeTools);
log.debug("获取到Agent '{}' 的工具实例数量: {}", agent.getName(), tools.size());
return tools;
} catch (Exception e) {
log.error("获取Agent工具实例时发生错误", e);
return new ArrayList<>();
}
} }
} }
\ No newline at end of file
package pangea.hiagent.agent.react;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class EventSplitter {
private final List<String> keywords = Arrays.asList(
"Thought", "Action", "Observation", "Final_Answer"
);
private final Pattern keywordPattern = Pattern.compile(
String.format("(?i)(Thought|Action|Observation|Final[ _]Answer):", String.join("|", keywords)), Pattern.CASE_INSENSITIVE
);
private String currentType = null;
private StringBuilder currentContent = new StringBuilder();
private StringBuilder buffer = new StringBuilder();
private final ReactCallback callback;
private volatile int stepNumber = 0;
public EventSplitter(ReactCallback callback) {
this.callback = callback;
}
// 每收到一个token/字符,调用此方法
public void feedToken(String token) {
buffer.append(token);
// log.debug("当前缓冲区: {}", buffer.toString());
Matcher matcher = keywordPattern.matcher(buffer);
while (matcher.find()) {
log.debug("发现新事件关键词: {}", matcher.group(1));
// 发现新事件
if (currentType != null && currentContent.length() > 0) {
// 实时输出已分割事件
callback.onStep(new ReactStep(stepNumber++, ReactStepType.fromString(currentType), currentContent.toString()));
}
// 更新事件类型
currentType = matcher.group(1);
currentContent.setLength(0);
// 累积匹配位置后的内容
currentContent.append(buffer.substring(matcher.end()));
// 重置buffer为剩余内容
buffer.setLength(0);
buffer.append(currentContent);
// 重新查找
matcher = keywordPattern.matcher(buffer);
}
// 检查是否有部分关键词在buffer末尾
if (buffer.length() > 0) {
// 检查是否可能是关键词的一部分
boolean isPartialKeyword = false;
String bufferStr = buffer.toString();
for (String keyword : keywords) {
if (keyword.startsWith(bufferStr) || bufferStr.startsWith(keyword)) {
isPartialKeyword = true;
break;
}
}
if (!isPartialKeyword) {
// 不是部分关键词,添加到内容中
currentContent.append(buffer);
buffer.setLength(0);
}
}
}
// 流式结束时,调用此方法输出最后一个事件
public void endStream(ReactCallback tokenConsumer) {
if (currentType != null && currentContent.length() > 0) {
callback.onStep(new ReactStep(stepNumber++, ReactStepType.fromString(currentType), currentContent.toString()));
}
}
}
...@@ -10,6 +10,4 @@ public interface ReactCallback { ...@@ -10,6 +10,4 @@ public interface ReactCallback {
* @param reactStep ReAct步骤对象,包含步骤的所有核心信息 * @param reactStep ReAct步骤对象,包含步骤的所有核心信息
*/ */
void onStep(ReactStep reactStep); void onStep(ReactStep reactStep);
void onFinalAnswer(String ragResponse);
} }
\ 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; }
} }
/** /**
* 工具观察结果类 * 工具观察结果内部
*/ */
@Data
public static class ToolObservation { public static class ToolObservation {
/** private String result;
* 观察内容
*/
private String content;
public ToolObservation() {} public ToolObservation(String result) {
this.result = result;
public ToolObservation(String content) {
this.content = content;
} }
public String getResult() { return result; }
public void setResult(String result) { this.result = result; }
} }
} }
\ No newline at end of file
...@@ -22,5 +22,9 @@ public enum ReactStepType { ...@@ -22,5 +22,9 @@ public enum ReactStepType {
/** /**
* 最终答案步骤:结合工具结果生成最终回答 * 最终答案步骤:结合工具结果生成最终回答
*/ */
FINAL_ANSWER FINAL_ANSWER;
public static ReactStepType fromString(String currentType) {
return ReactStepType.valueOf(currentType.toUpperCase().replace(" ", "_"));
}
} }
\ No newline at end of file
...@@ -2,23 +2,18 @@ package pangea.hiagent.agent.service; ...@@ -2,23 +2,18 @@ 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;
import pangea.hiagent.tool.AgentToolManager; import pangea.hiagent.tool.AgentToolManager;
import pangea.hiagent.web.dto.AgentRequest; import pangea.hiagent.web.dto.AgentRequest;
import pangea.hiagent.workpanel.event.EventService;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/** /**
* Agent 对话服务 * Agent 对话服务
...@@ -28,153 +23,248 @@ import java.util.concurrent.TimeUnit; ...@@ -28,153 +23,248 @@ 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 userSseService;
private final pangea.hiagent.web.service.AgentService agentService;
public AgentChatService( public AgentChatService(
EventService eventService, ErrorHandlerService errorHandlerService,
ChatErrorHandler chatErrorHandler,
AgentValidationService agentValidationService,
AgentProcessorFactory agentProcessorFactory, AgentProcessorFactory agentProcessorFactory,
StreamRequestService streamRequestService,
AgentToolManager agentToolManager, AgentToolManager agentToolManager,
UserSseService workPanelSseService) { UserSseService userSseService,
this.chatErrorHandler = chatErrorHandler; pangea.hiagent.web.service.AgentService agentService) {
this.agentValidationService = agentValidationService; this.errorHandlerService = errorHandlerService;
this.agentProcessorFactory = agentProcessorFactory; this.agentProcessorFactory = agentProcessorFactory;
this.streamRequestService = streamRequestService;
this.agentToolManager = agentToolManager; this.agentToolManager = agentToolManager;
this.workPanelSseService = workPanelSseService; this.userSseService = userSseService;
this.agentService = agentService;
} }
// 专用线程池配置 - 使用静态变量确保线程池在整个应用中是单例的
private static final ExecutorService executorService = new ThreadPoolExecutor(
20,
200,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
// /** // /**
// * 处理同步对话请求的统一入口 // * 处理同步对话请求的统一入口
// * @param agent Agent对象 // * @param agent Agent对象
// * @param request 请求对象 // * @param request 请求对象
// * @param userId 用户ID // * @param userId 用户ID
// * @return 处理结果 // * @return 处理结果
// */ // */
// public String handleChatSync(Agent agent, AgentRequest request, String userId) { // public String handleChatSync(Agent agent, AgentRequest request, String
// log.info("开始处理同步对话请求,AgentId: {}, 用户消息: {}", agent.getId(), request.getUserMessage()); // userId) {
// // log.info("开始处理同步对话请求,AgentId: {}, 用户消息: {}", agent.getId(),
// try { // request.getUserMessage());
// // 获取处理器 //
// AgentProcessor processor = agentProcessorFactory.getProcessor(agent); // try {
// if (processor == null) { // // 获取处理器
// log.error("无法获取Agent处理器"); // AgentProcessor processor = agentProcessorFactory.getProcessor(agent);
// return "[错误] 无法获取Agent处理器"; // if (processor == null) {
// } // log.error("无法获取Agent处理器");
// // return "[错误] 无法获取Agent处理器";
// // 处理请求 // }
// return processor.processRequest(agent, request, userId); //
// } catch (Exception e) { // // 处理请求
// log.error("处理普通Agent请求时发生错误", e); // return processor.processRequest(agent, request, userId);
// return "[错误] 处理请求时发生错误: " + e.getMessage(); // } catch (Exception e) {
// } // log.error("处理普通Agent请求时发生错误", e);
// return "[错误] 处理请求时发生错误: " + e.getMessage();
// }
// } // }
/** /**
* 处理流式对话请求的统一入口 * 处理流式对话请求的统一入口
* *
* @param agentId Agent ID * @param agentId Agent ID
* @param chatRequest 对话请求 * @param chatRequest 对话请求
* @param response HTTP响应 * @param response HTTP响应
* @return SSE emitter * @return SSE emitter
*/ */
public SseEmitter handleChatStream(String agentId, ChatRequest chatRequest, HttpServletResponse response) { public SseEmitter handleChatStream(String agentId, ChatRequest chatRequest, HttpServletResponse response) {
log.info("开始处理流式对话请求,AgentId: {}, 用户消息: {}", agentId, chatRequest.getMessage()); log.info("开始处理流式对话请求,AgentId: {}, 用户消息: {}", agentId, chatRequest.getMessage());
// 尝试获取当前用户ID,优先从SecurityContext获取,其次从请求中解析JWT // 尝试获取当前用户ID,优先从SecurityContext获取,其次从请求中解析JWT
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
// 如果在主线程中未能获取到用户ID,尝试在异步环境中获取 // 如果在主线程中未能获取到用户ID,再次尝试获取(支持异步环境)
if (userId == null) { if (userId == null) {
userId = UserUtils.getCurrentUserIdInAsync(); userId = UserUtils.getCurrentUserIdStatic();
} }
if (userId == null) { if (userId == null) {
log.error("用户未认证"); log.error("用户未认证");
SseEmitter emitter = workPanelSseService.createEmitter(); SseEmitter emitter = userSseService.createEmitter();
// 检查响应是否已经提交 // 检查响应是否已经提交
if (!response.isCommitted()) { if (!response.isCommitted()) {
chatErrorHandler.handleChatError(emitter, "用户未认证,请重新登录"); errorHandlerService.handleChatError(emitter, "用户未认证,请重新登录");
} else { } else {
log.warn("响应已提交,无法发送用户未认证错误信息"); log.warn("响应已提交,无法发送用户未认证错误信息");
emitter.complete(); // 检查emitter是否已经完成,避免重复关闭
if (!userSseService.isEmitterCompleted(emitter)) {
emitter.complete();
}
}
return emitter;
}
// 验证Agent是否存在
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
log.warn("Agent不存在: {}", agentId);
SseEmitter emitter = userSseService.createEmitter();
// 检查响应是否已经提交
if (!response.isCommitted()) {
errorHandlerService.handleChatError(emitter, "Agent不存在");
} else {
log.warn("响应已提交,无法发送Agent不存在错误信息");
// 检查emitter是否已经完成,避免重复关闭
if (!userSseService.isEmitterCompleted(emitter)) {
emitter.complete();
}
} }
return emitter; return emitter;
} }
// 创建 SSE emitter // 创建 SSE emitter
SseEmitter emitter = workPanelSseService.createEmitter(); SseEmitter emitter = userSseService.createAndRegisterConnection(userId);
// 将userId设为final以在Lambda表达式中使用
final String finalUserId = userId;
// 异步处理对话,避免阻塞HTTP连接 // 异步处理对话,避免阻塞HTTP连接
executorService.execute(() -> { processChatStreamAsync(emitter, agent, chatRequest, userId);
try {
processChatRequest(emitter, agentId, chatRequest, finalUserId);
} catch (Exception e) {
log.error("处理聊天请求时发生异常", e);
// 检查响应是否已经提交
if (emitter != null) {
chatErrorHandler.handleChatError(emitter, "处理请求时发生错误", e, null);
} else {
log.warn("响应已提交,无法发送处理请求错误信息");
}
}
});
return emitter; return emitter;
} }
/**
* 异步处理流式对话
*/
@Async
private void processChatStreamAsync(SseEmitter emitter, Agent agent, ChatRequest chatRequest, String userId) {
try {
// 首先检查连接状态
if (emitter != null && userSseService.isEmitterCompleted(emitter)) {
log.debug("SSE连接已关闭,跳过异步处理");
return;
}
processChatRequest(emitter, agent, chatRequest, userId);
} catch (Exception e) {
log.error("处理聊天请求时发生异常", e);
// 检查响应是否已经提交
if (emitter != null && !userSseService.isEmitterCompleted(emitter)) {
errorHandlerService.handleChatError(emitter, "处理请求时发生错误", e, null);
}
}
}
/** /**
* 处理聊天请求的核心逻辑 * 处理聊天请求的核心逻辑
* 注意:权限验证已在主线程中完成,此正仅执行业务逻辑不进行权限检查
* *
* @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) { 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; // 权限验证失败,直接返回 }
// 获取处理器前检查连接状态
if (userSseService.isEmitterCompleted(emitter)) {
log.debug("SSE连接已关闭,跳过获取处理器");
return;
} }
// 获取处理器并启动心跳保活机制 // 获取处理器
AgentProcessor processor = agentProcessorFactory.getProcessor(agent); AgentProcessor processor = agentProcessorFactory.getProcessor(agent);
if (processor == null) { if (processor == null) {
return; // 获取处理器失败,直接返回 log.error("无法获取Agent处理器,Agent: {}", agent.getId());
if (!userSseService.isEmitterCompleted(emitter)) {
errorHandlerService.handleChatError(emitter, "无法获取Agent处理器");
}
return;
}
// 处理请求前检查连接状态
if (userSseService.isEmitterCompleted(emitter)) {
log.debug("SSE连接已关闭,跳过处理请求");
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);
// 处理流式请求 // 创建新的SseTokenEmitter实例
streamRequestService.handleStreamRequest(emitter, processor, request, agent, userId); SseTokenEmitter tokenEmitter = new SseTokenEmitter(userSseService, emitter, agent, request, userId, this::handleCompletion);
// 处理流式请求前再次检查连接状态
if (!userSseService.isEmitterCompleted(emitter)) {
processor.processStreamRequest(request, agent, userId, tokenEmitter);
} else {
log.debug("SSE连接已关闭,跳过流式处理");
}
} 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);
// 保存对话记录 - 安全操作,不抛出异常
saveDialogue(agent, request, userId, fullContent);
}
/**
* 保存对话记录
*/
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);
log.info("对话记录保存成功");
} catch (Exception e) {
log.error("保存对话记录失败", 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
...@@ -102,7 +102,7 @@ public class ErrorHandlerService { ...@@ -102,7 +102,7 @@ public class ErrorHandlerService {
* *
* @param emitter SSE发射器 * @param emitter SSE发射器
* @param errorMessage 错误信息 * @param errorMessage 错误信息
* @param exception 异常对象 * @param exception 异常对象(可选)
* @param processorType 处理器类型(可选) * @param processorType 处理器类型(可选)
*/ */
public void handleChatError(SseEmitter emitter, String errorMessage, Exception exception, String processorType) { public void handleChatError(SseEmitter emitter, String errorMessage, Exception exception, String processorType) {
...@@ -129,47 +129,38 @@ public class ErrorHandlerService { ...@@ -129,47 +129,38 @@ public class ErrorHandlerService {
errorMessage, exception); errorMessage, exception);
try { try {
String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, processorType); // 检查emitter是否已经完成,避免向已完成的连接发送错误信息
eventService.sendErrorEvent(emitter, fullErrorMessage); if (userSseService != null && !userSseService.isEmitterCompleted(emitter)) {
String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, processorType);
userSseService.sendErrorEvent(emitter, fullErrorMessage);
} else {
log.debug("[{}] SSE emitter已完成,跳过发送错误信息", errorId);
}
} catch (Exception sendErrorEx) { } catch (Exception sendErrorEx) {
log.error("[{}] 发送错误信息失败", errorId, sendErrorEx); log.error("[{}] 发送错误信息失败", errorId, sendErrorEx);
} }
} }
/** /**
* 处理聊天过程中的异常() * 处理聊天过程中的异常(简化版
* *
* @param emitter SSE发射器 * @param emitter SSE发射器
* @param errorMessage 错误信息 * @param errorMessage 错误信息
*/ */
public void handleChatError(SseEmitter emitter, String errorMessage) { public void handleChatError(SseEmitter emitter, String errorMessage) {
// 参数验证 handleChatError(emitter, errorMessage, null, null);
if (errorMessage == null || errorMessage.isEmpty()) {
errorMessage = "未知错误";
}
// 生成错误跟踪ID
String errorId = generateErrorId();
log.error("[{}] 处理聊天请求时发生错误: {}", errorId, errorMessage);
try {
String fullErrorMessage = buildFullErrorMessage(errorMessage, null, errorId, null);
eventService.sendErrorEvent(emitter, fullErrorMessage);
} catch (Exception sendErrorEx) {
log.error("[{}] 发送错误信息失败", errorId, sendErrorEx);
}
} }
/** /**
* 处理Token处理过程中的异常 * 处理带完成状态标记的异常
* *
* @param emitter SSE发射器 * @param emitter SSE发射器
* @param errorMessage 错误信息
* @param processorType 处理器类型 * @param processorType 处理器类型
* @param exception 异常对象 * @param exception 异常对象
* @param isCompleted 完成状态标记 * @param isCompleted 完成状态标记
*/ */
public void handleTokenError(SseEmitter emitter, String processorType, Exception exception, AtomicBoolean isCompleted) { private void handleErrorWithCompletion(SseEmitter emitter, String errorMessage, String processorType, Exception exception, AtomicBoolean isCompleted) {
// 参数验证 // 参数验证
if (processorType == null || processorType.isEmpty()) { if (processorType == null || processorType.isEmpty()) {
processorType = "未知处理器"; processorType = "未知处理器";
...@@ -182,17 +173,22 @@ public class ErrorHandlerService { ...@@ -182,17 +173,22 @@ public class ErrorHandlerService {
if (exception != null) { if (exception != null) {
exceptionMonitoringService.recordException( exceptionMonitoringService.recordException(
exception.getClass().getSimpleName(), exception.getClass().getSimpleName(),
"处理token时发生错误", errorMessage,
java.util.Arrays.toString(exception.getStackTrace()) java.util.Arrays.toString(exception.getStackTrace())
); );
} }
log.error("[{}] {}处理token时发生错误", errorId, processorType, exception); log.error("[{}] {}: {}", errorId, processorType, errorMessage, exception);
if (!isCompleted.getAndSet(true)) { if (!isCompleted.getAndSet(true)) {
try { try {
String errorMessage = "处理响应时发生错误"; // 检查emitter是否已经完成,避免向已完成的连接发送错误信息
String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, processorType); if (userSseService != null && !userSseService.isEmitterCompleted(emitter)) {
eventService.sendErrorEvent(emitter, fullErrorMessage); String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, processorType);
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);
...@@ -201,6 +197,18 @@ public class ErrorHandlerService { ...@@ -201,6 +197,18 @@ public class ErrorHandlerService {
} }
} }
/**
* 处理Token处理过程中的异常
*
* @param emitter SSE发射器
* @param processorType 处理器类型
* @param exception 异常对象
* @param isCompleted 完成状态标记
*/
public void handleTokenError(SseEmitter emitter, String processorType, Exception exception, AtomicBoolean isCompleted) {
handleErrorWithCompletion(emitter, "处理token时发生错误", processorType, exception, isCompleted);
}
/** /**
* 处理完成回调过程中的异常 * 处理完成回调过程中的异常
* *
...@@ -223,24 +231,27 @@ public class ErrorHandlerService { ...@@ -223,24 +231,27 @@ public class ErrorHandlerService {
log.error("[{}] 发送完成事件失败", errorId, exception); log.error("[{}] 发送完成事件失败", errorId, exception);
try { try {
String errorMessage = "发送完成事件失败,请联系技术支持"; // 检查emitter是否已经完成,避免向已完成的连接发送错误信息
String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, "完成回调"); if (userSseService != null && !userSseService.isEmitterCompleted(emitter)) {
eventService.sendErrorEvent(emitter, fullErrorMessage); String errorMessage = "发送完成事件失败,请联系技术支持";
String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, "完成回调");
userSseService.sendErrorEvent(emitter, fullErrorMessage);
} else {
log.debug("[{}] SSE emitter已完成,跳过发送错误信息", errorId);
}
} catch (Exception sendErrorEx) { } catch (Exception sendErrorEx) {
log.error("[{}] 发送错误信息失败", errorId, sendErrorEx); log.error("[{}] 发送错误信息失败", errorId, sendErrorEx);
} }
} }
/** /**
* 处理流式处理中的错误 * 处理基于Consumer的流式错误
* *
* @param e 异常对象 * @param e 异常对象
* @param tokenConsumer token处理回调函数 * @param tokenConsumer token处理回调函数
* @param errorMessagePrefix 错误消息前缀 * @param errorMessage 完整错误消息
*/ */
public void handleStreamError(Throwable e, Consumer<String> tokenConsumer, String errorMessagePrefix) { private void handleConsumerError(Throwable e, Consumer<String> tokenConsumer, String errorMessage) {
String errorMessage = errorMessagePrefix + ": " + e.getMessage();
// 记录异常到监控服务 // 记录异常到监控服务
exceptionMonitoringService.recordException( exceptionMonitoringService.recordException(
e.getClass().getSimpleName(), e.getClass().getSimpleName(),
...@@ -248,12 +259,24 @@ public class ErrorHandlerService { ...@@ -248,12 +259,24 @@ public class ErrorHandlerService {
java.util.Arrays.toString(e.getStackTrace()) java.util.Arrays.toString(e.getStackTrace())
); );
log.error("流式处理错误: {}", errorMessage, e); log.error(errorMessage, e);
if (tokenConsumer != null) { if (tokenConsumer != null) {
tokenConsumer.accept("[ERROR] " + errorMessage); tokenConsumer.accept("[ERROR] " + errorMessage);
} }
} }
/**
* 处理流式处理中的错误
*
* @param e 异常对象
* @param tokenConsumer token处理回调函数
* @param errorMessagePrefix 错误消息前缀
*/
public void handleStreamError(Throwable e, Consumer<String> tokenConsumer, String errorMessagePrefix) {
String errorMessage = errorMessagePrefix + ": " + e.getMessage();
handleConsumerError(e, tokenConsumer, errorMessage);
}
/** /**
* 发送错误信息给客户端 * 发送错误信息给客户端
* *
...@@ -274,18 +297,7 @@ public class ErrorHandlerService { ...@@ -274,18 +297,7 @@ public class ErrorHandlerService {
*/ */
public void handleReactFlowError(Exception e, Consumer<String> tokenConsumer) { public void handleReactFlowError(Exception e, Consumer<String> tokenConsumer) {
String errorMessage = "处理ReAct流程时发生错误: " + e.getMessage(); String errorMessage = "处理ReAct流程时发生错误: " + e.getMessage();
handleConsumerError(e, tokenConsumer, errorMessage);
// 记录异常到监控服务
exceptionMonitoringService.recordException(
e.getClass().getSimpleName(),
errorMessage,
java.util.Arrays.toString(e.getStackTrace())
);
log.error("ReAct流程错误: {}", errorMessage, e);
if (tokenConsumer != null) {
tokenConsumer.accept("[ERROR] " + errorMessage);
}
} }
/** /**
...@@ -317,28 +329,6 @@ public class ErrorHandlerService { ...@@ -317,28 +329,6 @@ public class ErrorHandlerService {
* @param isCompleted 完成状态标记 * @param isCompleted 完成状态标记
*/ */
public void handleSaveDialogueError(SseEmitter emitter, Exception exception, AtomicBoolean isCompleted) { public void handleSaveDialogueError(SseEmitter emitter, Exception exception, AtomicBoolean isCompleted) {
// 生成错误跟踪ID handleErrorWithCompletion(emitter, "保存对话记录失败", "对话记录", exception, isCompleted);
String errorId = generateErrorId();
// 记录异常到监控服务
if (exception != null) {
exceptionMonitoringService.recordException(
exception.getClass().getSimpleName(),
"保存对话记录失败",
java.util.Arrays.toString(exception.getStackTrace())
);
}
log.error("[{}] 保存对话记录失败", errorId, exception);
if (!isCompleted.getAndSet(true)) {
try {
String errorMessage = "保存对话记录失败,请联系技术支持";
String fullErrorMessage = buildFullErrorMessage(errorMessage, exception, errorId, "对话记录");
eventService.sendErrorEvent(emitter, fullErrorMessage);
} catch (Exception sendErrorEx) {
log.error("[{}] 发送错误信息失败", errorId, sendErrorEx);
}
}
} }
} }
\ 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;
/**
* 错误处理工具类
* 提供统一的错误处理方法,减少重复代码
* 委托给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
...@@ -2,9 +2,10 @@ package pangea.hiagent.agent.service; ...@@ -2,9 +2,10 @@ package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.Map; import java.util.concurrent.locks.ReentrantReadWriteLock;
/** /**
* 异常监控服务 * 异常监控服务
...@@ -17,12 +18,18 @@ public class ExceptionMonitoringService { ...@@ -17,12 +18,18 @@ public class ExceptionMonitoringService {
// 异常统计信息 // 异常统计信息
private final Map<String, AtomicLong> exceptionCounters = new ConcurrentHashMap<>(); private final Map<String, AtomicLong> exceptionCounters = new ConcurrentHashMap<>();
// 异常详细信息缓存 // 异常详细信息缓存,使用时间戳作为键,便于按时间排序
private final Map<String, String> exceptionDetails = new ConcurrentHashMap<>(); private final Map<Long, String> exceptionDetails = new ConcurrentHashMap<>();
// 锁,用于保护缓存清理操作
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 最大缓存条目数 // 最大缓存条目数
private static final int MAX_CACHE_SIZE = 1000; private static final int MAX_CACHE_SIZE = 1000;
// 清理阈值,当缓存超过最大值时,清理到这个值
private static final int CLEANUP_THRESHOLD = MAX_CACHE_SIZE - 200;
/** /**
* 记录异常信息 * 记录异常信息
* *
...@@ -37,14 +44,31 @@ public class ExceptionMonitoringService { ...@@ -37,14 +44,31 @@ public class ExceptionMonitoringService {
counter.incrementAndGet(); counter.incrementAndGet();
// 记录异常详细信息(保留最新的) // 记录异常详细信息(保留最新的)
String detailKey = exceptionType + "_" + System.currentTimeMillis(); long timestamp = System.currentTimeMillis();
exceptionDetails.put(detailKey, formatExceptionDetail(exceptionType, errorMessage, stackTrace)); exceptionDetails.put(timestamp, formatExceptionDetail(exceptionType, errorMessage, stackTrace));
// 控制缓存大小 // 控制缓存大小,使用写锁保护清理操作
if (exceptionDetails.size() > MAX_CACHE_SIZE) { if (exceptionDetails.size() > MAX_CACHE_SIZE) {
// 移除最老的条目 lock.writeLock().lock();
String oldestKey = exceptionDetails.keySet().iterator().next(); try {
exceptionDetails.remove(oldestKey); // 再次检查,避免竞态条件
if (exceptionDetails.size() > MAX_CACHE_SIZE) {
// 找出最老的条目并移除,直到达到清理阈值
while (exceptionDetails.size() > CLEANUP_THRESHOLD) {
// 找出最小的时间戳(最老的条目)
Long oldestTimestamp = exceptionDetails.keySet().stream()
.min(Long::compare)
.orElse(null);
if (oldestTimestamp != null) {
exceptionDetails.remove(oldestTimestamp);
} else {
break;
}
}
}
} finally {
lock.writeLock().unlock();
}
} }
// 记录日志 // 记录日志
...@@ -102,7 +126,11 @@ public class ExceptionMonitoringService { ...@@ -102,7 +126,11 @@ public class ExceptionMonitoringService {
* @return 异常详细信息 * @return 异常详细信息
*/ */
public Map<String, String> getExceptionDetails() { public Map<String, String> getExceptionDetails() {
return new ConcurrentHashMap<>(exceptionDetails); Map<String, String> result = new ConcurrentHashMap<>();
for (Map.Entry<Long, String> entry : exceptionDetails.entrySet()) {
result.put(entry.getKey().toString(), entry.getValue());
}
return result;
} }
/** /**
......
package pangea.hiagent.agent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.model.Agent;
import pangea.hiagent.web.dto.AgentRequest;
import java.io.IOException;
/**
* SSE Token发射器
* 专注于将token转换为SSE事件并发送
* 无状态设计,每次使用时创建新实例
*/
@Slf4j
public class SseTokenEmitter implements TokenConsumerWithCompletion {
private final UserSseService userSseService;
// 所有状态通过构造函数一次性传入
private final SseEmitter emitter;
private final Agent agent;
private final AgentRequest request;
private final String userId;
private final CompletionCallback completionCallback;
/**
* 构造函数
* @param userSseService SSE服务
* @param emitter SSE发射器
* @param agent Agent对象
* @param request 请求对象
* @param userId 用户ID
* @param completionCallback 完成回调
*/
public SseTokenEmitter(UserSseService userSseService, SseEmitter emitter, Agent agent,
AgentRequest request, String userId, CompletionCallback completionCallback) {
this.userSseService = userSseService;
this.emitter = emitter;
this.agent = agent;
this.request = request;
this.userId = userId;
this.completionCallback = completionCallback;
}
/**
* 无参构造函数,用于Spring容器初始化
*/
public SseTokenEmitter() {
this(null, null, null, null, null, null);
}
/**
* 构造函数,用于Spring容器初始化(带UserSseService参数)
*/
public SseTokenEmitter(UserSseService userSseService) {
this(userSseService, null, null, null, null, null);
}
/**
* 创建新的SseTokenEmitter实例
* @param emitter SSE发射器
* @param agent Agent对象
* @param request 请求对象
* @param userId 用户ID
* @param completionCallback 完成回调
* @return 新的SseTokenEmitter实例
*/
public SseTokenEmitter createNewInstance(SseEmitter emitter, Agent agent, AgentRequest request,
String userId, CompletionCallback completionCallback) {
return new SseTokenEmitter(userSseService, emitter, agent, request, userId, 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 (IllegalStateException e) {
// 处理emitter已关闭的情况,这通常是由于客户端断开连接
log.debug("无法发送token,SSE emitter已关闭: {}", e.getMessage());
// 将emitter标记为已完成,避免后续再次尝试发送
if (emitter != null) {
userSseService.removeEmitter(emitter);
}
} catch (IOException e) {
// 处理IO异常,这通常是由于客户端断开连接或网络问题
log.debug("无法发送token,IO异常: {}", e.getMessage());
// 将emitter标记为已完成,避免后续再次尝试发送
if (emitter != null) {
userSseService.removeEmitter(emitter);
}
} catch (Exception e) {
log.error("发送token失败", e);
// 对于其他异常,也将emitter标记为已完成,避免后续再次尝试发送
if (emitter != null) {
userSseService.removeEmitter(emitter);
}
}
}
@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 (IllegalStateException e) {
// 处理emitter已关闭的情况,这通常是由于客户端断开连接
log.debug("无法发送完成信号,SSE emitter已关闭: {}", e.getMessage());
} catch (IOException e) {
// 处理IO异常,这通常是由于客户端断开连接或网络问题
log.debug("无法发送完成信号,IO异常: {}", e.getMessage());
} 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) {
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);
// 处理流式请求,将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;
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;
}
@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.service; package pangea.hiagent.agent.service;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.workpanel.event.EventService;
/** /**
* Token消费者接口,支持完成回调 * Token消费者接口,支持完成回调
...@@ -17,17 +14,4 @@ public interface TokenConsumerWithCompletion extends Consumer<String> { ...@@ -17,17 +14,4 @@ public interface TokenConsumerWithCompletion extends Consumer<String> {
default void onComplete(String fullContent) { default void onComplete(String fullContent) {
// 默认实现为空 // 默认实现为空
} }
/**
* 当流式处理完成时调用,发送完成事件到前端
* @param fullContent 完整的内容
* @param emitter SSE发射器
* @param sseEventSender SSE事件发送器
* @param isCompleted 完成状态标记
*/
default void onComplete(String fullContent, SseEmitter emitter,
EventService eventService,
AtomicBoolean isCompleted) {
// 默认实现将在子类中覆盖
}
} }
\ 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.agent.data.ErrorEventDataBuilder;
import pangea.hiagent.workpanel.event.EventService; import pangea.hiagent.agent.data.MapPoolService;
import pangea.hiagent.agent.data.TokenEventDataBuilder;
import pangea.hiagent.agent.data.WorkPanelEvent;
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 java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
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;
...@@ -22,29 +26,38 @@ import java.util.concurrent.ScheduledFuture; ...@@ -22,29 +26,38 @@ import java.util.concurrent.ScheduledFuture;
@Slf4j @Slf4j
@Service @Service
public class UserSseService { public class UserSseService {
// 存储所有活动的 emitter // 存储所有活动的 emitter
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>(); private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
// 存储用户ID到SSE Emitter的映射关系 // 存储用户ID到SSE Emitter的映射关系
private final ConcurrentMap<String, SseEmitter> userEmitters = new ConcurrentHashMap<>(); private final ConcurrentMap<String, SseEmitter> userEmitters = new ConcurrentHashMap<>();
// 存储SSE Emitter到用户ID的反向映射关系(用于快速查找) // 存储SSE Emitter到用户ID的反向映射关系(用于快速查找)
private final ConcurrentMap<SseEmitter, String> emitterUsers = new ConcurrentHashMap<>(); private final ConcurrentMap<SseEmitter, String> emitterUsers = new ConcurrentHashMap<>();
// 存储已完成的emitter集合,用于快速检查状态
private final Set<SseEmitter> completedEmitters = ConcurrentHashMap.newKeySet();
// 心跳任务执行器 - 使用共享线程池以提高资源利用率 // 心跳任务执行器 - 使用共享线程池以提高资源利用率
private final ScheduledExecutorService heartbeatExecutor; private final ScheduledExecutorService heartbeatExecutor;
private final TokenEventDataBuilder tokenEventDataBuilder;
private final ErrorEventDataBuilder errorEventDataBuilder;
private final MapPoolService mapPoolService;
// SSE超时时间(毫秒) // SSE超时时间(毫秒)
private static final long SSE_TIMEOUT = 120000L; // 2分钟超时,提高连接稳定性 private static final long SSE_TIMEOUT = 0L; // 0表示不使用默认超时,由心跳机制管理连接
private final EventService eventService; public UserSseService(TokenEventDataBuilder tokenEventDataBuilder, ErrorEventDataBuilder errorEventDataBuilder, MapPoolService mapPoolService) {
this.tokenEventDataBuilder = tokenEventDataBuilder;
public UserSseService(EventService eventService) { this.errorEventDataBuilder = errorEventDataBuilder;
this.eventService = eventService; this.mapPoolService = mapPoolService;
this.heartbeatExecutor = Executors.newScheduledThreadPool(2); this.heartbeatExecutor = Executors.newScheduledThreadPool(2);
} }
/** /**
* 创建并注册SSE连接 * 创建并注册SSE连接
* *
...@@ -53,27 +66,27 @@ public class UserSseService { ...@@ -53,27 +66,27 @@ public class UserSseService {
*/ */
public SseEmitter createAndRegisterConnection(String userId) { public SseEmitter createAndRegisterConnection(String userId) {
log.debug("开始为用户 {} 创建SSE连接", userId); log.debug("开始为用户 {} 创建SSE连接", userId);
// 创建 SSE emitter // 创建 SSE emitter
SseEmitter emitter = createEmitter(); SseEmitter emitter = createEmitter();
log.debug("SSE Emitter创建成功"); log.debug("SSE Emitter创建成功");
// 注册用户的SSE连接 // 注册用户的SSE连接
registerSession(userId, emitter); registerSession(userId, emitter);
log.debug("用户 {} 的SSE连接注册成功", userId); log.debug("用户 {} 的SSE连接注册成功", userId);
// 注册 emitter 回调 // 注册 emitter 回调
registerCallbacks(emitter, userId); registerCallbacks(emitter, userId);
log.debug("SSE Emitter回调注册成功"); log.debug("SSE Emitter回调注册成功");
// 启动心跳机制 // 启动心跳机制
startHeartbeat(emitter, new AtomicBoolean(false)); startHeartbeat(emitter, new AtomicBoolean(false));
log.debug("心跳机制启动成功"); log.debug("心跳机制启动成功");
log.info("用户 {} 的SSE连接创建和注册完成", userId); log.info("用户 {} 的SSE连接创建和注册完成", userId);
return emitter; return emitter;
} }
/** /**
* 创建SSE发射器 * 创建SSE发射器
* *
...@@ -81,16 +94,17 @@ public class UserSseService { ...@@ -81,16 +94,17 @@ public class UserSseService {
*/ */
public SseEmitter createEmitter() { public SseEmitter createEmitter() {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT); SseEmitter emitter = new SseEmitter(SSE_TIMEOUT);
registerCallbacks(emitter);
emitters.add(emitter); emitters.add(emitter);
// 启动心跳机制,确保新创建的连接有心跳
startHeartbeat(emitter, new AtomicBoolean(false));
return emitter; return emitter;
} }
/** /**
* 注册用户的SSE连接 * 注册用户的SSE连接
* 如果该用户已有连接,则先关闭旧连接再注册新连接 * 如果该用户已有连接,则先关闭旧连接再注册新连接
* *
* @param userId 用户ID * @param userId 用户ID
* @param emitter SSE Emitter * @param emitter SSE Emitter
* @return true表示注册成功,false表示注册失败 * @return true表示注册成功,false表示注册失败
*/ */
...@@ -99,7 +113,7 @@ public class UserSseService { ...@@ -99,7 +113,7 @@ public class UserSseService {
log.warn("注册SSE会话失败:用户ID或Emitter为空"); log.warn("注册SSE会话失败:用户ID或Emitter为空");
return false; return false;
} }
try { try {
// 检查该用户是否已有连接 // 检查该用户是否已有连接
SseEmitter existingEmitter = userEmitters.get(userId); SseEmitter existingEmitter = userEmitters.get(userId);
...@@ -114,11 +128,11 @@ public class UserSseService { ...@@ -114,11 +128,11 @@ public class UserSseService {
userEmitters.remove(userId); userEmitters.remove(userId);
emitterUsers.remove(existingEmitter); emitterUsers.remove(existingEmitter);
} }
// 注册新连接 // 注册新连接
userEmitters.put(userId, emitter); userEmitters.put(userId, emitter);
emitterUsers.put(emitter, userId); emitterUsers.put(emitter, userId);
log.info("用户 {} 的SSE连接注册成功", userId); log.info("用户 {} 的SSE连接注册成功", userId);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
...@@ -126,7 +140,7 @@ public class UserSseService { ...@@ -126,7 +140,7 @@ public class UserSseService {
return false; return false;
} }
} }
/** /**
* 获取用户的SSE连接 * 获取用户的SSE连接
* *
...@@ -136,94 +150,89 @@ public class UserSseService { ...@@ -136,94 +150,89 @@ public class UserSseService {
public SseEmitter getSession(String userId) { public SseEmitter getSession(String userId) {
return userEmitters.get(userId); return userEmitters.get(userId);
} }
/** /**
* 处理连接完成事件 * 通用连接关闭处理方法
* *
* @param emitter SSE Emitter * @param emitter SSE Emitter
* @param connectionType 连接类型(用于日志)
*/ */
public void handleConnectionCompletion(SseEmitter emitter) { private void handleConnectionClose(SseEmitter emitter, String connectionType) {
if (emitter == null) { if (emitter == null) {
return; return;
} }
try { try {
// 添加到已完成集合
completedEmitters.add(emitter);
// 检查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) {
userEmitters.remove(userId); userEmitters.remove(userId);
} }
emitters.remove(emitter); emitters.remove(emitter);
log.debug("SSE连接完成,用户: {}", userId); log.debug("SSE连接{},用户: {}", connectionType, userId);
} catch (Exception e) { } catch (Exception e) {
log.error("处理SSE连接完成事件时发生异常", e); log.error("处理SSE连接{}事件时发生异常", connectionType, e);
} }
} }
/**
* 处理连接完成事件
*
* @param emitter SSE Emitter
*/
public void handleConnectionCompletion(SseEmitter emitter) {
handleConnectionClose(emitter, "完成");
}
/** /**
* 处理连接超时事件 * 处理连接超时事件
* *
* @param emitter SSE Emitter * @param emitter SSE Emitter
*/ */
public void handleConnectionTimeout(SseEmitter emitter) { public void handleConnectionTimeout(SseEmitter emitter) {
if (emitter == null) { handleConnectionClose(emitter, "超时");
return;
}
try {
// 从映射表中移除连接
String userId = emitterUsers.remove(emitter);
if (userId != null) {
userEmitters.remove(userId);
}
emitters.remove(emitter);
log.debug("SSE连接超时,用户: {}", userId);
} catch (Exception e) {
log.error("处理SSE连接超时事件时发生异常", e);
}
} }
/** /**
* 处理连接错误事件 * 处理连接错误事件
* *
* @param emitter SSE Emitter * @param emitter SSE Emitter
*/ */
public void handleConnectionError(SseEmitter emitter) { public void handleConnectionError(SseEmitter emitter) {
if (emitter == null) { handleConnectionClose(emitter, "错误");
return;
}
try {
// 从映射表中移除连接
String userId = emitterUsers.remove(emitter);
if (userId != null) {
userEmitters.remove(userId);
}
emitters.remove(emitter);
log.debug("SSE连接错误,用户: {}", userId);
} catch (Exception e) {
log.error("处理SSE连接错误事件时发生异常", e);
}
} }
/** /**
* 移除SSE发射器 * 移除SSE发射器
* *
* @param emitter SSE发射器 * @param emitter SSE发射器
*/ */
public void removeEmitter(SseEmitter emitter) { public void removeEmitter(SseEmitter emitter) {
if (emitter != null && emitters.remove(emitter)) { if (emitter != null) {
log.debug("已移除SSE Emitter,剩余连接数: {}", emitters.size()); completedEmitters.add(emitter); // 添加到已完成集合
if (emitters.remove(emitter)) {
log.debug("已移除SSE Emitter,剩余连接数: {}", emitters.size());
}
} }
} }
/** /**
* 启动心跳机制 * 启动心跳机制
* *
* @param emitter SSE发射器 * @param emitter SSE发射器
* @param isCompleted 是否已完成 * @param isCompleted 是否已完成
*/ */
public void startHeartbeat(SseEmitter emitter, AtomicBoolean isCompleted) { public void startHeartbeat(SseEmitter emitter, AtomicBoolean isCompleted) {
...@@ -231,122 +240,134 @@ public class UserSseService { ...@@ -231,122 +240,134 @@ public class UserSseService {
log.warn("SSE发射器为空,无法启动心跳机制"); log.warn("SSE发射器为空,无法启动心跳机制");
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(() -> {
// 检查emitter是否已经完成
if (isCompleted.get() || !isEmitterValid(emitter)) {
log.debug("SSE Emitter已完成或无效,取消心跳任务");
// 返回前确保任务被取消
return;
}
try { try {
// 检查emitter是否已经完成
if (isCompleted.get() || isEmitterCompleted(emitter)) {
log.debug("SSE Emitter已完成或无效,取消心跳任务");
if (heartbeatTaskRef[0] != null && !heartbeatTaskRef[0].isCancelled()) {
heartbeatTaskRef[0].cancel(true);
}
return;
}
// 发送心跳事件 // 发送心跳事件
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连接已关闭");
}
} 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);
} }
}, 30, 30, TimeUnit.SECONDS); // 每30秒发送一次心跳 }, 20, 20, TimeUnit.SECONDS); // 每20秒发送一次心跳,确保前端60秒超时前至少收到2次心跳
// 注册回调,在连接完成时取消心跳任务
emitter.onCompletion(() -> {
if (heartbeatTask != null && !heartbeatTask.isCancelled()) {
heartbeatTask.cancel(true);
log.debug("SSE连接完成,心跳任务已取消");
}
});
// 注册回调,在连接超时时取消心跳任务
emitter.onTimeout(() -> {
if (heartbeatTask != null && !heartbeatTask.isCancelled()) {
heartbeatTask.cancel(true);
log.debug("SSE连接超时,心跳任务已取消");
}
});
// 注册回调,在连接错误时取消心跳任务
emitter.onError(throwable -> {
if (heartbeatTask != null && !heartbeatTask.isCancelled()) {
heartbeatTask.cancel(true);
log.debug("SSE连接错误,心跳任务已取消");
}
});
} }
/** /**
* 注册回调函数 * 注册回调函数
* *
* @param emitter SSE发射器 * @param emitter SSE发射器
* @param userId 用户ID(可选,用于完整的连接管理)
*/ */
public void registerCallbacks(SseEmitter emitter) { public void registerCallbacks(SseEmitter emitter, String... userId) {
boolean hasUserId = userId != null && userId.length > 0 && userId[0] != null;
emitter.onCompletion(() -> { emitter.onCompletion(() -> {
log.debug("SSE连接完成"); log.debug("【注册回调函数】SSE连接完成");
removeEmitter(emitter); if (hasUserId) {
handleConnectionCompletion(emitter);
} else {
removeEmitter(emitter);
}
}); });
emitter.onError((Throwable t) -> { emitter.onError((Throwable t) -> {
log.error("SSE连接发生错误: {}", t.getMessage(), t); String errorMessage = t.getMessage();
removeEmitter(emitter); String errorType = t.getClass().getSimpleName();
}); log.debug("SSE连接发生错误 - 类型: {}, 消息: {}", errorType, errorMessage);
emitter.onTimeout(() -> { if (hasUserId) {
log.warn("SSE连接超时"); handleConnectionError(emitter);
try { } else {
emitter.complete(); removeEmitter(emitter);
} catch (Exception e) {
log.error("关闭SSE连接时发生异常: {}", e.getMessage(), e);
} }
removeEmitter(emitter);
});
}
/**
* 注册Emitter回调函数
* 职责:注册所有必要的回调处理函数
*
* @param emitter SSE Emitter
* @param userId 用户ID
*/
public void registerCallbacks(SseEmitter emitter, String userId) {
emitter.onCompletion(() -> {
log.debug("SSE连接完成");
// 通知用户连接管理器连接已完成
handleConnectionCompletion(emitter);
}); });
emitter.onTimeout(() -> { emitter.onTimeout(() -> {
log.warn("SSE连接超时"); log.warn("SSE连接超时");
try { try {
emitter.complete(); // 检查emitter是否已经完成,避免重复关闭
if (!isEmitterCompleted(emitter)) {
emitter.complete();
}
} catch (Exception e) { } catch (Exception e) {
log.error("关闭SSE连接失败", e); log.debug("关闭SSE连接时发生异常(可能是由于已关闭): {}", e.getMessage());
}
if (hasUserId) {
handleConnectionTimeout(emitter);
} else {
removeEmitter(emitter);
} }
// 通知用户连接管理器连接已超时
handleConnectionTimeout(emitter);
});
emitter.onError(throwable -> {
// 记录详细的错误信息,包括异常类型和消息
String errorMessage = throwable.getMessage();
String errorType = throwable.getClass().getSimpleName();
log.error("SSE连接错误 - 类型: {}, 消息: {}", errorType, errorMessage, throwable);
// 通知用户连接管理器连接发生错误
handleConnectionError(emitter);
}); });
// 注册 emitter 到管理器
registerCallbacks(emitter);
} }
/** /**
* 完成SSE发射器 * 完成SSE发射器
* *
* @param emitter SSE发射器 * @param emitter SSE发射器
* @param isCompleted 是否已完成 * @param isCompleted 是否已完成
*/ */
public void completeEmitter(SseEmitter emitter, AtomicBoolean isCompleted) { public void completeEmitter(SseEmitter emitter, AtomicBoolean isCompleted) {
...@@ -355,13 +376,25 @@ public class UserSseService { ...@@ -355,13 +376,25 @@ public class UserSseService {
} }
if (emitter != null) { if (emitter != null) {
try { try {
// 检查emitter是否已经完成,避免重复关闭
if (isEmitterCompleted(emitter)) {
log.debug("Emitter已经完成,跳过关闭操作");
return;
}
emitter.complete(); emitter.complete();
completedEmitters.add(emitter); // 添加到已完成集合
log.debug("Emitter已成功关闭");
} catch (IllegalStateException e) {
log.debug("Emitter已经关闭: {}", e.getMessage());
completedEmitters.add(emitter); // 添加到已完成集合
} catch (Exception e) { } catch (Exception e) {
log.warn("完成Emitter时发生异常: {}", e.getMessage()); log.warn("完成Emitter时发生异常: {}", e.getMessage());
completedEmitters.add(emitter); // 添加到已完成集合
} }
} }
} }
/** /**
* 检查SSE Emitter是否仍然有效 * 检查SSE Emitter是否仍然有效
* 职责:提供轻量级的连接有效性检查 * 职责:提供轻量级的连接有效性检查
...@@ -373,10 +406,16 @@ public class UserSseService { ...@@ -373,10 +406,16 @@ public class UserSseService {
if (emitter == null) { if (emitter == null) {
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) {
...@@ -384,65 +423,129 @@ public class UserSseService { ...@@ -384,65 +423,129 @@ public class UserSseService {
return false; return false;
} }
} }
/** /**
* 发送SSE事件 * 安全检查SSE Emitter是否仍然有效(不发送实际事件)
* 职责:统一发送SSE事件的基础方法 * 职责:提供非侵入性的连接有效性检查
* *
* @param emitter SSE发射器 * @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是已完成的
}
// 首先检查本地集合,避免网络操作
if (completedEmitters.contains(emitter)) {
return true;
}
// 检查emitter是否在活动列表中
if (!emitters.contains(emitter)) {
completedEmitters.add(emitter); // 添加到已完成集合
return true;
}
// 尝试使用安全的方式检查emitter状态,避免发送实际事件
// 注意:这个方法不会捕获所有连接问题,但能有效避免UT005023异常
return false;
}
/**
* 安全发送SSE事件,处理所有异常情况
*
* @param emitter SSE发射器
* @param eventName 事件名称 * @param eventName 事件名称
* @param data 事件数据 * @param data 事件数据
* @throws IOException IO异常 * @return 是否发送成功
*/ */
public void sendEvent(SseEmitter emitter, String eventName, Object data) throws IOException { private boolean safeSendEvent(SseEmitter emitter, String eventName, Object data) {
// 参数验证 // 参数验证
if (emitter == null || eventName == null || eventName.isEmpty() || data == null) { if (emitter == null || eventName == null || eventName.isEmpty() || data == null) {
log.warn("参数验证失败,无法发送事件"); log.warn("参数验证失败,无法发送事件");
return; return false;
} }
// 检查emitter是否已经完成
if (isEmitterCompleted(emitter)) {
log.debug("SSE emitter已完成,跳过发送{}事件", eventName);
return false;
}
try { try {
emitter.send(SseEmitter.event().name(eventName).data(data)); emitter.send(SseEmitter.event().name(eventName).data(data));
return true;
} catch (IllegalStateException e) { } catch (IllegalStateException e) {
// 处理 emitter 已关闭的情况 // 处理 emitter 已关闭的情况
log.debug("无法发送事件,emitter已关闭: {}", e.getMessage()); log.debug("无法发送{}事件,emitter已关闭: {}", eventName, e.getMessage());
// 不重新抛出异常,避免影响主流程 return false;
} catch (Exception e) {
log.error("发送{}事件失败: {}", eventName, e.getMessage(), e);
return false;
} }
} }
/**
* 发送SSE事件
* 职责:统一发送SSE事件的基础方法
*
* @param emitter SSE发射器
* @param eventName 事件名称
* @param data 事件数据
* @throws IOException IO异常
*/
public void sendEvent(SseEmitter emitter, String eventName, Object data) throws IOException {
safeSendEvent(emitter, eventName, data);
}
/** /**
* 发送心跳事件 * 发送心跳事件
* *
* @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;
} }
try { // 发送心跳事件
// 发送心跳事件 long heartbeatTimestamp = System.currentTimeMillis();
emitter.send(SseEmitter.event().name("heartbeat").data(System.currentTimeMillis())); boolean success = safeSendEvent(emitter, "heartbeat", heartbeatTimestamp);
} catch (IllegalStateException e) {
// 处理 emitter 已关闭的情况 if (success) {
log.debug("无法发送心跳事件,emitter已关闭: {}", e.getMessage()); log.debug("[心跳] 成功发送心跳事件,时间戳: {}", heartbeatTimestamp);
// 不重新抛出异常,避免影响主流程
} catch (Exception e) {
log.warn("发送心跳事件失败: {}", e.getMessage());
throw e;
} }
return success;
} }
/** /**
* 发送工作面板事件给指定的SSE连接 * 发送工作面板事件给指定的SSE连接
* *
* @param emitter SSE发射器 * @param emitter SSE发射器
* @param event 工作面板事件 * @param event 工作面板事件
* @throws IOException IO异常 * @throws IOException IO异常
*/ */
public void sendWorkPanelEvent(SseEmitter emitter, WorkPanelEvent event) throws IOException { public void sendWorkPanelEvent(WorkPanelEvent event) throws IOException {
if (event == null) { if (event == null) {
log.warn("工作面板事件为空,无法发送事件"); log.warn("工作面板事件为空,无法发送事件");
return; return;
...@@ -450,55 +553,66 @@ public class UserSseService { ...@@ -450,55 +553,66 @@ public class UserSseService {
try { try {
// 构建事件数据 // 构建事件数据
Map<String, Object> data = eventService.buildWorkPanelEventData(event); Map<String, Object> data = buildWorkPanelEventData(event);
if (data != null) { if (data != null) {
log.debug("准备发送工作面板事件: 类型={}, 事件内容={}", event.getType(), event); log.debug("准备发送工作面板事件: 类型={}, 事件内容={}", event.getType(), event);
log.debug("事件数据: {}", data); log.debug("事件数据: {}", data);
SseEmitter emitter = getSession(event.getUserId());
if (emitter == null) {
log.debug("未找到用户 {} 的SSE连接,跳过发送事件", event.getUserId());
return;
}
// 发送事件 // 发送事件
emitter.send(SseEmitter.event().name("message").data(data)); emitter.send(SseEmitter.event().name("message").data(data));
log.debug("工作面板事件发送成功: 类型={}", event.getType()); log.debug("工作面板事件发送成功: 类型={}", event.getType());
} 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;
}
} }
} }
/** /**
* 发送工作面板事件给指定用户 * 构建工作面板事件数据
* *
* @param userId 用户ID
* @param event 工作面板事件 * @param event 工作面板事件
* @return 事件数据
*/ */
public void sendWorkPanelEventToUser(String userId, WorkPanelEvent event) { private Map<String, Object> buildWorkPanelEventData(WorkPanelEvent event) {
log.debug("开始向用户 {} 发送工作面板事件: {}", userId, event.getType()); if (event == null) {
return null;
}
// 检查连接是否仍然有效 // 从对象池获取Map,避免频繁创建对象
SseEmitter emitter = getSession(userId); Map<String, Object> data = mapPoolService.acquireMap();
if (emitter != null) {
try { // 设置基础属性
// 直接向当前 emitter 发送事件 data.put("eventType", event.getType());
sendWorkPanelEvent(emitter, event); data.put("timestamp", event.getTimestamp());
log.debug("已发送工作面板事件到客户端: {}", event.getType()); data.put("title", event.getTitle());
} catch (IOException e) { data.put("content", event.getContent());
log.error("发送工作面板事件失败: {}", e.getMessage(), e); // data.put("userId", event.getUserId());
}
} else { if(event.getMetadata() != null) {
log.debug("连接已失效,跳过发送事件: {}", event.getType()); data.putAll(event.getMetadata());
} }
return data;
} }
/** /**
* 发送连接成功事件 * 发送连接成功事件
* *
...@@ -510,24 +624,77 @@ public class UserSseService { ...@@ -510,24 +624,77 @@ public class UserSseService {
log.warn("SSE发射器为空,无法发送连接成功事件"); log.warn("SSE发射器为空,无法发送连接成功事件");
return; return;
} }
try { WorkPanelEvent connectedEvent = WorkPanelEvent.builder()
WorkPanelEvent connectedEvent = WorkPanelEvent.builder()
.type("observation") .type("observation")
.title("连接成功") .title("连接成功")
.timestamp(System.currentTimeMillis()) .timestamp(System.currentTimeMillis())
.build(); .build();
Map<String, Object> data = eventService.buildWorkPanelEventData(connectedEvent); Map<String, Object> data = buildWorkPanelEventData(connectedEvent);
emitter.send(SseEmitter.event().name("message").data(data)); boolean success = safeSendEvent(emitter, "message", data);
if (success) {
log.debug("已发送连接成功事件"); log.debug("已发送连接成功事件");
} catch (IOException e) {
log.error("发送连接成功事件失败", e);
throw e;
} }
} }
/**
* 发送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 {
// 构建token事件数据
Map<String, Object> data = tokenEventDataBuilder.createOptimizedTokenEventData(token);
if (data != null) {
// 发送事件
safeSendEvent(emitter, "token", data);
} else {
log.warn("构建token事件数据失败,无法发送事件");
}
} 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 {
// 构建错误事件数据
Map<String, Object> data = errorEventDataBuilder.createErrorEventData(errorMessage);
if (data != null) {
// 发送事件
safeSendEvent(emitter, "error", data);
} else {
log.warn("构建错误事件数据失败,无法发送事件");
}
} catch (Exception e) {
log.error("发送错误事件失败: 错误信息={}, 错误={}", errorMessage, e.getMessage(), e);
}
}
/** /**
* 获取所有活动的emitters * 获取所有活动的emitters
* *
...@@ -536,7 +703,7 @@ public class UserSseService { ...@@ -536,7 +703,7 @@ public class UserSseService {
public List<SseEmitter> getEmitters() { public List<SseEmitter> getEmitters() {
return new ArrayList<>(emitters); return new ArrayList<>(emitters);
} }
/** /**
* 销毁资源 * 销毁资源
*/ */
......
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
...@@ -15,8 +15,8 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry ...@@ -15,8 +15,8 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
import org.springframework.web.socket.server.HandshakeInterceptor; import org.springframework.web.socket.server.HandshakeInterceptor;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import pangea.hiagent.workpanel.playwright.PlaywrightManager;
import pangea.hiagent.common.utils.JwtUtil; import pangea.hiagent.common.utils.JwtUtil;
import pangea.hiagent.tool.playwright.PlaywrightManager;
import pangea.hiagent.websocket.DomSyncHandler; import pangea.hiagent.websocket.DomSyncHandler;
import java.util.Map; import java.util.Map;
......
...@@ -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,31 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler { ...@@ -101,4 +100,31 @@ public class MetaObjectHandlerConfig implements MetaObjectHandler {
} }
} }
} }
/**
* 获取当前用户ID,支持异步线程上下文
* 该方法支持以下场景:
* 1. 优先从ThreadLocal获取(支持异步线程)
* 2. 从SecurityContext获取(支持同步请求和AsyncUserContextDecorator传播)
* 3. 从请求中解析Token获取用户ID
*
* @return 用户ID,如果无法获取则返回null
*/
private String getCurrentUserIdWithContext() {
try {
// 直接调用UserUtils.getCurrentUserIdStatic(),该方法已经包含了所有获取用户ID的方式
// 并且优先从ThreadLocal获取,支持异步线程
String userId = UserUtils.getCurrentUserIdStatic();
if (userId != null) {
log.debug("成功获取用户ID: {}", userId);
return userId;
}
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;
} }
...@@ -131,21 +134,25 @@ public class SecurityConfig { ...@@ -131,21 +134,25 @@ public class SecurityConfig {
try { try {
// 对于SSE端点的特殊处理 // 对于SSE端点的特殊处理
boolean isStreamEndpoint = request.getRequestURI().contains("/api/v1/agent/chat-stream"); boolean isStreamEndpoint = request.getRequestURI().contains("/api/v1/agent/chat-stream");
boolean isTimelineEndpoint = request.getRequestURI().contains("/api/v1/agent/timeline-events");
if (isStreamEndpoint) {
if (isStreamEndpoint || isTimelineEndpoint) { // 再次检查响应是否已经提交
// 对于SSE端点,发送SSE格式的错误事件 if (response.isCommitted()) {
response.setContentType("text/event-stream;charset=UTF-8"); log.warn("SSE端点响应已提交,无法发送认证错误");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("event: error\ndata: {\"error\": \"未授权访问\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
// 确保响应被正确提交
if (!response.isCommitted()) {
response.flushBuffer();
}
return; return;
} }
// 对于SSE端点,发送SSE格式的错误事件
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("event: error\ndata: {\"error\": \"未授权访问\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
// 确保响应被正确提交
if (!response.isCommitted()) {
response.flushBuffer();
}
return;
}
response.setStatus(401); response.setStatus(401);
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
...@@ -171,21 +178,25 @@ public class SecurityConfig { ...@@ -171,21 +178,25 @@ public class SecurityConfig {
try { try {
// 对于SSE端点的特殊处理 // 对于SSE端点的特殊处理
boolean isStreamEndpoint = request.getRequestURI().contains("/api/v1/agent/chat-stream"); boolean isStreamEndpoint = request.getRequestURI().contains("/api/v1/agent/chat-stream");
boolean isTimelineEndpoint = request.getRequestURI().contains("/api/v1/agent/timeline-events");
if (isStreamEndpoint) {
if (isStreamEndpoint || isTimelineEndpoint) { // 再次检查响应是否已经提交
// 对于SSE端点,发送SSE格式的错误事件 if (response.isCommitted()) {
response.setContentType("text/event-stream;charset=UTF-8"); log.warn("SSE端点响应已提交,无法发送访问拒绝错误");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("event: error\ndata: {\"error\": \"访问被拒绝\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
// 确保响应被正确提交
if (!response.isCommitted()) {
response.flushBuffer();
}
return; return;
} }
// 对于SSE端点,发送SSE格式的错误事件
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("event: error\ndata: {\"error\": \"访问被拒绝\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
// 确保响应被正确提交
if (!response.isCommitted()) {
response.flushBuffer();
}
return;
}
response.setStatus(403); response.setStatus(403);
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
...@@ -205,6 +216,8 @@ public class SecurityConfig { ...@@ -205,6 +216,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;
...@@ -207,44 +208,79 @@ public class GlobalExceptionHandler { ...@@ -207,44 +208,79 @@ public class GlobalExceptionHandler {
/** /**
* 处理授权拒绝异常 * 处理授权拒绝异常
*/ */
/**
* 获取当前响应对象
*/
private jakarta.servlet.http.HttpServletResponse getCurrentResponse() {
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;
return servletRequestAttributes.getResponse();
}
}
return null;
}
/**
* 检查响应是否已提交
*/
private boolean isResponseCommitted() {
jakarta.servlet.http.HttpServletResponse httpResponse = getCurrentResponse();
return httpResponse != null && httpResponse.isCommitted();
}
@ExceptionHandler(AuthorizationDeniedException.class) @ExceptionHandler(AuthorizationDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAuthorizationDeniedException( public ResponseEntity<ApiResponse<Void>> handleAuthorizationDeniedException(
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");
// 检查request属性 // 检查响应是否已提交
if (request.getAttribute("jakarta.servlet.error.exception") != null) { if (isResponseCommitted()) {
responseCommitted = true; log.warn("响应已提交,无法发送访问拒绝错误: {}", request.getRequestURL());
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 = getCurrentResponse();
if (((jakarta.servlet.http.HttpServletResponse) nativeResponse).isCommitted()) {
responseCommitted = true; if (sseResponse != null) {
sseResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
sseResponse.setContentType("text/event-stream;charset=UTF-8");
sseResponse.setCharacterEncoding("UTF-8");
// 发送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();
} }
} }
// 如果响应已提交,记录日志并返回空响应以避免二次异常
if (responseCommitted) {
log.warn("响应已提交,无法发送访问拒绝错误: {}", request.getRequestURL());
// 返回空响应而不是build(),避免潜在的响应提交冲突
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null);
}
ApiResponse.ErrorDetail errorDetail = ApiResponse.ErrorDetail.builder() ApiResponse.ErrorDetail errorDetail = ApiResponse.ErrorDetail.builder()
.type("ACCESS_DENIED") .type("ACCESS_DENIED")
.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,26 @@ public class AsyncUserContextDecorator { ...@@ -20,18 +97,26 @@ public class AsyncUserContextDecorator {
*/ */
public static Runnable wrapWithContext(Runnable runnable) { public static Runnable wrapWithContext(Runnable runnable) {
// 捕获当前线程的用户上下文 // 捕获当前线程的用户上下文
UserContextPropagationUtil.UserContextHolder userContext = UserContextPropagationUtil.captureUserContext(); UserContextHolder userContext = captureUserContext();
// 同时捕获当前线程的用户ID(用于ThreadLocal传播)
String currentUserId = UserUtils.getCurrentUserIdStatic();
return () -> { return () -> {
try { try {
// 在异步线程中传播用户上下文 // 在异步线程中传播用户上下文
UserContextPropagationUtil.propagateUserContext(userContext); propagateUserContext(userContext);
// 将用户ID设置到ThreadLocal中,增强可靠性
if (currentUserId != null) {
UserUtils.setCurrentUserIdStatic(currentUserId);
}
// 执行原始任务 // 执行原始任务
runnable.run(); runnable.run();
} finally { } finally {
// 清理当前线程的用户上下文 // 清理当前线程的用户上下文
UserContextPropagationUtil.clearUserContext(); clearUserContext();
// 清理ThreadLocal中的用户ID
UserUtils.clearCurrentUserIdStatic();
} }
}; };
} }
...@@ -44,18 +129,26 @@ public class AsyncUserContextDecorator { ...@@ -44,18 +129,26 @@ 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();
// 同时捕获当前线程的用户ID(用于ThreadLocal传播)
String currentUserId = UserUtils.getCurrentUserIdStatic();
return () -> { return () -> {
try { try {
// 在异步线程中传播用户上下文 // 在异步线程中传播用户上下文
UserContextPropagationUtil.propagateUserContext(userContext); propagateUserContext(userContext);
// 将用户ID设置到ThreadLocal中,增强可靠性
if (currentUserId != null) {
UserUtils.setCurrentUserIdStatic(currentUserId);
}
// 执行原始任务 // 执行原始任务
return callable.call(); return callable.call();
} finally { } finally {
// 清理当前线程的用户上下文 // 清理当前线程的用户上下文
UserContextPropagationUtil.clearUserContext(); clearUserContext();
// 清理ThreadLocal中的用户ID
UserUtils.clearCurrentUserIdStatic();
} }
}; };
} }
......
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;
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
...@@ -4,122 +4,155 @@ import lombok.extern.slf4j.Slf4j; ...@@ -4,122 +4,155 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.util.StringUtils;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import pangea.hiagent.common.utils.JwtUtil;
import java.lang.InheritableThreadLocal;
/** /**
* 用户相关工具类 * 用户相关工具类
* 提供统一的用户信息获取方法 * 提供统一的用户信息获取方法,支持异步线程安全
*/ */
@Slf4j @Slf4j
@Component @Component
public class UserUtils { public class UserUtils {
// 注入JwtUtil bean private volatile JwtUtil jwtUtil;
private static JwtUtil jwtUtil;
// 使用InheritableThreadLocal存储用户ID,支持异步线程继承
private final InheritableThreadLocal<String> USER_ID_THREAD_LOCAL = new InheritableThreadLocal<>();
// 静态Holder模式确保单例
private static class Holder {
private static UserUtils INSTANCE;
}
public UserUtils(JwtUtil jwtUtil) { public UserUtils(JwtUtil jwtUtil) {
UserUtils.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
Holder.INSTANCE = this;
}
/**
* 设置当前线程的用户ID
* @param userId 用户ID
*/
public void setCurrentUserId(String userId) {
if (StringUtils.hasText(userId)) {
USER_ID_THREAD_LOCAL.set(userId);
log.debug("设置当前线程的用户ID: {}", userId);
} else {
USER_ID_THREAD_LOCAL.remove();
log.debug("清除当前线程的用户ID");
}
}
/**
* 清除当前线程的用户ID
*/
public void clearCurrentUserId() {
USER_ID_THREAD_LOCAL.remove();
log.debug("清除当前线程的用户ID");
}
/**
* 从ThreadLocal获取用户ID
* @return 用户ID,如果不存在则返回null
*/
public String getCurrentUserIdFromThreadLocal() {
String userId = USER_ID_THREAD_LOCAL.get();
if (userId != null) {
log.debug("从ThreadLocal获取到用户ID: {}", userId);
}
else{
userId="user-001";
}
return userId;
} }
/** /**
* 获取当前认证用户ID * 获取当前认证用户ID
* 优先从ThreadLocal获取,其次从SecurityContext获取,最后从请求中解析JWT
* @return 用户ID,如果未认证则返回null * @return 用户ID,如果未认证则返回null
*/ */
public static String getCurrentUserId() { public String getCurrentUserId() {
// 优先从ThreadLocal获取(支持异步线程)
String userId = getCurrentUserIdFromThreadLocal();
if (userId != null) {
return userId;
}
// 从SecurityContext获取
userId = getCurrentUserIdFromSecurityContext();
if (userId != null) {
setCurrentUserId(userId);
return userId;
}
// 从请求中解析JWT
userId = getCurrentUserIdFromRequest();
if (userId != null) {
setCurrentUserId(userId);
}
return userId;
}
/**
* 从SecurityContext获取当前认证用户ID
* @return 用户ID,如果未认证则返回null
*/
private String getCurrentUserIdFromSecurityContext() {
try { try {
// 首先尝试从SecurityContext获取
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated() && authentication.getPrincipal() != null) { if (authentication != null && authentication.isAuthenticated() && !"anonymousUser".equals(authentication.getPrincipal())) {
Object principal = authentication.getPrincipal(); Object principal = authentication.getPrincipal();
if (principal instanceof String) { if (principal instanceof String) {
String userId = (String) principal; String userId = (String) principal;
log.debug("从SecurityContext获取到用户ID: {}", userId); log.debug("从SecurityContext获取到用户ID: {}", userId);
return userId; return userId;
} else { } else {
// 如果principal不是String类型,尝试获取getName()方法的返回值 // 尝试获取principal的字符串表示
log.debug("Authentication principal is not a String: {}", principal.getClass().getName()); log.debug("Authentication principal类型: {}", principal.getClass().getName());
try { String userId = principal.toString();
String userId = principal.toString(); log.debug("将principal转换为字符串获取用户ID: {}", userId);
log.debug("将principal转换为字符串获取用户ID: {}", userId); return userId;
return userId;
} catch (Exception toStringEx) {
log.warn("无法将principal转换为字符串: {}", toStringEx.getMessage());
}
} }
} }
// 如果SecurityContext中没有认证信息,尝试从请求中解析JWT令牌
String userId = getUserIdFromRequest();
if (userId != null) {
log.debug("从请求中解析到用户ID: {}", userId);
return userId;
}
log.debug("未能获取到有效的用户ID");
return null;
} catch (Exception e) {
log.error("获取当前用户ID时发生异常", e);
return null;
}
}
/**
* 在异步线程环境中获取当前认证用户ID
* 该方法专为异步线程环境设计,通过JWT令牌解析获取用户ID
* @return 用户ID,如果未认证则返回null
*/
public static String getCurrentUserIdInAsync() {
try {
log.debug("在异步线程中尝试获取用户ID");
// 直接从请求中解析JWT令牌获取用户ID
String userId = getUserIdFromRequest();
if (userId != null) {
log.debug("在异步线程中成功获取用户ID: {}", userId);
return userId;
}
log.debug("在异步线程中未能获取到有效的用户ID");
return null;
} catch (Exception e) { } catch (Exception e) {
log.error("在异步线程中获取用户ID时发生异常", e); log.error("从SecurityContext获取用户ID时发生异常", e);
return null;
} }
return null;
} }
/** /**
* 从当前请求中提取JWT令牌并解析用户ID * 从当前请求中提取JWT令牌并解析用户ID
* @return 用户ID,如果无法解析则返回null * @return 用户ID,如果无法解析则返回null
*/ */
private static String getUserIdFromRequest() { private String getCurrentUserIdFromRequest() {
try { try {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes) { if (requestAttributes instanceof ServletRequestAttributes) {
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
// 从请求头或参数中提取Token
String token = extractTokenFromRequest(request); String token = extractTokenFromRequest(request);
if (StringUtils.hasText(token) && jwtUtil != null) { if (StringUtils.hasText(token) && getJwtUtil() != null) {
// 验证token是否有效 boolean isValid = getJwtUtil().validateToken(token);
boolean isValid = jwtUtil.validateToken(token);
log.debug("JWT验证结果: {}", isValid); log.debug("JWT验证结果: {}", isValid);
if (isValid) { if (isValid) {
String userId = jwtUtil.getUserIdFromToken(token); String userId = getJwtUtil().getUserIdFromToken(token);
log.debug("从JWT令牌中提取用户ID: {}", userId); log.debug("从JWT令牌中提取用户ID: {}", userId);
return userId; return userId;
} else { } else {
log.warn("JWT验证失败,token可能已过期或无效"); log.warn("JWT验证失败,token可能已过期或无效");
} }
} else { } else {
if (jwtUtil == null) { if (getJwtUtil() == null) {
log.error("jwtUtil未初始化"); log.warn("jwtUtil未初始化");
} else { } else {
log.debug("未找到有效的token"); log.debug("未找到有效的token");
} }
...@@ -130,14 +163,13 @@ public class UserUtils { ...@@ -130,14 +163,13 @@ public class UserUtils {
} catch (Exception e) { } catch (Exception e) {
log.error("从请求中解析用户ID时发生异常", e); log.error("从请求中解析用户ID时发生异常", e);
} }
return null; return null;
} }
/** /**
* 从请求头或参数中提取Token * 从请求头或参数中提取Token
*/ */
private static String extractTokenFromRequest(HttpServletRequest request) { private String extractTokenFromRequest(HttpServletRequest request) {
// 首先尝试从请求头中提取Token // 首先尝试从请求头中提取Token
String authHeader = request.getHeader("Authorization"); String authHeader = request.getHeader("Authorization");
log.debug("从请求头中提取Authorization: {}", authHeader); log.debug("从请求头中提取Authorization: {}", authHeader);
...@@ -146,7 +178,7 @@ public class UserUtils { ...@@ -146,7 +178,7 @@ public class UserUtils {
log.debug("从Authorization头中提取到token"); log.debug("从Authorization头中提取到token");
return token; return token;
} }
// 如果请求头中没有Token,则尝试从URL参数中提取 // 如果请求头中没有Token,则尝试从URL参数中提取
String tokenParam = request.getParameter("token"); String tokenParam = request.getParameter("token");
log.debug("从URL参数中提取token参数: {}", tokenParam); log.debug("从URL参数中提取token参数: {}", tokenParam);
...@@ -154,28 +186,97 @@ public class UserUtils { ...@@ -154,28 +186,97 @@ public class UserUtils {
log.debug("从URL参数中提取到token"); log.debug("从URL参数中提取到token");
return tokenParam; return tokenParam;
} }
log.debug("未找到有效的token"); log.debug("未找到有效的token");
return null; return null;
} }
/**
* 获取JwtUtil实例,确保线程安全
*/
private JwtUtil getJwtUtil() {
if (jwtUtil == null) {
synchronized (UserUtils.class) {
if (jwtUtil == null) {
log.error("jwtUtil尚未初始化,请确保UserUtils已被Spring容器正确管理");
}
}
}
return jwtUtil;
}
/** /**
* 检查当前用户是否已认证 * 检查当前用户是否已认证
* @return true表示已认证,false表示未认证 * @return true表示已认证,false表示未认证
*/ */
public static boolean isAuthenticated() { public boolean isAuthenticated() {
return getCurrentUserId() != null; return getCurrentUserId() != null;
} }
/** /**
* 检查用户是否是管理员 * 检查用户是否是管理员
* @param userId 用户ID * @param userId 用户ID
* @return true表示是管理员,false表示不是管理员 * @return true表示是管理员,false表示不是管理员
*/ */
public static boolean isAdminUser(String userId) { public boolean isAdminUser(String userId) {
// 这里可以根据实际需求实现管理员检查逻辑 // 根据实际需求实现管理员检查逻辑
// 例如查询数据库或检查特殊用户ID
// 当前实现保留原有逻辑,但可以通过配置或数据库来管理管理员用户
return "admin".equals(userId) || "user-001".equals(userId); return "admin".equals(userId) || "user-001".equals(userId);
} }
}
\ No newline at end of file // 以下是静态方法,用于支持静态调用
/**
* 获取UserUtils单例实例
*/
private static UserUtils getInstance() {
UserUtils instance = Holder.INSTANCE;
if (instance == null) {
// 如果还没有初始化,返回默认实现
log.warn("UserUtils实例尚未初始化,使用默认实现");
instance = new UserUtils(null);
}
return instance;
}
/**
* 静态方法:设置当前线程的用户ID
*/
public static void setCurrentUserIdStatic(String userId) {
getInstance().setCurrentUserId(userId);
}
/**
* 静态方法:清除当前线程的用户ID
*/
public static void clearCurrentUserIdStatic() {
getInstance().clearCurrentUserId();
}
/**
* 静态方法:从ThreadLocal获取用户ID
*/
public static String getCurrentUserIdFromThreadLocalStatic() {
return getInstance().getCurrentUserIdFromThreadLocal();
}
/**
* 静态方法:获取当前认证用户ID
*/
public static String getCurrentUserIdStatic() {
return getInstance().getCurrentUserId();
}
/**
* 静态方法:检查当前用户是否已认证
*/
public static boolean isAuthenticatedStatic() {
return getInstance().isAuthenticated();
}
/**
* 静态方法:检查用户是否是管理员
*/
public static boolean isAdminUserStatic(String userId) {
return getInstance().isAdminUser(userId);
}
}
...@@ -59,7 +59,7 @@ public class MemoryService { ...@@ -59,7 +59,7 @@ public class MemoryService {
* @return 用户ID * @return 用户ID
*/ */
private String getCurrentUserId() { private String getCurrentUserId() {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
if (userId == null) { if (userId == null) {
log.warn("无法通过UserUtils获取当前用户ID"); log.warn("无法通过UserUtils获取当前用户ID");
} }
......
...@@ -4,11 +4,10 @@ import lombok.extern.slf4j.Slf4j; ...@@ -4,11 +4,10 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.PermissionEvaluator; import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import pangea.hiagent.web.service.AgentService;
import pangea.hiagent.web.service.TimerService;
import pangea.hiagent.model.Agent; import pangea.hiagent.model.Agent;
import pangea.hiagent.model.TimerConfig; import pangea.hiagent.model.TimerConfig;
import pangea.hiagent.web.service.AgentService;
import pangea.hiagent.web.service.TimerService;
import java.io.Serializable; import java.io.Serializable;
...@@ -20,6 +19,9 @@ import java.io.Serializable; ...@@ -20,6 +19,9 @@ import java.io.Serializable;
@Component("permissionEvaluator") @Component("permissionEvaluator")
public class DefaultPermissionEvaluator implements PermissionEvaluator { public class DefaultPermissionEvaluator implements PermissionEvaluator {
private static final String AGENT_TYPE = "Agent";
private static final String TIMER_CONFIG_TYPE = "TimerConfig";
private final AgentService agentService; private final AgentService agentService;
private final TimerService timerService; private final TimerService timerService;
...@@ -37,33 +39,21 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator { ...@@ -37,33 +39,21 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator {
return false; return false;
} }
Object principal = authentication.getPrincipal(); String userId = authentication.getPrincipal().toString();
if (principal == null) {
return false;
}
String userId = principal.toString();
String perm = (String) permission; String perm = (String) permission;
try { try {
// 处理Agent访问权限 // 处理Agent访问权限
if (targetDomainObject instanceof Agent) { if (targetDomainObject instanceof Agent) {
Agent agent = (Agent) targetDomainObject; return checkAgentAccess(userId, (Agent) targetDomainObject, perm);
return checkAgentAccess(userId, agent, perm);
} }
// 处理TimerConfig访问权限 // 处理TimerConfig访问权限
else if (targetDomainObject instanceof TimerConfig) { else if (targetDomainObject instanceof TimerConfig) {
TimerConfig timer = (TimerConfig) targetDomainObject; return checkTimerAccess(userId, (TimerConfig) targetDomainObject, perm);
return checkTimerAccess(userId, timer, perm);
}
// 处理基于ID的资源访问
else if (targetDomainObject instanceof String) {
// 这种情况在hasPermission(Authentication, Serializable, String, Object)方法中处理
return false;
} }
} catch (Exception e) { } catch (Exception e) {
log.error("权限检查过程中发生异常: userId={}, targetDomainObject={}, permission={}", userId, targetDomainObject, permission, e); log.error("权限检查异常: userId={}, target={}, permission={}, error={}",
return false; userId, targetDomainObject.getClass().getSimpleName(), perm, e.getMessage());
} }
return false; return false;
...@@ -75,36 +65,23 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator { ...@@ -75,36 +65,23 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator {
return false; return false;
} }
Object principal = authentication.getPrincipal(); String userId = authentication.getPrincipal().toString();
if (principal == null) {
return false;
}
String userId = principal.toString();
String perm = (String) permission; String perm = (String) permission;
try { try {
// 处理基于ID的权限检查 // 处理基于ID的权限检查
if ("Agent".equals(targetType)) { if (AGENT_TYPE.equals(targetType)) {
Agent agent = agentService.getAgent(targetId.toString()); Agent agent = agentService.getAgent(targetId.toString());
if (agent == null) { return agent != null && checkAgentAccess(userId, agent, perm);
log.warn("未找到ID为 {} 的Agent", targetId);
return false;
}
return checkAgentAccess(userId, agent, perm);
} }
// 处理TimerConfig资源的权限检查 // 处理TimerConfig资源的权限检查
else if ("TimerConfig".equals(targetType)) { else if (TIMER_CONFIG_TYPE.equals(targetType)) {
TimerConfig timer = timerService.getTimerById(targetId.toString()); TimerConfig timer = timerService.getTimerById(targetId.toString());
if (timer == null) { return timer != null && checkTimerAccess(userId, timer, perm);
log.warn("未找到ID为 {} 的TimerConfig", targetId);
return false;
}
return checkTimerAccess(userId, timer, perm);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("基于ID的权限检查过程中发生异常: userId={}, targetId={}, targetType={}, permission={}", userId, targetId, targetType, permission, e); log.error("基于ID的权限检查异常: userId={}, targetId={}, targetType={}, permission={}, error={}",
return false; userId, targetId, targetType, perm, e.getMessage());
} }
return false; return false;
...@@ -119,24 +96,17 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator { ...@@ -119,24 +96,17 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator {
return true; return true;
} }
// 检查Agent所有者 // 所有者可以访问
if (agent.getOwner().equals(userId)) { if (agent.getOwner().equals(userId)) {
return true; return true;
} }
// 根据权限类型进行检查 // 根据权限类型进行检查(目前只支持所有者访问)
switch (permission.toLowerCase()) { String permissionLower = permission.toLowerCase();
case "read": return switch (permissionLower) {
// 所有用户都可以读取公开的Agent(如果有此概念) case "read", "write", "delete", "execute" -> agent.getOwner().equals(userId);
return false; // 暂时不支持公开Agent default -> false;
case "write": };
case "delete":
case "execute":
// 只有所有者可以写入、删除或执行Agent
return agent.getOwner().equals(userId);
default:
return false;
}
} }
/** /**
...@@ -148,32 +118,24 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator { ...@@ -148,32 +118,24 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator {
return true; return true;
} }
// 检查定时器创建者 // 创建者可以访问
if (timer.getCreatedBy() != null && timer.getCreatedBy().equals(userId)) { if (timer.getCreatedBy() != null && timer.getCreatedBy().equals(userId)) {
return true; return true;
} }
// 根据权限类型进行检查 // 根据权限类型进行检查(目前只支持创建者访问)
switch (permission.toLowerCase()) { String permissionLower = permission.toLowerCase();
case "read": return switch (permissionLower) {
// 所有用户都可以读取公开的定时器(如果有此概念) case "read", "write", "delete" -> timer.getCreatedBy() != null && timer.getCreatedBy().equals(userId);
return false; // 暂时不支持公开定时器 default -> false;
case "write": };
case "delete":
// 只有创建者可以修改或删除定时器
return timer.getCreatedBy() != null && timer.getCreatedBy().equals(userId);
default:
return false;
}
} }
/** /**
* 检查是否为管理员用户 * 检查是否为管理员用户
*/ */
private boolean isAdminUser(String userId) { private boolean isAdminUser(String userId) {
// 这里可以根据实际需求实现管理员检查逻辑 // 管理员用户检查,可扩展为从配置或数据库读取
// 例如查询数据库或检查特殊用户ID
// 当前实现保留原有逻辑,但可以通过配置或数据库来管理管理员用户
return "admin".equals(userId) || "user-001".equals(userId); return "admin".equals(userId) || "user-001".equals(userId);
} }
} }
\ No newline at end of file
...@@ -5,18 +5,17 @@ import jakarta.servlet.ServletException; ...@@ -5,18 +5,17 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.common.utils.JwtUtil;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import pangea.hiagent.common.utils.JwtUtil;
import pangea.hiagent.common.utils.UserUtils;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.List;
/** /**
* JWT认证过滤器 * JWT认证过滤器
...@@ -26,95 +25,50 @@ import java.util.List; ...@@ -26,95 +25,50 @@ import java.util.List;
@Component @Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer ";
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final UserUtils userUtils;
public JwtAuthenticationFilter(JwtUtil jwtUtil) { public JwtAuthenticationFilter(JwtUtil jwtUtil, UserUtils userUtils) {
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
this.userUtils = userUtils;
} }
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException { throws ServletException, IOException {
boolean isStreamEndpoint = request.getRequestURI().contains("/api/v1/agent/chat-stream");
boolean isTimelineEndpoint = request.getRequestURI().contains("/api/v1/agent/timeline-events");
if (isStreamEndpoint) {
log.info("处理Agent流式对话请求: {} {}", request.getMethod(), request.getRequestURI());
}
if (isTimelineEndpoint) {
log.info("处理时间轴事件订阅请求: {} {}", request.getMethod(), request.getRequestURI());
}
// 对于OPTIONS请求,直接放行 // 对于OPTIONS请求,直接放行
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
log.debug("OPTIONS请求,直接放行");
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
try { try {
String token = extractTokenFromRequest(request); String token = extractTokenFromRequest(request);
log.debug("JWT过滤器处理请求: {} {},提取到token: {}", request.getMethod(), request.getRequestURI(), token);
if (StringUtils.hasText(token)) { if (StringUtils.hasText(token)) {
log.debug("开始JWT验证,token长度: {}", token.length());
// 验证token是否有效 // 验证token是否有效
boolean isValid = jwtUtil.validateToken(token); if (jwtUtil.validateToken(token)) {
log.debug("JWT验证结果: {}", isValid);
if (isValid) {
String userId = jwtUtil.getUserIdFromToken(token); String userId = jwtUtil.getUserIdFromToken(token);
log.debug("JWT验证通过,用户ID: {}", userId);
if (userId != null) { if (userId != null) {
// 创建认证对象,添加基本权限 // 创建认证对象,添加基本权限
List<SimpleGrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")); var authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
UsernamePasswordAuthenticationToken authentication = var authentication = new UsernamePasswordAuthenticationToken(userId, null, authorities);
new UsernamePasswordAuthenticationToken(userId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("已设置SecurityContext中的认证信息,用户ID: {}, 权限: {}", userId, authentication.getAuthorities());
} else { userUtils.setCurrentUserId(userId);
log.warn("从token中提取的用户ID为空");
} }
} else {
log.warn("JWT验证失败,token可能已过期或无效");
// 检查token是否过期
boolean isExpired = jwtUtil.isTokenExpired(token);
log.warn("Token过期状态: {}", isExpired);
} }
} else {
log.debug("未找到有效的token");
// 记录请求信息以便调试
log.debug("请求URL: {}", request.getRequestURL());
log.debug("请求方法: {}", request.getMethod());
log.debug("Authorization头: {}", request.getHeader("Authorization"));
log.debug("token参数: {}", request.getParameter("token"));
} }
} catch (Exception e) { } catch (Exception e) {
log.error("JWT认证处理异常", e); log.error("JWT认证处理异常: {}", e.getMessage());
// 不在此处发送错误响应,让Spring Security的ExceptionTranslationFilter处理 // 不在此处发送错误响应,让Spring Security的ExceptionTranslationFilter处理
// 这样可以避免响应被提前提交
}
// 特别处理流式端点的权限问题
if (isStreamEndpoint || isTimelineEndpoint) {
// 检查是否已认证
if (SecurityContextHolder.getContext().getAuthentication() == null) {
log.warn("流式端点未认证访问: {} {}", request.getMethod(), request.getRequestURI());
// 对于SSE端点,如果未认证,我们不立即返回错误,而是让后续处理决定
// 因为客户端可能会在重新连接时带上token
}
// 对于SSE端点,直接执行过滤器链,不进行额外的响应检查
filterChain.doFilter(request, response);
log.debug("JwtAuthenticationFilter处理完成(SSE端点): {} {}", request.getMethod(), request.getRequestURI());
return;
} }
// 继续执行过滤器链,让Spring Security的其他过滤器处理认证和授权 // 继续执行过滤器链
// 这样可以让ExceptionTranslationFilter和AuthorizationFilter正确处理认证失败和权限拒绝
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
log.debug("JwtAuthenticationFilter处理完成: {} {}", request.getMethod(), request.getRequestURI());
} }
/** /**
...@@ -124,23 +78,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { ...@@ -124,23 +78,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private String extractTokenFromRequest(HttpServletRequest request) { private String extractTokenFromRequest(HttpServletRequest request) {
// 首先尝试从请求头中提取Token // 首先尝试从请求头中提取Token
String authHeader = request.getHeader("Authorization"); String authHeader = request.getHeader("Authorization");
log.debug("从请求头中提取Authorization: {}", authHeader); if (StringUtils.hasText(authHeader) && authHeader.startsWith(BEARER_PREFIX)) {
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) { return authHeader.substring(BEARER_PREFIX.length());
String token = authHeader.substring(7);
log.debug("从Authorization头中提取到token");
return token;
} }
// 如果请求头中没有Token,则尝试从URL参数中提取 // 如果请求头中没有Token,则尝试从URL参数中提取
// 这对于SSE连接特别有用,因为浏览器在自动重连时可能不会发送Authorization头 return request.getParameter("token");
String tokenParam = request.getParameter("token");
log.debug("从URL参数中提取token参数: {}", tokenParam);
if (StringUtils.hasText(tokenParam)) {
log.debug("从URL参数中提取到token");
return tokenParam;
}
log.debug("未找到有效的token");
return null;
} }
} }
\ No newline at end of file
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.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import pangea.hiagent.model.Agent;
import pangea.hiagent.web.service.AgentService;
import java.io.IOException;
/**
* 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 AgentService agentService;
public SseAuthorizationFilter(AgentService agentService) {
this.agentService = agentService;
}
/**
* 发送SSE格式的错误响应
*/
private void sendSseError(HttpServletResponse response, int status, String errorMessage) {
// 检查响应是否已经提交,避免后续错误处理异常
if (response.isCommitted()) {
log.warn("响应已提交,无法发送SSE错误响应: {} - {}", status, errorMessage);
return;
}
try {
response.setStatus(status);
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// 发送SSE格式的错误事件
response.getWriter().write("event: error\n");
response.getWriter().write("data: {\"error\": \"" + errorMessage + "\", \"code\": " + status + ", \"timestamp\": " +
System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
log.debug("已发送SSE错误响应: {} - {}", status, errorMessage);
} catch (IOException e) {
log.error("发送SSE错误响应失败: {}", e.getMessage());
}
}
@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 = getCurrentUserId();
if (userId != null) {
log.debug("SSE端点已认证,用户: {}", userId);
// 如果是chat-stream端点,需要额外验证agent权限
if (isStreamEndpoint) {
String agentId = request.getParameter("agentId");
if (agentId != null) {
try {
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
log.warn("SSE端点访问失败:Agent不存在 - AgentId: {}", agentId);
sendSseError(response, HttpServletResponse.SC_NOT_FOUND, "Agent不存在");
return;
}
// 验证用户是否有权限访问该agent
if (!agent.getOwner().equals(userId) && !isAdminUser(userId)) {
log.warn("SSE端点访问失败:用户 {} 无权限访问Agent: {}", userId, agentId);
sendSseError(response, HttpServletResponse.SC_FORBIDDEN, "访问被拒绝,无权限访问该Agent");
return;
}
log.debug("SSE端点Agent权限验证成功,用户: {}, Agent: {}", userId, agentId);
} catch (Exception e) {
log.error("SSE端点Agent权限验证异常: {}", e.getMessage());
sendSseError(response, HttpServletResponse.SC_FORBIDDEN, "访问被拒绝");
return;
}
} else {
log.warn("SSE端点请求缺少agentId参数");
sendSseError(response, HttpServletResponse.SC_NOT_FOUND, "Agent不存在");
return;
}
}
// 再次检查响应是否已经提交,避免后续过滤器尝试修改已提交的响应
if (response.isCommitted()) {
log.warn("响应已提交,跳过继续执行过滤器链");
return;
}
// 继续执行过滤器链
filterChain.doFilter(request, response);
return;
} else {
// 用户未认证,拒绝连接
log.warn("SSE端点未认证访问,拒绝连接: {} {}", request.getMethod(), requestUri);
sendSseError(response, HttpServletResponse.SC_UNAUTHORIZED, "未授权访问,请先登录");
return;
}
}
// 继续执行过滤器链(非SSE端点)
filterChain.doFilter(request, response);
}
/**
* 从SecurityContext获取当前认证用户ID
*/
private String getCurrentUserId() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated() && !"anonymousUser".equals(authentication.getPrincipal())) {
return authentication.getName();
}
return null;
}
/**
* 检查是否为管理员用户
*/
private boolean isAdminUser(String userId) {
// 与DefaultPermissionEvaluator保持一致的管理员检查逻辑
return "admin".equals(userId) || "user-001".equals(userId);
}
/**
* 确定此过滤器是否应处理给定请求
* 只处理SSE流式端点
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String requestUri = request.getRequestURI();
return !(requestUri.contains(STREAM_ENDPOINT) || requestUri.contains(TIMELINE_ENDPOINT));
}
}
...@@ -12,6 +12,7 @@ import pangea.hiagent.web.service.ToolService; ...@@ -12,6 +12,7 @@ import pangea.hiagent.web.service.ToolService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
...@@ -38,37 +39,29 @@ public class AgentToolManager { ...@@ -38,37 +39,29 @@ public class AgentToolManager {
* @return 工具列表 * @return 工具列表
*/ */
public List<Tool> getAvailableTools(Agent agent) { public List<Tool> getAvailableTools(Agent agent) {
try { log.info("获取Agent可用工具列表,Agent ID: {}, 名称: {}", agent.getId(), agent.getName());
log.info("获取Agent可用工具列表,Agent ID: {}, 名称: {}", agent.getId(), agent.getName());
// 获取与Agent关联的Tool ID列表
// 获取与Agent关联的Tool ID列表 List<String> toolIds = agentToolRelationRepository.getToolIdsByAgentId(agent.getId());
List<String> toolIds = agentToolRelationRepository.getToolIdsByAgentId(agent.getId()); log.info("Agent关联的工具ID数量: {}", toolIds != null ? toolIds.size() : 0);
log.info("Agent关联的工具ID数量: {}", toolIds != null ? toolIds.size() : 0);
// 如果没有关联特定工具,则返回该用户的所有活跃工具
if (toolIds == null || toolIds.isEmpty()) { if (toolIds == null || toolIds.isEmpty()) {
// 如果没有关联特定工具,则返回该用户的所有活跃工具 List<Tool> allTools = toolService.getUserToolsByStatus(agent.getOwner(), "active");
List<Tool> allTools = toolService.getUserToolsByStatus(agent.getOwner(), "active"); log.info("返回用户所有活跃工具,数量: {}", allTools != null ? allTools.size() : 0);
log.info("返回用户所有活跃工具,数量: {}", allTools != null ? allTools.size() : 0); return allTools != null ? allTools : List.of();
return allTools != null ? allTools : List.of();
}
// 根据Tool ID获取具体的Tool对象
List<Tool> tools = new ArrayList<>();
for (String toolId : toolIds) {
Tool tool = toolService.getById(toolId);
if (tool != null) {
tools.add(tool);
}
}
log.info("获取到的具体工具数量: {}", tools.size());
tools.forEach(tool -> log.info("工具名称: {}", tool.getName()));
return tools;
} catch (Exception e) {
log.error("获取Agent可用工具时发生错误", e);
return List.of();
} }
// 根据Tool ID获取具体的Tool对象
List<Tool> tools = toolIds.stream()
.map(toolService::getById)
.filter(Objects::nonNull)
.collect(Collectors.toList());
log.info("获取到的具体工具数量: {}", tools.size());
log.debug("工具列表: {}", tools.stream().map(Tool::getName).collect(Collectors.joining(", ")));
return tools;
} }
/** /**
...@@ -100,34 +93,18 @@ public class AgentToolManager { ...@@ -100,34 +93,18 @@ public class AgentToolManager {
* @return 筛选后的工具实例列表 * @return 筛选后的工具实例列表
*/ */
public List<Object> filterToolsByInstances(List<Object> allTools, Set<String> toolNames) { public List<Object> filterToolsByInstances(List<Object> allTools, Set<String> toolNames) {
log.debug("开始筛选工具实例,工具名称集合: {}", toolNames);
if (toolNames == null || toolNames.isEmpty()) { if (toolNames == null || toolNames.isEmpty()) {
log.debug("工具名称集合为空,返回所有工具实例");
return allTools; return allTools;
} }
List<Object> filteredTools = allTools.stream() return allTools.stream()
.filter(tool -> { .filter(tool -> {
// 获取工具类名(不含包名)
String className = tool.getClass().getSimpleName(); String className = tool.getClass().getSimpleName();
log.debug("检查工具类: {}", className); return toolNames.contains(className) ||
toolNames.stream().anyMatch(name ->
// 检查类名是否匹配 className.toLowerCase().contains(name.toLowerCase()));
boolean isMatch = toolNames.contains(className) ||
toolNames.stream().anyMatch(name ->
className.toLowerCase().contains(name.toLowerCase()));
if (isMatch) {
log.debug("工具 {} 匹配成功", className);
}
return isMatch;
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
log.debug("筛选完成,返回 {} 个工具实例", filteredTools.size());
return filteredTools;
} }
/** /**
...@@ -143,8 +120,9 @@ public class AgentToolManager { ...@@ -143,8 +120,9 @@ public class AgentToolManager {
StringBuilder description = new StringBuilder(); StringBuilder description = new StringBuilder();
for (int i = 0; i < tools.size(); i++) { for (int i = 0; i < tools.size(); i++) {
Tool tool = tools.get(i); Tool tool = tools.get(i);
description.append(i + 1).append(". "); description.append(i + 1).append(". ")
description.append(tool.getName()); .append(tool.getName());
if (hasValue(tool.getDisplayName())) { if (hasValue(tool.getDisplayName())) {
description.append(" - ").append(tool.getDisplayName()); description.append(" - ").append(tool.getDisplayName());
} }
...@@ -168,20 +146,11 @@ public class AgentToolManager { ...@@ -168,20 +146,11 @@ public class AgentToolManager {
/** /**
* 获取Bean的原始目标类(穿透Spring AOP代理) * 获取Bean的原始目标类(穿透Spring AOP代理)
*
* 用于处理以下场景:
* 1. Bean被Spring AOP代理,需要获取原始类信息
* 2. 获取原始类的方法和字段信息
* 3. 进行类型检查和反射操作
*
* @param bean Bean实例(可能是代理对象) * @param bean Bean实例(可能是代理对象)
* @return 原始目标类的Class对象 * @return 原始目标类的Class对象
*/ */
private Class<?> getTargetClass(Object bean) { private Class<?> getTargetClass(Object bean) {
if (bean == null) { return bean == null ? null : AopUtils.getTargetClass(bean);
return null;
}
return AopUtils.getTargetClass(bean);
} }
/** /**
...@@ -209,62 +178,43 @@ public class AgentToolManager { ...@@ -209,62 +178,43 @@ public class AgentToolManager {
return result; return result;
} }
try { List<Tool> availableTools = getAvailableTools(agent);
log.debug("[{}] 根据原始类名'{}' 查找工具实例,精确匹配: {}", agent.getName(), originalClassName, isExactMatch);
for (Tool tool : availableTools) {
if (tool.getBeanName() == null || tool.getBeanName().trim().isEmpty()) {
continue;
}
Object bean;
try {
bean = applicationContext.getBean(tool.getBeanName());
} catch (Exception e) {
log.debug("[{}] 工具'{}' 的Bean查找失败,跳过", agent.getName(), tool.getName());
continue;
}
List<Tool> availableTools = getAvailableTools(agent); if (bean == null) {
continue;
}
for (Tool tool : availableTools) { // 获取原始目标类
try { Class<?> targetClass = getTargetClass(bean);
if (tool.getBeanName() == null || tool.getBeanName().trim().isEmpty()) { if (targetClass == null) {
continue; targetClass = bean.getClass();
}
Object bean = null;
try {
bean = applicationContext.getBean(tool.getBeanName());
} catch (Exception e) {
log.debug("[{}] 工具'{}' 的Bean查找失败,跳过", agent.getName(), tool.getName());
continue;
}
if (bean == null) {
continue;
}
// 获取原始目标类
Class<?> targetClass = getTargetClass(bean);
if (targetClass == null) {
targetClass = bean.getClass();
}
String targetClassName = targetClass.getSimpleName();
String targetFullClassName = targetClass.getName();
// 根据匹配模式进行判断
boolean matches = false;
if (isExactMatch) {
// 精确匹配:检查简单类名和完整类名
matches = originalClassName.equals(targetClassName) ||
originalClassName.equals(targetFullClassName);
} else {
// 模糊匹配:检查是否包含(不区分大小写)
matches = targetClassName.toLowerCase().contains(originalClassName.toLowerCase()) ||
targetFullClassName.toLowerCase().contains(originalClassName.toLowerCase());
}
if (matches) {
result.add(bean);
log.debug("[{}] 根据原始类名'{}' 匹配到工具实例: {}", agent.getName(), originalClassName, targetClassName);
}
} catch (Exception e) {
log.debug("[{}] 处理工具'{}' 时出错", agent.getName(), tool.getName(), e);
}
} }
log.debug("[{}] 根据原始类名'{}' 共找到 {} 个工具实例", agent.getName(), originalClassName, result.size()); String targetClassName = targetClass.getSimpleName();
} catch (Exception e) { String targetFullClassName = targetClass.getName();
log.error("[{}] 根据原始类名查找工具实例时发生错误", agent.getName(), e);
// 根据匹配模式进行判断
boolean matches = isExactMatch
? originalClassName.equals(targetClassName) || originalClassName.equals(targetFullClassName)
: targetClassName.toLowerCase().contains(originalClassName.toLowerCase()) ||
targetFullClassName.toLowerCase().contains(originalClassName.toLowerCase());
if (matches) {
result.add(bean);
}
} }
return result; return result;
...@@ -316,64 +266,54 @@ public class AgentToolManager { ...@@ -316,64 +266,54 @@ public class AgentToolManager {
* @return 工具实例列表(包含AOP代理后的实例) * @return 工具实例列表(包含AOP代理后的实例)
*/ */
public List<Object> getAvailableToolInstances(Agent agent) { public List<Object> getAvailableToolInstances(Agent agent) {
try { log.info("[{}] 开始获取可用的工具实例", agent.getName());
log.info("[{}] 开始获取可用的工具实例", agent.getName());
// 获取Agent可用的工具定义
// 获取Agent可用的工具定义 List<Tool> availableTools = getAvailableTools(agent);
List<Tool> availableTools = getAvailableTools(agent); log.debug("[{}] 获取到了{}个工具定义", agent.getName(), availableTools.size());
log.debug("[{}] 获取到了{}个工具定义", agent.getName(), availableTools.size());
List<Object> toolInstances = new ArrayList<>();
List<Object> toolInstances = new ArrayList<>(); List<String> failedBeans = new ArrayList<>();
List<String> failedBeans = new ArrayList<>();
// 遍历每个工具定义,根据beanName查找Spring Bean实例
// 遍历每个工具定义,根据beanName查找Spring Bean实例 for (Tool tool : availableTools) {
for (Tool tool : availableTools) { // 验证beanName是否为空
try { if (tool.getBeanName() == null || tool.getBeanName().trim().isEmpty()) {
// 验证beanName是否为空 log.warn("[{}] 工具'{}' 没有配置beanName,跳过此工具", agent.getName(), tool.getName());
if (tool.getBeanName() == null || tool.getBeanName().trim().isEmpty()) { failedBeans.add(tool.getName() + " (beanName为null)");
log.warn("[{}] 工具'{}' 没有配置beanName,跳过此工具", agent.getName(), tool.getName()); continue;
failedBeans.add(tool.getName() + " (beanName为null)");
continue;
}
// 根据beanName查找Bean实例
Object bean = null;
try {
bean = applicationContext.getBean(tool.getBeanName());
} catch (Exception e) {
log.warn("[{}] 工具'{}' 查找Bean'{}' 失败,错误消息: {}", agent.getName(), tool.getName(), tool.getBeanName(), e.getMessage());
failedBeans.add(tool.getName() + " (bean'" + tool.getBeanName() + "'不存在)");
continue;
}
if (bean != null) {
// 获取原始目标类(处理Spring AOP代理)
Class<?> targetClass = getTargetClass(bean);
String simpleName = targetClass != null ? targetClass.getSimpleName() : bean.getClass().getSimpleName();
toolInstances.add(bean);
log.debug("[{}] 成功查找工具'{}' 的Bean实例,原始类: {}", agent.getName(), tool.getName(), simpleName);
} else {
log.warn("[{}] 工具'{}' 的Bean实例为null", agent.getName(), tool.getName());
failedBeans.add(tool.getName() + " (bean实例为null)");
}
} catch (Exception e) {
log.error("[{}] 处理工具'{}' 时发生意外错误,详细信息", agent.getName(), tool.getName(), e);
failedBeans.add(tool.getName() + " (异常: " + e.getMessage() + ")");
}
} }
log.info("[{}] 成功获取了{}个工具实例", agent.getName(), toolInstances.size()); // 根据beanName查找Bean实例
Object bean;
// 打印未成功查找的工具(便于故障诊断) try {
if (!failedBeans.isEmpty()) { bean = applicationContext.getBean(tool.getBeanName());
log.warn("[{}] 以下工具无法加载: {}", agent.getName(), failedBeans); } catch (Exception e) {
log.warn("[{}] 工具'{}' 查找Bean'{}' 失败,错误消息: {}", agent.getName(), tool.getName(), tool.getBeanName(), e.getMessage());
failedBeans.add(tool.getName() + " (bean'" + tool.getBeanName() + "'不存在)");
continue;
} }
return toolInstances; if (bean != null) {
} catch (Exception e) { // 获取原始目标类(处理Spring AOP代理)
log.error("[{}] 获取可用的工具实例时发生了意外错误", agent.getName(), e); Class<?> targetClass = getTargetClass(bean);
return List.of(); String simpleName = targetClass != null ? targetClass.getSimpleName() : bean.getClass().getSimpleName();
toolInstances.add(bean);
log.debug("[{}] 成功查找工具'{}' 的Bean实例,原始类: {}", agent.getName(), tool.getName(), simpleName);
} else {
log.warn("[{}] 工具'{}' 的Bean实例为null", agent.getName(), tool.getName());
failedBeans.add(tool.getName() + " (bean实例为null)");
}
} }
log.info("[{}] 成功获取了{}个工具实例", agent.getName(), toolInstances.size());
// 打印未成功查找的工具(便于故障诊断)
if (!failedBeans.isEmpty()) {
log.warn("[{}] 以下工具无法加载: {}", agent.getName(), failedBeans);
}
return toolInstances;
} }
} }
\ No newline at end of file
package pangea.hiagent.tool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import pangea.hiagent.agent.service.UserSseService;
import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.agent.data.WorkPanelEvent;
import java.util.HashMap;
import java.util.Map;
/**
* 所有工具类的基础抽象类
* 提供工具执行监控和SSE事件发送功能
*/
@Slf4j
public abstract class BaseTool {
@Autowired
private UserSseService userSseService;
@Autowired
private UserUtils userUtils;
/**
* 工具执行包装方法
* 监控工具方法的完整执行生命周期
* @param methodName 被调用的方法名称
* @param params 方法参数映射
* @param action 实际执行的工具逻辑
* @param <T> 返回类型
* @return 工具执行结果
*/
protected <T> T execute(String methodName, Map<String, Object> params, ToolAction<T> action) {
String toolName = this.getClass().getSimpleName();
long startTime = System.currentTimeMillis();
// 在方法开始时获取用户ID,此时线程通常是原始请求线程,能够正确获取
String userId = userUtils.getCurrentUserId();
// 1. 发送工具开始执行事件
sendToolEvent(toolName, methodName, params, null, "执行中", startTime, null,null, userId);
T result = null;
String status = "成功";
Exception exception = null;
try {
// 2. 执行实际的工具逻辑
result = action.run();
} catch (Exception e) {
status = "失败";
exception = e;
throw new RuntimeException("工具执行失败: " + e.getMessage(), e);
} finally {
// 记录结束时间和耗时
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
// 3. 发送工具执行完成事件
sendToolEvent(toolName, methodName, params, result, status, startTime, duration, exception, userId);
}
return result;
}
/**
* 简化版execute方法,无需手动构建参数映射
* @param methodName 被调用的方法名称
* @param action 实际执行的工具逻辑
* @param <T> 返回类型
* @return 工具执行结果
*/
protected <T> T execute(String methodName, ToolAction<T> action) {
return execute(methodName, new HashMap<>(), action);
}
/**
* 发送工具事件给前端
* @param toolName 工具名称
* @param methodName 方法名称
* @param params 参数信息
* @param result 执行结果
* @param status 执行状态(执行中/成功/失败)
* @param startTime 开始时间戳
* @param duration 执行耗时(毫秒)
* @param exception 异常信息(可选)
* @param userId 用户ID,从方法开始时传递
*/
private void sendToolEvent(String toolName, String methodName,
Map<String, Object> params, Object result, String status,
Long startTime, Long duration, Exception exception, String userId) {
try {
Map<String, Object> eventData = new HashMap<>();
eventData.put("toolName", toolName);
eventData.put("methodName", methodName);
eventData.put("params", params);
eventData.put("result", result);
eventData.put("status", status);
eventData.put("startTime", startTime);
eventData.put("duration", duration);
if (exception != null) {
eventData.put("error", exception.getMessage());
eventData.put("errorType", exception.getClass().getSimpleName());
}
WorkPanelEvent event = WorkPanelEvent.builder()
.type("tool_call")
.title(toolName + "." + methodName)
.timestamp(System.currentTimeMillis())
.metadata(eventData)
.userId(userId)
.build();
// 获取用户的SSE发射器
userSseService.sendWorkPanelEvent(event);
log.debug("已发送工具事件: {}#{}, 状态: {}", toolName, methodName, status);
} catch (Exception e) {
log.error("发送工具事件失败: {}", e.getMessage(), e);
}
}
/**
* 工具动作函数式接口
* 用于封装实际要执行的工具逻辑
* @param <T> 返回类型
*/
@FunctionalInterface
protected interface ToolAction<T> {
T run() throws Exception;
}
}
\ No newline at end of file
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;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import pangea.hiagent.workpanel.IWorkPanelDataCollector;
import java.util.HashMap;
import java.util.Map;
/**
* 工具执行日志记录切面类
* 自动记录带有@Tool注解的方法执行信息,包括工具名称、方法名、输入参数、输出结果、运行时长等
*/
@Slf4j
@Aspect
@Component
public class ToolExecutionLoggerAspect {
@Autowired
private IWorkPanelDataCollector workPanelDataCollector;
/**
* 环绕通知,拦截所有带有@Tool注解的方法
* @param joinPoint 连接点
* @return 方法执行结果
* @throws Throwable 异常
*/
@Around("@annotation(tool)")
public Object logToolExecution(ProceedingJoinPoint joinPoint, Tool tool) throws Throwable {
// 获取方法签名
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName = signature.getName();
String className = signature.getDeclaringType().getSimpleName();
String fullMethodName = className + "." + methodName;
// 获取工具描述
String toolDescription = tool.description();
// 获取方法参数
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
// 构建输入参数映射
Map<String, Object> inputParams = new HashMap<>();
if (paramNames != null && args != null) {
for (int i = 0; i < paramNames.length; i++) {
if (i < args.length) {
inputParams.put(paramNames[i], args[i]);
}
}
}
// 记录开始时间
long startTime = System.currentTimeMillis();
// 记录工具调用开始
if (workPanelDataCollector != null) {
try {
workPanelDataCollector.recordToolCallAction(className, inputParams, null, "pending", null);
} catch (Exception e) {
log.warn("记录工具调用开始时发生错误: {}", e.getMessage(), e);
}
}
log.info("开始执行工具方法: {},描述: {}", fullMethodName, toolDescription);
log.debug("工具方法参数: {}", inputParams);
try {
// 执行原方法
Object result = joinPoint.proceed();
// 记录结束时间
long endTime = System.currentTimeMillis();
long executionTime = endTime - startTime;
// 记录工具调用完成
if (workPanelDataCollector != null) {
try {
workPanelDataCollector.recordToolCallAction(className, inputParams, result, "success", executionTime);
} catch (Exception e) {
log.warn("记录工具调用完成时发生错误: {}", e.getMessage(), e);
}
}
log.info("工具方法执行成功: {},描述: {},耗时: {}ms", fullMethodName, toolDescription, executionTime);
// 精简日志记录,避免过多的debug级别日志
if (log.isTraceEnabled()) {
log.trace("工具方法执行结果类型: {},结果: {}",
result != null ? result.getClass().getSimpleName() : "null", result);
}
return result;
} catch (Exception e) {
// 记录结束时间
long endTime = System.currentTimeMillis();
long executionTime = endTime - startTime;
// 记录工具调用错误
if (workPanelDataCollector != null) {
try {
workPanelDataCollector.recordToolCallAction(className, inputParams, e, "error", executionTime);
} catch (Exception ex) {
log.warn("记录工具调用错误时发生错误: {}", ex.getMessage(), ex);
}
}
log.error("工具方法执行失败: {},描述: {},耗时: {}ms,错误类型: {}",
fullMethodName, toolDescription, executionTime, e.getClass().getSimpleName(), e);
throw e;
}
}
}
\ No newline at end of file
...@@ -2,6 +2,9 @@ package pangea.hiagent.tool.impl; ...@@ -2,6 +2,9 @@ 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.BaseTool;
import java.util.HashMap;
import java.util.Map;
/** /**
* 计算器工具类 * 计算器工具类
...@@ -9,38 +12,62 @@ import org.springframework.ai.tool.annotation.Tool; ...@@ -9,38 +12,62 @@ import org.springframework.ai.tool.annotation.Tool;
*/ */
@Slf4j @Slf4j
@Component @Component
public class CalculatorTools { public class CalculatorTools extends BaseTool {
@Tool(description = "执行两个数字的加法运算") @Tool(description = "执行两个数字的加法运算")
public double add(double a, double b) { public double add(double a, double b) {
double result = a + b; Map<String, Object> params = new HashMap<>();
log.debug("执行加法运算: {} + {} = {}", a, b, result); params.put("a", a);
return result; params.put("b", b);
return execute("add", params, () -> {
double result = a + b;
log.debug("执行加法运算: {} + {} = {}", a, b, result);
return result;
});
} }
@Tool(description = "执行两个数字的减法运算") @Tool(description = "执行两个数字的减法运算")
public double subtract(double a, double b) { public double subtract(double a, double b) {
double result = a - b; Map<String, Object> params = new HashMap<>();
log.debug("执行减法运算: {} - {} = {}", a, b, result); params.put("a", a);
return result; params.put("b", b);
return execute("subtract", params, () -> {
double result = a - b;
log.debug("执行减法运算: {} - {} = {}", a, b, result);
return result;
});
} }
@Tool(description = "执行两个数字的乘法运算") @Tool(description = "执行两个数字的乘法运算")
public double multiply(double a, double b) { public double multiply(double a, double b) {
double result = a * b; Map<String, Object> params = new HashMap<>();
log.debug("执行乘法运算: {} * {} = {}", a, b, result); params.put("a", a);
return result; params.put("b", b);
return execute("multiply", params, () -> {
double result = a * b;
log.debug("执行乘法运算: {} * {} = {}", a, b, result);
return result;
});
} }
@Tool(description = "执行两个数字的除法运算") @Tool(description = "执行两个数字的除法运算")
public String divide(double a, double b) { public String divide(double a, double b) {
log.debug("执行除法运算: {} / {}", a, b); Map<String, Object> params = new HashMap<>();
if (b == 0) { params.put("a", a);
log.warn("除法运算错误:除数不能为零"); params.put("b", b);
return "错误:除数不能为零";
} return execute("divide", params, () -> {
double result = a / b; log.debug("执行除法运算: {} / {}", a, b);
log.debug("除法运算结果: {}", result); if (b == 0) {
return String.valueOf(result); log.warn("除法运算错误:除数不能为零");
return "错误:除数不能为零";
}
double result = a / b;
log.debug("除法运算结果: {}", result);
return String.valueOf(result);
});
} }
} }
\ 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 = "最大数据点数量限制",
defaultValue = "100",
type = "integer",
required = true,
group = "chart"
)
private Integer maxDataPoints;
@ToolParam( private Integer percentageDecimalPlaces = 2;
name = "percentageDecimalPlaces",
description = "百分比显示的小数位数",
defaultValue = "2",
type = "integer",
required = true,
group = "chart"
)
private Integer percentageDecimalPlaces;
@ToolParam( private String defaultSeriesName = "数据";
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 pangea.hiagent.tool.BaseTool;
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;
/** /**
...@@ -15,39 +16,79 @@ import java.time.format.DateTimeFormatter; ...@@ -15,39 +16,79 @@ import java.time.format.DateTimeFormatter;
*/ */
@Slf4j @Slf4j
@Component @Component
public class DateTimeTools { public class DateTimeTools extends BaseTool {
@ToolParam( private String dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
name = "dateTimeFormat",
description = "日期时间格式",
defaultValue = "yyyy-MM-dd HH:mm:ss",
type = "string",
required = true,
group = "datetime"
)
private String dateTimeFormat;
@ToolParam( private String dateFormat = "yyyy-MM-dd";
name = "dateFormat",
description = "日期格式",
defaultValue = "yyyy-MM-dd",
type = "string",
required = true,
group = "datetime"
)
private String dateFormat;
@Tool(description = "获取当前日期和时间") private String timeFormat = "HH:mm:ss";
@Tool(description = "获取当前日期和时间,返回格式为 'yyyy-MM-dd HH:mm:ss'")
public String getCurrentDateTime() { public String getCurrentDateTime() {
String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateTimeFormat)); return execute("getCurrentDateTime", () -> {
log.debug("获取当前日期时间: {}", dateTime); try {
return dateTime; if (dateTimeFormat == null || dateTimeFormat.trim().isEmpty()) {
dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
}
String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateTimeFormat));
log.info("【时间工具】获取当前日期时间: {}", dateTime);
return dateTime;
} catch (Exception e) {
log.error("获取当前日期时间时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
});
} }
@Tool(description = "获取当前日期") @Tool(description = "获取当前日期,返回格式为 'yyyy-MM-dd'")
public String getCurrentDate() { public String getCurrentDate() {
String date = LocalDate.now().format(DateTimeFormatter.ofPattern(dateFormat)); return execute("getCurrentDate", () -> {
log.debug("获取当前日期: {}", date); try {
return date; if (dateFormat == null || dateFormat.trim().isEmpty()) {
dateFormat = "yyyy-MM-dd";
}
String date = LocalDate.now().format(DateTimeFormatter.ofPattern(dateFormat));
log.info("【时间工具】获取当前日期: {}", 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() {
return execute("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() {
return execute("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());
}
});
} }
} }
\ No newline at end of file
...@@ -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服务器端口",
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( private String pop3SocketFactoryClass = "javax.net.ssl.SSLSocketFactory";
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 java.util.HashMap;
import java.util.Map;
import pangea.hiagent.tool.BaseTool;
import pangea.hiagent.tool.playwright.PlaywrightManager;
import pangea.hiagent.web.service.ToolConfigService;
/**
* 海信LBPM流程审批工具类
* 专门负责LBPM流程审批功能,需要先通过HisenseSsoLoginTool登录
* 该工具专注于流程审批操作,不处理登录逻辑
*/
@Slf4j
@Component
public class HisenseLbpmApprovalTool extends BaseTool {
// 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) {
Map<String, Object> params = new HashMap<>();
params.put("approvalUrl", approvalUrl);
params.put("approvalOpinion", approvalOpinion);
return execute("processHisenseLeaveApproval", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理海信请假审批,URL: {}", ssoUsername, approvalUrl);
// 参数校验
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);
log.info("请假审批处理完成");
return "请假审批处理成功";
} catch (Exception e) {
String errorMsg = "请假审批处理失败: " + e.getMessage();
log.error("请假审批处理失败", 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) {
Map<String, Object> params = new HashMap<>();
params.put("businessSystemUrl", businessSystemUrl);
return execute("getHisenseLbpmBusinessSystemContent", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 获取海信业务系统内容,URL: {}", ssoUsername, businessSystemUrl);
// 参数校验
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();
log.info("成功获取业务系统页面内容");
// 检查是否包含错误信息
if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) {
log.warn("检测到页面中可能存在JavaScript错误,但这不会影响主要功能");
}
return content;
} catch (Exception e) {
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
log.error("获取海信业务系统内容失败", 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() {
Map<String, Object> params = new HashMap<>();
return execute("processAllPendingLeaveApprovals", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理所有待审批的请假流程", ssoUsername);
// 参数校验
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);
}
}
log.info("待审批处理完成");
return urls;
} catch (Exception e) {
String errorMsg = "处理待审批项目失败: " + e.getMessage();
log.error("处理待审批项目失败", 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 java.util.HashMap;
import java.util.Map;
import pangea.hiagent.tool.BaseTool;
import pangea.hiagent.tool.playwright.PlaywrightManager;
import pangea.hiagent.web.service.ToolConfigService;
/**
* 海信绩效系统流程审批工具类
* 专门负责海信绩效管理系统(HIPMS)的审批功能,需要先通过HisenseSsoLoginTool登录
* 该工具专注于流程审批操作,不处理登录逻辑
*/
@Slf4j
@Component
public class HisensePerformanceApprovalTool extends BaseTool {
// 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() {
Map<String, Object> params = new HashMap<>();
return execute("checkHisensePerformancePendingTasks", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 查找所有待审批的绩效流程", ssoUsername);
// 参数校验
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());
}
}
log.info("待审批流程查找完成,共 {} 个流程", urls.size());
if (urls.isEmpty()) {
return List.of("没有找到待审批的流程");
}
return urls;
} catch (Exception e) {
String errorMsg = "查找待审批流程失败: " + e.getMessage();
log.error("查找待审批流程失败", 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) {
Map<String, Object> params = new HashMap<>();
params.put("approvalUrl", approvalUrl);
return execute("getHisensePerformancePageContent", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 获取绩效审批页面内容,URL: {}", ssoUsername, approvalUrl);
// 参数校验
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();
log.info("成功获取绩效审批页面内容");
return content;
} catch (Exception e) {
String errorMsg = "获取绩效审批页面内容失败: " + e.getMessage();
log.error("获取绩效审批页面内容失败", 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) {
Map<String, Object> params = new HashMap<>();
params.put("approvalUrl", approvalUrl);
params.put("isApproved", isApproved);
params.put("approvalOpinion", approvalOpinion);
return execute("performSinglePerformanceApproval", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理绩效审批,URL: {}, 是否通过: {}", ssoUsername, approvalUrl, isApproved);
// 参数校验
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);
log.info("绩效审批处理完成");
return "绩效审批处理成功";
} catch (Exception e) {
String errorMsg = "绩效审批处理失败: " + e.getMessage();
log.error("绩效审批处理失败", 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
private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/";
// 用户名输入框选择器
private static final String USERNAME_INPUT_SELECTOR = "input[placeholder='账号名/海信邮箱/手机号']";
// 密码输入框选择器
private static final String PASSWORD_INPUT_SELECTOR = "input[placeholder='密码'][type='password']";
// 登录按钮选择器
private static final String LOGIN_BUTTON_SELECTOR = "#login-button";
// 注入Playwright管理器
@Autowired
private PlaywrightManager playwrightManager;
// 浏览器实例(从Playwright管理器获取)
private Browser browser;
// 共享的浏览器上下文,用于保持登录状态
private BrowserContext sharedContext;
// 上次登录时间
private long lastLoginTime = 0;
// 登录状态有效期(毫秒),设置为30分钟
private static final long LOGIN_VALIDITY_PERIOD = 30 * 60 * 1000;
// SSO用户名(从配置文件读取)
@Value("${hisense.sso.username:}")
private String ssoUsername;
// SSO密码(从配置文件读取)
@Value("${hisense.sso.password:}")
private String ssoPassword;
// 存储目录路径
private static final String STORAGE_DIR = "storage";
/**
* 延迟初始化浏览器实例引用和共享上下文
*/
private void initializeIfNeeded() {
if (browser == null || sharedContext == null) {
try {
log.info("正在初始化海信SSO认证工具的Playwright...");
// 从Playwright管理器获取共享的浏览器实例
this.browser = playwrightManager.getBrowser();
// 初始化共享上下文
this.sharedContext = browser.newContext();
log.info("海信SSO认证工具的Playwright初始化成功");
} catch (Exception e) {
log.error("海信SSO认证工具的Playwright初始化失败: ", e);
}
}
}
// 移除@PostConstruct注解以避免在启动时初始化
/*
@PostConstruct
public void initialize() {
try {
log.info("正在初始化海信SSO认证工具的Playwright...");
// 从Playwright管理器获取共享的浏览器实例
this.browser = playwrightManager.getBrowser();
// 初始化共享上下文
this.sharedContext = browser.newContext();
log.info("海信SSO认证工具的Playwright初始化成功");
} catch (Exception e) {
log.error("海信SSO认证工具的Playwright初始化失败: ", e);
}
}
*/
/**
* 销毁Playwright资源
*/
@PreDestroy
public void destroy() {
try {
if (sharedContext != null) {
sharedContext.close();
log.info("海信SSO认证工具的共享浏览器上下文已关闭");
}
} catch (Exception e) {
log.error("海信SSO认证工具的Playwright资源释放失败: ", e);
}
}
/**
* 工具方法:获取海信业务系统的网页内容(自动处理SSO认证)
*
* @param businessSystemUrl 海信业务系统页面URL
* @return 页面内容(HTML文本)
*/
@Tool(description = "获取海信业务系统的网页内容(自动处理SSO认证)")
public String getHisenseBusinessSystemContent(String businessSystemUrl) {
initializeIfNeeded();
log.info("开始获取海信业务系统内容,URL: {}", businessSystemUrl);
// 校验SSO凭证是否配置
if (ssoUsername == null || ssoUsername.isEmpty() || ssoPassword == null || ssoPassword.isEmpty()) {
String errorMsg = "SSO用户名或密码未配置,海信SSO工具不可用";
log.warn(errorMsg);
return errorMsg;
}
long startTime = System.currentTimeMillis();
// 参数校验
if (businessSystemUrl == null || businessSystemUrl.isEmpty()) {
String errorMsg = "业务系统URL不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 检查是否已有有效的登录会话
boolean sessionValid = isSessionLoggedIn() && validateSession(businessSystemUrl);
if (sessionValid) {
log.info("检测到有效会话,直接使用共享上下文");
page = sharedContext.newPage();
} else {
log.info("未检测到有效会话,使用共享上下文并重新登录");
page = sharedContext.newPage();
// 访问业务系统页面
log.info("正在访问业务系统页面: {}", businessSystemUrl);
page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
log.info("检测到SSO登录页面,开始自动登录...");
// 执行SSO登录
performLoginAndUpdateStatus(page);
// 等待登录完成并重定向回业务系统
page.waitForURL(businessSystemUrl, new Page.WaitForURLOptions().setTimeout(10000));
log.info("登录成功,已重定向回业务系统页面");
} else {
// 即使没有跳转到登录页面,也更新登录时间
lastLoginTime = System.currentTimeMillis();
log.info("直接访问业务系统页面成功,无需SSO登录,更新会话时间");
}
}
// 如果页面尚未导航到业务系统URL,则导航到该URL
if (!page.url().equals(businessSystemUrl) && !page.url().startsWith(businessSystemUrl)) {
log.info("正在访问业务系统页面: {}", businessSystemUrl);
page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
}
// 提取页面内容
String content = page.locator("body").innerText();
long endTime = System.currentTimeMillis();
log.info("成功获取业务系统页面内容,耗时: {} ms", endTime - startTime);
// 检查是否包含错误信息
if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) {
log.warn("检测到页面中可能存在JavaScript错误,但这不会影响主要功能");
}
return content;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
log.error("获取海信业务系统内容失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
} finally {
// 释放页面资源
if (page != null) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
}
}
}
/**
* 工具方法:处理海信请假审批
*
* @param approvalUrl 请假审批页面URL
* @param approvalOpinion 审批意见
* @return 处理结果
*/
@Tool(description = "处理海信请假审批、自驾车审批、调休审批")
public String processHisenseLeaveApproval(String approvalUrl, String approvalOpinion) {
initializeIfNeeded();
log.info("开始处理海信请假审批,URL: {}", approvalUrl);
// 校验SSO凭证是否配置
if (ssoUsername == null || ssoUsername.isEmpty() || ssoPassword == null || ssoPassword.isEmpty()) {
String errorMsg = "SSO用户名或密码未配置,海信SSO工具不可用";
log.warn(errorMsg);
return errorMsg;
}
long startTime = System.currentTimeMillis();
// 参数校验
if (approvalUrl == null || approvalUrl.isEmpty()) {
String errorMsg = "审批URL不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalOpinion == null || approvalOpinion.isEmpty()) {
String errorMsg = "审批意见不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 检查是否已有有效的登录会话
boolean sessionValid = isSessionLoggedIn() && validateSession(approvalUrl);
if (sessionValid) {
log.info("检测到有效会话,直接使用共享上下文");
page = sharedContext.newPage();
} else {
log.info("未检测到有效会话,使用共享上下文并重新登录");
page = sharedContext.newPage();
// 访问审批页面
log.info("正在访问审批页面: {}", approvalUrl);
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
log.info("检测到SSO登录页面,开始自动登录...");
// 执行SSO登录
performLoginAndUpdateStatus(page);
// 等待登录完成并重定向回审批页面
page.waitForURL(approvalUrl, new Page.WaitForURLOptions().setTimeout(10000));
log.info("登录成功,已重定向回审批页面");
} else {
// 即使没有跳转到登录页面,也更新登录时间
lastLoginTime = System.currentTimeMillis();
log.info("直接访问审批页面成功,无需SSO登录,更新会话时间");
}
}
// 如果页面尚未导航到审批URL,则导航到该URL
if (!page.url().equals(approvalUrl) && !page.url().startsWith(approvalUrl)) {
log.info("正在访问审批页面: {}", approvalUrl);
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
}
// 执行审批操作
performApprovalOperation(page, approvalOpinion);
// 截图并保存
takeScreenshotAndSave(page, "leave_approval_success");
long endTime = System.currentTimeMillis();
log.info("请假审批处理完成,耗时: {} ms", endTime - startTime);
return "请假审批处理成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "请假审批处理失败: " + e.getMessage();
log.error("请假审批处理失败,耗时: {} ms", endTime - startTime, e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
try {
takeScreenshotAndSave(page, "leave_approval_fail");
} catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
}
}
return errorMsg;
}
// 注意:这里不再释放页面资源,以保持会话状态供后续使用
/*finally {
// 释放页面资源
if (page != null) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
}
}*/
}
/**
* 工具方法:海信SSO登录工具,用于登录海信SSO系统
*
* @param username 用户名
* @param password 密码
* @return 登录结果
*/
@Tool(description = "海信SSO登录工具,用于登录海信SSO系统")
public String hisenseSsoLogin(String username, String password) {
initializeIfNeeded();
log.info("开始执行海信SSO登录,用户名: {}", username);
long startTime = System.currentTimeMillis();
// 参数校验
if (username == null || username.isEmpty()) {
String errorMsg = "用户名不能为空";
log.error(errorMsg);
return errorMsg;
}
if (password == null || password.isEmpty()) {
String errorMsg = "密码不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 访问SSO登录页面
log.info("正在访问SSO登录页面: {}", SSO_LOGIN_URL);
page = sharedContext.newPage();
page.navigate(SSO_LOGIN_URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 执行SSO登录
performLoginAndUpdateStatus(page);
// 等待登录完成并重定向回业务系统
page.waitForURL(SSO_LOGIN_URL, new Page.WaitForURLOptions().setTimeout(10000));
log.info("登录成功,已重定向回SSO登录页面");
long endTime = System.currentTimeMillis();
log.info("海信SSO登录完成,耗时: {} ms", endTime - startTime);
return "海信SSO登录成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "海信SSO登录失败: " + e.getMessage();
log.error("海信SSO登录失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
} finally {
// 释放页面资源
if (page != null) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
}
}
}
/**
* 工具方法:海信SSO登出工具,用于退出海信SSO系统
*
* @return 登出结果
*/
@Tool(description = "海信SSO登出工具,用于退出海信SSO系统")
public String hisenseSsoLogout() {
initializeIfNeeded();
log.info("开始执行海信SSO登出");
long startTime = System.currentTimeMillis();
try {
// 关闭共享上下文
if (sharedContext != null) {
sharedContext.close();
log.info("共享上下文已关闭");
}
// 重置登录时间
lastLoginTime = 0;
log.info("登录时间已重置");
long endTime = System.currentTimeMillis();
log.info("海信SSO登出完成,耗时: {} ms", endTime - startTime);
return "海信SSO登出成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "海信SSO登出失败: " + e.getMessage();
log.error("海信SSO登出失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
}
}
/**
* 工具方法:检查海信SSO登录状态
*/
@Tool(description = "检查海信SSO登录状态")
public String checkHisenseSsoLoginStatus() {
initializeIfNeeded();
log.info("开始检查海信SSO登录状态");
long startTime = System.currentTimeMillis();
try {
boolean isLoggedIn = isSessionLoggedIn();
long endTime = System.currentTimeMillis();
log.info("海信SSO登录状态检查完成,耗时: {} ms", endTime - startTime);
return isLoggedIn ? "已登录" : "未登录";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "海信SSO登录状态检查失败: " + e.getMessage();
log.error("海信SSO登录状态检查失败,耗时: {} ms", endTime - startTime, e);
return errorMsg;
}
}
/**
* 检查当前会话是否已登录
*
* @return true表示已登录且会话有效,false表示未登录或会话已过期
*/
private boolean isSessionLoggedIn() {
// 检查是否存在共享上下文
if (sharedContext == null) {
return false;
}
// 检查登录是否过期
long currentTime = System.currentTimeMillis();
if (currentTime - lastLoginTime > LOGIN_VALIDITY_PERIOD) {
log.debug("会话已过期,上次登录时间: {},当前时间: {}", lastLoginTime, currentTime);
return false;
}
return true;
}
/** public HisenseSsoAuthTool() {
* 验证当前会话是否仍然有效 log.warn("HisenseSsoAuthTool 已被弃用,请使用 HisenseSsoLoginTool 和 HisenseLbpmApprovalTool 代替");
* 通过访问一个需要登录的页面来验证会话状态
*
* @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;
}
} }
/** // 此类不再包含任何功能实现
* 执行登录并更新登录状态 // 所有功能已迁移到 HisenseSsoLoginTool 和 HisenseLbpmApprovalTool
*
* @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 java.util.HashMap;
import java.util.Map;
import pangea.hiagent.tool.BaseTool;
import pangea.hiagent.tool.playwright.PlaywrightManager;
import pangea.hiagent.web.service.ToolConfigService;
/**
* 海信SSO认证工具类
* 用于访问需要SSO认证的海信业务系统,自动完成登录并提取页面内容
*/
@Slf4j
@Component
public class HisenseSsoLoginTool extends BaseTool {
// 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) {
Map<String, Object> params = new HashMap<>();
params.put("businessSystemUrl", businessSystemUrl);
return execute("getHisenseBusinessSystemContent", params, () -> {
// 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;
}
// 参数校验
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();
log.info("成功获取业务系统页面内容");
// 检查是否包含错误信息
if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) {
log.warn("检测到页面中可能存在JavaScript错误,但这不会影响主要功能");
}
return content;
} catch (Exception e) {
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
log.error("获取海信业务系统内容失败", 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() {
Map<String, Object> params = new HashMap<>();
return execute("hisenseSsoLogin", params, () -> {
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);
// 参数校验
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配置页面");
}
log.info("海信SSO登录完成");
return "海信SSO登录成功";
} catch (Exception e) {
String errorMsg = "海信SSO登录失败: " + e.getMessage();
log.error("海信SSO登录失败", 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) {
Map<String, Object> params = new HashMap<>();
params.put("verificationCode", verificationCode);
return execute("handleMfaVerification", params, () -> {
log.info("开始处理MFA验证码验证");
String username = getUserName();
// 参数校验
if (verificationCode == null || verificationCode.isEmpty()) {
String errorMsg = "验证码不能为空";
log.error(errorMsg);
return errorMsg;
}
// 清理过期的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();
log.info("MFA验证完成");
return "MFA验证成功,登录完成";
} else {
// 如果仍在MFA页面,说明可能超时但验证仍在进行中,也认为成功
log.info("MFA验证可能仍在进行中,当前仍在MFA页面");
// 从缓存中移除会话
mfaSessions.remove(username);
// 更新登录时间
lastLoginTime = System.currentTimeMillis();
log.info("MFA验证处理完成");
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();
log.info("MFA验证完成");
return "MFA验证成功,登录完成";
}
}
} catch (com.microsoft.playwright.impl.TargetClosedError e) {
// 专门处理TargetClosedError
String errorMsg = "MFA验证时BrowserContext已关闭,请重新触发验证码发送流程";
log.error("MFA验证失败 - TargetClosedError,完整错误堆栈: ", e);
mfaSessions.remove(username);
return errorMsg;
} catch (Exception e) {
String errorMsg = "MFA验证过程发生异常: " + e.getMessage();
log.error("MFA验证失败,错误类型: {},完整错误堆栈: ", e.getClass().getName(), e);
mfaSessions.remove(username);
return errorMsg;
}
});
}
/**
* 工具方法:海信SSO登出工具,用于退出海信SSO系统
*
* @return 登出结果
*/
@Tool(description = "海信SSO登出工具,用于退出海信SSO系统")
public String hisenseSsoLogout() {
Map<String, Object> params = new HashMap<>();
return execute("hisenseSsoLogout", params, () -> {
// initializeIfNeeded();
log.info("开始执行海信SSO登出");
try {
// 关闭共享上下文
if (getUSerContext() != null) {
getUSerContext().close();
log.info("共享上下文已关闭");
}
// 重置登录时间
lastLoginTime = 0;
log.info("登录时间已重置");
log.info("海信SSO登出完成");
return "海信SSO登出成功";
} catch (Exception e) {
String errorMsg = "海信SSO登出失败: " + e.getMessage();
log.error("海信SSO登出失败", e);
return errorMsg;
}
});
}
/**
* 工具方法:检查海信SSO登录状态
*/
@Tool(description = "检查海信SSO登录状态")
public String checkHisenseSsoLoginStatus() {
Map<String, Object> params = new HashMap<>();
return execute("checkHisenseSsoLoginStatus", params, () -> {
// initializeIfNeeded();
log.info("开始检查海信SSO登录状态");
try {
boolean isLoggedIn = isSessionLoggedIn();
log.info("海信SSO登录状态检查完成:{}", isLoggedIn);
return isLoggedIn ? "已登录" : "未登录";
} catch (Exception e) {
String errorMsg = "海信SSO登录状态检查失败: " + e.getMessage();
log.error("海信SSO登录状态检查失败", 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);
}
}
}
...@@ -4,10 +4,11 @@ import com.microsoft.playwright.*; ...@@ -4,10 +4,11 @@ import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState; import com.microsoft.playwright.options.LoadState;
import com.microsoft.playwright.options.WaitUntilState; import com.microsoft.playwright.options.WaitUntilState;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.tool.playwright.PlaywrightManager;
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.playwright.PlaywrightManager;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
...@@ -262,7 +263,21 @@ public class PlaywrightWebTools { ...@@ -262,7 +263,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);
}); });
} }
......
...@@ -3,7 +3,6 @@ package pangea.hiagent.tool.impl; ...@@ -3,7 +3,6 @@ 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.workpanel.IWorkPanelDataCollector;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.File; import java.io.File;
...@@ -21,7 +20,6 @@ import java.util.List; ...@@ -21,7 +20,6 @@ import java.util.List;
@Component @Component
public class StorageFileAccessTool { public class StorageFileAccessTool {
private final IWorkPanelDataCollector workPanelDataCollector;
// 支持的文件扩展名 // 支持的文件扩展名
private static final List<String> SUPPORTED_EXTENSIONS = Arrays.asList( private static final List<String> SUPPORTED_EXTENSIONS = Arrays.asList(
...@@ -31,10 +29,7 @@ public class StorageFileAccessTool { ...@@ -31,10 +29,7 @@ public class StorageFileAccessTool {
// storage目录路径 // storage目录路径
private static final String STORAGE_DIR = "backend" + File.separator + "storage"; private static final String STORAGE_DIR = "backend" + File.separator + "storage";
public StorageFileAccessTool(IWorkPanelDataCollector workPanelDataCollector) {
this.workPanelDataCollector = workPanelDataCollector;
}
/** /**
* 访问并预览storage目录下的文件 * 访问并预览storage目录下的文件
...@@ -87,9 +82,7 @@ public class StorageFileAccessTool { ...@@ -87,9 +82,7 @@ public class StorageFileAccessTool {
log.info("成功读取文件: {}", fileName); log.info("成功读取文件: {}", fileName);
String result = "已成功在工作面板中预览文件: " + fileName; String result = "已成功在工作面板中预览文件: " + fileName;
// 发送embed事件到工作面板
workPanelDataCollector.recordEmbed(filePath, mimeType, title, content);
return result; return result;
......
package pangea.hiagent.tool.impl;import lombok.extern.slf4j.Slf4j; package pangea.hiagent.tool.impl;import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.workpanel.IWorkPanelDataCollector;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
...@@ -18,13 +17,6 @@ import java.net.URLConnection; ...@@ -18,13 +17,6 @@ import java.net.URLConnection;
@Component @Component
public class WebPageAccessTools { public class WebPageAccessTools {
// 通过构造器注入的方式引入IWorkPanelDataCollector依赖
private final IWorkPanelDataCollector workPanelDataCollector;
public WebPageAccessTools(IWorkPanelDataCollector workPanelDataCollector) {
this.workPanelDataCollector = workPanelDataCollector;
}
/** /**
* 根据网站名称访问网页并在工作面板中预览 * 根据网站名称访问网页并在工作面板中预览
* @param siteName 网站名称(如"百度"、"谷歌"等) * @param siteName 网站名称(如"百度"、"谷歌"等)
...@@ -84,9 +76,6 @@ public class WebPageAccessTools { ...@@ -84,9 +76,6 @@ public class WebPageAccessTools {
log.info("成功访问网页: {}", url); log.info("成功访问网页: {}", url);
String result = "已成功在工作面板中预览网页: " + url; String result = "已成功在工作面板中预览网页: " + url;
// 发送embed事件到工作面板
workPanelDataCollector.recordEmbed(url, "text/html", title, webContent);
return result; return result;
} catch (Exception e) { } catch (Exception e) {
return handleError(e, "获取网页内容时发生错误"); return handleError(e, "获取网页内容时发生错误");
......
package pangea.hiagent.workpanel.playwright; package pangea.hiagent.tool.playwright;
import com.microsoft.playwright.Browser; import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext; import com.microsoft.playwright.BrowserContext;
......
package pangea.hiagent.workpanel.playwright; package pangea.hiagent.tool.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,11 +209,9 @@ public class PlaywrightManagerImpl implements PlaywrightManager { ...@@ -192,11 +209,9 @@ 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);
......
...@@ -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
...@@ -44,7 +44,7 @@ public class AgentController { ...@@ -44,7 +44,7 @@ public class AgentController {
@PostMapping @PostMapping
public ApiResponse<Agent> createAgent(@RequestBody Agent agent) { public ApiResponse<Agent> createAgent(@RequestBody Agent agent) {
try { try {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
if (userId == null) { if (userId == null) {
return ApiResponse.error(4001, "用户未认证"); return ApiResponse.error(4001, "用户未认证");
} }
...@@ -67,7 +67,7 @@ public class AgentController { ...@@ -67,7 +67,7 @@ public class AgentController {
@PostMapping("/with-tools") @PostMapping("/with-tools")
public ApiResponse<Agent> createAgentWithTools(@RequestBody AgentWithToolsDTO agentWithToolsDTO) { public ApiResponse<Agent> createAgentWithTools(@RequestBody AgentWithToolsDTO agentWithToolsDTO) {
try { try {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
if (userId == null) { if (userId == null) {
return ApiResponse.error(4001, "用户未认证"); return ApiResponse.error(4001, "用户未认证");
} }
...@@ -109,7 +109,7 @@ public class AgentController { ...@@ -109,7 +109,7 @@ public class AgentController {
@PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'Agent', 'write')") @PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'Agent', 'write')")
@PutMapping("/{id}") @PutMapping("/{id}")
public ApiResponse<Agent> updateAgent(@PathVariable(name = "id") String id, @RequestBody Agent agent) { public ApiResponse<Agent> updateAgent(@PathVariable(name = "id") String id, @RequestBody Agent agent) {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
if (userId == null) { if (userId == null) {
log.warn("用户未认证,无法更新Agent: {}", id); log.warn("用户未认证,无法更新Agent: {}", id);
return ApiResponse.error(4001, "用户未认证"); return ApiResponse.error(4001, "用户未认证");
...@@ -163,7 +163,7 @@ public class AgentController { ...@@ -163,7 +163,7 @@ public class AgentController {
@PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'Agent', 'write')") @PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'Agent', 'write')")
@PutMapping("/{id}/with-tools") @PutMapping("/{id}/with-tools")
public ApiResponse<Agent> updateAgentWithTools(@PathVariable(name = "id") String id, @RequestBody AgentWithToolsDTO agentWithToolsDTO) { public ApiResponse<Agent> updateAgentWithTools(@PathVariable(name = "id") String id, @RequestBody AgentWithToolsDTO agentWithToolsDTO) {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
if (userId == null) { if (userId == null) {
log.warn("用户未认证,无法更新Agent: {}", id); log.warn("用户未认证,无法更新Agent: {}", id);
return ApiResponse.error(4001, "用户未认证"); return ApiResponse.error(4001, "用户未认证");
...@@ -238,7 +238,7 @@ public class AgentController { ...@@ -238,7 +238,7 @@ public class AgentController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ApiResponse<Void> deleteAgent(@PathVariable(name = "id") String id) { public ApiResponse<Void> deleteAgent(@PathVariable(name = "id") String id) {
try { try {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
log.info("用户 {} 开始删除Agent: {}", userId, id); log.info("用户 {} 开始删除Agent: {}", userId, id);
agentService.deleteAgent(id); agentService.deleteAgent(id);
log.info("用户 {} 成功删除Agent: {}", userId, id); log.info("用户 {} 成功删除Agent: {}", userId, id);
...@@ -292,7 +292,7 @@ public class AgentController { ...@@ -292,7 +292,7 @@ public class AgentController {
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
public ApiResponse<java.util.List<Agent>> getUserAgents() { public ApiResponse<java.util.List<Agent>> getUserAgents() {
try { try {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
if (userId == null) { if (userId == null) {
return ApiResponse.error(4001, "用户未认证"); return ApiResponse.error(4001, "用户未认证");
} }
......
...@@ -40,7 +40,7 @@ public class MemoryController { ...@@ -40,7 +40,7 @@ public class MemoryController {
@GetMapping("/dialogue") @GetMapping("/dialogue")
public ApiResponse<List<Map<String, Object>>> getDialogueMemories() { public ApiResponse<List<Map<String, Object>>> getDialogueMemories() {
try { try {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
if (userId == null) { if (userId == null) {
log.warn("用户未认证,无法获取对话记忆列表"); log.warn("用户未认证,无法获取对话记忆列表");
return ApiResponse.error(401, "用户未认证"); return ApiResponse.error(401, "用户未认证");
...@@ -82,7 +82,7 @@ public class MemoryController { ...@@ -82,7 +82,7 @@ public class MemoryController {
@GetMapping("/knowledge") @GetMapping("/knowledge")
public ApiResponse<List<Map<String, Object>>> getKnowledgeMemories() { public ApiResponse<List<Map<String, Object>>> getKnowledgeMemories() {
try { try {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
if (userId == null) { if (userId == null) {
log.warn("用户未认证,无法获取知识记忆列表"); log.warn("用户未认证,无法获取知识记忆列表");
return ApiResponse.error(401, "用户未认证"); return ApiResponse.error(401, "用户未认证");
...@@ -110,7 +110,7 @@ public class MemoryController { ...@@ -110,7 +110,7 @@ public class MemoryController {
@GetMapping("/dialogue/agent/{agentId}") @GetMapping("/dialogue/agent/{agentId}")
public ApiResponse<Map<String, Object>> getDialogueMemoryDetail(@PathVariable String agentId) { public ApiResponse<Map<String, Object>> getDialogueMemoryDetail(@PathVariable String agentId) {
try { try {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
if (userId == null) { if (userId == null) {
log.warn("用户未认证,无法获取对话记忆详情"); log.warn("用户未认证,无法获取对话记忆详情");
return ApiResponse.error(401, "用户未认证"); return ApiResponse.error(401, "用户未认证");
...@@ -190,7 +190,7 @@ public class MemoryController { ...@@ -190,7 +190,7 @@ public class MemoryController {
@DeleteMapping("/dialogue/{sessionId}") @DeleteMapping("/dialogue/{sessionId}")
public ApiResponse<Void> clearDialogueMemory(@PathVariable String sessionId) { public ApiResponse<Void> clearDialogueMemory(@PathVariable String sessionId) {
try { try {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
if (userId == null) { if (userId == null) {
log.warn("用户未认证,无法清空对话记忆"); log.warn("用户未认证,无法清空对话记忆");
return ApiResponse.error(401, "用户未认证"); return ApiResponse.error(401, "用户未认证");
...@@ -223,7 +223,7 @@ public class MemoryController { ...@@ -223,7 +223,7 @@ public class MemoryController {
@DeleteMapping("/knowledge/{id}") @DeleteMapping("/knowledge/{id}")
public ApiResponse<Void> deleteKnowledgeMemory(@PathVariable String id) { public ApiResponse<Void> deleteKnowledgeMemory(@PathVariable String id) {
try { try {
String userId = UserUtils.getCurrentUserId(); String userId = UserUtils.getCurrentUserIdStatic();
if (userId == null) { if (userId == null) {
log.warn("用户未认证,无法删除知识记忆"); log.warn("用户未认证,无法删除知识记忆");
return ApiResponse.error(401, "用户未认证"); return ApiResponse.error(401, "用户未认证");
......
...@@ -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;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.agent.sse.UserSseService;
import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.workpanel.event.EventService;
/**
* 时间轴事件控制器
* 提供ReAct过程的实时事件推送功能
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/agent")
public class TimelineEventController {
private final UserSseService workPanelSseService;
public TimelineEventController(UserSseService workPanelSseService, EventService eventService) {
this.workPanelSseService = workPanelSseService;
}
/**
* 订阅时间轴事件
* 支持 SSE (Server-Sent Events) 格式的实时事件推送
*
* @return SSE emitter
*/
@GetMapping("/timeline-events")
public SseEmitter subscribeTimelineEvents() {
log.info("开始处理时间轴事件订阅请求");
// 获取当前认证用户ID
String userId = UserUtils.getCurrentUserId();
if (userId == null) {
log.warn("用户未认证,无法创建时间轴事件订阅");
throw new org.springframework.security.access.AccessDeniedException("用户未认证");
}
log.info("开始为用户 {} 创建SSE连接", userId);
// 创建并注册SSE连接
return workPanelSseService.createAndRegisterConnection(userId);
}
}
\ No newline at end of file
...@@ -258,7 +258,7 @@ public class TimerController { ...@@ -258,7 +258,7 @@ public class TimerController {
* 获取当前认证用户ID * 获取当前认证用户ID
*/ */
private String getCurrentUserId() { private String getCurrentUserId() {
return UserUtils.getCurrentUserId(); return UserUtils.getCurrentUserIdStatic();
} }
/** /**
......
...@@ -39,7 +39,7 @@ public class ToolController { ...@@ -39,7 +39,7 @@ public class ToolController {
* @return 用户ID * @return 用户ID
*/ */
private String getCurrentUserId() { private String getCurrentUserId() {
return UserUtils.getCurrentUserId(); return UserUtils.getCurrentUserIdStatic();
} }
/** /**
......
package pangea.hiagent.web.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
/**
* 嵌入事件数据传输对象
* 用于表示需要嵌入显示的事件(如网页预览等)
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public class EmbedEvent extends WorkPanelEvent {
private static final long serialVersionUID = 1L;
/**
* Embed事件信息 - 嵌入资源URL
*/
private String embedUrl;
/**
* Embed事件信息 - MIME类型
*/
private String embedType;
/**
* Embed事件信息 - 嵌入事件标题
*/
private String embedTitle;
/**
* Embed事件信息 - HTML内容
*/
private String embedHtmlContent;
}
\ No newline at end of file
package pangea.hiagent.web.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
/**
* 日志事件数据传输对象
* 用于表示系统日志事件
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public class LogEvent extends WorkPanelEvent {
private static final long serialVersionUID = 1L;
/**
* 日志内容
*/
private String content;
/**
* 日志级别(info/warn/error/debug)
*/
private String logLevel;
}
\ No newline at end of file
package pangea.hiagent.web.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
/**
* 结果事件数据传输对象
* 用于表示最终结果事件
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public class ResultEvent extends WorkPanelEvent {
private static final long serialVersionUID = 1L;
/**
* 结果内容
*/
private String content;
}
\ No newline at end of file
package pangea.hiagent.web.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
/**
* 思考事件数据传输对象
* 用于表示Agent的思考过程事件
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public class ThoughtEvent extends WorkPanelEvent {
private static final long serialVersionUID = 1L;
/**
* 思考内容
*/
private String content;
/**
* 思考类型(分析、规划、执行等)
*/
private String thinkingType;
}
\ No newline at end of file
package pangea.hiagent.web.dto;
import java.util.Map;
/**
* 时间轴事件工厂类
* 专门负责根据事件类型创建相应的事件DTO对象,遵循工厂模式设计原则
*/
public class TimelineEventFactory {
/**
* 根据事件类型创建相应的事件DTO对象
* 这是工厂类的唯一公共入口方法,确保所有事件对象创建都通过工厂完成
*
* @param eventType 事件类型
* @param eventData 事件数据
* @return 相应的事件DTO对象
*/
public static WorkPanelEvent createTimelineEvent(String eventType, Map<String, Object> eventData) {
if (eventType == null || eventData == null) {
return null;
}
switch (eventType) {
case "thought":
return createThoughtEvent(eventData);
case "tool_call":
case "tool_result":
case "tool_error":
return createToolEvent(eventType, eventData);
case "embed":
return createEmbedEvent(eventData);
case "log":
return createLogEvent(eventData);
case "result":
return createResultEvent(eventData);
default:
// 对于其他类型的事件,创建基础事件对象
return createBaseEvent(eventType, eventData);
}
}
/**
* 创建思考事件
* 专门处理思考类型事件的创建
*/
private static ThoughtEvent createThoughtEvent(Map<String, Object> eventData) {
return ThoughtEvent.builder()
.type(getStringValue(eventData, "type"))
.title(getStringValue(eventData, "title"))
.timestamp(getLongValue(eventData, "timestamp"))
.metadata(getMapValue(eventData, "metadata"))
.content(getStringValue(eventData, "content"))
.thinkingType(getStringValue(eventData, "thinkingType"))
.build();
}
/**
* 创建工具事件
* 统一处理所有工具相关事件的创建(调用、结果、错误)
*/
private static ToolEvent createToolEvent(String eventType, Map<String, Object> eventData) {
return ToolEvent.builder()
.type(eventType)
.title(getStringValue(eventData, "title"))
.timestamp(getLongValue(eventData, "timestamp"))
.metadata(getMapValue(eventData, "metadata"))
.toolName(getStringValue(eventData, "toolName"))
.toolAction(getStringValue(eventData, "toolAction"))
.toolInput(getMapValue(eventData, "toolInput"))
.toolOutput(eventData.get("toolOutput"))
.toolStatus(getStringValue(eventData, "toolStatus"))
.executionTime(getLongValue(eventData, "executionTime"))
.errorMessage(getStringValue(eventData, "errorMessage"))
.errorCode(getStringValue(eventData, "errorCode"))
.build();
}
/**
* 创建嵌入事件
* 专门处理嵌入类型事件的创建
*/
private static EmbedEvent createEmbedEvent(Map<String, Object> eventData) {
return EmbedEvent.builder()
.type(getStringValue(eventData, "type"))
.title(getStringValue(eventData, "title"))
.timestamp(getLongValue(eventData, "timestamp"))
.metadata(getMapValue(eventData, "metadata"))
.embedUrl(getStringValue(eventData, "embedUrl"))
.embedType(getStringValue(eventData, "embedType"))
.embedTitle(getStringValue(eventData, "embedTitle"))
.embedHtmlContent(getStringValue(eventData, "embedHtmlContent"))
.build();
}
/**
* 创建日志事件
* 专门处理日志类型事件的创建
*/
private static LogEvent createLogEvent(Map<String, Object> eventData) {
return LogEvent.builder()
.type(getStringValue(eventData, "type"))
.title(getStringValue(eventData, "title"))
.timestamp(getLongValue(eventData, "timestamp"))
.metadata(getMapValue(eventData, "metadata"))
.content(getStringValue(eventData, "content"))
.logLevel(getStringValue(eventData, "logLevel"))
.build();
}
/**
* 创建结果事件
* 专门处理结果类型事件的创建
*/
private static ResultEvent createResultEvent(Map<String, Object> eventData) {
return ResultEvent.builder()
.type(getStringValue(eventData, "type"))
.title(getStringValue(eventData, "title"))
.timestamp(getLongValue(eventData, "timestamp"))
.metadata(getMapValue(eventData, "metadata"))
.content(getStringValue(eventData, "content"))
.build();
}
/**
* 创建基础事件
* 处理所有其他类型事件的创建
*/
private static WorkPanelEvent createBaseEvent(String eventType, Map<String, Object> eventData) {
return WorkPanelEvent.builder()
.type(eventType)
.title(getStringValue(eventData, "title"))
.timestamp(getLongValue(eventData, "timestamp"))
.metadata(getMapValue(eventData, "metadata"))
.build();
}
/**
* 从Map中获取字符串值
* 工具方法,用于安全地从Map中提取字符串值
*/
private static String getStringValue(Map<String, Object> map, String key) {
Object value = map.get(key);
return value != null ? value.toString() : null;
}
/**
* 从Map中获取长整型值
* 工具方法,用于安全地从Map中提取长整型值
*/
private static Long getLongValue(Map<String, Object> map, String key) {
Object value = map.get(key);
if (value instanceof Number) {
return ((Number) value).longValue();
} else if (value instanceof String) {
try {
return Long.parseLong((String) value);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/**
* 从Map中获取Map值
* 工具方法,用于安全地从Map中提取嵌套Map值
*/
@SuppressWarnings("unchecked")
private static Map<String, Object> getMapValue(Map<String, Object> map, String key) {
Object value = map.get(key);
if (value instanceof Map) {
return (Map<String, Object>) value;
}
return null;
}
}
\ No newline at end of file
package pangea.hiagent.web.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.util.Map;
/**
* 工具事件数据传输对象
* 用于表示工具调用相关的所有事件(调用、结果、错误)
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public class ToolEvent extends WorkPanelEvent {
private static final long serialVersionUID = 1L;
/**
* 工具名称
*/
private String toolName;
/**
* 工具执行的方法/action
*/
private String toolAction;
/**
* 工具输入参数
*/
private Map<String, Object> toolInput;
/**
* 工具输出结果
*/
private Object toolOutput;
/**
* 工具执行状态(pending/success/failure/error)
*/
private String toolStatus;
/**
* 执行耗时(毫秒)
*/
private Long executionTime;
/**
* 错误信息
*/
private String errorMessage;
/**
* 错误代码
*/
private String errorCode;
}
\ No newline at end of file
package pangea.hiagent.web.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.List;
/**
* 工作面板状态数据传输对象
* 用于API返回工作面板的当前状态
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WorkPanelStatusDto implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 工作面板ID
*/
private String id;
/**
* 当前Agent ID
*/
private String agentId;
/**
* 当前Agent名称
*/
private String agentName;
/**
* 所有事件列表
*/
private List<WorkPanelEvent> events;
/**
* 思考过程事件列表
*/
private List<WorkPanelEvent> thinkingEvents;
/**
* 工具调用事件列表
*/
private List<WorkPanelEvent> toolCallEvents;
/**
* 执行日志事件列表
*/
private List<WorkPanelEvent> logEvents;
/**
* 总事件数量
*/
private Integer totalEvents;
/**
* 成功的工具调用数
*/
private Integer successfulToolCalls;
/**
* 失败的工具调用数
*/
private Integer failedToolCalls;
/**
* 更新时间戳
*/
private Long updateTimestamp;
/**
* 是否正在处理中
*/
private Boolean isProcessing;
}
...@@ -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;
...@@ -146,7 +145,7 @@ public class AgentService { ...@@ -146,7 +145,7 @@ public class AgentService {
} }
// 验证用户权限(确保用户是所有者) // 验证用户权限(确保用户是所有者)
String currentUserId = UserUtils.getCurrentUserId(); String currentUserId = UserUtils.getCurrentUserIdStatic();
if (currentUserId == null) { if (currentUserId == null) {
log.warn("用户未认证,无法更新Agent: {}", agent.getId()); log.warn("用户未认证,无法更新Agent: {}", agent.getId());
throw new BusinessException(ErrorCode.UNAUTHORIZED.getCode(), "用户未认证"); throw new BusinessException(ErrorCode.UNAUTHORIZED.getCode(), "用户未认证");
......
...@@ -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();
......
...@@ -2,9 +2,10 @@ package pangea.hiagent.websocket; ...@@ -2,9 +2,10 @@ package pangea.hiagent.websocket;
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSON;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.tool.playwright.PlaywrightManager;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.socket.*; import org.springframework.web.socket.*;
import pangea.hiagent.workpanel.playwright.PlaywrightManager;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
......
...@@ -3,7 +3,8 @@ package pangea.hiagent.websocket; ...@@ -3,7 +3,8 @@ package pangea.hiagent.websocket;
import com.microsoft.playwright.*; 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.common.utils.AsyncUserContextDecorator;
import pangea.hiagent.tool.playwright.PlaywrightManager;
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 -> {
......
...@@ -2,8 +2,9 @@ package pangea.hiagent.websocket; ...@@ -2,8 +2,9 @@ package pangea.hiagent.websocket;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.*; import org.springframework.web.socket.*;
import pangea.hiagent.workpanel.playwright.PlaywrightManager;
import pangea.hiagent.common.utils.UserUtils; import pangea.hiagent.common.utils.UserUtils;
import pangea.hiagent.tool.playwright.PlaywrightManager;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
...@@ -89,7 +90,7 @@ public class WebSocketConnectionManager { ...@@ -89,7 +90,7 @@ public class WebSocketConnectionManager {
String userId = (String) session.getAttributes().get("userId"); String userId = (String) session.getAttributes().get("userId");
if (userId == null || userId.isEmpty()) { if (userId == null || userId.isEmpty()) {
// 如果没有有效的用户ID,尝试从SecurityContext获取 // 如果没有有效的用户ID,尝试从SecurityContext获取
userId = UserUtils.getCurrentUserId(); userId = UserUtils.getCurrentUserIdStatic();
if (userId == null || userId.isEmpty()) { if (userId == null || userId.isEmpty()) {
// 如果仍然无法获取用户ID,使用默认值 // 如果仍然无法获取用户ID,使用默认值
userId = "unknown-user"; userId = "unknown-user";
......
package pangea.hiagent.workpanel;
import pangea.hiagent.web.dto.WorkPanelEvent;
import java.util.function.Consumer;
import java.util.List;
/**
* 工作面板数据收集器接口
* 用于采集Agent执行过程中的各类数据(思考过程、工具调用等)
*/
public interface IWorkPanelDataCollector {
/**
* 记录思考过程
* @param content 思考内容
* @param thinkingType 思考类型(分析、规划、执行等)
*/
void recordThinking(String content, String thinkingType);
/**
* 记录工具调用Action
* @param toolName 工具名称
* @param input 工具输入参数
* @param output 工具输出结果
* @param status 执行状态(pending/success/failure/error)
* @param executionTime 执行时间(毫秒)
*/
void recordToolCallAction(String toolName, Object input, Object output, String status, Long executionTime);
/**
* 记录日志
* @param message 日志消息
* @param level 日志级别(info/warn/error/debug)
*/
void recordLog(String message, String level);
/**
* 记录embed嵌入事件
* @param url 嵌入资源URL(可选)
* @param type MIME类型
* @param title 嵌入标题
* @param htmlContent HTML内容(可选)
*/
void recordEmbed(String url, String type, String title, String htmlContent);
/**
* 获取所有收集的事件
*/
List<WorkPanelEvent> getEvents();
/**
* 订阅事件(用于实时推送)
* @param consumer 事件处理函数
*/
void subscribe(Consumer<WorkPanelEvent> consumer);
/**
* 清空所有事件
*/
void clear();
/**
* 获取最后一个工具调用事件
*/
WorkPanelEvent getLastToolCall();
/**
* 记录最终答案
* @param content 最终答案内容
*/
void recordFinalAnswer(String content);
/**
* 添加事件到收集器
* 统一的事件添加方法,用于避免重复实现
* @param event 工作面板事件
*/
void addEvent(WorkPanelEvent event);
}
\ No newline at end of file
package pangea.hiagent.workpanel;
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 pangea.hiagent.workpanel.event.EventDeduplicationService;
import pangea.hiagent.workpanel.event.EventService;
import pangea.hiagent.agent.sse.UserSseService;
import pangea.hiagent.web.dto.LogEvent;
import pangea.hiagent.web.dto.ResultEvent;
import pangea.hiagent.web.dto.ThoughtEvent;
import pangea.hiagent.web.dto.ToolEvent;
import pangea.hiagent.web.dto.WorkPanelEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
/**
* 工作面板数据收集器实现
* 专门负责采集Agent执行过程中的各类数据,遵循单一职责原则
*/
@Slf4j
@Component
public class WorkPanelDataCollector implements IWorkPanelDataCollector {
/**
* 事件列表(线程安全)
*/
private final List<WorkPanelEvent> events = new CopyOnWriteArrayList<>();
/**
* 事件订阅者列表(线程安全)
*/
private final List<Consumer<WorkPanelEvent>> subscribers = new CopyOnWriteArrayList<>();
/**
* 用户ID到订阅者的映射,用于更好地管理订阅者
*/
private final Map<String, Consumer<WorkPanelEvent>> userSubscribers = new ConcurrentHashMap<>();
/**
* 事件去重服务
*/
@Autowired
private EventDeduplicationService eventDeduplicationService;
/**
* 统一事件服务
*/
@Autowired
private EventService eventService;
/**
* SSE服务
*/
@Autowired
private UserSseService unifiedSseService;
/**
* 最大事件数量,防止内存溢出
*/
private static final int MAX_EVENTS = 1000;
public WorkPanelDataCollector() {
// 默认构造函数
}
@Override
public void recordThinking(String content, String thinkingType) {
try {
WorkPanelEvent event = createThinkingEvent(content, thinkingType);
if (event != null && !eventDeduplicationService.isDuplicateEvent(event)) {
addEvent(event);
}
} catch (Exception e) {
logError("记录思考过程时发生错误", "content={}, type={}", content, thinkingType, e);
}
}
@Override
public void recordToolCallAction(String toolName, Object input, Object output, String status, Long executionTime) {
try {
WorkPanelEvent event = createToolCallActionEvent(toolName, input, output, status, executionTime);
if (event != null) {
handleToolCallAction(toolName, event, status, null);
}
} catch (Exception e) {
logError("记录工具调用Action时发生错误", "toolName={}", toolName, null, e);
}
}
@Override
public void recordLog(String message, String level) {
try {
WorkPanelEvent event = createLogEvent(message, level);
if (event != null) {
addEvent(event);
}
} catch (Exception e) {
logError("记录日志时发生错误", "message={}, level={}", message, level, e);
}
}
@Override
public void recordEmbed(String url, String type, String title, String htmlContent) {
try {
WorkPanelEvent event = createEmbedEvent(url, type, title, htmlContent);
if (event != null) {
addEvent(event);
}
} catch (Exception e) {
logError("recordEmbed时发生错误", "title={}, type={}", title, type, e);
}
}
@Override
public void recordFinalAnswer(String content) {
try {
WorkPanelEvent event = createFinalAnswerEvent(content);
if (event != null && !eventDeduplicationService.isDuplicateEvent(event)) {
addEvent(event);
}
} catch (Exception e) {
logError("记录最终答案时发生错误", "content={}", content, null, e);
}
}
@Override
public List<WorkPanelEvent> getEvents() {
return new ArrayList<>(events);
}
@Override
public void subscribe(Consumer<WorkPanelEvent> consumer) {
if (consumer != null) {
subscribers.add(consumer);
if (log.isTraceEnabled()) {
log.trace("已添加事件订阅者,当前订阅者数量: {}", subscribers.size());
}
}
}
/**
* 为指定用户订阅事件
*
* @param userId 用户ID
* @param consumer 事件消费者
*/
public void subscribe(String userId, Consumer<WorkPanelEvent> consumer) {
if (!isValidSubscription(userId, consumer)) {
return;
}
// 移除该用户之前的订阅者(如果存在)
removeExistingSubscriber(userId);
// 添加新的订阅者
addNewSubscriber(userId, consumer);
logSubscriptionDetails(userId);
}
/**
* 取消指定用户的事件订阅
*
* @param userId 用户ID
*/
public void unsubscribe(String userId) {
if (userId != null) {
Consumer<WorkPanelEvent> subscriber = userSubscribers.remove(userId);
if (subscriber != null) {
subscribers.remove(subscriber);
log.debug("已移除用户 {} 的事件订阅者", userId);
}
}
}
@Override
public void clear() {
try {
events.clear();
// 精简日志记录,避免过多的debug级别日志
if (log.isTraceEnabled()) {
log.trace("已清空工作面板事件");
}
} catch (Exception e) {
log.error("清空事件时发生错误", e);
}
}
@Override
public WorkPanelEvent getLastToolCall() {
// 从后往前查找最后一个工具调用事件
for (int i = events.size() - 1; i >= 0; i--) {
WorkPanelEvent event = events.get(i);
if (event instanceof ToolEvent) {
String type = event.getType();
if ("tool_call".equals(type) || "tool_result".equals(type)) {
return event;
}
}
}
return null;
}
@Override
public void addEvent(WorkPanelEvent event) {
// 直接复用已有的私有方法
if (event != null) {
// 调用内部的addEvent方法
addEventInternal(event);
}
}
/**
* 内部的添加事件方法,避免接口方法与私有方法的命名冲突
*/
private void addEventInternal(WorkPanelEvent event) {
try {
// 控制事件数量,防止内存溢出
if (events.size() >= MAX_EVENTS) {
events.remove(0); // 移除最老的事件
}
events.add(event);
String content = "";
if (event instanceof ThoughtEvent) {
content = ((ThoughtEvent) event).getContent();
} else if (event instanceof LogEvent) {
content = ((LogEvent) event).getContent();
} else if (event instanceof ResultEvent) {
content = ((ResultEvent) event).getContent();
}
log.debug("添加事件到列表: 类型={}, 内容={}", event.getType(), content);
// 更新最近事件缓存
eventDeduplicationService.updateRecentEventsCache(event);
// 通知所有订阅者
notifySubscribers(event);
} catch (Exception e) {
// 即使在addEvent方法中也增加异常保护,防止影响主流程
logDebug("添加事件失败: {}", e.getMessage());
}
}
// ==================== 私有方法 ====================
/**
* 创建思考事件
*/
private WorkPanelEvent createThinkingEvent(String content, String thinkingType) {
return eventService.recordThinking(content, thinkingType);
}
/**
* 创建工具调用Action事件
*/
private WorkPanelEvent createToolCallActionEvent(String toolName, Object input, Object output, String status, Long executionTime) {
return eventService.recordToolCallComplete(toolName, output, status, executionTime);
}
/**
* 处理工具调用Action
*/
private void handleToolCallAction(String toolName, WorkPanelEvent event, String status, String errorMessage) {
// 对于pending状态,直接添加事件
if ("pending".equals(status)) {
addEvent(event);
return;
}
// 查找最近的该工具的pending事件并更新它
WorkPanelEvent lastToolCall = getLastPendingToolCall(toolName);
if (lastToolCall != null) {
// 更新现有事件
if ("error".equals(status) && lastToolCall instanceof ToolEvent) {
updateEventAsError((ToolEvent) lastToolCall, errorMessage);
} else if (lastToolCall instanceof ToolEvent) {
ToolEvent toolEvent = (ToolEvent) lastToolCall;
toolEvent.setType(WorkPanelUtils.getEventTypeFromStatus(status));
toolEvent.setToolStatus(status);
// 注意:这里不设置toolOutput,因为event中可能包含完整的输出信息
toolEvent.setTimestamp(System.currentTimeMillis());
}
// 重新发布更新后的事件
notifySubscribers(lastToolCall);
} else {
addEvent(event);
}
}
/**
* 更新事件为错误状态
*/
private void updateEventAsError(WorkPanelEvent event, String errorMessage) {
event.setType("tool_error");
if (event instanceof ToolEvent) {
ToolEvent toolEvent = (ToolEvent) event;
// 使用反射设置content字段,因为ToolEvent没有setContent方法
try {
java.lang.reflect.Field contentField = WorkPanelEvent.class.getDeclaredField("content");
contentField.setAccessible(true);
contentField.set(toolEvent, errorMessage);
} catch (Exception e) {
// 如果反射失败,忽略错误
}
}
if (event instanceof ToolEvent) {
((ToolEvent) event).setToolStatus("failure");
}
event.setTimestamp(System.currentTimeMillis());
}
/**
* 创建日志事件
*/
private WorkPanelEvent createLogEvent(String message, String level) {
return eventService.recordLog(message, level);
}
/**
* 创建嵌入事件
*/
private WorkPanelEvent createEmbedEvent(String url, String type, String title, String htmlContent) {
return eventService.recordEmbed(url, type, title, htmlContent);
}
/**
* 创建最终答案事件
*/
private WorkPanelEvent createFinalAnswerEvent(String content) {
return eventService.recordFinalAnswer(content);
}
/**
* 通知所有订阅者
*/
private void notifySubscribers(WorkPanelEvent event) {
if (event == null) {
return;
}
try {
// 遍历所有订阅者并发送事件
for (Consumer<WorkPanelEvent> subscriber : subscribers) {
try {
if (subscriber != null) {
subscriber.accept(event);
}
} catch (Exception e) {
// 异常降级为debug日志,避免过度日志记录
// 异常通常由于SSE连接已断开导致,这是正常情况
if (e instanceof org.springframework.web.context.request.async.AsyncRequestNotUsableException) {
log.debug("订阅者处理事件失败:异步请求不可用(客户端已断开连接)");
} else if (e instanceof java.io.IOException) {
log.debug("订阅者处理事件失败:客户端连接已断开");
} else if (e.getMessage() != null && e.getMessage().contains("response has already been committed")) {
log.debug("订阅者处理事件失败:响应已提交");
} else {
// 其他异常也降级为debug,避免日志污染
log.debug("订阅者处理事件失败: {}", e.getMessage());
}
}
}
// 通过EventService发送事件到所有SSE连接
for (SseEmitter emitter : unifiedSseService.getEmitters()) {
try {
eventService.sendWorkPanelEvent(emitter, event);
} catch (Exception e) {
log.debug("通过EventService发送事件失败: {}", e.getMessage());
}
}
} catch (Exception e) {
log.debug("通知订阅者时发生错误: {}", e.getMessage());
}
}
/**
* 统一日志错误处理
*/
private void logError(String message, String format, Object arg1, Object arg2, Exception e) {
if (arg2 != null) {
log.error(message + ": " + format, arg1, arg2, e);
} else {
log.error(message + ": " + format, arg1, e);
}
}
/**
* 统一日志调试处理
*/
private void logDebug(String format, Object... arguments) {
// 即使在addEvent方法中也增加异常保护,防止影响主流程
log.debug(format, arguments);
}
/**
* 验证订阅参数是否有效
*/
private boolean isValidSubscription(String userId, Consumer<WorkPanelEvent> consumer) {
return userId != null && consumer != null;
}
/**
* 移除已存在的订阅者
*/
private void removeExistingSubscriber(String userId) {
Consumer<WorkPanelEvent> existingSubscriber = userSubscribers.get(userId);
if (existingSubscriber != null) {
subscribers.remove(existingSubscriber);
log.debug("已移除用户 {} 的旧订阅者", userId);
}
}
/**
* 添加新的订阅者
*/
private void addNewSubscriber(String userId, Consumer<WorkPanelEvent> consumer) {
subscribers.add(consumer);
userSubscribers.put(userId, consumer);
}
/**
* 记录订阅详情
*/
private void logSubscriptionDetails(String userId) {
// 精简日志记录,避免过多的debug级别日志
if (log.isTraceEnabled()) {
log.trace("已为用户 {} 添加事件订阅者,当前订阅者数量: {}", userId, subscribers.size());
}
}
/**
* 检查是否为指定工具的待处理调用事件
*/
private boolean isPendingToolCallEvent(WorkPanelEvent event, String toolName) {
// 检查事件是否为ToolEvent类型
if (!(event instanceof ToolEvent)) {
return false;
}
ToolEvent toolEvent = (ToolEvent) event;
return "tool_call".equals(event.getType()) &&
toolName != null && toolName.equals(toolEvent.getToolName()) &&
"pending".equals(toolEvent.getToolStatus());
}
/**
* 查找最近的指定工具的pending事件
*
* @param toolName 工具名称
* @return 最近的pending事件,如果不存在则返回null
*/
public WorkPanelEvent getLastPendingToolCall(String toolName) {
// 从后往前查找最近的该工具的pending事件
for (int i = events.size() - 1; i >= 0; i--) {
WorkPanelEvent event = events.get(i);
if (isPendingToolCallEvent(event, toolName)) {
return event;
}
}
return null;
}
}
\ No newline at end of file
package pangea.hiagent.workpanel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import pangea.hiagent.web.dto.*;
import pangea.hiagent.web.service.AgentService;
import pangea.hiagent.model.Agent;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.HashSet;
import java.util.concurrent.ConcurrentHashMap;
/**
* 工作面板服务
* 负责处理工作面板相关的状态和事件
*/
@Slf4j
@Service
public class WorkPanelService {
@Autowired
private AgentService agentService;
// 用于跟踪已发送的事件ID,防止重复发送
private final Map<String, Set<String>> sentEventIds = new ConcurrentHashMap<>();
/**
* 获取工作面板当前状态
*/
public WorkPanelStatusDto getWorkPanelStatus(String agentId, String userId) {
try {
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
throw new RuntimeException("Agent不存在");
}
if (!agent.getOwner().equals(userId)) {
throw new RuntimeException("无权访问该Agent");
}
log.info("获取Agent {} 的工作面板状态", agentId);
// 从工作面板收集器中读取数据
IWorkPanelDataCollector collector = null; // 移除了对ReActService的依赖
List<WorkPanelEvent> allEvents = collector != null ? collector.getEvents() : new ArrayList<>();
// 统计不同类型的事件
int totalEvents = allEvents.size();
int successfulCalls = (int) allEvents.stream()
.filter(e -> "tool_result".equals(e.getType()) &&
(e instanceof ToolEvent && "success".equals(((ToolEvent) e).getToolStatus())))
.count();
int failedCalls = (int) allEvents.stream()
.filter(e -> "tool_error".equals(e.getType()) ||
("tool_result".equals(e.getType()) &&
(e instanceof ToolEvent && "failure".equals(((ToolEvent) e).getToolStatus()))))
.count();
List<WorkPanelEvent> thinkingEvents = new ArrayList<>();
List<WorkPanelEvent> toolCallEvents = new ArrayList<>();
List<WorkPanelEvent> logEvents = new ArrayList<>();
for (WorkPanelEvent event : allEvents) {
switch (event.getType()) {
case "thought" -> thinkingEvents.add(event);
case "tool_call", "tool_result", "tool_error" -> toolCallEvents.add(event);
case "log" -> logEvents.add(event);
default -> {
}
}
}
WorkPanelStatusDto status = WorkPanelStatusDto.builder()
.id(agentId + "_workpanel")
.agentId(agentId)
.agentName(agent.getName())
.events(allEvents)
.thinkingEvents(thinkingEvents)
.toolCallEvents(toolCallEvents)
.logEvents(logEvents)
.totalEvents(totalEvents)
.successfulToolCalls(successfulCalls)
.failedToolCalls(failedCalls)
.updateTimestamp(System.currentTimeMillis())
.isProcessing(false)
.build();
return status;
} catch (Exception e) {
log.error("获取工作面板状态失败", e);
throw new RuntimeException("获取工作面板状态失败: " + e.getMessage(), e);
}
}
/**
* 清空工作面板数据
*/
public void clearWorkPanel(String agentId, String userId) {
try {
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
throw new RuntimeException("Agent不存在");
}
if (!agent.getOwner().equals(userId)) {
throw new RuntimeException("无权访问该Agent");
}
log.info("清空Agent {} 的工作面板", agentId);
// 在实际应用中,这里应该从缓存中清除工作面板数据
// 清空已发送事件ID跟踪
sentEventIds.remove(agentId);
} catch (Exception e) {
log.error("清空工作面板失败", e);
throw new RuntimeException("清空工作面板失败: " + e.getMessage(), e);
}
}
/**
* 生成事件唯一标识
*/
public String generateEventId(WorkPanelEvent event) {
if (event == null) {
return "null_event_" + System.currentTimeMillis();
}
StringBuilder sb = new StringBuilder();
sb.append(event.getType()).append("_");
switch (event.getType()) {
case "thought":
if (event instanceof ThoughtEvent) {
ThoughtEvent thoughtEvent = (ThoughtEvent) event;
sb.append(thoughtEvent.getThinkingType() != null ? thoughtEvent.getThinkingType() : "default")
.append("_")
.append(thoughtEvent.getContent() != null ? thoughtEvent.getContent().hashCode() : 0);
} else {
sb.append("default").append("_").append(0);
}
break;
case "tool_call":
case "tool_result":
case "tool_error":
if (event instanceof ToolEvent) {
ToolEvent toolEvent = (ToolEvent) event;
sb.append(toolEvent.getToolName() != null ? toolEvent.getToolName() : "unknown")
.append("_")
.append(toolEvent.getToolAction() != null ? toolEvent.getToolAction() : "unknown")
.append("_")
.append(event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis());
} else {
sb.append("unknown").append("_").append("unknown").append("_").append(System.currentTimeMillis());
}
break;
case "log":
if (event instanceof LogEvent) {
LogEvent logEvent = (LogEvent) event;
sb.append(logEvent.getLogLevel() != null ? logEvent.getLogLevel() : "info")
.append("_")
.append(logEvent.getContent() != null ? logEvent.getContent().hashCode() : 0);
} else {
sb.append("info").append("_").append(0);
}
break;
case "embed":
if (event instanceof EmbedEvent) {
EmbedEvent embedEvent = (EmbedEvent) event;
sb.append(embedEvent.getEmbedTitle() != null ? embedEvent.getEmbedTitle() : "untitled")
.append("_")
.append(embedEvent.getEmbedUrl() != null ? embedEvent.getEmbedUrl().hashCode() : 0);
} else {
sb.append("untitled").append("_").append(0);
}
break;
default:
sb.append(event.getTimestamp() != null ? event.getTimestamp() : System.currentTimeMillis());
break;
}
return sb.toString();
}
/**
* 检查事件是否已发送
*/
public boolean isEventAlreadySent(String agentId, WorkPanelEvent event) {
String eventId = generateEventId(event);
Set<String> agentEventIds = sentEventIds.computeIfAbsent(agentId, k -> new HashSet<>());
return !agentEventIds.add(eventId); // 如果已存在,add返回false,表示已发送
}
}
\ No newline at end of file
package pangea.hiagent.workpanel;
import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.workpanel.event.EventTypeConverter;
import java.util.HashMap;
import java.util.Map;
/**
* 工作面板工具类
* 提供各种辅助方法用于工作面板事件处理
* 注意:此工具类已优化,移除了与EventTypeConverter重复的功能
*/
@Slf4j
public class WorkPanelUtils {
/**
* 将对象转换为Map(用于工具输入参数)
*/
public static Map<String, Object> convertToMap(Object input) {
if (input == null) {
return new HashMap<>();
}
if (input instanceof Map) {
// 安全地转换Map类型,确保键为String类型
Map<?, ?> rawMap = (Map<?, ?>) input;
Map<String, Object> resultMap = new HashMap<>();
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
// 将键转换为String类型
String key = entry.getKey() != null ? entry.getKey().toString() : "null";
resultMap.put(key, entry.getValue());
}
return resultMap;
}
// 简单对象转换为Map
Map<String, Object> result = new HashMap<>();
result.put("value", input);
return result;
}
/**
* 获取状态文本
* 已委托给EventTypeConverter处理
*/
public static String getStatusText(String status) {
EventTypeConverter converter = new EventTypeConverter();
return converter.getStatusText(status);
}
/**
* 根据状态确定事件类型
* 已委托给EventTypeConverter处理
*/
public static String getEventTypeFromStatus(String status) {
EventTypeConverter converter = new EventTypeConverter();
return converter.getEventTypeFromStatus(status);
}
/**
* 将对象转换为JSON字符串
*
* @param obj 要转换的对象
* @return JSON字符串表示
*/
public static String convertToJsonString(Object obj) {
if (obj == null) {
return "null";
}
try {
// 使用Jackson ObjectMapper进行序列化
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
return mapper.writeValueAsString(obj);
} catch (Exception e) {
// 如果序列化失败,返回对象的toString()表示
return obj.toString();
}
}
}
\ No newline at end of file
package pangea.hiagent.workpanel.event;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import pangea.hiagent.web.dto.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 事件去重服务
* 统一处理事件去重逻辑,避免在多个地方重复实现
*/
@Slf4j
@Component
public class EventDeduplicationService {
/**
* 最近事件的缓存,用于去重检查
*/
private final Map<String, WorkPanelEvent> recentEventsCache = new ConcurrentHashMap<>();
/**
* 缓存过期时间(毫秒),默认5秒
*/
private static final long CACHE_EXPIRY_TIME = 5000L;
/**
* 最大缓存大小,防止内存溢出
*/
private static final int MAX_CACHE_SIZE = 1000;
/**
* 上次清理缓存的时间
*/
private volatile long lastCleanupTime = 0;
/**
* 清理间隔(毫秒)
*/
private static final long CLEANUP_INTERVAL = 30000L;
/**
* 检查是否为重复事件
*/
public boolean isDuplicateEvent(WorkPanelEvent event) {
if (event == null) {
return false;
}
// 定期清理过期缓存
cleanupExpiredCache();
// 生成事件唯一标识
String eventKey = generateEventKey(event);
WorkPanelEvent cachedEvent = recentEventsCache.get(eventKey);
if (cachedEvent != null) {
// 检查缓存是否过期
long currentTime = System.currentTimeMillis();
if ((currentTime - cachedEvent.getTimestamp()) <= CACHE_EXPIRY_TIME) {
// 检查事件内容是否相同
return isEventContentEqual(event, cachedEvent);
} else {
// 缓存过期,移除
recentEventsCache.remove(eventKey);
}
}
return false;
}
/**
* 更新最近事件缓存
*/
public void updateRecentEventsCache(WorkPanelEvent event) {
if (event == null) {
return;
}
// 控制缓存大小,防止内存溢出
if (recentEventsCache.size() >= MAX_CACHE_SIZE) {
// 移除最老的条目(简单实现,实际可以根据LRU算法优化)
java.util.Iterator<Map.Entry<String, WorkPanelEvent>> iterator = recentEventsCache.entrySet().iterator();
if (iterator.hasNext()) {
iterator.next();
iterator.remove();
}
}
// 添加新条目
String eventKey = generateEventKey(event);
recentEventsCache.put(eventKey, event);
}
/**
* 生成事件唯一标识
*/
private String generateEventKey(WorkPanelEvent event) {
StringBuilder key = new StringBuilder();
key.append(event.getType() != null ? event.getType() : "");
key.append("_");
// 获取工具名称(如果是ToolEvent)
String toolName = "";
if (event instanceof ToolEvent) {
toolName = ((ToolEvent) event).getToolName();
}
key.append(toolName != null ? toolName : "");
key.append("_");
// 获取思考类型(如果是ThoughtEvent)
String thinkingType = "";
if (event instanceof ThoughtEvent) {
thinkingType = ((ThoughtEvent) event).getThinkingType();
}
key.append(thinkingType != null ? thinkingType : "");
// 对于思考事件,添加内容摘要
if ("thought".equals(event.getType()) && event instanceof ThoughtEvent) {
ThoughtEvent thoughtEvent = (ThoughtEvent) event;
if (thoughtEvent.getContent() != null) {
// 取内容的前50个字符作为摘要
String contentSummary = thoughtEvent.getContent().length() > 50 ?
thoughtEvent.getContent().substring(0, 50) : thoughtEvent.getContent();
key.append("_").append(contentSummary.hashCode());
}
}
return key.toString();
}
/**
* 检查两个事件的内容是否相等
*/
private boolean isEventContentEqual(WorkPanelEvent event1, WorkPanelEvent event2) {
if (event1 == event2) {
return true;
}
if (event1 == null || event2 == null) {
return false;
}
// 比较基本字段
if (!java.util.Objects.equals(event1.getType(), event2.getType())) {
return false;
}
// 比较工具名称(如果是ToolEvent)
String toolName1 = "";
String toolName2 = "";
if (event1 instanceof ToolEvent) {
toolName1 = ((ToolEvent) event1).getToolName();
}
if (event2 instanceof ToolEvent) {
toolName2 = ((ToolEvent) event2).getToolName();
}
if (!java.util.Objects.equals(toolName1, toolName2)) {
return false;
}
// 比较思考类型(如果是ThoughtEvent)
String thinkingType1 = "";
String thinkingType2 = "";
if (event1 instanceof ThoughtEvent) {
thinkingType1 = ((ThoughtEvent) event1).getThinkingType();
}
if (event2 instanceof ThoughtEvent) {
thinkingType2 = ((ThoughtEvent) event2).getThinkingType();
}
if (!java.util.Objects.equals(thinkingType1, thinkingType2)) {
return false;
}
// 比较内容字段(根据不同事件类型)
if (event1 instanceof ThoughtEvent && event2 instanceof ThoughtEvent) {
ThoughtEvent thought1 = (ThoughtEvent) event1;
ThoughtEvent thought2 = (ThoughtEvent) event2;
return java.util.Objects.equals(thought1.getContent(), thought2.getContent());
}
// 比较工具输入(确保都是ToolEvent)
if (event1 instanceof ToolEvent && event2 instanceof ToolEvent) {
if (!java.util.Objects.equals(((ToolEvent) event1).getToolInput(),
((ToolEvent) event2).getToolInput())) {
return false;
}
// 比较工具输出
if (!java.util.Objects.equals(((ToolEvent) event1).getToolOutput(),
((ToolEvent) event2).getToolOutput())) {
return false;
}
} else if (event1 instanceof ResultEvent && event2 instanceof ResultEvent) {
// 比较结果内容
String content1 = ((ResultEvent) event1).getContent();
String content2 = ((ResultEvent) event2).getContent();
if (!java.util.Objects.equals(content1, content2)) {
return false;
}
}
return true;
}
/**
* 清理过期缓存
*/
private void cleanupExpiredCache() {
long currentTime = System.currentTimeMillis();
// 检查是否需要执行清理
if (currentTime - lastCleanupTime < CLEANUP_INTERVAL) {
return;
}
// 更新上次清理时间
lastCleanupTime = currentTime;
// 清理过期条目
recentEventsCache.entrySet().removeIf(entry -> {
WorkPanelEvent event = entry.getValue();
return event == null || (currentTime - event.getTimestamp()) > CACHE_EXPIRY_TIME;
});
}
/**
* 清空缓存
*/
public void clearCache() {
recentEventsCache.clear();
}
}
\ No newline at end of file
package pangea.hiagent.workpanel.event;
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.web.dto.EmbedEvent;
import pangea.hiagent.web.dto.LogEvent;
import pangea.hiagent.web.dto.ResultEvent;
import pangea.hiagent.web.dto.ThoughtEvent;
import pangea.hiagent.web.dto.ToolEvent;
import pangea.hiagent.web.dto.WorkPanelEvent;
import pangea.hiagent.workpanel.WorkPanelUtils;
import pangea.hiagent.workpanel.data.ErrorEventDataBuilder;
import pangea.hiagent.workpanel.data.MapPoolService;
import pangea.hiagent.workpanel.data.TokenEventDataBuilder;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.io.IOException;
/**
* 统一事件服务
* 整合事件创建、构建、发送等功能,事件处理架构
*/
@Slf4j
@Service
public class EventService {
@Autowired
private MapPoolService mapPoolService;
@Autowired
private ErrorEventDataBuilder errorEventDataBuilder;
@Autowired
private TokenEventDataBuilder tokenEventDataBuilder;
/**
* 工具调用状态跟踪映射
* key: toolName + timestamp
* value: pending状态的事件
*/
private final Map<String, ToolEvent> pendingToolCalls = new ConcurrentHashMap<>();
// 日期格式化对象,用于日志输出
private static final java.text.SimpleDateFormat DATE_FORMAT = new java.text.SimpleDateFormat("HH:mm:ss");
/**
* 记录工具调用开始事件
*
* @param toolName 工具名称
* @param input 工具输入参数
* @return 工具调用开始事件
*/
public ToolEvent recordToolCallStart(String toolName, Object input) {
try {
long currentTime = System.currentTimeMillis();
ToolEvent event = ToolEvent.builder()
.type("tool_call")
.timestamp(currentTime)
.toolName(toolName != null ? toolName : "未知工具")
.toolInput(WorkPanelUtils.convertToMap(input))
.toolStatus("pending")
.build();
// 跟踪pending状态的工具调用
String key = generateToolCallKey(toolName, currentTime);
pendingToolCalls.put(key, event);
// 输出工具调用日志
logToolCallStart(toolName, input, currentTime);
log.debug("已记录工具调用开始: 工具={}, 状态=pending", toolName);
return event;
} catch (Exception e) {
log.error("记录工具调用开始时发生错误: toolName={}", toolName, e);
return null;
}
}
/**
* 记录工具调用完成事件
*
* @param toolName 工具名称
* @param output 工具输出结果
* @param status 执行状态(success/failure)
* @param executionTime 执行时间(毫秒)
* @return 工具调用完成事件
*/
public ToolEvent recordToolCallComplete(String toolName, Object output, String status, Long executionTime) {
return handleToolCallCompletion(toolName, output, status, executionTime);
}
/**
* 记录工具调用错误事件
*
* @param toolName 工具名称
* @param errorMessage 错误信息
* @return 工具调用错误事件
*/
public ToolEvent recordToolCallError(String toolName, String errorMessage) {
return handleToolCallError(toolName, errorMessage);
}
/**
* 记录思考事件
*
* @param content 思考内容
* @param thinkingType 思考类型(分析、规划、执行等)
* @return 思考事件
*/
public ThoughtEvent recordThinking(String content, String thinkingType) {
try {
// 过滤掉空内容,避免记录过多无关信息
if (content == null || content.trim().isEmpty()) {
return null;
}
ThoughtEvent event = ThoughtEvent.builder()
.type("thought")
.timestamp(System.currentTimeMillis())
.content(content)
.thinkingType(thinkingType != null ? thinkingType : "reasoning")
.build();
log.debug("已记录思考过程: 类型={}, 内容={}", thinkingType, content);
return event;
} catch (Exception e) {
log.error("记录思考过程时发生错误: content={}, type={}", content, thinkingType, e);
return null;
}
}
/**
* 记录最终答案事件
*
* @param content 最终答案内容
* @return 最终答案事件
*/
public ResultEvent recordFinalAnswer(String content) {
try {
ResultEvent event = ResultEvent.builder()
.type("result")
.timestamp(System.currentTimeMillis())
.content(content)
.build();
log.debug("已记录最终答案: 内容={}", content);
return event;
} catch (Exception e) {
log.error("记录最终答案时发生错误: content={}", content, e);
return null;
}
}
/**
* 记录日志事件
*
* @param message 日志消息
* @param level 日志级别(info/warn/error/debug)
* @return 日志事件
*/
public LogEvent recordLog(String message, String level) {
try {
LogEvent event = LogEvent.builder()
.type("log")
.timestamp(System.currentTimeMillis())
.content(message)
.logLevel(level != null ? level : "info")
.build();
log.debug("已记录日志: 级别={}, 消息={}", level, message);
return event;
} catch (Exception e) {
log.error("记录日志时发生错误: message={}, level={}", message, level, e);
return null;
}
}
/**
* 记录嵌入事件
*
* @param url 嵌入资源URL(可选)
* @param type MIME类型
* @param title 嵌入标题
* @param htmlContent HTML内容(可选)
* @return 嵌入事件
*/
public EmbedEvent recordEmbed(String url, String type, String title, String htmlContent) {
try {
EmbedEvent event = EmbedEvent.builder()
.type("embed")
.timestamp(System.currentTimeMillis())
.embedUrl(url)
.embedType(type)
.embedTitle(title != null ? title : "网页预览")
.embedHtmlContent(htmlContent)
.build();
log.debug("已记录嵌入事件: 标题={}, URL={}", title, url);
return event;
} catch (Exception e) {
log.error("记录嵌入事件时发生错误: title={}, url={}", title, url, e);
return null;
}
}
/**
* 构建工作面板事件数据
*
* @param event 工作面板事件
* @return 事件数据
*/
public Map<String, Object> buildWorkPanelEventData(WorkPanelEvent event) {
if (event == null) {
return null;
}
// 从对象池获取Map,避免频繁创建对象
Map<String, Object> data = acquireMap();
// 设置基础属性
data.put("type", event.getType());
data.put("eventType", event.getType());
data.put("timestamp", event.getTimestamp());
// 根据事件类型设置特有属性
setEventSpecificProperties(event, data);
// 处理事件类型特定逻辑
processEventTypeSpecifics(event, data);
return data;
}
/**
* 发送工作面板事件给指定的SSE连接
*
* @param emitter SSE发射器
* @param event 工作面板事件
* @throws IOException IO异常
*/
public void sendWorkPanelEvent(SseEmitter emitter, WorkPanelEvent event) throws IOException {
if (event == null) {
log.warn("工作面板事件为空,无法发送事件");
return;
}
try {
// 构建事件数据
Map<String, Object> data = buildWorkPanelEventData(event);
if (data != null) {
log.debug("准备发送工作面板事件: 类型={}, 事件内容={}", event.getType(), event);
log.debug("事件数据: {}", data);
// 发送事件
emitter.send(org.springframework.web.servlet.mvc.method.annotation.SseEmitter.event().name("message").data(data));
log.debug("工作面板事件发送成功: 类型={}", event.getType());
} else {
log.warn("构建事件数据失败,无法发送事件: 类型={}", event.getType());
}
} catch (Exception e) {
// 记录详细错误信息,但不中断主流程
log.error("发送工作面板事件失败: 类型={}, 错误={}", event.getType(), e.getMessage(), e);
// 如果是连接已关闭的异常,重新抛出以便上层处理
if (e instanceof IllegalStateException && e.getMessage() != null &&
e.getMessage().contains("Emitter is already completed")) {
throw e;
}
}
}
/**
* 发送工作面板事件给指定用户
*
* @param emitter SSE发射器
* @param event 工作面板事件
*/
public void sendWorkPanelEventToUser(SseEmitter emitter, WorkPanelEvent event) {
log.debug("开始发送工作面板事件");
if (emitter != null) {
try {
// 直接向当前 emitter 发送事件
sendWorkPanelEvent(emitter, event);
log.debug("已发送工作面板事件到客户端: {}", event.getType());
} catch (IOException e) {
log.error("发送工作面板事件失败: {}", e.getMessage(), e);
}
} else {
log.debug("连接已失效,跳过发送事件: {}", event.getType());
}
}
/**
* 发送错误事件
*
* @param emitter SSE发射器
* @param errorMessage 错误信息
* @throws IOException IO异常
*/
public void sendErrorEvent(SseEmitter emitter, String errorMessage) throws IOException {
if (emitter == null) {
log.warn("SSE发射器为空,无法发送错误事件");
return;
}
try {
// 构建错误事件数据
Map<String, Object> data = errorEventDataBuilder.createErrorEventData(errorMessage);
if (data != null) {
log.debug("准备发送错误事件: 错误信息={}", errorMessage);
log.debug("错误事件数据: {}", data);
// 发送事件
emitter.send(SseEmitter.event().name("error").data(data));
log.debug("错误事件发送成功");
} else {
log.warn("构建错误事件数据失败,无法发送事件");
}
} catch (Exception e) {
// 记录详细错误信息,但不中断主流程
log.error("发送错误事件失败: 错误信息={}, 错误={}", errorMessage, e.getMessage(), e);
// 如果是连接已关闭的异常,重新抛出以便上层处理
if (e instanceof IllegalStateException && e.getMessage() != null &&
e.getMessage().contains("Emitter is already completed")) {
throw e;
}
}
}
/**
* 发送Token事件
*
* @param emitter SSE发射器
* @param token Token内容
* @throws IOException IO异常
*/
public void sendTokenEvent(SseEmitter emitter, String token) throws IOException {
if (emitter == null) {
log.warn("SSE发射器为空,无法发送token事件");
return;
}
try {
// 构建token事件数据
Map<String, Object> data = tokenEventDataBuilder.createOptimizedTokenEventData(token);
if (data != null) {
log.debug("准备发送token事件: token长度={}", token != null ? token.length() : 0);
// 发送事件
emitter.send(SseEmitter.event().name("token").data(data));
log.debug("token事件发送成功");
} else {
log.warn("构建token事件数据失败,无法发送事件");
}
} catch (Exception 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;
}
}
}
// ==================== 私有方法 ====================
/**
* 处理工具调用完成
*
* @param toolName 工具名称
* @param output 工具输出结果
* @param status 执行状态
* @param executionTime 执行时间
* @return 工作面板事件
*/
private ToolEvent handleToolCallCompletion(String toolName, Object output, String status, Long executionTime) {
long currentTime = System.currentTimeMillis();
// 查找对应的pending事件
ToolEvent pendingEvent = findPendingToolCall(toolName);
if (pendingEvent != null) {
// 移除跟踪记录
String key = generateToolCallKey(toolName, pendingEvent.getTimestamp());
pendingToolCalls.remove(key);
// 更新现有事件
updatePendingEventForCompletion(pendingEvent, output, status, executionTime, currentTime);
// 输出工具调用完成日志
logToolCallCompletion(toolName, output, status, executionTime, currentTime);
log.debug("已记录工具调用完成: 工具={}, 状态={}, 执行时间={}ms", toolName, status, executionTime);
return pendingEvent;
} else {
// 如果没有对应的pending事件,创建一个新事件
ToolEvent event = createNewCompletionEvent(toolName, output, status, executionTime, currentTime);
log.debug("已记录工具调用完成(无对应pending事件): 工具={}, 状态={}, 执行时间={}ms", toolName, status, executionTime);
return event;
}
}
/**
* 处理工具调用错误
*
* @param toolName 工具名称
* @param errorMessage 错误信息
* @return 工作面板事件
*/
private ToolEvent handleToolCallError(String toolName, String errorMessage) {
// 查找对应的pending事件
ToolEvent pendingEvent = findPendingToolCall(toolName);
if (pendingEvent != null) {
// 移除跟踪记录
String key = generateToolCallKey(toolName, pendingEvent.getTimestamp());
pendingToolCalls.remove(key);
// 更新现有事件为错误状态
pendingEvent.setType("tool_error");
// 使用反射设置content字段
try {
java.lang.reflect.Field contentField = WorkPanelEvent.class.getDeclaredField("content");
contentField.setAccessible(true);
contentField.set(pendingEvent, errorMessage);
} catch (Exception e) {
// 如果反射失败,忽略错误
}
pendingEvent.setToolStatus("failure");
pendingEvent.setTimestamp(System.currentTimeMillis());
log.debug("已记录工具调用错误: 工具={}, 错误={}", toolName, errorMessage);
return pendingEvent;
} else {
// 如果没有对应的pending事件,创建一个新事件
ToolEvent event = ToolEvent.builder()
.type("tool_error")
.timestamp(System.currentTimeMillis())
.toolName(toolName)
.toolStatus("failure")
.build();
// 使用反射设置content字段
try {
java.lang.reflect.Field contentField = WorkPanelEvent.class.getDeclaredField("content");
contentField.setAccessible(true);
contentField.set(event, errorMessage);
} catch (Exception e) {
// 如果反射失败,忽略错误
}
log.debug("已记录工具调用错误(无对应pending事件): 工具={}, 错误={}", toolName, errorMessage);
return event;
}
}
/**
* 生成工具调用键
*
* @param toolName 工具名称
* @param timestamp 时间戳
* @return 工具调用键
*/
private String generateToolCallKey(String toolName, long timestamp) {
return (toolName != null ? toolName : "unknown") + "_" + timestamp;
}
/**
* 查找pending状态的工具调用事件
*
* @param toolName 工具名称
* @return pending状态的事件,如果未找到则返回null
*/
private ToolEvent findPendingToolCall(String toolName) {
if (toolName == null) {
return null;
}
// 查找最近的该工具的pending事件
for (Map.Entry<String, ToolEvent> entry : pendingToolCalls.entrySet()) {
ToolEvent event = entry.getValue();
if (toolName.equals(event.getToolName()) && "pending".equals(event.getToolStatus())) {
return event;
}
}
return null;
}
/**
* 更新pending事件为完成状态
*
* @param pendingEvent pending状态的事件
* @param output 工具输出结果
* @param status 执行状态
* @param executionTime 执行时间
* @param currentTime 当前时间
*/
private void updatePendingEventForCompletion(ToolEvent pendingEvent, Object output, String status, Long executionTime, long currentTime) {
pendingEvent.setType(WorkPanelUtils.getEventTypeFromStatus(status));
pendingEvent.setToolOutput(output);
pendingEvent.setToolStatus(status != null ? status : "unknown");
pendingEvent.setExecutionTime(executionTime);
pendingEvent.setTimestamp(currentTime);
}
/**
* 创建新的完成事件
*
* @param toolName 工具名称
* @param output 工具输出结果
* @param status 执行状态
* @param executionTime 执行时间
* @param currentTime 当前时间
* @return 新的完成事件
*/
private ToolEvent createNewCompletionEvent(String toolName, Object output, String status, Long executionTime, long currentTime) {
return ToolEvent.builder()
.type(WorkPanelUtils.getEventTypeFromStatus(status))
.timestamp(currentTime)
.toolName(toolName != null ? toolName : "未知工具")
.toolOutput(output)
.toolStatus(status != null ? status : "unknown")
.executionTime(executionTime)
.build();
}
/**
* 输出工具调用开始日志
*
* @param toolName 工具名称
* @param input 工具输入参数
* @param currentTime 当前时间
*/
private void logToolCallStart(String toolName, Object input, long currentTime) {
String formattedTime = DATE_FORMAT.format(new java.util.Date(currentTime));
log.info("\n🔧 工具调用: [{}]\n⏰ 时间: {}\n📥 输入: {}\n📊 状态: 处理中",
toolName != null ? toolName : "未知工具",
formattedTime,
WorkPanelUtils.convertToJsonString(input));
}
/**
* 输出工具调用完成日志
*
* @param toolName 工具名称
* @param output 工具输出结果
* @param status 执行状态
* @param executionTime 执行时间
* @param currentTime 当前时间
*/
private void logToolCallCompletion(String toolName, Object output, String status, Long executionTime, long currentTime) {
String formattedTime = DATE_FORMAT.format(new java.util.Date(currentTime));
if ("success".equals(status)) {
log.info("\n🔧 工具调用: [{}]\n⏰ 时间: {}\n✅ 状态: 成功\n📤 输出: {}{}",
toolName != null ? toolName : "未知工具",
formattedTime,
WorkPanelUtils.convertToJsonString(output),
executionTime != null ? "\n⏱️ 耗时: " + executionTime + "ms" : "");
} else if ("failure".equals(status) || "error".equals(status)) {
log.info("\n🔧 工具调用: [{}]\n⏰ 时间: {}\n❌ 状态: 失败\n💬 错误: {}{}",
toolName != null ? toolName : "未知工具",
formattedTime,
WorkPanelUtils.convertToJsonString(output),
executionTime != null ? "\n⏱️ 耗时: " + executionTime + "ms" : "");
}
}
/**
* 根据事件类型设置特有属性
*
* @param event 工作面板事件
* @param data 事件数据
*/
private void setEventSpecificProperties(WorkPanelEvent event, Map<String, Object> data) {
String eventType = event.getType();
// 设置通用属性
if (event instanceof ThoughtEvent) {
data.put("content", ((ThoughtEvent) event).getContent());
} else if (event instanceof LogEvent) {
data.put("content", ((LogEvent) event).getContent());
} else if (event instanceof ResultEvent) {
data.put("content", ((ResultEvent) event).getContent());
}
switch (eventType) {
case "thought":
// 思考事件特有属性
if (event instanceof ThoughtEvent) {
ThoughtEvent thoughtEvent = (ThoughtEvent) event;
data.put("thinkingType", thoughtEvent.getThinkingType());
}
data.put("title", generateTitleForThought(event));
break;
case "tool_call":
case "tool_result":
case "tool_error":
// 工具事件特有属性
if (event instanceof ToolEvent) {
ToolEvent toolEvent = (ToolEvent) event;
data.put("toolName", toolEvent.getToolName());
data.put("toolInput", toolEvent.getToolInput());
data.put("toolOutput", toolEvent.getToolOutput());
data.put("toolStatus", toolEvent.getToolStatus());
data.put("executionTime", toolEvent.getExecutionTime());
// 根据状态设置错误信息
if ("tool_error".equals(eventType)) {
data.put("errorMessage", toolEvent.getErrorMessage());
data.put("errorCode", toolEvent.getErrorCode());
}
}
data.put("title", event instanceof ToolEvent && ((ToolEvent) event).getToolName() != null ? ((ToolEvent) event).getToolName() : "未知工具");
break;
case "log":
// 日志事件特有属性
if (event instanceof LogEvent) {
LogEvent logEvent = (LogEvent) event;
data.put("logLevel", logEvent.getLogLevel());
}
data.put("title", "日志");
break;
case "embed":
// 嵌入事件特有属性
if (event instanceof EmbedEvent) {
EmbedEvent embedEvent = (EmbedEvent) event;
data.put("embedUrl", embedEvent.getEmbedUrl());
data.put("embedType", embedEvent.getEmbedType());
data.put("embedTitle", embedEvent.getEmbedTitle());
data.put("embedHtmlContent", embedEvent.getEmbedHtmlContent());
}
data.put("title", event instanceof EmbedEvent && ((EmbedEvent) event).getEmbedTitle() != null ? ((EmbedEvent) event).getEmbedTitle() : "网页预览");
break;
case "result":
// 结果事件特有属性
data.put("title", "最终答案");
break;
default:
// 默认标题
data.put("title", generateDefaultTitle(event));
break;
}
}
/**
* 生成思考事件的标题
*
* @param event 思考事件
* @return 标题
*/
private String generateTitleForThought(WorkPanelEvent event) {
String content = null;
if (event instanceof ThoughtEvent) {
content = ((ThoughtEvent) event).getContent();
} else if (event instanceof LogEvent) {
content = ((LogEvent) event).getContent();
} else if (event instanceof ResultEvent) {
content = ((ResultEvent) event).getContent();
}
if (content != null && !content.isEmpty()) {
return content.substring(0, Math.min(content.length(), 50)) + (content.length() > 50 ? "..." : "");
}
return "思考过程";
}
/**
* 生成默认标题
*
* @param event 事件
* @return 标题
*/
private String generateDefaultTitle(WorkPanelEvent event) {
String content = null;
if (event instanceof ThoughtEvent) {
content = ((ThoughtEvent) event).getContent();
} else if (event instanceof LogEvent) {
content = ((LogEvent) event).getContent();
} else if (event instanceof ResultEvent) {
content = ((ResultEvent) event).getContent();
}
if (content != null && !content.isEmpty()) {
return content.substring(0, Math.min(content.length(), 50)) + (content.length() > 50 ? "..." : "");
}
return "事件";
}
/**
* 处理事件类型特定逻辑
*
* @param event 工作面板事件
* @param data 事件数据
*/
private void processEventTypeSpecifics(WorkPanelEvent event, Map<String, Object> data) {
String eventType = event.getType();
if (eventType != null) {
switch (eventType) {
case "tool_call":
case "tool_result":
case "tool_error":
// 处理工具事件
if (event instanceof ToolEvent) {
processToolEvent((ToolEvent) event, data);
}
break;
case "thought":
// 处理思考事件
if (event instanceof ThoughtEvent) {
processThinkingEvent((ThoughtEvent) event, data);
}
break;
// 其他事件类型可以根据需要添加处理逻辑
}
}
}
/**
* 处理工具事件
*
* @param event 工具事件
* @param data 事件数据
*/
private void processToolEvent(ToolEvent event, Map<String, Object> data) {
if (event == null || data == null) {
return;
}
// 添加工具事件特定的日志
if (log.isInfoEnabled()) {
log.info("[工具事件] 类型={}, 工具={}, 有toolInput={}, 有toolOutput={}",
event.getType(),
event.getToolName(),
event.getToolInput() != null,
event.getToolOutput() != null);
}
if (log.isDebugEnabled()) {
log.debug("[工具事件详情] toolInput={}, toolOutput={}",
event.getToolInput(),
event.getToolOutput());
}
}
/**
* 处理思考事件
*
* @param event 思考事件
* @param data 事件数据
*/
private void processThinkingEvent(ThoughtEvent event, Map<String, Object> data) {
if (event == null || data == null) {
return;
}
// 对于最终答案类型的思考事件,添加特殊标记
if ("final_answer".equals(event.getThinkingType())) {
data.put("isFinalAnswer", true);
}
}
/**
* 从对象池获取HashMap实例
*/
private Map<String, Object> acquireMap() {
return mapPoolService.acquireMap();
}
/**
* 获取对象池统计信息
*/
public String getMapPoolStatistics() {
return mapPoolService.getMapPoolStatistics();
}
/**
* 将HashMap实例归还到对象池
*/
public void releaseMap(Map<String, Object> map) {
mapPoolService.releaseMap(map);
}
}
\ No newline at end of file
package pangea.hiagent.workpanel.event;
import org.springframework.stereotype.Component;
/**
* 事件类型转换工具类
* 统一处理事件类型转换逻辑,避免在多个地方重复实现
*/
@Component
public class EventTypeConverter {
/**
* 根据状态确定事件类型
*/
public String getEventTypeFromStatus(String status) {
if (status == null) {
return "tool_result";
}
switch (status.toLowerCase()) {
case "success":
return "tool_result";
case "error":
case "failure":
return "tool_error";
default:
return "tool_result";
}
}
/**
* 获取状态文本
*/
public String getStatusText(String status) {
if (status == null) {
return "未知";
}
switch (status.toLowerCase()) {
case "success": return "成功";
case "pending": return "处理中";
case "error": return "错误";
case "failure": return "失败";
default: return status;
}
}
}
\ No newline at end of file
package pangea.hiagent.workpanel.event;
import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.agent.sse.UserSseService;
import pangea.hiagent.web.dto.ToolEvent;
import pangea.hiagent.web.dto.WorkPanelEvent;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* SSE事件广播器
* 专门负责广播事件给所有订阅者
*/
@Slf4j
@Component
public class SseEventBroadcaster {
@Autowired
private UserSseService unifiedSseService;
@Autowired
private EventService eventService;
/**
* 广播工作面板事件给所有订阅者
*
* @param event 工作面板事件
*/
public void broadcastWorkPanelEvent(WorkPanelEvent event) {
if (event == null) {
log.warn("广播事件时接收到null事件");
return;
}
try {
// 预构建事件数据,避免重复构建
Map<String, Object> eventData = eventService.buildWorkPanelEventData(event);
try {
// 获取所有emitter并广播
List<SseEmitter> emitters = unifiedSseService.getEmitters();
int successCount = 0;
int failureCount = 0;
// 使用CopyOnWriteArrayList避免并发修改异常
for (SseEmitter emitter : new CopyOnWriteArrayList<>(emitters)) {
try {
// 检查emitter是否仍然有效
if (unifiedSseService.isEmitterValid(emitter)) {
emitter.send(SseEmitter.event().name("message").data(eventData));
successCount++;
} else {
// 移除无效的emitter
log.debug("移除无效的SSE连接");
unifiedSseService.removeEmitter(emitter);
failureCount++;
}
} catch (IOException e) {
log.error("发送事件失败,移除失效连接: {}", e.getMessage());
unifiedSseService.removeEmitter(emitter);
failureCount++;
} catch (IllegalStateException e) {
log.debug("Emitter已关闭,移除连接: {}", e.getMessage());
unifiedSseService.removeEmitter(emitter);
failureCount++;
} catch (Exception e) {
log.error("发送事件时发生未知异常,移除连接: {}", e.getMessage(), e);
unifiedSseService.removeEmitter(emitter);
failureCount++;
}
}
if (failureCount > 0) {
log.warn("事件广播部分失败: 成功={}, 失败={}", successCount, failureCount);
}
// 记录对象池使用统计信息(每100次广播记录一次)
if ((successCount + failureCount) % 100 == 0) {
log.debug("对象池使用统计: {}", eventService.getMapPoolStatistics());
}
} finally {
// 确保eventData被归还到对象池
eventService.releaseMap(eventData);
}
} catch (Exception e) {
String toolName = null;
if (event instanceof ToolEvent) {
toolName = ((ToolEvent) event).getToolName();
}
log.error("广播事件失败: 事件类型={}, 工具={}, 错误信息={}",
event.getType(),
toolName,
e.getMessage(),
e);
}
}
}
\ No newline at end of file
...@@ -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"
......
...@@ -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"
...@@ -230,6 +228,40 @@ hiagent: ...@@ -230,6 +228,40 @@ hiagent:
top-k: 5 top-k: 5
score-threshold: 0.8 score-threshold: 0.8
# ReAct配置
react:
system-prompt: >
You are a Spring AI tool orchestration assistant. Your TOP PRIORITY: ALWAYS CALL TOOLS FIRST, answer EXCLUSIVELY based on tool results.
=== CORE RULES ===
1. Tool-First Mandate: For any non-trivial query, EXECUTE RELEVANT TOOLS, never just describe them. Only use internal knowledge for simple common sense.
2. Result-Based Answers: All conclusions must come directly from tool execution results. Never fabricate data.
3. Multi-Tool Support: Call multiple tools in sequence where one tool's output feeds into the next.
4. Iterative Loop: If results are incomplete, re-analyze, adjust tools, and repeat until satisfactory.
5. Complex Queries: Use multiple tools for complex tasks; avoid single-tool reliance.
=== REACT PROCESS ===
Cyclic process for every query, execute in order until complete:
- Step 1 - THOUGHT: Analyze the query, break into sub-tasks, select relevant tools with alternatives, define execution sequence.
- Step 2 - ACTION: EXECUTE TOOLS DIRECTLY, NEVER JUST DESCRIBE THEM. Call specific tools in planned order, execute multiple if needed, use alternatives if a tool fails.
- Step 3 - OBSERVATION: Analyze all tool results, extract key insights, check completeness. If results are complete → Proceed to Final Answer; if incomplete → Return to Thought.
- Step 4 - FINAL ANSWER: Synthesize tool results into a clear, complete answer. Explain tool synergy if helpful. Keep it conversational.
=== RESPONSE FORMAT ===
Strictly follow this structure:
1. Thought: Problem analysis, tool selection, execution sequence
2. Action: Actual tool calls (not descriptions)
3. Observation: Key results summary, decision (terminate/restart)
4. Final_Answer: Result-based answer
=== HARD RULES ===
- Execute tools first, never just describe them
- Only use tool results for answers
- Use multiple tools for complex queries
- Support serial tool chaining
- Iterate until results are complete
- Follow Spring AI framework rules
# Milvus Lite配置 # Milvus Lite配置
milvus: milvus:
data-dir: ./milvus_data data-dir: ./milvus_data
......
...@@ -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
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -90,6 +90,26 @@ import MessageItem from "./MessageItem.vue"; ...@@ -90,6 +90,26 @@ import MessageItem from "./MessageItem.vue";
import request from "@/utils/request"; import request from "@/utils/request";
import { useFormStore } from "@/stores/form"; import { useFormStore } from "@/stores/form";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import type { TimelineEvent } from "../types/timeline";
// 接收从父组件传递的添加事件到时间轴的方法
const props = defineProps<{
addEventToTimeline?: (event: TimelineEvent) => void;
}>();
// 生成唯一事件ID
const generateEventId = (): string => {
return `event-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
};
// 添加事件到时间轴
const addEventToTimeline = (event: TimelineEvent) => {
if (props.addEventToTimeline) {
props.addEventToTimeline(event);
} else {
console.warn("[ChatArea] addEventToTimeline prop is not provided");
}
};
interface Message { interface Message {
content: string; content: string;
...@@ -134,7 +154,15 @@ const loadAgents = async () => { ...@@ -134,7 +154,15 @@ 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 +180,17 @@ const getAgentName = (agentId?: string): string => { ...@@ -152,8 +180,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 +277,7 @@ const loadHistoryMessagesInternal = async (agentId: string) => { ...@@ -240,7 +277,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 +306,7 @@ const loadHistoryMessagesInternal = async (agentId: string) => { ...@@ -269,7 +306,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);
...@@ -505,6 +542,13 @@ const processSSELine = async ( ...@@ -505,6 +542,13 @@ const processSSELine = async (
// 根据事件类型处理数据 // 根据事件类型处理数据
switch (eventType) { switch (eventType) {
case "heartbeat":
// 收到心跳事件,重置超时计时器
resetStreamTimeout();
// 心跳事件本身不处理,只用于保活连接
console.debug("[心跳] 收到心跳事件,连接保活");
return false;
case "token": case "token":
// 重置超时计时器,接收到token说明连接还活跃 // 重置超时计时器,接收到token说明连接还活跃
resetStreamTimeout(); resetStreamTimeout();
...@@ -519,13 +563,21 @@ const processSSELine = async ( ...@@ -519,13 +563,21 @@ 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;
// 添加完成事件到时间轴
const completeEvent: TimelineEvent = {
id: generateEventId(),
type: "complete",
title: "对话完成",
content: "智能体已完成回答",
timestamp: Date.now(),
};
addEventToTimeline(completeEvent);
return true; // 返回true表示流已完成 return true; // 返回true表示流已完成
case "error": case "error":
...@@ -546,22 +598,32 @@ const processSSELine = async ( ...@@ -546,22 +598,32 @@ const processSSELine = async (
isLoading.value = false; isLoading.value = false;
// 记录错误日志便于调试 // 记录错误日志便于调试
console.error("[SSE错误事件]", data); console.error("[SSE错误事件]", data);
// 添加错误事件到时间轴
const errorEvent: TimelineEvent = {
id: generateEventId(),
type: "error",
title: "对话错误",
content: errorMsg || "未知错误",
timestamp: Date.now(),
};
addEventToTimeline(errorEvent);
return true; // 返回true表示流已完成 return true; // 返回true表示流已完成
case "thinking": case "thinking":
// 处理思考事件,将其发送到时间轴面板 // 处理思考事件,将其发送到时间轴面板
const event = { const thoughtEvent: TimelineEvent = {
id: generateEventId(),
type: "thought", type: "thought",
title: title:
data.thinkingType === "final_answer" ? "最终答案" : "思考过程", data.thinkingType === "final_answer" ? "最终答案" : "思考过程",
content: data.content, content: data.content,
timestamp: data.timestamp, timestamp: data.timestamp || Date.now(),
}; };
// 通过事件总线将事件发送到时间轴 // 调用添加事件到时间轴的方法
window.dispatchEvent( addEventToTimeline(thoughtEvent);
new CustomEvent("timeline-event", { detail: event })
);
// 如果是最终答案,也应该显示在主要对话框中 // 如果是最终答案,也应该显示在主要对话框中
// 修复:确保最终答案只添加一次,避免重复显示 // 修复:确保最终答案只添加一次,避免重复显示
...@@ -598,11 +660,16 @@ const processSSELine = async ( ...@@ -598,11 +660,16 @@ const processSSELine = async (
if (eventType === "tool_call") { if (eventType === "tool_call") {
if (data.toolName) metadata["工具"] = data.toolName; if (data.toolName) metadata["工具"] = data.toolName;
if (data.toolAction) metadata["操作"] = data.toolAction; if (data.toolAction) metadata["操作"] = data.toolAction;
if (data.toolInput) if (data.toolInput) {
metadata["输入"] = JSON.stringify(data.toolInput).substring( try {
0, metadata["输入"] = JSON.stringify(data.toolInput).substring(
100 0,
); 100
);
} catch (e) {
metadata["输入"] = String(data.toolInput).substring(0, 100);
}
}
if (data.toolOutput) if (data.toolOutput)
metadata["输出"] = String(data.toolOutput).substring(0, 100); metadata["输出"] = String(data.toolOutput).substring(0, 100);
if (data.toolStatus) metadata["状态"] = data.toolStatus; if (data.toolStatus) metadata["状态"] = data.toolStatus;
...@@ -613,7 +680,9 @@ const processSSELine = async ( ...@@ -613,7 +680,9 @@ const processSSELine = async (
if (data.embedType) metadata["类型"] = data.embedType; if (data.embedType) metadata["类型"] = data.embedType;
} }
const timelineEvent = { // 构建时间轴事件
const timelineEvent: TimelineEvent = {
id: generateEventId(),
type: eventType, type: eventType,
title: title, title: title,
content: data.content, content: data.content,
...@@ -631,11 +700,8 @@ const processSSELine = async ( ...@@ -631,11 +700,8 @@ const processSSELine = async (
timestamp: data.timestamp || Date.now(), timestamp: data.timestamp || Date.now(),
}; };
// 通过事件总线将事件发送到时间轴 // 调用添加事件到时间轴的方法
console.log("[ChatArea] 发送timeline-event事件:", timelineEvent); addEventToTimeline(timelineEvent);
window.dispatchEvent(
new CustomEvent("timeline-event", { detail: timelineEvent })
);
// 对于embed事件,还需要触发embed-event事件 // 对于embed事件,还需要触发embed-event事件
if (eventType === "embed" && data.embedUrl) { if (eventType === "embed" && data.embedUrl) {
...@@ -819,26 +885,36 @@ const sendMessage = async (displayTextOrEvent?: string | Event) => { ...@@ -819,26 +885,36 @@ const sendMessage = async (displayTextOrEvent?: string | Event) => {
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) {
isStreamComplete = true; // 检查是否在指定时间内收到过心跳或数据
reader.cancel(); const timeSinceLastHeartbeat = Date.now() - lastHeartbeatTime;
messages.value[aiMessageIndex].isStreaming = false; if (timeSinceLastHeartbeat >= HEARTBEAT_TIMEOUT) {
isLoading.value = false; isStreamComplete = true;
// 提示用户是否要重试 reader.cancel();
ElMessage.warning("流式输出超时,您可以点击重试按钮重新发送消息"); messages.value[aiMessageIndex].isStreaming = false;
isLoading.value = false;
// 提示用户是否要重试
ElMessage.warning("流式输出超时,您可以点击重试按钮重新发送消息");
// 显示重试按钮 // 显示重试按钮
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();
...@@ -970,7 +1046,14 @@ onMounted(async () => { ...@@ -970,7 +1046,14 @@ onMounted(async () => {
await loadAgents(); await loadAgents();
// 等待下一个tick确保agents加载完成后再加载历史消息 // 等待下一个tick确保agents加载完成后再加载历史消息
await nextTick(); await nextTick();
loadHistoryMessages();
// 优先使用路由参数中的agentId,如果没有则使用localStorage中保存的或默认选中的
const routeAgentId = route.query.agentId as string;
if (routeAgentId) {
await loadHistoryMessagesInternal(routeAgentId);
} else {
loadHistoryMessages();
}
}); });
// 暴露方法给父组件使用 // 暴露方法给父组件使用
...@@ -986,7 +1069,8 @@ defineExpose({ ...@@ -986,7 +1069,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 {
...@@ -1011,7 +1095,8 @@ defineExpose({ ...@@ -1011,7 +1095,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++
// 如果还有重试机会,继续重试 // 如果还有重试机会,继续重试
......
<template> <template>
<TimelinePanel <div class="timeline-manager">
:events="events" <!-- 过滤和搜索面板 -->
:getEventTypeLabel="getEventTypeLabel" <div class="timeline-filter-panel">
:formatTime="formatTime" <div class="filter-row">
:getExpandedState="getExpandedState" <el-input
:toggleExpand="toggleExpand" v-model="searchQuery"
:isToolEventType="isToolEventType" placeholder="搜索事件..."
:hasValidToolInput="hasValidToolInput" clearable
:hasValidToolOutput="hasValidToolOutput" size="small"
:onClearTimeline="handleClearTimeline" prefix-icon="Search"
/> />
<el-select
v-model="selectedEventTypes"
placeholder="事件类型"
multiple
size="small"
class="filter-select"
>
<el-option
v-for="(label, type) in eventTypeLabels"
:key="type"
:label="label"
:value="type"
/>
</el-select>
</div>
<div class="filter-row">
<el-button
type="primary"
size="small"
@click="applyFilters"
>
应用过滤
</el-button>
<el-button
size="small"
@click="resetFilters"
>
重置
</el-button>
<el-button
size="small"
@click="exportEvents"
>
导出事件
</el-button>
<el-dropdown @command="handleExport">
<el-button size="small">
导出格式 <el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="json">JSON</el-dropdown-item>
<el-dropdown-item command="csv">CSV</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 时间轴面板 -->
<TimelinePanel
:events="filteredEvents"
:getEventTypeLabel="getEventTypeLabel"
:formatTime="formatTime"
:getExpandedState="getExpandedState"
:toggleExpand="toggleExpand"
:isToolEventType="isToolEventType"
:hasValidToolInput="hasValidToolInput"
:hasValidToolOutput="hasValidToolOutput"
:onClearTimeline="handleClearTimeline"
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onUnmounted, onMounted } from 'vue' import { computed, ref, onUnmounted, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { TimelineEvent, ToolResultEvent } from '../types/timeline' import type { TimelineEvent, ToolResultEvent } from '../types/timeline'
import { eventTypeLabels } from '../types/timeline' import { eventTypeLabels } from '../types/timeline'
import { TimelineService } from '../services/TimelineService'
import TimelinePanel from './TimelinePanel.vue' import TimelinePanel from './TimelinePanel.vue'
import { isToolEventType, hasValidToolInput, hasValidToolOutput } from '../utils/timelineUtils';
import sseService from '../services/sseService';
// 事件数据 // 事件数据
const events = ref<TimelineEvent[]>([]); const events = ref<TimelineEvent[]>([]);
// 过滤和搜索状态
const searchQuery = ref('');
const selectedEventTypes = ref<string[]>([]);
const activeFilters = ref({
searchQuery: '',
eventTypes: [] as string[]
});
// 持久化配置
const persistenceEnabled = ref(true);
const STORAGE_KEY = 'timeline_events';
// 从本地存储加载事件
const loadEventsFromStorage = () => {
if (!persistenceEnabled.value) return;
try {
const storedEvents = localStorage.getItem(STORAGE_KEY);
if (storedEvents) {
const parsedEvents = JSON.parse(storedEvents) as TimelineEvent[];
events.value = parsedEvents;
console.log('[TimelineContainer] 从本地存储加载了', parsedEvents.length, '个事件');
}
} catch (error) {
console.error('[TimelineContainer] 从本地存储加载事件失败:', error);
}
};
// 保存事件到本地存储
const saveEventsToStorage = () => {
if (!persistenceEnabled.value) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(events.value));
console.log('[TimelineContainer] 事件已保存到本地存储');
} catch (error) {
console.error('[TimelineContainer] 保存事件到本地存储失败:', error);
}
};
// 获取事件类型标签 // 获取事件类型标签
const getEventTypeLabel = (type: string): string => { const getEventTypeLabel = (type: string): string => {
return eventTypeLabels[type] || type; return eventTypeLabels[type] || type;
...@@ -49,19 +153,121 @@ const toggleExpand = (index: number): void => { ...@@ -49,19 +153,121 @@ const toggleExpand = (index: number): void => {
console.log('切换展开状态:', index); console.log('切换展开状态:', index);
}; };
// 工具事件类型判断 // 过滤后的事件列表
const isToolEventType = (type: string): boolean => { const filteredEvents = computed(() => {
return ['tool_call', 'tool_result', 'tool_error'].includes(type); let result = [...events.value];
// 应用搜索过滤
if (activeFilters.value.searchQuery) {
const query = activeFilters.value.searchQuery.toLowerCase();
result = result.filter(event => {
return (
event.title.toLowerCase().includes(query) ||
('content' in event && event.content && event.content.toLowerCase().includes(query)) ||
(event.type && event.type.toLowerCase().includes(query))
);
});
}
// 应用事件类型过滤
if (activeFilters.value.eventTypes.length > 0) {
result = result.filter(event => {
return activeFilters.value.eventTypes.includes(event.type);
});
}
return result;
});
// 应用过滤
const applyFilters = () => {
activeFilters.value = {
searchQuery: searchQuery.value,
eventTypes: [...selectedEventTypes.value]
};
};
// 重置过滤
const resetFilters = () => {
searchQuery.value = '';
selectedEventTypes.value = [];
activeFilters.value = {
searchQuery: '',
eventTypes: []
};
};
// 导出事件(默认JSON格式)
const exportEvents = () => {
handleExport('json');
};
// 处理导出
const handleExport = (format: 'json' | 'csv') => {
const exportData = filteredEvents.value;
if (exportData.length === 0) {
ElMessage.warning('没有可导出的事件');
return;
}
let content: string;
let fileName: string;
let mimeType: string;
if (format === 'json') {
content = JSON.stringify(exportData, null, 2);
fileName = `timeline_events_${new Date().toISOString().slice(0, 10)}.json`;
mimeType = 'application/json';
} else {
content = convertToCSV(exportData);
fileName = `timeline_events_${new Date().toISOString().slice(0, 10)}.csv`;
mimeType = 'text/csv';
}
downloadFile(content, fileName, mimeType);
}; };
// 工具输入有效性检查 // 转换为CSV格式
const hasValidToolInput = (event: TimelineEvent): boolean => { const convertToCSV = (events: TimelineEvent[]): string => {
return event.type === 'tool_call' && (event as any).toolInput !== null && (event as any).toolInput !== undefined; // 定义CSV表头
const headers = ['ID', 'Type', 'Title', 'Timestamp', 'Content', 'Metadata'];
// 转换数据行
const rows = events.map(event => {
// 安全获取content字段,只有部分事件类型有content字段
const content = 'content' in event ? event.content || '' : '';
return [
event.id,
event.type,
event.title,
new Date(event.timestamp).toISOString(),
content,
event.metadata ? JSON.stringify(event.metadata) : ''
];
});
// 合并表头和数据行
const csvContent = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\n');
return csvContent;
}; };
// 工具输出有效性检查 // 下载文件
const hasValidToolOutput = (event: TimelineEvent): boolean => { const downloadFile = (content: string, fileName: string, mimeType: string) => {
return event.type === 'tool_result' && (event as any).toolOutput !== null && (event as any).toolOutput !== undefined; const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}; };
// 时间轴服务不再需要,因为我们现在直接处理事件 // 时间轴服务不再需要,因为我们现在直接处理事件
...@@ -71,16 +277,30 @@ const hasValidToolOutput = (event: TimelineEvent): boolean => { ...@@ -71,16 +277,30 @@ const hasValidToolOutput = (event: TimelineEvent): boolean => {
// console.log('[TimelineContainer] 成功添加事件:', event.type, event.title); // console.log('[TimelineContainer] 成功添加事件:', event.type, event.title);
// }); // });
// 生成唯一事件ID
const generateEventId = (): string => {
return 'evt_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
};
// 添加时间轴事件 // 添加时间轴事件
const addEvent = (event: any) => { const addEvent = (event: any) => {
// 确保事件有唯一ID
const eventWithId = {
...event,
id: event.id || generateEventId()
};
// 直接添加事件到列表,而不是通过时间轴服务 // 直接添加事件到列表,而不是通过时间轴服务
events.value.push(event); events.value.push(eventWithId);
console.log('[TimelineContainer] 成功添加事件:', event.type, event.title); console.log('[TimelineContainer] 成功添加事件:', eventWithId.type, eventWithId.title);
// 保存到本地存储
saveEventsToStorage();
}; };
// 清除时间轴 // 清除时间轴
const handleClearTimeline = () => { const handleClearTimeline = () => {
events.value = []; events.value = [];
// 保存到本地存储
saveEventsToStorage();
// 不再调用timelineService.clearTimeline(),因为它是空实现 // 不再调用timelineService.clearTimeline(),因为它是空实现
// stateManager.clearAllStates(); // stateManager.clearAllStates();
// cacheService.clearAllCaches(); // cacheService.clearAllCaches();
...@@ -137,43 +357,122 @@ defineExpose({ ...@@ -137,43 +357,122 @@ defineExpose({
showPerformanceStats showPerformanceStats
}); });
// 组件卸载时清理资源 // 组件挂载时启动定期性能监控
onUnmounted(() => { let intervalId: number;
// 移除事件监听器
window.removeEventListener('timeline-event', handleTimelineEvent as EventListener);
});
// 处理timeline-event事件
const handleTimelineEvent = (e: Event) => {
const customEvent = e as CustomEvent;
const eventData = customEvent.detail;
console.log('[TimelineContainer] 接收到timeline-event事件:', eventData);
// 直接添加事件到列表
events.value.push(eventData);
console.log('[TimelineContainer] 成功添加事件:', eventData.type, eventData.title);
};
// 组件挂载时启动定期性能监控和事件监听
onMounted(() => { onMounted(() => {
// 监听timeline-event事件 // 从本地存储加载事件
window.addEventListener('timeline-event', handleTimelineEvent as EventListener); loadEventsFromStorage();
// 连接SSE服务
sseService.connect();
// 添加SSE事件监听器
sseService.on('timeline-event', handleTimelineEvent);
// 启动定期性能监控(每30秒输出一次) // 启动定期性能监控(每30秒输出一次)
const intervalId = setInterval(() => { intervalId = setInterval(() => {
if (events.value.length > 0) { if (events.value.length > 0) {
console.log('[TimelineContainer] 定期性能统计 - 事件总数:', events.value.length); console.log('[TimelineContainer] 定期性能统计 - 事件总数:', events.value.length);
} }
}, 30000); }, 30000);
});
// 组件卸载时清理资源
onUnmounted(() => {
// 清除定期性能监控定时器
clearInterval(intervalId);
// 保存事件到本地存储
saveEventsToStorage();
// 在组件卸载时清除定时器 // 移除SSE事件监听器
onUnmounted(() => { sseService.off('timeline-event', handleTimelineEvent);
clearInterval(intervalId); // 断开SSE连接
}); // 注意:这里不要断开SSE连接,因为其他组件可能也在使用
// sseService.disconnect();
}); });
// 处理SSE时间轴事件
const handleTimelineEvent = (eventData: any): void => {
try {
console.log('[TimelineContainer] 处理时间轴事件:', eventData);
// 验证事件数据格式
if (!eventData || typeof eventData !== 'object') {
console.error('[TimelineContainer] 无效的事件数据格式:', eventData);
return;
}
// 确保事件有基本属性
if (!eventData.type || !eventData.timestamp) {
console.error('[TimelineContainer] 事件缺少必要属性:', eventData);
return;
}
// 转换为TimelineEvent类型
const timelineEvent: TimelineEvent = {
id: eventData.id || ('evt_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)),
type: eventData.type,
title: eventData.title || eventData.type,
timestamp: eventData.timestamp,
content: eventData.content,
metadata: eventData.metadata,
// 工具事件特有属性
...(eventData.toolName && { toolName: eventData.toolName }),
...(eventData.toolInput && { toolInput: eventData.toolInput }),
...(eventData.toolOutput && { toolOutput: eventData.toolOutput }),
...(eventData.toolStatus && { toolStatus: eventData.toolStatus }),
...(eventData.executionTime !== undefined && { executionTime: eventData.executionTime }),
// 嵌入事件特有属性
...(eventData.embedUrl && { embedUrl: eventData.embedUrl }),
...(eventData.embedType && { embedType: eventData.embedType }),
...(eventData.embedTitle && { embedTitle: eventData.embedTitle }),
...(eventData.embedHtmlContent && { embedHtmlContent: eventData.embedHtmlContent })
};
// 添加事件到列表
addEvent(timelineEvent);
} catch (error) {
console.error('[TimelineContainer] 处理时间轴事件失败:', error);
}
};
</script> </script>
<style scoped> <style scoped>
.timeline-container-wrapper { .timeline-manager {
display: flex;
flex-direction: column;
height: 100%; height: 100%;
} }
.timeline-filter-panel {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-2);
border-radius: var(--border-radius-base);
margin-bottom: var(--spacing-2);
}
.filter-row {
display: flex;
gap: var(--spacing-2);
align-items: center;
margin-bottom: var(--spacing-1);
flex-wrap: wrap;
}
.filter-row:last-child {
margin-bottom: 0;
}
.filter-select {
min-width: 150px;
}
.timeline-filter-panel .el-input {
max-width: 300px;
}
.timeline-filter-panel .el-button {
margin-right: var(--spacing-1);
}
</style> </style>
\ No newline at end of file
<template>
<div class="timeline-event-detail">
<div class="detail-header">
<h3>事件详情</h3>
<el-button type="primary" size="small" @click="handleClose">关闭</el-button>
</div>
<div v-if="event" class="detail-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ event.id }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ eventTypeLabels[event.type] || event.type }}</el-descriptions-item>
<el-descriptions-item label="标题">{{ event.title }}</el-descriptions-item>
<el-descriptions-item label="时间">
<span>{{ formatTime(event.timestamp) }}</span>
<span class="full-time">{{ new Date(event.timestamp).toISOString() }}</span>
</el-descriptions-item>
<el-descriptions-item label="内容" :span="2">
<div v-if="event.content" class="event-content">
{{ event.content }}
</div>
<div v-else class="empty-content">无内容</div>
</el-descriptions-item>
<el-descriptions-item label="元数据" :span="2">
<div v-if="event.metadata" class="metadata-section">
<pre>{{ JSON.stringify(event.metadata, null, 2) }}</pre>
</div>
<div v-else class="empty-content">无元数据</div>
</el-descriptions-item>
<!-- 工具事件特有字段 -->
<template v-if="isToolEventType(event.type)">
<el-descriptions-item label="工具名称" v-if="(event as any).toolName">{{ (event as any).toolName }}</el-descriptions-item>
<el-descriptions-item label="工具状态" v-if="(event as any).toolStatus">{{ (event as any).toolStatus }}</el-descriptions-item>
<el-descriptions-item label="执行时间" v-if="(event as any).executionTime">
{{ (event as any).executionTime }} ms
</el-descriptions-item>
<el-descriptions-item label="工具输入" :span="2" v-if="(event as any).toolInput">
<pre>{{ JSON.stringify((event as any).toolInput, null, 2) }}</pre>
</el-descriptions-item>
<el-descriptions-item label="工具输出" :span="2" v-if="(event as any).toolOutput">
<pre>{{ JSON.stringify((event as any).toolOutput, null, 2) }}</pre>
</el-descriptions-item>
<el-descriptions-item label="错误信息" :span="2" v-if="(event as any).errorMessage">
{{ (event as any).errorMessage }}
</el-descriptions-item>
</template>
<!-- Embed事件特有字段 -->
<template v-if="event.type === 'embed'">
<el-descriptions-item label="嵌入URL" v-if="(event as any).embedUrl">{{ (event as any).embedUrl }}</el-descriptions-item>
<el-descriptions-item label="嵌入类型" v-if="(event as any).embedType">{{ (event as any).embedType }}</el-descriptions-item>
<el-descriptions-item label="嵌入标题" v-if="(event as any).embedTitle">{{ (event as any).embedTitle }}</el-descriptions-item>
</template>
</el-descriptions>
</div>
<div v-else class="no-event">
<div class="empty-icon">📋</div>
<div class="empty-text">未选择事件</div>
</div>
</div>
</template>
<script setup lang="ts">
import { eventTypeLabels } from '../types/timeline'
import type { TimelineEvent } from '../types/timeline'
import { isToolEventType } from '../utils/timelineUtils'
// 定义组件属性
const props = defineProps<{
event: TimelineEvent | null
}>()
// 定义组件事件
const emit = defineEmits<{
close: []
}>()
// 格式化时间
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp)
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
}
// 关闭详情页
const handleClose = () => {
emit('close')
}
</script>
<style scoped>
.timeline-event-detail {
background-color: var(--bg-primary);
border-radius: var(--border-radius-base);
padding: var(--spacing-4);
height: 100%;
overflow-y: auto;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-3);
}
.detail-header h3 {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.detail-content {
margin-top: var(--spacing-2);
}
.event-content {
background-color: var(--bg-tertiary);
padding: var(--spacing-2);
border-radius: var(--border-radius-sm);
font-family: var(--font-family-mono);
white-space: pre-wrap;
word-break: break-all;
}
.metadata-section {
background-color: var(--bg-tertiary);
padding: var(--spacing-2);
border-radius: var(--border-radius-sm);
overflow-x: auto;
}
.metadata-section pre {
margin: 0;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-normal);
}
.empty-content {
color: var(--text-tertiary);
font-style: italic;
padding: var(--spacing-2);
background-color: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
}
.full-time {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin-left: var(--spacing-2);
}
.no-event {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-tertiary);
}
.empty-icon {
font-size: 48px;
margin-bottom: var(--spacing-2);
}
.empty-text {
font-size: var(--font-size-sm);
}
/* 滚动样式 */
.timeline-event-detail::-webkit-scrollbar {
width: 6px;
}
.timeline-event-detail::-webkit-scrollbar-track {
background: transparent;
}
.timeline-event-detail::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: 3px;
}
.timeline-event-detail::-webkit-scrollbar-thumb:hover {
background: var(--gray-400);
}
</style>
\ No newline at end of file
...@@ -11,8 +11,14 @@ ...@@ -11,8 +11,14 @@
<div class="empty-text">等待执行过程...</div> <div class="empty-text">等待执行过程...</div>
</div> </div>
<div v-else class="timeline-list"> <div v-else class="timeline-list" v-el-infinite-scroll="loadMore" :infinite-scroll-distance="50">
<div v-for="(event, index) in reversedEvents" :key="event.timestamp + '-' + index" class="timeline-item" :class="event.type"> <div
v-for="(event, index) in displayedEvents"
:key="event.id || (event.timestamp + '-' + index)"
class="timeline-item"
:class="event.type"
@click="showEventDetail(event)"
>
<div class="timeline-dot"></div> <div class="timeline-dot"></div>
<div class="timeline-content"> <div class="timeline-content">
<div class="event-header"> <div class="event-header">
...@@ -24,7 +30,7 @@ ...@@ -24,7 +30,7 @@
<div v-if="event.content" class="event-content"> <div v-if="event.content" class="event-content">
<div <div
class="content-text-wrapper" class="content-text-wrapper"
@click="shouldShowToggle(event.timestamp) && toggleContentExpand(event.timestamp)" @click.stop="shouldShowToggle(event.timestamp) && toggleContentExpand(event.timestamp)"
> >
<div <div
class="content-text" class="content-text"
...@@ -37,7 +43,7 @@ ...@@ -37,7 +43,7 @@
<div <div
v-if="shouldShowToggle(event.timestamp)" v-if="shouldShowToggle(event.timestamp)"
class="content-toggle" class="content-toggle"
@click="toggleContentExpand(event.timestamp)" @click.stop="toggleContentExpand(event.timestamp)"
> >
{{ getContentExpandedState(event.timestamp) ? '收起' : '展开' }} {{ getContentExpandedState(event.timestamp) ? '收起' : '展开' }}
</div> </div>
...@@ -49,13 +55,13 @@ ...@@ -49,13 +55,13 @@
class="tool-details" class="tool-details"
> >
<!-- 展开/折叠按钮 --> <!-- 展开/折叠按钮 -->
<div class="detail-toggle" @click="props.toggleExpand(props.events.length - 1 - index)"> <div class="detail-toggle" @click.stop="props.toggleExpand(displayedEvents.length - 1 - index)">
<span class="toggle-text">{{ props.getExpandedState(props.events.length - 1 - index) ? '收起详情' : '查看详情' }}</span> <span class="toggle-text">{{ props.getExpandedState(displayedEvents.length - 1 - index) ? '收起详情' : '查看详情' }}</span>
<span class="toggle-icon">{{ props.getExpandedState(props.events.length - 1 - index) ? '▲' : '▼' }}</span> <span class="toggle-icon">{{ props.getExpandedState(displayedEvents.length - 1 - index) ? '▲' : '▼' }}</span>
</div> </div>
<!-- 详细信息内容 --> <!-- 详细信息内容 -->
<div v-show="getExpandedState(props.events.length - 1 - index)" class="detail-content"> <div v-show="getExpandedState(displayedEvents.length - 1 - index)" class="detail-content">
<!-- 输入参数段 --> <!-- 输入参数段 -->
<ToolDataSection <ToolDataSection
v-if="props.hasValidToolInput(event)" v-if="props.hasValidToolInput(event)"
...@@ -82,14 +88,31 @@ ...@@ -82,14 +88,31 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="loading" class="loading-more">加载中...</div>
<div v-else-if="displayedEvents.length >= reversedEvents.length" class="no-more">没有更多了</div>
</div> </div>
</div> </div>
<!-- 事件详情抽屉 -->
<el-drawer
v-model="isDetailVisible"
title="事件详情"
size="50%"
direction="rtl"
>
<TimelineEventDetail
:event="selectedEvent"
@close="isDetailVisible = false"
/>
</el-drawer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { ElInfiniteScroll } from 'element-plus'
import ToolDataSection from './ToolDataSection.vue' import ToolDataSection from './ToolDataSection.vue'
import TimelineEventDetail from './TimelineEventDetail.vue'
import type { TimelineEvent } from '../types/timeline' import type { TimelineEvent } from '../types/timeline'
import { useContentExpansion } from '../composables/useContentExpansion' import { useContentExpansion } from '../composables/useContentExpansion'
import { truncateTitle } from '../utils/timelineUtils' import { truncateTitle } from '../utils/timelineUtils'
...@@ -107,9 +130,47 @@ const props = defineProps<{ ...@@ -107,9 +130,47 @@ const props = defineProps<{
onClearTimeline: () => void onClearTimeline: () => void
}>() }>()
// 虚拟滚动相关状态
const loading = ref(false)
const pageSize = ref(20) // 每次加载的事件数量
// 事件详情相关状态
const selectedEvent = ref<TimelineEvent | null>(null)
const isDetailVisible = ref(false)
// 计算反转后的事件列表(最新事件在顶部) // 计算反转后的事件列表(最新事件在顶部)
const reversedEvents = computed(() => [...props.events].reverse()) const reversedEvents = computed(() => [...props.events].reverse())
// 计算当前显示的事件
const displayedEvents = computed(() => {
return reversedEvents.value.slice(0, pageSize.value)
})
// 显示事件详情
const showEventDetail = (event: TimelineEvent) => {
selectedEvent.value = event
isDetailVisible.value = true
}
// 加载更多事件
const loadMore = () => {
if (loading.value) return
if (displayedEvents.value.length >= reversedEvents.value.length) return
loading.value = true
// 模拟异步加载,实际是直接从reversedEvents中截取
setTimeout(() => {
pageSize.value += 20
loading.value = false
}, 300)
}
// 监听事件变化,重置分页
watch(() => props.events.length, () => {
pageSize.value = 20 // 重置为初始值
updateLineCounts()
})
// 使用内容展开管理hook // 使用内容展开管理hook
const { const {
getContentExpandedState, getContentExpandedState,
...@@ -556,6 +617,15 @@ watch(() => props.events, () => { ...@@ -556,6 +617,15 @@ watch(() => props.events, () => {
background: var(--gray-400); background: var(--gray-400);
} }
/* 虚拟滚动相关样式 */
.loading-more,
.no-more {
text-align: center;
padding: var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* 响应式设计 */ /* 响应式设计 */
@media (max-width: 768px) { @media (max-width: 768px) {
.timeline-header { .timeline-header {
......
...@@ -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>
......
<template> <template>
<div class="work-area"> <div class="work-area">
<el-tabs v-model="activeTab" class="work-tabs"> <el-tabs v-model="activeTab" class="work-tabs">
<el-tab-pane label="表单" name="form">
<form-render ref="formRender" />
</el-tab-pane>
<el-tab-pane label="📋 时间轴" name="timeline"> <el-tab-pane label="📋 时间轴" name="timeline">
<timeline-container ref="timelineContainerRef" /> <timeline-container ref="timelineContainerRef" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="🌐 网页浏览" name="browser"> <el-tab-pane label="🌐 网页浏览" name="browser">
<webpage-browser ref="webBrowser" /> <webpage-browser ref="webBrowser" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="表单" name="form">
<form-render ref="formRender" />
</el-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
</template> </template>
...@@ -19,30 +19,19 @@ import { ref, onMounted, onUnmounted } from "vue"; ...@@ -19,30 +19,19 @@ import { ref, onMounted, onUnmounted } from "vue";
import FormRender from "./FormRender.vue"; import FormRender from "./FormRender.vue";
import TimelineContainer from "./TimelineContainer.vue"; import TimelineContainer from "./TimelineContainer.vue";
import WebpageBrowser from "./WebpageBrowser.vue"; import WebpageBrowser from "./WebpageBrowser.vue";
import { TimelineService } from "../services/TimelineService";
const activeTab = ref("form"); const activeTab = ref("timeline");
const formRender = ref(); const formRender = ref();
const timelineContainerRef = ref<InstanceType<typeof TimelineContainer> | null>( const timelineContainerRef = ref<InstanceType<typeof TimelineContainer> | null>(
null null
); );
const webBrowser = ref(); const webBrowser = ref();
let timelineService: TimelineService | null = null;
// 添加事件到时间轴 // 添加事件到时间轴
const addEvent = (event: any): void => { const addEvent = (event: any): void => {
timelineContainerRef.value?.addEvent(event); timelineContainerRef.value?.addEvent(event);
}; };
// 初始化Timeline服务
const initTimelineService = () => {
if (timelineContainerRef.value) {
timelineService = new TimelineService((event: any) => {
addEvent(event);
});
timelineService.connectSSE();
}
};
// 清除时间轴 // 清除时间轴
const clearTimeline = (): void => { const clearTimeline = (): void => {
timelineContainerRef.value?.clearTimeline(); timelineContainerRef.value?.clearTimeline();
...@@ -98,18 +87,10 @@ const handleEmbedEvent = (e: Event) => { ...@@ -98,18 +87,10 @@ const handleEmbedEvent = (e: Event) => {
onMounted(() => { onMounted(() => {
// 监听embed事件 // 监听embed事件
window.addEventListener("embed-event", handleEmbedEvent as EventListener); window.addEventListener("embed-event", handleEmbedEvent as EventListener);
// 初始化Timeline服务
initTimelineService();
}); });
onUnmounted(() => { onUnmounted(() => {
// 移除事件监听 // 移除事件监听
window.removeEventListener("embed-event", handleEmbedEvent as EventListener); window.removeEventListener("embed-event", handleEmbedEvent as EventListener);
// 清理Timeline服务
if (timelineService) {
timelineService.cleanup();
}
}); // 暴露方法供父组件调用 }); // 暴露方法供父组件调用
defineExpose({ defineExpose({
formRender, formRender,
......
// 内容展开管理hook // 内容展开管理hook
import { ref, nextTick } from 'vue' import { nextTick, ref, type Ref } from 'vue'
import type { Ref } from 'vue'
import type { TimelineEvent } from '../types/timeline' import type { TimelineEvent } from '../types/timeline'
export function useContentExpansion(props: { export function useContentExpansion(props: {
events: TimelineEvent[] events: TimelineEvent[]
}) { }) {
// 内容展开状态管理 // 内容展开状态管理 - 使用WeakMap提高性能
const contentExpandedStates = ref<Record<number, boolean>>({}) const contentExpandedStates = new WeakMap<HTMLElement, boolean>()
const contentLineCounts = ref<Record<number, number>>({}) const contentLineCounts = ref<Record<string, number>>({})
const contentElements = ref<Record<number, HTMLElement>>({}) const contentElements = new Map<string, HTMLElement>()
// 事件ID到时间戳的映射,用于快速查找
const eventIdToTimestamp = ref<Record<string, number>>({})
// 更新事件ID映射
const updateEventIdMapping = () => {
props.events.forEach(event => {
if (event.id) {
eventIdToTimestamp.value[event.id] = event.timestamp
}
})
}
// 获取内容展开状态 // 获取内容展开状态
const getContentExpandedState = (timestamp: number): boolean => { const getContentExpandedState = (timestamp: number): boolean => {
return contentExpandedStates.value[timestamp] || false const key = timestamp.toString()
const element = contentElements.get(key)
return element ? (contentExpandedStates.get(element) || false) : false
} }
// 注册内容元素引用 // 注册内容元素引用
const setContentRef = (el: HTMLElement | null, timestamp: number) => { const setContentRef = (el: HTMLElement | null, timestamp: number) => {
if (el) { if (el) {
contentElements.value[timestamp] = el const key = timestamp.toString()
contentElements.set(key, el)
// 初始化展开状态为false
if (!contentExpandedStates.has(el)) {
contentExpandedStates.set(el, false)
}
// 更新行数计算 // 更新行数计算
updateLineCountForElement(timestamp) updateLineCountForElement(timestamp)
} }
...@@ -28,18 +46,24 @@ export function useContentExpansion(props: { ...@@ -28,18 +46,24 @@ export function useContentExpansion(props: {
// 为特定元素更新行数计算 // 为特定元素更新行数计算
const updateLineCountForElement = (timestamp: number) => { const updateLineCountForElement = (timestamp: number) => {
const event = props.events.find(e => e.timestamp === timestamp) const event = props.events.find(e => e.timestamp === timestamp)
if (event && event.content && contentElements.value[timestamp]) { const key = timestamp.toString()
contentLineCounts.value[timestamp] = calculateLineCount(event.content, contentElements.value[timestamp]) const element = contentElements.get(key)
// 如果内容超过两行,初始化为折叠状态
if (contentLineCounts.value[timestamp] > 2 && contentExpandedStates.value[timestamp] === undefined) { if (event && 'content' in event && event.content && element) {
contentExpandedStates.value[timestamp] = false const lineCount = calculateLineCount(event.content, element)
} const contentKey = event.id || key
contentLineCounts.value[contentKey] = lineCount
} }
} }
// 切换内容展开状态 // 切换内容展开状态
const toggleContentExpand = (timestamp: number) => { const toggleContentExpand = (timestamp: number) => {
contentExpandedStates.value[timestamp] = !getContentExpandedState(timestamp) const key = timestamp.toString()
const element = contentElements.get(key)
if (element) {
const currentState = contentExpandedStates.get(element) || false
contentExpandedStates.set(element, !currentState)
}
} }
// 检查是否应该显示切换按钮 // 检查是否应该显示切换按钮
...@@ -65,18 +89,24 @@ export function useContentExpansion(props: { ...@@ -65,18 +89,24 @@ export function useContentExpansion(props: {
} }
const shouldShowToggle = (timestamp: number): boolean => { const shouldShowToggle = (timestamp: number): boolean => {
return contentLineCounts.value[timestamp] > 2 const event = props.events.find(e => e.timestamp === timestamp)
if (!event) return false
const key = event.id || timestamp.toString()
return (contentLineCounts.value[key] || 0) > 2
} }
// 更新内容行数计数 // 更新内容行数计数
const updateLineCounts = () => { const updateLineCounts = () => {
nextTick(() => { nextTick(() => {
updateEventIdMapping()
props.events.forEach((event) => { props.events.forEach((event) => {
if (event.content) { if ('content' in event && event.content) {
// 行数将在元素引用设置时计算 const key = event.timestamp.toString()
// 这里只初始化展开状态 const element = contentElements.get(key)
if (contentExpandedStates.value[event.timestamp] === undefined) { if (element) {
contentExpandedStates.value[event.timestamp] = false updateLineCountForElement(event.timestamp)
} }
} }
}) })
...@@ -84,9 +114,6 @@ export function useContentExpansion(props: { ...@@ -84,9 +114,6 @@ export function useContentExpansion(props: {
} }
return { return {
contentExpandedStates,
contentLineCounts,
contentElements,
getContentExpandedState, getContentExpandedState,
setContentRef, setContentRef,
toggleContentExpand, toggleContentExpand,
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div class="new-chat-page"> <div class="new-chat-page">
<!-- 左侧对话区 --> <!-- 左侧对话区 -->
<div class="left-panel"> <div class="left-panel">
<chat-area ref="chatArea" /> <chat-area ref="chatArea" :add-event-to-timeline="addEventToTimeline" />
</div> </div>
<!-- 中间分割线 --> <!-- 中间分割线 -->
...@@ -37,6 +37,13 @@ watch(() => route.query.agentId, (newAgentId) => { ...@@ -37,6 +37,13 @@ watch(() => route.query.agentId, (newAgentId) => {
} }
}, { immediate: true }) }, { immediate: true })
// 添加事件到时间轴
const addEventToTimeline = (event: any) => {
if (workArea.value && typeof workArea.value.addEvent === 'function') {
workArea.value.addEvent(event)
}
}
// 开始拖动分割线 // 开始拖动分割线
const startResize = (e: MouseEvent) => { const startResize = (e: MouseEvent) => {
isResizing.value = true isResizing.value = true
......
/**
* Timeline服务类
* 整合了SSE管理功能,减少服务层级
*/
export class TimelineService {
private eventSource: EventSource | null = null;
private retryCount = 0;
private maxRetries = 5;
private retryDelay = 3000;
private addEventCallback: Function;
private messageQueue: any[] = [];
private processingQueue = false;
constructor(addEvent: Function) {
this.addEventCallback = addEvent;
}
/**
* 建立SSE连接
*/
connectSSE() {
// 构造带认证参数的URL
let eventSourceUrl = '/api/v1/agent/timeline-events';
// 从localStorage获取token
const token = localStorage.getItem('token');
// 使用请求头而不是URL参数传递token
if (token) {
// 创建自定义的EventSource实现,支持添加请求头
const eventSource = new EventSourceWithAuth(eventSourceUrl, token);
this.eventSource = eventSource as unknown as EventSource;
} else {
// 如果没有token,仍然使用标准EventSource
this.eventSource = new EventSource(eventSourceUrl);
}
this.eventSource.onmessage = this.handleMessage.bind(this);
this.eventSource.onerror = this.handleError.bind(this);
this.eventSource.onopen = this.handleOpen.bind(this);
return this.eventSource;
}
/**
* 处理SSE消息队列
*/
private processMessageQueue() {
// 如果未连接或正在处理队列,则跳过
if (!this.eventSource || this.eventSource.readyState !== EventSource.OPEN || this.processingQueue || this.messageQueue.length === 0) {
return;
}
this.processingQueue = true;
// 批量处理消息以提高性能
const batchSize = 10;
const batch = this.messageQueue.splice(0, batchSize);
batch.forEach(data => {
this.handleSingleSseMessage(data);
});
this.processingQueue = false;
// 如果还有消息,继续处理
if (this.messageQueue.length > 0) {
setTimeout(() => this.processMessageQueue(), 0);
}
}
/**
* 处理单个SSE消息
*/
private handleSingleSseMessage(data: any) {
this.addEvent(data);
// 触发embed事件给父组件(如果需要)
if (data.type === 'embed') {
window.dispatchEvent(new CustomEvent('embed-event', { detail: data }));
}
// 重置重试计数
this.retryCount = 0;
}
/**
* 处理消息事件
*/
private handleMessage(event: MessageEvent) {
try {
// 的消息解析
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
if (data) {
// 将消息加入队列进行批处理,但限制队列大小以避免内存泄漏
if (this.messageQueue.length < 100) {
this.messageQueue.push(data);
this.processMessageQueue();
} else {
console.warn('[TimelinePanel] 消息队列已满,丢弃新消息');
}
}
} catch (err) {
console.error('解析时间轴事件失败:', err);
}
}
/**
* 处理错误事件
*/
private handleError(event: Event) {
console.error('[SSE] 连接错误:', event);
// 尝试重新连接
if (this.retryCount < this.maxRetries) {
this.retryCount++;
setTimeout(() => {
console.log(`[SSE] 尝试重新连接 (${this.retryCount}/${this.maxRetries})`);
this.connectSSE();
}, this.retryDelay * this.retryCount);
this.addEvent({
type: 'observation',
title: '重新连接中',
content: `正在尝试重新连接 (${this.retryCount}/${this.maxRetries})`,
timestamp: Date.now()
});
} else {
this.addEvent({
type: 'error',
title: '连接失败',
content: '无法连接到服务器事件流,请刷新页面重试',
timestamp: Date.now()
});
// 显示用户通知
// if (typeof window !== 'undefined' && window.alert) {
// window.alert('时间轴连接失败,请刷新页面重试');
// }
}
}
/**
* 处理连接成功事件
*/
private handleOpen() {
console.log('[SSE] 连接已建立');
// 添加连接成功事件到时间轴
this.addEvent({
type: 'observation',
title: 'SSE连接已建立',
content: '成功连接到服务器事件流',
timestamp: Date.now()
});
// 处理队列中积压的消息
this.processMessageQueue();
}
/**
* 组件卸载时清理资源
*/
cleanup() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
/**
* 处理来自ChatArea的思考事件
*/
handleTimelineEvent(e: CustomEvent) {
const eventData = e.detail;
console.log('[TimelinePanel] 接收到timeline-event事件:', eventData);
// 确保时间戳存在且有效
const timestamp = eventData.timestamp || Date.now();
this.addEvent({
type: eventData.type || 'thought',
title: eventData.title || '思考过程',
content: eventData.content,
toolName: eventData.toolName,
toolAction: eventData.toolAction,
toolInput: eventData.toolInput,
toolOutput: eventData.toolOutput,
toolStatus: eventData.toolStatus,
executionTime: eventData.executionTime,
embedUrl: eventData.embedUrl,
embedType: eventData.embedType,
embedTitle: eventData.embedTitle,
embedHtmlContent: eventData.embedHtmlContent,
metadata: eventData.metadata,
timestamp: timestamp
});
}
/**
* 处理SSE连接失败事件
*/
handleSseConnectionFailed() {
console.error('[SSE] 时间轴事件连接失败,已达到最大重试次数');
// 添加连接失败事件到时间轴
this.addEvent({
type: 'error',
title: 'SSE连接失败',
content: '无法连接到服务器事件流,请刷新页面重试',
timestamp: Date.now()
});
// 显示用户通知
// if (typeof window !== 'undefined' && window.alert) {
// window.alert('时间轴连接失败,请刷新页面重试');
// }
}
/**
* 获取当前EventSource实例
*/
getEventSource(): EventSource | null {
return this.eventSource;
}
/**
* 添加事件
*/
addEvent(event: any) {
if (this.addEventCallback) {
this.addEventCallback(event);
}
}
/**
* 清除Timeline
*/
clearTimeline() {
// 实现清除逻辑
}
/**
* 获取事件类型标签
*/
getEventTypeLabel(eventTypeLabels: Record<string, string>, type: string): string {
return eventTypeLabels[type] || type;
}
/**
* 格式化时间
*/
formatTime(timestamp: number): string {
const date = new Date(timestamp);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
/**
* 获取事件的展开状态
*/
getExpandedState(): boolean {
// 实现
return false;
}
/**
* 切换事件详细信息的展开状态
*/
toggleExpand() {
// 实现
}
/**
* 工具事件类型判断
*/
isToolEventType(type: string): boolean {
return ['tool_call', 'tool_result', 'tool_error'].includes(type);
}
/**
* 工具输入有效性检查
*/
hasValidToolInput(event: any): boolean {
return event.type === 'tool_call' && event.toolInput !== null && event.toolInput !== undefined;
}
/**
* 工具输出有效性检查
*/
hasValidToolOutput(event: any): boolean {
return event.type === 'tool_result' && event.toolOutput !== null && event.toolOutput !== undefined;
}
}
// 自定义EventSource实现,支持添加Authorization请求头
class EventSourceWithAuth extends EventTarget {
private xhr: XMLHttpRequest | null = null;
private timeoutId: number | null = null;
private _readyState: number;
private _url: string;
private _token: string;
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 2;
constructor(url: string, token: string) {
super();
this._url = url;
this._token = token;
this._readyState = EventSourceWithAuth.CONNECTING;
this.connect();
}
private connect() {
if (this.xhr) {
this.xhr.abort();
}
this.xhr = new XMLHttpRequest();
this.xhr.open('GET', this._url, true);
this.xhr.setRequestHeader('Accept', 'text/event-stream');
this.xhr.setRequestHeader('Cache-Control', 'no-cache');
this.xhr.setRequestHeader('Authorization', `Bearer ${this._token}`);
this.xhr.withCredentials = true;
this.xhr.onreadystatechange = () => {
if (this.xhr?.readyState === XMLHttpRequest.HEADERS_RECEIVED) {
if (this.xhr.status === 200) {
this._readyState = EventSourceWithAuth.OPEN;
this.dispatchEvent(new Event('open'));
} else {
this.handleError();
}
}
};
this.xhr.onprogress = () => {
if (this.xhr) {
const lines = this.xhr.responseText.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
const event = new MessageEvent('message', { data });
this.dispatchEvent(event);
}
}
}
};
this.xhr.onload = () => {
this._readyState = EventSourceWithAuth.CLOSED;
this.dispatchEvent(new Event('close'));
};
this.xhr.onerror = () => {
this.handleError();
};
this.xhr.send();
// 每30秒重新连接一次,保持连接活跃
this.timeoutId = window.setTimeout(() => {
this.reconnect();
}, 30000);
}
private handleError() {
this._readyState = EventSourceWithAuth.CLOSED;
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.dispatchEvent(new Event('error'));
// 尝试重新连接
setTimeout(() => this.reconnect(), 3000);
}
private reconnect() {
if (this._readyState !== EventSourceWithAuth.CLOSED) {
this.connect();
}
}
close() {
this._readyState = EventSourceWithAuth.CLOSED;
if (this.xhr) {
this.xhr.abort();
}
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.dispatchEvent(new Event('close'));
}
get readyState() {
return this._readyState;
}
get url() {
return this._url;
}
get withCredentials() {
return false;
}
}
\ No newline at end of file
import type { TimelineEvent } from '../types/timeline';
/**
* SSE服务类,用于处理与后端的Server-Sent Events连接
*/
export class SseService {
private eventSource: EventSource | null = null;
private eventListeners: Map<string, Array<(data: any) => void>> = new Map();
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
private url: string;
private isConnecting = false;
constructor(url: string = '/api/v1/events') {
this.url = url;
}
/**
* 连接到SSE服务器
*/
connect(): void {
if (this.eventSource || this.isConnecting) {
return;
}
this.isConnecting = true;
this.reconnectAttempts = 0;
try {
// 创建EventSource连接
this.eventSource = new EventSource(this.url);
// 监听open事件
this.eventSource.onopen = () => {
console.log('[SSE] 连接已建立');
this.reconnectAttempts = 0;
this.isConnecting = false;
this.dispatchEvent('connect', {});
};
// 监听message事件(默认事件)
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('[SSE] 收到消息:', data);
this.dispatchEvent('message', data);
// 如果是时间轴事件,分发特定事件
if (data.type) {
this.dispatchEvent('timeline-event', data);
}
} catch (error) {
console.error('[SSE] 解析消息失败:', error);
}
};
// 监听error事件
this.eventSource.onerror = (error) => {
console.error('[SSE] 连接错误:', error);
this.isConnecting = false;
this.dispatchEvent('error', error);
this.handleReconnect();
};
// 监听特定事件类型
this.eventSource.addEventListener('error', (event) => {
try {
const data = JSON.parse((event as MessageEvent).data);
console.error('[SSE] 服务器错误:', data);
this.dispatchEvent('server-error', data);
} catch (error) {
console.error('[SSE] 解析错误消息失败:', error);
}
});
this.eventSource.addEventListener('token', (event) => {
try {
const data = JSON.parse((event as MessageEvent).data);
console.log('[SSE] 收到Token:', data);
this.dispatchEvent('token', data);
} catch (error) {
console.error('[SSE] 解析Token消息失败:', error);
}
});
} catch (error) {
console.error('[SSE] 创建连接失败:', error);
this.isConnecting = false;
this.handleReconnect();
}
}
/**
* 断开SSE连接
*/
disconnect(): void {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
console.log('[SSE] 连接已断开');
this.dispatchEvent('disconnect', {});
}
this.isConnecting = false;
this.reconnectAttempts = 0;
}
/**
* 处理重连逻辑
*/
private handleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[SSE] 达到最大重连次数,停止重连');
this.dispatchEvent('connection-failed', { attempts: this.reconnectAttempts });
window.dispatchEvent(new CustomEvent('sse-connection-failed', { detail: { attempts: this.reconnectAttempts } }));
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`[SSE] 尝试重连... (${this.reconnectAttempts}/${this.maxReconnectAttempts}),延迟 ${delay}ms`);
setTimeout(() => {
this.connect();
}, delay);
}
/**
* 添加事件监听器
* @param eventType 事件类型
* @param callback 回调函数
*/
on(eventType: string, callback: (data: any) => void): void {
if (!this.eventListeners.has(eventType)) {
this.eventListeners.set(eventType, []);
}
this.eventListeners.get(eventType)?.push(callback);
}
/**
* 移除事件监听器
* @param eventType 事件类型
* @param callback 回调函数(可选,如果不提供则移除所有该类型的监听器)
*/
off(eventType: string, callback?: (data: any) => void): void {
if (!this.eventListeners.has(eventType)) {
return;
}
if (callback) {
const callbacks = this.eventListeners.get(eventType)?.filter(cb => cb !== callback) || [];
this.eventListeners.set(eventType, callbacks);
} else {
this.eventListeners.delete(eventType);
}
}
/**
* 分发事件
* @param eventType 事件类型
* @param data 事件数据
*/
private dispatchEvent(eventType: string, data: any): void {
const callbacks = this.eventListeners.get(eventType) || [];
callbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`[SSE] 执行事件监听器失败 (${eventType}):`, error);
}
});
}
/**
* 检查连接状态
*/
isConnected(): boolean {
return this.eventSource !== null && this.eventSource.readyState === EventSource.OPEN;
}
}
// 创建单例实例
const sseService = new SseService();
export default sseService;
\ No newline at end of file
// 统一的时间轴事件类型定义 // 统一的时间轴事件类型定义
export interface BaseTimelineEvent { export interface BaseTimelineEvent {
id: string;
type: string; type: string;
title: string; title: string;
timestamp: number; timestamp: number;
...@@ -39,12 +40,22 @@ export interface EmbedEvent extends BaseTimelineEvent { ...@@ -39,12 +40,22 @@ export interface EmbedEvent extends BaseTimelineEvent {
embedHtmlContent?: string; embedHtmlContent?: string;
} }
export interface CompleteEvent extends BaseTimelineEvent {
content: string;
}
export interface ErrorEvent extends BaseTimelineEvent {
content: string;
}
export type TimelineEvent = export type TimelineEvent =
| ThoughtEvent | ThoughtEvent
| ToolCallEvent | ToolCallEvent
| ToolResultEvent | ToolResultEvent
| ToolErrorEvent | ToolErrorEvent
| EmbedEvent | EmbedEvent
| CompleteEvent
| ErrorEvent
| BaseTimelineEvent; | BaseTimelineEvent;
// 事件类型标签映射 // 事件类型标签映射
...@@ -56,5 +67,7 @@ export const eventTypeLabels: Record<string, string> = { ...@@ -56,5 +67,7 @@ export const eventTypeLabels: Record<string, string> = {
embed: '🌐 网页预览', embed: '🌐 网页预览',
log: '📝 日志', log: '📝 日志',
result: '🎯 最终答案', result: '🎯 最终答案',
observation: '🔍 观察' observation: '🔍 观察',
complete: '✅ 完成',
error: '❌ 错误'
}; };
\ No newline at end of file
...@@ -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