Commit c107c059 authored by 王舵's avatar 王舵

merge: 合并 main分支代码,解决冲突

parents 5daacfc6 23ae72a7
# HiAgent 问题修复指南
## 问题分析
从终端日志中发现两个主要问题:
1. **LLM配置验证失败**`java.lang.IllegalArgumentException: LLM配置验证失败: deepseek`
2. **Spring Security访问被拒绝**`AuthorizationDeniedException: Access Denied`
## 根本原因
### LLM配置问题
[data.sql](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/backend/src/main/resources/data.sql)中的deepseek配置API密钥为空,而[DeepSeekModelAdapter.java](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/backend/src/main/java/pangea/hiagent/llm/DeepSeekModelAdapter.java)的验证逻辑要求必须有非空的API密钥。
### 安全配置问题
缺少必要的环境变量,包括`DEEPSEEK_API_KEY``JWT_SECRET`
## 解决方案
### 方案一:使用环境变量(推荐)
1. 编辑[run-with-env.bat](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/run-with-env.bat)文件,将占位符替换为实际值:
```batch
set DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # 替换为你的DeepSeek API密钥
set JWT_SECRET=your-secure-jwt-secret-key # 替换为你自己的JWT密钥
```
2. 运行[run-with-env.bat](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/run-with-env.bat)启动应用:
```bash
run-with-env.bat
```
### 方案二:临时修复(仅用于测试)
如果你只是想快速测试应用而不关心安全性,可以:
1. 修改[LlmConfigService.java](file:///c:/Users/Gavin/Documents/PangeaFinal/HiAgent/backend/src/main/java/pangea/hiagent/service/LlmConfigService.java)中的验证逻辑,允许空API密钥:
```java
// 在DeepSeekModelAdapter.java中修改validateConfig方法
@Override
public boolean validateConfig(LlmConfig config) {
return config != null &&
config.getEnabled();
// 移除了对API密钥非空的检查
}
```
注意:这种方法仅适用于测试环境,生产环境中必须配置有效的API密钥。
## 登录凭证
默认登录账户:
- 用户名:`admin`
- 密码:`admin123` (如果使用的是开发环境默认密码)
## 验证修复
启动应用后,可以通过以下方式验证修复是否成功:
1. 访问 http://localhost:8080 并使用默认账户登录
2. 进入Agent管理页面,确认Agent可以正常加载
3. 尝试与Agent进行对话,确认不再出现"LLM配置验证失败"错误
## 故障排除
如果仍然遇到问题,请检查:
1. 确认环境变量已正确设置
2. 确认数据库已正确初始化
3. 查看应用启动日志中是否有其他错误信息
4. 确认网络连接正常,可以访问DeepSeek API
\ No newline at end of file
# HiAgent - 智能AI助手
HiAgent 是一个功能强大的个人AI助手,集成了多种工具和服务,能够帮助用户完成各种任务。
## 🌟 核心功能
### 网页访问和内容提取
- **网页访问工具**:能够根据网站名称或URL访问网页并在工作面板中预览
- **网页内容提取工具**:智能提取网页正文内容,自动识别并提取文章标题和主要内容,过滤掉广告、导航栏等无关内容
- **增强型网页嵌入预览**:支持多种加载策略(直接HTML、直接获取内容、iframe),自动处理X-Frame-Options等安全限制
### 计算和数据处理
- **计算器工具**:执行基本数学运算和复杂数学计算
- **日期时间工具**:获取当前时间、日期计算等
- **文件处理工具**:文件上传、下载和处理
- **字符串处理工具**:文本处理和转换功能
### 其他实用工具
- **天气查询工具**:获取指定城市的天气信息
- **OAuth2.0授权工具**:支持通过用户名和密码凭证获取网页资源访问授权,实现标准OAuth2.0认证流程
## 🛠 技术架构
### 后端技术栈
- **Spring Boot 3.3.4**:基于Java 17的现代化Web框架
- **Spring AI**:集成多种AI模型和服务
- **MySQL/H2**:数据存储
- **Redis**:缓存和会话管理
- **Milvus**:向量数据库支持
- **RabbitMQ**:消息队列服务
### 前端技术栈
- **Vue 3**:现代化的前端框架
- **TypeScript**:类型安全的JavaScript超集
- **Vite**:快速的构建工具
## 📦 主要工具介绍
### 网页内容提取工具 (WebContentExtractorTool)
这是一个专门用于从网页中提取有意义文本内容的工具。它能够自动识别并提取网页的标题和正文内容,同时过滤掉广告、导航栏等无关内容。
#### 功能特点
1. **智能内容提取**:自动识别网页的主要内容区域
2. **广告过滤**:自动过滤广告、导航栏等无关内容
3. **格式保留**:保留原文的标题层级和段落结构
4. **错误处理**:完善的错误处理机制和日志记录
#### 使用方法
在Agent对话中直接调用:
```
extractWebContent("https://example.com/article")
```
### 网页访问工具 (WebPageAccessTools)
提供根据网站名称或URL地址访问网页并在工作面板中预览的功能。
#### 功能特点
1. **多种访问方式**:支持按网站名称或直接URL访问
2. **内置网站映射**:支持常见网站的快捷访问
3. **工作面板集成**:直接在工作面板中预览网页内容
4. **多种加载策略**:支持HTML内容、直接获取内容和iframe三种加载方式
5. **智能错误处理**:自动处理X-Frame-Options等安全限制,提供友好的错误提示
#### 使用方法
```
accessWebSiteByName("百度")
accessWebSiteByUrl("https://www.example.com")
```
### 增强型网页嵌入预览 (EmbedPreview)
提供增强的网页嵌入预览功能,支持多种加载策略以应对不同的安全限制。
#### 功能特点
1. **多种加载策略**
- **HTML内容**:直接渲染后端提供的HTML内容
- **直接获取**:通过代理API获取网页内容并直接渲染(绕过X-Frame-Options限制)
- **iframe加载**:传统的iframe嵌入方式(备选方案)
2. **智能回退机制**:当一种策略失败时自动尝试其他策略
3. **安全处理**:使用DOMPurify清理内容,防止XSS攻击
4. **错误处理**:完善的错误处理和用户友好的错误提示
5. **响应式设计**:适配不同屏幕尺寸
#### 使用方法
```vue
<EmbedPreview
:html-content="htmlContent"
:embed-url="url"
embed-title="预览标题"
embed-type="网页"
/>
```
### OAuth2.0授权工具 (OAuth2AuthorizationTool)
这是一个支持OAuth2.0标准认证流程的工具,允许用户通过用户名和密码凭证获取访问受保护资源的令牌。
#### 功能特点
1. **标准OAuth2.0支持**:完全符合OAuth2.0 RFC标准
2. **密码凭证流**:支持Resource Owner Password Credentials Grant流程
3. **令牌管理**:自动管理和缓存访问令牌
4. **令牌刷新**:支持使用刷新令牌获取新的访问令牌
5. **安全存储**:令牌安全存储,自动处理过期令牌
6. **资源访问**:使用获取的令牌访问受保护的资源
#### 使用方法
```
// 1. 获取访问令牌
authorizeWithPasswordCredentials(
"https://example.com/oauth/token",
"your-client-id",
"your-client-secret",
"your-username",
"your-password",
"read write"
)
// 2. 刷新访问令牌
refreshToken(
"https://example.com/oauth/token",
"your-client-id",
"your-client-secret",
"your-refresh-token"
)
// 3. 访问受保护资源
accessProtectedResource(
"https://example.com/api/protected",
"https://example.com/oauth/token",
"your-client-id"
)
```
有关更详细的使用说明,请参阅 [OAuth2.0工具使用指南](OAUTH2_TOOL_USAGE_GUIDE.md)
## 🚀 快速开始
### 环境要求
- Java 17+
- Node.js 16+
- Maven 3.8+
- MySQL 8.0+ (可选,也可使用内置H2数据库)
### 后端启动
```bash
cd backend
mvn spring-boot:run
```
### 前端启动
```bash
cd frontend
npm install
npm run dev
```
### 一键启动脚本
- Windows: `run-all-debug.bat`
- 后端独立: `run-backend-debug.bat`
- 前端独立: `run-frontend-debug.bat`
## 📁 项目结构
```
HiAgent/
├── backend/ # 后端服务
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/ # Java源代码
│ │ │ └── resources/ # 配置文件和静态资源
│ │ └── test/ # 测试代码
│ └── pom.xml # Maven配置文件
├── frontend/ # 前端应用
│ ├── src/ # Vue源代码
│ ├── public/ # 静态资源
│ └── package.json # NPM配置文件
├── docker-compose.yml # Docker编排文件
└── README.md # 项目说明文件
```
## 🔧 配置说明
### 数据库配置
项目支持MySQL和H2数据库,默认使用H2内存数据库,可通过修改`application.yml`配置切换。
### AI模型配置
支持多种AI模型:
- OpenAI/DeepSeek
- Ollama本地模型
- 其他兼容OpenAI API的模型
## 🧪 测试
### 后端测试
```bash
cd backend
mvn test
```
### 前端测试
```bash
cd frontend
npm run test
```
## 🐳 Docker部署
使用docker-compose一键部署:
```bash
docker-compose up -d
```
## 📚 文档
项目包含丰富的技术文档:
- 工具使用说明
- 架构设计文档
- 部署指南
- 故障排查手册
## 🤝 贡献
欢迎提交Issue和Pull Request来改进项目。
## 📄 许可证
本项目采用MIT许可证。
## 🙏 致谢
感谢所有开源项目的贡献者们,以及支持这个项目开发的用户。
\ No newline at end of file
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"playwright": "^1.57.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
{
"dependencies": {
"playwright": "^1.57.0"
}
}
...@@ -28,6 +28,7 @@ ...@@ -28,6 +28,7 @@
<milvus-lite.version>2.3.0</milvus-lite.version> <milvus-lite.version>2.3.0</milvus-lite.version>
<jjwt.version>0.12.6</jjwt.version> <jjwt.version>0.12.6</jjwt.version>
<caffeine.version>3.1.8</caffeine.version> <caffeine.version>3.1.8</caffeine.version>
<maven.compiler.encoding>UTF-8</maven.compiler.encoding>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
...@@ -307,6 +308,25 @@ ...@@ -307,6 +308,25 @@
<version>2.0.48</version> <version>2.0.48</version>
</dependency> </dependency>
<!-- Quartz for job scheduling -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<!-- Spring Boot Mail Starter for POP3 email access -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Cron utils for cron expression parsing -->
<dependency>
<groupId>com.cronutils</groupId>
<artifactId>cron-utils</artifactId>
<version>9.2.0</version>
</dependency>
</dependencies> </dependencies>
...@@ -381,6 +401,7 @@ ...@@ -381,6 +401,7 @@
<configuration> <configuration>
<source>17</source> <source>17</source>
<target>17</target> <target>17</target>
<encoding>UTF-8</encoding>
<annotationProcessorPaths> <annotationProcessorPaths>
<path> <path>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
...@@ -396,6 +417,9 @@ ...@@ -396,6 +417,9 @@
<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> <version>3.0.0</version>
<configuration>
<argLine>-Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
......
...@@ -17,24 +17,32 @@ import pangea.hiagent.utils.JwtUtil; ...@@ -17,24 +17,32 @@ import pangea.hiagent.utils.JwtUtil;
import pangea.hiagent.websocket.DomSyncHandler; import pangea.hiagent.websocket.DomSyncHandler;
import java.util.Map; import java.util.Map;
import lombok.extern.slf4j.Slf4j;
/** /**
* WebSocket配置类 * WebSocket配置类
*/ */
@Slf4j
@Configuration @Configuration
@EnableWebSocket @EnableWebSocket
public class DomSyncWebSocketConfig implements WebSocketConfigurer { public class DomSyncWebSocketConfig implements WebSocketConfigurer {
private final JwtHandshakeInterceptor jwtHandshakeInterceptor; private final JwtHandshakeInterceptor jwtHandshakeInterceptor;
private final pangea.hiagent.core.PlaywrightManager playwrightManager;
public DomSyncWebSocketConfig(JwtHandshakeInterceptor jwtHandshakeInterceptor) { public DomSyncWebSocketConfig(JwtHandshakeInterceptor jwtHandshakeInterceptor,
pangea.hiagent.core.PlaywrightManager playwrightManager) {
this.jwtHandshakeInterceptor = jwtHandshakeInterceptor; this.jwtHandshakeInterceptor = jwtHandshakeInterceptor;
this.playwrightManager = playwrightManager;
} }
// 注入DomSyncHandler,交由Spring管理生命周期 // 注入DomSyncHandler,交由Spring管理生命周期
@Bean @Bean
public DomSyncHandler domSyncHandler() { public DomSyncHandler domSyncHandler() {
return new DomSyncHandler(); DomSyncHandler handler = new DomSyncHandler();
// 通过设置器注入PlaywrightManager
handler.setPlaywrightManager(playwrightManager);
return handler;
} }
@Override @Override
...@@ -50,6 +58,7 @@ public class DomSyncWebSocketConfig implements WebSocketConfigurer { ...@@ -50,6 +58,7 @@ public class DomSyncWebSocketConfig implements WebSocketConfigurer {
/** /**
* JWT握手拦截器,用于WebSocket连接时的认证 * JWT握手拦截器,用于WebSocket连接时的认证
*/ */
@Slf4j
@Component @Component
class JwtHandshakeInterceptor implements HandshakeInterceptor { class JwtHandshakeInterceptor implements HandshakeInterceptor {
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
...@@ -62,9 +71,13 @@ class JwtHandshakeInterceptor implements HandshakeInterceptor { ...@@ -62,9 +71,13 @@ class JwtHandshakeInterceptor implements HandshakeInterceptor {
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
String token = extractTokenFromRequest(request); String token = extractTokenFromRequest(request);
String clientInfo = "[" + (request.getRemoteAddress() != null ? request.getRemoteAddress().toString() : "unknown") + "] ";
log.info(clientInfo + "WebSocket握手请求 - URI: {}, Query: {}", request.getURI(), request.getURI().getQuery());
if (StringUtils.hasText(token)) { if (StringUtils.hasText(token)) {
try { try {
log.debug(clientInfo + "Token提取成功,长度: {}", token.length());
// 验证token是否有效 // 验证token是否有效
boolean isValid = jwtUtil.validateToken(token); boolean isValid = jwtUtil.validateToken(token);
if (isValid) { if (isValid) {
...@@ -73,30 +86,62 @@ class JwtHandshakeInterceptor implements HandshakeInterceptor { ...@@ -73,30 +86,62 @@ class JwtHandshakeInterceptor implements HandshakeInterceptor {
if (userId != null) { if (userId != null) {
attributes.put("token", token); attributes.put("token", token);
attributes.put("userId", userId); attributes.put("userId", userId);
System.out.println("WebSocket连接认证成功,用户ID: " + userId); log.info(clientInfo + "WebSocket连接认证成功,用户ID: {}", userId);
return true; return true;
} else { } else {
System.err.println("无法从token中提取用户ID"); log.error(clientInfo + "错误:无法从token中提取用户ID。Token长度: {}", token.length());
log.error(clientInfo + "token前50字符: {}", token.substring(0, Math.min(50, token.length())));
// 尝试从token的payload中直接解析userId
try {
String[] parts = token.split("\\.");
if (parts.length > 1) {
String payload = new String(java.util.Base64.getUrlDecoder().decode(parts[1]));
log.error(clientInfo + "token payload: {}", payload);
}
} catch (Exception payloadEx) {
log.error(clientInfo + "解析token payload时发生异常: {}", payloadEx.getMessage(), payloadEx);
}
} }
} else { } else {
System.err.println("JWT验证失败,token可能已过期或无效"); boolean isExpired = jwtUtil.isTokenExpired(token);
log.error(clientInfo + "JWT验证失败。Token已过期: {}", isExpired);
// 如果Token已过期,返回401状态码和明确的错误信息
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED);
response.getHeaders().add("WWW-Authenticate", "Bearer error=\"invalid_token\", error_description=\"Token expired\"");
return false;
} }
} catch (Exception e) { } catch (Exception e) {
System.err.println("JWT验证过程中发生错误: " + e.getMessage()); log.error(clientInfo + "JWT验证过程中发生异常: {}", e.getClass().getSimpleName(), e);
e.printStackTrace();
// 如果验证过程出现异常,返回401状态码
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED);
response.getHeaders().add("WWW-Authenticate", "Bearer error=\"invalid_token\", error_description=\"Token validation failed\"");
return false;
} }
} else {
log.warn(clientInfo + "WebSocket连接缺少认证token");
log.warn(clientInfo + "请求头Authorization: {}", request.getHeaders().getFirst("Authorization"));
String query = request.getURI().getQuery();
log.warn(clientInfo + "查询字符串: {}", query != null ? query : "(为空)");
} }
// 如果没有有效的token,拒绝连接 // 如果没有有效的token,拒绝连接
System.err.println("WebSocket连接缺少有效的认证token"); log.warn(clientInfo + "拒绝WebSocket连接,返回401 UNAUTHORIZED");
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED); response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED);
response.getHeaders().add("WWW-Authenticate", "Bearer realm=\"WebSocket\"");
return false; return false;
} }
@Override @Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) { WebSocketHandler wsHandler, Exception exception) {
// 握手后处理,这里不需要特殊处理 String clientInfo = "[" + (request.getRemoteAddress() != null ? request.getRemoteAddress().toString() : "unknown") + "] ";
if (exception != null) {
log.error(clientInfo + "WebSocket握手失败,异常: {}", exception.getClass().getSimpleName(), exception);
} else {
log.info(clientInfo + "WebSocket握手后处理完成");
}
} }
/** /**
...@@ -108,16 +153,24 @@ class JwtHandshakeInterceptor implements HandshakeInterceptor { ...@@ -108,16 +153,24 @@ class JwtHandshakeInterceptor implements HandshakeInterceptor {
String authHeader = request.getHeaders().getFirst("Authorization"); String authHeader = request.getHeaders().getFirst("Authorization");
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) { if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); String token = authHeader.substring(7);
log.debug("从Authorization头中提取Token,长度: {}", token.length());
return token; return token;
} }
// 如果请求头中没有Token,则尝试从URL参数中提取 // 如果请求头中没有Token,则尝试从URL参数中提取
String query = request.getURI().getQuery(); String query = request.getURI().getQuery();
if (query != null) { if (query != null) {
try {
UriComponentsBuilder builder = UriComponentsBuilder.newInstance().query(query); UriComponentsBuilder builder = UriComponentsBuilder.newInstance().query(query);
String token = builder.build().getQueryParams().getFirst("token"); String token = builder.build().getQueryParams().getFirst("token");
if (StringUtils.hasText(token)) { if (StringUtils.hasText(token)) {
log.debug("从URL参数中提取Token,长度: {}", token.length());
return token; return token;
} else {
log.debug("URL中没有token参数,Query: {}", query);
}
} catch (Exception e) {
log.warn("解析URL参数时出错: {}", e.getMessage());
} }
} }
......
package pangea.hiagent.config;
import org.quartz.Scheduler;
import org.quartz.spi.JobFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.scheduling.quartz.SpringBeanJobFactory;
/**
* Quartz配置类
* 配置Quartz调度器和相关组件
*/
@Configuration
public class QuartzConfig {
/**
* 配置JobFactory,用于将Spring的Bean注入到Quartz的Job中
*/
@Bean
public JobFactory jobFactory() {
SpringBeanJobFactory jobFactory = new SpringBeanJobFactory();
return jobFactory;
}
/**
* 配置SchedulerFactoryBean,用于创建Scheduler实例
*/
@Bean
public SchedulerFactoryBean schedulerFactoryBean(@Autowired JobFactory jobFactory) {
SchedulerFactoryBean factory = new SchedulerFactoryBean();
factory.setJobFactory(jobFactory);
factory.setWaitForJobsToCompleteOnShutdown(true);
factory.setOverwriteExistingJobs(true);
return factory;
}
/**
* 配置Scheduler实例,用于管理和执行定时任务
*/
@Bean
public Scheduler scheduler(@Autowired SchedulerFactoryBean factory) {
return factory.getScheduler();
}
}
...@@ -19,6 +19,8 @@ import org.springframework.web.cors.CorsConfigurationSource; ...@@ -19,6 +19,8 @@ import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import pangea.hiagent.security.DefaultPermissionEvaluator; import pangea.hiagent.security.DefaultPermissionEvaluator;
import pangea.hiagent.security.JwtAuthenticationFilter; import pangea.hiagent.security.JwtAuthenticationFilter;
import pangea.hiagent.service.AgentService;
import pangea.hiagent.service.TimerService;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
...@@ -30,11 +32,13 @@ import java.util.Collections; ...@@ -30,11 +32,13 @@ import java.util.Collections;
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final DefaultPermissionEvaluator customPermissionEvaluator; private final AgentService agentService;
private final TimerService timerService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, DefaultPermissionEvaluator customPermissionEvaluator) { public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, AgentService agentService, TimerService timerService) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.customPermissionEvaluator = customPermissionEvaluator; this.agentService = agentService;
this.timerService = timerService;
} }
/** /**
...@@ -51,7 +55,9 @@ public class SecurityConfig { ...@@ -51,7 +55,9 @@ public class SecurityConfig {
@Bean @Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler() { public MethodSecurityExpressionHandler methodSecurityExpressionHandler() {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(customPermissionEvaluator); // 创建带有AgentService和TimerService的权限评估器
DefaultPermissionEvaluator permissionEvaluator = new DefaultPermissionEvaluator(agentService, timerService);
expressionHandler.setPermissionEvaluator(permissionEvaluator);
return expressionHandler; return expressionHandler;
} }
...@@ -87,6 +93,8 @@ public class SecurityConfig { ...@@ -87,6 +93,8 @@ public class SecurityConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 配置请求授权 // 配置请求授权
.authorizeHttpRequests(authz -> authz .authorizeHttpRequests(authz -> authz
// WebSocket端点 - 由握手拦截器处理认证,不需要通过Spring Security过滤链
.requestMatchers("/ws/**").permitAll()
// OAuth2 相关端点公开访问 // OAuth2 相关端点公开访问
.requestMatchers("/api/v1/auth/oauth2/**").permitAll() .requestMatchers("/api/v1/auth/oauth2/**").permitAll()
// OAuth2提供商管理端点需要认证(仅管理员可访问) // OAuth2提供商管理端点需要认证(仅管理员可访问)
...@@ -123,6 +131,12 @@ public class SecurityConfig { ...@@ -123,6 +131,12 @@ public class SecurityConfig {
response.getWriter().write("{\"code\":401,\"message\":\"未授权访问\",\"timestamp\":" + System.currentTimeMillis() + "}"); response.getWriter().write("{\"code\":401,\"message\":\"未授权访问\",\"timestamp\":" + System.currentTimeMillis() + "}");
}) })
.accessDeniedHandler((request, response, accessDeniedException) -> { .accessDeniedHandler((request, response, accessDeniedException) -> {
// 检查响应是否已经提交
if (response.isCommitted()) {
System.err.println("响应已经提交,无法处理访问拒绝异常: " + request.getRequestURI());
return;
}
response.setStatus(403); response.setStatus(403);
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"message\":\"访问被拒绝\",\"timestamp\":" + System.currentTimeMillis() + "}"); response.getWriter().write("{\"code\":403,\"message\":\"访问被拒绝\",\"timestamp\":" + System.currentTimeMillis() + "}");
......
package pangea.hiagent.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.context.SecurityContextHolder;
import jakarta.annotation.PostConstruct;
/**
* SecurityContext配置类
* 用于配置SecurityContextHolder策略,支持异步线程间传播认证信息
*/
@Configuration
public class SecurityContextConfig {
/**
* 在应用启动时设置SecurityContextHolder策略为MODE_INHERITABLETHREADLOCAL
* 这样可以在父子线程之间自动传播SecurityContext
*/
@PostConstruct
public void configureSecurityContextHolderStrategy() {
// 设置SecurityContextHolder策略为可继承的ThreadLocal模式
// 这样在异步线程中也可以获取到父线程的认证信息
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
}
\ No newline at end of file
...@@ -13,6 +13,8 @@ import pangea.hiagent.dto.AgentRequest; ...@@ -13,6 +13,8 @@ import pangea.hiagent.dto.AgentRequest;
import pangea.hiagent.dto.ChatRequest; import pangea.hiagent.dto.ChatRequest;
import pangea.hiagent.model.Agent; import pangea.hiagent.model.Agent;
import pangea.hiagent.service.AgentService; import pangea.hiagent.service.AgentService;
import pangea.hiagent.utils.AsyncUserContextDecorator;
import pangea.hiagent.utils.UserUtils;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
...@@ -20,6 +22,9 @@ import java.util.HashMap; ...@@ -20,6 +22,9 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/** /**
* Agent 对话控制器 * Agent 对话控制器
...@@ -39,7 +44,14 @@ public class AgentChatController { ...@@ -39,7 +44,14 @@ public class AgentChatController {
@Autowired @Autowired
private SseEventManager sseEventManager; private SseEventManager sseEventManager;
private final ExecutorService executorService = Executors.newFixedThreadPool(10); private final ExecutorService executorService = new ThreadPoolExecutor(
10, // 核心线程数
50, // 最大线程数
60L, // 空闲线程存活时间
TimeUnit.SECONDS, // 时间单位
new LinkedBlockingQueue<>(100), // 任务队列
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
/** /**
* 流式对话接口 * 流式对话接口
...@@ -50,7 +62,6 @@ public class AgentChatController { ...@@ -50,7 +62,6 @@ public class AgentChatController {
* @return SSE emitter * @return SSE emitter
*/ */
@PostMapping("/chat-stream") @PostMapping("/chat-stream")
@PreAuthorize("hasRole('USER')")
public SseEmitter chatStream( public SseEmitter chatStream(
@RequestParam String agentId, @RequestParam String agentId,
@RequestBody ChatRequest chatRequest, @RequestBody ChatRequest chatRequest,
...@@ -65,19 +76,22 @@ public class AgentChatController { ...@@ -65,19 +76,22 @@ public class AgentChatController {
// 检查用户消息是否为空 // 检查用户消息是否为空
if (request.getUserMessage() == null || request.getUserMessage().trim().isEmpty()) { if (request.getUserMessage() == null || request.getUserMessage().trim().isEmpty()) {
log.error("用户消息不能为空"); log.error("用户消息不能为空");
// 检查响应是否已经提交
if (response.isCommitted()) {
log.warn("响应已经提交,无法发送用户消息为空错误");
return new SseEmitter(300000L); // 返回一个空的emitter
}
SseEmitter emitter = new SseEmitter(300000L); SseEmitter emitter = new SseEmitter(300000L);
try { try {
// 检查响应是否已经提交
if (!response.isCommitted()) {
Map<String, Object> errorData = new HashMap<>();
errorData.put("error", "用户消息不能为空");
errorData.put("timestamp", System.currentTimeMillis());
emitter.send(SseEmitter.event() emitter.send(SseEmitter.event()
.name("error") .name("error")
.data("用户消息不能为空") .data(errorData)
.build()); .build());
emitter.complete(); emitter.complete();
} else {
log.warn("响应已经提交,无法发送用户消息为空错误");
emitter.complete();
}
} catch (IOException e) { } catch (IOException e) {
log.error("发送用户消息为空错误失败", e); log.error("发送用户消息为空错误失败", e);
emitter.completeWithError(e); emitter.completeWithError(e);
...@@ -88,31 +102,56 @@ public class AgentChatController { ...@@ -88,31 +102,56 @@ public class AgentChatController {
String userId = getCurrentUserId(); String userId = getCurrentUserId();
if (userId == null) { if (userId == null) {
log.error("用户未认证"); log.error("用户未认证");
// 检查响应是否已经提交
if (response.isCommitted()) {
log.warn("响应已经提交,无法发送未认证错误");
return new SseEmitter(300000L); // 返回一个空的emitter
}
SseEmitter emitter = new SseEmitter(300000L); SseEmitter emitter = new SseEmitter(300000L);
try { try {
// 检查响应是否已经提交
if (!response.isCommitted()) {
Map<String, Object> errorData = new HashMap<>();
errorData.put("error", "用户未认证,请重新登录");
errorData.put("timestamp", System.currentTimeMillis());
emitter.send(SseEmitter.event() emitter.send(SseEmitter.event()
.name("error") .name("error")
.data("用户未认证") .data(errorData)
.build()); .build());
emitter.complete(); emitter.complete();
} else {
log.warn("响应已经提交,无法发送未认证错误");
emitter.complete();
}
} catch (IOException e) { } catch (IOException e) {
log.error("发送未认证错误失败", e); log.error("发送未认证错误失败", e);
// 检查响应是否已经提交
if (!response.isCommitted()) {
emitter.completeWithError(e); emitter.completeWithError(e);
} else {
// 响应已提交,只能简单完成
emitter.complete();
}
} catch (IllegalStateException e) {
log.warn("Emitter已经完成: {}", e.getMessage());
// emitter已经完成,无需进一步操作
} }
return emitter; return emitter;
} }
// 创建 SSE emitter // 创建 SSE emitter
SseEmitter emitter = new SseEmitter(300000L); // 5分钟超时 SseEmitter emitter = new SseEmitter(300000L); // 5分钟超时,与前端保持一致
// 设置超时回调
emitter.onTimeout(() -> {
log.warn("SSE连接超时,AgentId: {}, 用户ID: {}", agentId, userId);
try {
emitter.complete();
} catch (IllegalStateException e) {
log.warn("Emitter已经完成: {}", e.getMessage());
} catch (Exception e) {
log.error("关闭SSE连接时发生错误", e);
}
});
// 异步处理对话,避免阻塞HTTP连接 // 异步处理对话,避免阻塞HTTP连接
executorService.execute(() -> { // 使用AsyncUserContextDecorator包装任务以传播用户上下文
executorService.execute(AsyncUserContextDecorator.wrapWithContext(() -> {
try { try {
// 获取Agent信息 // 获取Agent信息
Agent agent = agentService.getAgent(agentId); Agent agent = agentService.getAgent(agentId);
...@@ -167,9 +206,13 @@ public class AgentChatController { ...@@ -167,9 +206,13 @@ public class AgentChatController {
Map<String, Object> completeData = new HashMap<>(); Map<String, Object> completeData = new HashMap<>();
completeData.put("message", "对话完成"); completeData.put("message", "对话完成");
completeData.put("type", "complete"); completeData.put("type", "complete");
completeData.put("fullText", responseContent); // 添加完整文本内容
sseEventManagerParam.sendEvent(emitterParam, "complete", completeData, isCompleted); sseEventManagerParam.sendEvent(emitterParam, "complete", completeData, isCompleted);
// 确保标记为已完成
isCompleted.set(true);
} catch (IOException e) { } catch (IOException e) {
log.error("发送完成事件失败", e); log.error("发送完成事件失败", e);
isCompleted.set(true); // 出错时也标记为已完成
} }
} }
); );
...@@ -196,9 +239,13 @@ public class AgentChatController { ...@@ -196,9 +239,13 @@ public class AgentChatController {
Map<String, Object> completeData = new HashMap<>(); Map<String, Object> completeData = new HashMap<>();
completeData.put("message", "对话完成"); completeData.put("message", "对话完成");
completeData.put("type", "complete"); completeData.put("type", "complete");
completeData.put("fullText", responseContent); // 添加完整文本内容
sseEventManagerParam.sendEvent(emitterParam, "complete", completeData, isCompleted); sseEventManagerParam.sendEvent(emitterParam, "complete", completeData, isCompleted);
// 确保标记为已完成
isCompleted.set(true);
} catch (IOException e) { } catch (IOException e) {
log.error("发送完成事件失败", e); log.error("发送完成事件失败", e);
isCompleted.set(true); // 出错时也标记为已完成
} }
} }
); );
...@@ -219,15 +266,16 @@ public class AgentChatController { ...@@ -219,15 +266,16 @@ public class AgentChatController {
} }
} }
} }
}); }));
// 设置 emitter 的回调 // 设置 emitter 的回调
emitter.onCompletion(() -> log.debug("SSE连接完成")); emitter.onCompletion(() -> log.debug("SSE连接完成"));
emitter.onTimeout(() -> { emitter.onTimeout(() -> {
log.warn("SSE连接超时"); log.warn("SSE连接超时,准备关闭连接");
// complete方法内部已经处理了所有可能的异常,无需再次捕获IOException // complete方法内部已经处理了所有可能的异常,无需再次捕获IOException
try { try {
emitter.complete(); emitter.complete();
log.info("SSE连接已成功关闭");
} catch (IllegalStateException e) { } catch (IllegalStateException e) {
log.warn("Emitter已经完成: {}", e.getMessage()); log.warn("Emitter已经完成: {}", e.getMessage());
} catch (Exception e) { } catch (Exception e) {
...@@ -243,19 +291,17 @@ public class AgentChatController { ...@@ -243,19 +291,17 @@ public class AgentChatController {
* 获取当前认证用户ID * 获取当前认证用户ID
*/ */
private String getCurrentUserId() { private String getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return UserUtils.getCurrentUserId();
if (authentication != null && authentication.getPrincipal() != null) {
return (String) authentication.getPrincipal();
}
return null;
} }
/** /**
* 检查用户是否是管理员 * 检查用户是否是管理员
*/ */
private boolean isAdmin(String userId) { private boolean isAdmin(String userId) {
// 这里可以添加更复杂的权限检查逻辑 // 使用Spring Security获取当前认证用户ID
return "admin".equals(userId) || "user-001".equals(userId); String currentUserId = getCurrentUserId();
// 这里可以添加更复杂的权限检查逻辑,比如查询数据库或配置文件
return "admin".equals(currentUserId) || "user-001".equals(currentUserId);
} }
/** /**
......
...@@ -10,6 +10,7 @@ import pangea.hiagent.dto.PageData; ...@@ -10,6 +10,7 @@ import pangea.hiagent.dto.PageData;
import pangea.hiagent.model.Agent; import pangea.hiagent.model.Agent;
import pangea.hiagent.service.AgentService; import pangea.hiagent.service.AgentService;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import pangea.hiagent.utils.UserUtils;
/** /**
* Agent API控制器 * Agent API控制器
...@@ -152,8 +153,6 @@ public class AgentController { ...@@ -152,8 +153,6 @@ public class AgentController {
* 获取当前认证用户ID * 获取当前认证用户ID
*/ */
private String getCurrentUserId() { private String getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return UserUtils.getCurrentUserId();
return (authentication != null && authentication.getPrincipal() != null) ?
(String) authentication.getPrincipal() : null;
} }
} }
\ No newline at end of file
...@@ -8,6 +8,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; ...@@ -8,6 +8,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
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 pangea.hiagent.workpanel.SseEventManager; import pangea.hiagent.workpanel.SseEventManager;
import pangea.hiagent.utils.UserUtils;
import pangea.hiagent.dto.WorkPanelEvent; import pangea.hiagent.dto.WorkPanelEvent;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
...@@ -30,11 +31,7 @@ public class TimelineEventController { ...@@ -30,11 +31,7 @@ public class TimelineEventController {
* 获取当前认证用户ID * 获取当前认证用户ID
*/ */
private String getCurrentUserId() { private String getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return UserUtils.getCurrentUserId();
if (authentication != null && authentication.getPrincipal() != null) {
return (String) authentication.getPrincipal();
}
return null;
} }
/** /**
...@@ -48,23 +45,18 @@ public class TimelineEventController { ...@@ -48,23 +45,18 @@ public class TimelineEventController {
log.info("开始处理时间轴事件订阅请求"); log.info("开始处理时间轴事件订阅请求");
String userId = getCurrentUserId(); String userId = getCurrentUserId();
// 创建 SSE emitter
SseEmitter emitter = new SseEmitter(300000L); // 5分钟超时
if (userId == null) { if (userId == null) {
log.error("用户未认证"); log.error("用户未认证");
// 立即创建并完成emitter,不发送任何数据 // 使用sendError方法发送错误信息,而不是直接completeWithError
SseEmitter emitter = new SseEmitter(300000L); sseEventManager.sendError(emitter, "用户未认证");
try {
emitter.completeWithError(new IllegalArgumentException("用户未认证"));
} catch (Exception e) {
log.error("完成SSE连接失败", e);
}
return emitter; return emitter;
} }
log.debug("用户认证成功,用户ID: {}", userId); log.debug("用户认证成功,用户ID: {}", userId);
// 创建 SSE emitter
SseEmitter emitter = new SseEmitter(300000L); // 5分钟超时
// 注册 emitter 回调 // 注册 emitter 回调
emitter.onCompletion(() -> { emitter.onCompletion(() -> {
log.debug("SSE连接完成"); log.debug("SSE连接完成");
......
package pangea.hiagent.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import pangea.hiagent.dto.ApiResponse;
import pangea.hiagent.dto.TimerConfigDto;
import pangea.hiagent.dto.TimerExecutionHistoryDto;
import pangea.hiagent.dto.PromptTemplateDto;
import pangea.hiagent.exception.ErrorCode;
import pangea.hiagent.model.TimerConfig;
import pangea.hiagent.model.TimerExecutionHistory;
import pangea.hiagent.model.PromptTemplate;
import pangea.hiagent.service.TimerService;
import pangea.hiagent.service.PromptTemplateService;
import pangea.hiagent.utils.UserUtils;
import jakarta.validation.Valid;
import java.util.List;
import java.util.stream.Collectors;
/**
* 定时器API控制器
* 提供定时器的增删改查和管理功能
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/timer")
public class TimerController {
private final TimerService timerService;
private final PromptTemplateService promptTemplateService;
public TimerController(TimerService timerService, PromptTemplateService promptTemplateService) {
this.timerService = timerService;
this.promptTemplateService = promptTemplateService;
}
// ==================== 定时器管理API ====================
/**
* 创建定时器
*/
@PostMapping
public ApiResponse<TimerConfigDto> createTimer(@Valid @RequestBody TimerConfigDto timerConfigDto) {
try {
String userId = getCurrentUserId();
if (userId == null) {
return ApiResponse.error(4001, "用户未认证");
}
log.info("用户 {} 开始创建定时器: {}", userId, timerConfigDto.getName());
// 转换DTO为实体
TimerConfig timerConfig = convertToEntity(timerConfigDto);
timerConfig.setCreatedBy(userId);
TimerConfig created = timerService.createTimer(timerConfig);
log.info("用户 {} 成功创建定时器: {} (ID: {})", userId, created.getName(), created.getId());
return ApiResponse.success(convertToDto(created), "创建定时器成功");
} catch (IllegalArgumentException e) {
log.error("创建定时器失败 - 参数验证错误: {}", e.getMessage());
return ApiResponse.error(ErrorCode.PARAMETER_ERROR.getCode(), "创建定时器失败: " + e.getMessage());
} catch (Exception e) {
log.error("创建定时器失败", e);
Throwable cause = e.getCause();
String errorMsg = "创建定时器失败: " + e.getMessage();
if (cause != null) {
errorMsg += " (原因: " + cause.getMessage() + ")";
}
return ApiResponse.error(4001, errorMsg);
}
}
/**
* 更新定时器
*/
@PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'TimerConfig', 'write')")
@PutMapping("/{id}")
public ApiResponse<TimerConfigDto> updateTimer(@PathVariable String id, @Valid @RequestBody TimerConfigDto timerConfigDto) {
try {
String userId = getCurrentUserId();
log.info("用户 {} 开始更新定时器: {}", userId, id);
TimerConfig timerConfig = convertToEntity(timerConfigDto);
timerConfig.setId(id);
TimerConfig updated = timerService.updateTimer(timerConfig);
log.info("用户 {} 成功更新定时器: {}", userId, updated.getId());
return ApiResponse.success(convertToDto(updated), "更新定时器成功");
} catch (IllegalArgumentException e) {
log.error("更新定时器失败 - 参数验证错误: {}", e.getMessage());
return ApiResponse.error(ErrorCode.PARAMETER_ERROR.getCode(), "更新定时器失败: " + e.getMessage());
} catch (Exception e) {
log.error("更新定时器失败", e);
Throwable cause = e.getCause();
String errorMsg = "更新定时器失败: " + e.getMessage();
if (cause != null) {
errorMsg += " (原因: " + cause.getMessage() + ")";
}
return ApiResponse.error(4001, errorMsg);
}
}
/**
* 删除定时器
*/
@PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'TimerConfig', 'delete')")
@DeleteMapping("/{id}")
public ApiResponse<Void> deleteTimer(@PathVariable String id) {
try {
String userId = getCurrentUserId();
log.info("用户 {} 开始删除定时器: {}", userId, id);
timerService.deleteTimer(id);
log.info("用户 {} 成功删除定时器: {}", userId, id);
return ApiResponse.success(null, "删除定时器成功");
} catch (Exception e) {
log.error("删除定时器失败", e);
return ApiResponse.error(4001, "删除定时器失败: " + e.getMessage());
}
}
/**
* 获取定时器详情
*/
@PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'TimerConfig', 'read')")
@GetMapping("/{id}")
public ApiResponse<TimerConfigDto> getTimer(@PathVariable String id) {
try {
TimerConfig timerConfig = timerService.getTimerById(id);
if (timerConfig == null) {
return ApiResponse.error(4001, "定时器不存在");
}
return ApiResponse.success(convertToDto(timerConfig));
} catch (Exception e) {
log.error("获取定时器详情失败", e);
return ApiResponse.error(4001, "获取定时器详情失败: " + e.getMessage());
}
}
/**
* 获取定时器列表
*/
@GetMapping
public ApiResponse<List<TimerConfigDto>> listTimers() {
try {
String userId = getCurrentUserId();
if (userId == null) {
return ApiResponse.error(4001, "用户未认证");
}
List<TimerConfig> timers = timerService.listTimersByCreatedBy(userId);
List<TimerConfigDto> timerDtos = timers.stream()
.map(this::convertToDto)
.collect(Collectors.toList());
return ApiResponse.success(timerDtos);
} catch (Exception e) {
log.error("获取定时器列表失败", e);
return ApiResponse.error(4001, "获取定时器列表失败: " + e.getMessage());
}
}
/**
* 启用定时器
*/
@PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'TimerConfig', 'write')")
@PostMapping("/{id}/enable")
public ApiResponse<Void> enableTimer(@PathVariable String id) {
try {
String userId = getCurrentUserId();
log.info("用户 {} 开始启用定时器: {}", userId, id);
timerService.enableTimer(id);
log.info("用户 {} 成功启用定时器: {}", userId, id);
return ApiResponse.success(null, "启用定时器成功");
} catch (Exception e) {
log.error("启用定时器失败", e);
return ApiResponse.error(4001, "启用定时器失败: " + e.getMessage());
}
}
/**
* 禁用定时器
*/
@PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'TimerConfig', 'write')")
@PostMapping("/{id}/disable")
public ApiResponse<Void> disableTimer(@PathVariable String id) {
try {
String userId = getCurrentUserId();
log.info("用户 {} 开始禁用定时器: {}", userId, id);
timerService.disableTimer(id);
log.info("用户 {} 成功禁用定时器: {}", userId, id);
return ApiResponse.success(null, "禁用定时器成功");
} catch (Exception e) {
log.error("禁用定时器失败", e);
return ApiResponse.error(4001, "禁用定时器失败: " + e.getMessage());
}
}
// ==================== 提示词模板API ====================
/**
* 创建提示词模板
*/
@PostMapping("/prompt-template")
public ApiResponse<PromptTemplateDto> createPromptTemplate(@RequestBody PromptTemplateDto templateDto) {
try {
String userId = getCurrentUserId();
if (userId == null) {
return ApiResponse.error(4001, "用户未认证");
}
log.info("用户 {} 开始创建提示词模板: {}", userId, templateDto.getName());
PromptTemplate template = convertToEntity(templateDto);
template.setCreatedBy(userId);
PromptTemplate created = promptTemplateService.createTemplate(template);
log.info("用户 {} 成功创建提示词模板: {} (ID: {})", userId, created.getName(), created.getId());
return ApiResponse.success(convertToDto(created), "创建提示词模板成功");
} catch (Exception e) {
log.error("创建提示词模板失败", e);
return ApiResponse.error(4001, "创建提示词模板失败: " + e.getMessage());
}
}
/**
* 获取提示词模板列表
*/
@GetMapping("/prompt-template")
public ApiResponse<List<PromptTemplateDto>> listPromptTemplates() {
try {
List<PromptTemplate> templates = promptTemplateService.listTemplates();
List<PromptTemplateDto> templateDtos = templates.stream()
.map(this::convertToDto)
.collect(Collectors.toList());
return ApiResponse.success(templateDtos);
} catch (Exception e) {
log.error("获取提示词模板列表失败", e);
return ApiResponse.error(4001, "获取提示词模板列表失败: " + e.getMessage());
}
}
// ==================== 私有辅助方法 ====================
/**
* 获取当前认证用户ID
*/
private String getCurrentUserId() {
return UserUtils.getCurrentUserId();
}
/**
* 转换TimerConfig实体为DTO
*/
private TimerConfigDto convertToDto(TimerConfig timerConfig) {
if (timerConfig == null) {
return null;
}
return TimerConfigDto.builder()
.id(timerConfig.getId())
.name(timerConfig.getName())
.description(timerConfig.getDescription())
.cronExpression(timerConfig.getCronExpression())
.enabled(timerConfig.getEnabled())
.agentId(timerConfig.getAgentId())
.agentName(timerConfig.getAgentName())
.promptTemplate(timerConfig.getPromptTemplate())
.paramsJson(timerConfig.getParamsJson())
.lastExecutionTime(timerConfig.getLastExecutionTime())
.nextExecutionTime(timerConfig.getNextExecutionTime())
.createdAt(timerConfig.getCreatedAt())
.updatedAt(timerConfig.getUpdatedAt())
.createdBy(timerConfig.getCreatedBy())
.build();
}
/**
* 转换TimerConfigDto为实体
*/
private TimerConfig convertToEntity(TimerConfigDto timerConfigDto) {
if (timerConfigDto == null) {
return null;
}
TimerConfig timerConfig = new TimerConfig();
timerConfig.setId(timerConfigDto.getId());
timerConfig.setName(timerConfigDto.getName());
timerConfig.setDescription(timerConfigDto.getDescription());
timerConfig.setCronExpression(timerConfigDto.getCronExpression());
timerConfig.setEnabled(timerConfigDto.getEnabled());
timerConfig.setAgentId(timerConfigDto.getAgentId());
timerConfig.setAgentName(timerConfigDto.getAgentName());
timerConfig.setPromptTemplate(timerConfigDto.getPromptTemplate());
timerConfig.setParamsJson(timerConfigDto.getParamsJson());
timerConfig.setLastExecutionTime(timerConfigDto.getLastExecutionTime());
timerConfig.setNextExecutionTime(timerConfigDto.getNextExecutionTime());
return timerConfig;
}
/**
* 转换PromptTemplate实体为DTO
*/
private PromptTemplateDto convertToDto(PromptTemplate template) {
if (template == null) {
return null;
}
return PromptTemplateDto.builder()
.id(template.getId())
.name(template.getName())
.description(template.getDescription())
.templateContent(template.getTemplateContent())
.paramSchema(template.getParamSchema())
.templateType(template.getTemplateType())
.isSystem(template.getIsSystem())
.createdAt(template.getCreatedAt())
.updatedAt(template.getUpdatedAt())
.createdBy(template.getCreatedBy())
.build();
}
/**
* 转换PromptTemplateDto为实体
*/
private PromptTemplate convertToEntity(PromptTemplateDto templateDto) {
if (templateDto == null) {
return null;
}
PromptTemplate template = new PromptTemplate();
template.setId(templateDto.getId());
template.setName(templateDto.getName());
template.setDescription(templateDto.getDescription());
template.setTemplateContent(templateDto.getTemplateContent());
template.setParamSchema(templateDto.getParamSchema());
template.setTemplateType(templateDto.getTemplateType());
template.setIsSystem(templateDto.getIsSystem());
return template;
}
}
package pangea.hiagent.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import pangea.hiagent.dto.ApiResponse;
import pangea.hiagent.dto.TimerExecutionHistoryDto;
import pangea.hiagent.service.HistoryService;
/**
* 定时器执行历史API控制器
* 负责处理执行历史的查询和管理
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/timer-history")
public class TimerHistoryController {
private final HistoryService historyService;
public TimerHistoryController(HistoryService historyService) {
this.historyService = historyService;
}
/**
* 获取执行历史列表
*/
@GetMapping
public ApiResponse<Page<TimerExecutionHistoryDto>> listExecutionHistory(
@RequestParam(required = false) String timerId,
@RequestParam(required = false) Integer success,
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
try {
log.info("获取执行历史列表,timerId: {}, success: {}, startTime: {}, endTime: {}",
timerId, success, startTime, endTime);
Page<TimerExecutionHistoryDto> historyPage = historyService.getExecutionHistoryList(
timerId, success, startTime, endTime, page, size);
return ApiResponse.success(historyPage, "获取执行历史成功");
} catch (Exception e) {
log.error("获取执行历史失败", e);
return ApiResponse.error(4001, "获取执行历史失败: " + e.getMessage());
}
}
/**
* 获取指定定时器的执行历史
*/
@GetMapping("/{timerId}")
public ApiResponse<Page<TimerExecutionHistoryDto>> listTimerExecutionHistory(
@PathVariable String timerId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
try {
log.info("获取定时器 {} 的执行历史", timerId);
Page<TimerExecutionHistoryDto> historyPage = historyService.getExecutionHistoryByTimerId(
timerId, page, size);
return ApiResponse.success(historyPage, "获取定时器执行历史成功");
} catch (Exception e) {
log.error("获取定时器执行历史失败", e);
return ApiResponse.error(4001, "获取定时器执行历史失败: " + e.getMessage());
}
}
/**
* 获取执行历史详情
*/
@GetMapping("/detail/{id}")
public ApiResponse<TimerExecutionHistoryDto> getExecutionHistoryDetail(@PathVariable Long id) {
try {
log.info("获取执行历史详情: {}", id);
TimerExecutionHistoryDto historyDetail = historyService.getExecutionHistoryDetail(id);
if (historyDetail == null) {
return ApiResponse.error(4004, "执行历史不存在");
}
return ApiResponse.success(historyDetail, "获取执行历史详情成功");
} catch (Exception e) {
log.error("获取执行历史详情失败", e);
return ApiResponse.error(4001, "获取执行历史详情失败: " + e.getMessage());
}
}
}
\ No newline at end of file
package pangea.hiagent.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import pangea.hiagent.model.ToolConfig;
import pangea.hiagent.service.ToolConfigService;
import java.util.List;
import java.util.Map;
/**
* 工具配置控制器
* 提供参数配置的REST API
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/tool-configs")
public class ToolConfigController {
@Autowired
private ToolConfigService toolConfigService;
/**
* 获取所有工具配置
* @return 工具配置列表
*/
@GetMapping
public ResponseEntity<List<ToolConfig>> getAllToolConfigs() {
log.debug("获取所有工具配置");
List<ToolConfig> toolConfigs = toolConfigService.getAllToolConfigs();
return ResponseEntity.ok(toolConfigs);
}
/**
* 根据工具名称获取参数配置
* @param toolName 工具名称
* @return 参数配置键值对
*/
@GetMapping("/{toolName}")
public ResponseEntity<Map<String, String>> getToolParams(@PathVariable String toolName) {
log.debug("根据工具名称获取参数配置,工具名称:{}", toolName);
Map<String, String> params = toolConfigService.getToolParams(toolName);
return ResponseEntity.ok(params);
}
/**
* 根据工具名称和参数名称获取参数值
* @param toolName 工具名称
* @param paramName 参数名称
* @return 参数值
*/
@GetMapping("/{toolName}/{paramName}")
public ResponseEntity<String> getParamValue(@PathVariable String toolName, @PathVariable String paramName) {
log.debug("根据工具名称和参数名称获取参数值,工具名称:{},参数名称:{}", toolName, paramName);
String paramValue = toolConfigService.getParamValue(toolName, paramName);
return ResponseEntity.ok(paramValue);
}
/**
* 保存工具配置
* @param toolConfig 工具配置对象
* @return 保存后的工具配置对象
*/
@PostMapping
public ResponseEntity<ToolConfig> saveToolConfig(@RequestBody ToolConfig toolConfig) {
log.debug("保存工具配置:{}", toolConfig);
ToolConfig savedConfig = toolConfigService.saveToolConfig(toolConfig);
if (savedConfig != null) {
return ResponseEntity.ok(savedConfig);
} else {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* 保存参数值
* @param toolName 工具名称
* @param paramName 参数名称
* @param paramValue 参数值
* @return 保存结果
*/
@PutMapping("/{toolName}/{paramName}")
public ResponseEntity<Void> saveParamValue(@PathVariable String toolName, @PathVariable String paramName, @RequestBody String paramValue) {
log.debug("保存参数值,工具名称:{},参数名称:{},参数值:{}", toolName, paramName, paramValue);
toolConfigService.saveParamValue(toolName, paramName, paramValue);
return ResponseEntity.ok().build();
}
/**
* 删除工具配置
* @param id 配置ID
* @return 删除结果
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteToolConfig(@PathVariable String id) {
log.debug("删除工具配置,ID:{}", id);
toolConfigService.deleteToolConfig(id);
return ResponseEntity.ok().build();
}
}
\ No newline at end of file
...@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.*; ...@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.*;
import pangea.hiagent.dto.ApiResponse; import pangea.hiagent.dto.ApiResponse;
import pangea.hiagent.model.Tool; import pangea.hiagent.model.Tool;
import pangea.hiagent.service.ToolService; import pangea.hiagent.service.ToolService;
import pangea.hiagent.utils.UserUtils;
import java.util.List; import java.util.List;
...@@ -33,11 +34,7 @@ public class ToolController { ...@@ -33,11 +34,7 @@ public class ToolController {
* @return 用户ID * @return 用户ID
*/ */
private String getCurrentUserId() { private String getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return UserUtils.getCurrentUserId();
if (authentication != null && authentication.getPrincipal() instanceof String) {
return (String) authentication.getPrincipal();
}
return null;
} }
/** /**
...@@ -129,7 +126,7 @@ public class ToolController { ...@@ -129,7 +126,7 @@ public class ToolController {
* 获取工具列表 * 获取工具列表
*/ */
@GetMapping @GetMapping
@Operation(summary = "获取工具列表", description = "获取所有可用工具") @Operation(summary = "获取工具列表", description = "获取当前用户可用工具")
public ApiResponse<List<Tool>> getTools() { public ApiResponse<List<Tool>> getTools() {
try { try {
String userId = getCurrentUserId(); String userId = getCurrentUserId();
...@@ -137,7 +134,7 @@ public class ToolController { ...@@ -137,7 +134,7 @@ public class ToolController {
return ApiResponse.error(4001, "用户未认证"); return ApiResponse.error(4001, "用户未认证");
} }
List<Tool> tools = toolService.getAllTools(); List<Tool> tools = toolService.getUserTools(userId);
return ApiResponse.success(tools, "获取工具列表成功"); return ApiResponse.success(tools, "获取工具列表成功");
} catch (Exception e) { } catch (Exception e) {
log.error("获取工具列表失败", e); log.error("获取工具列表失败", e);
......
...@@ -200,48 +200,121 @@ public class AgentChatService { ...@@ -200,48 +200,121 @@ public class AgentChatService {
} }
/** /**
* 处理流式响应 * 处理流式响应 - 改进版本
* 确保正确处理所有异常情况,完整发送所有内容
*/ */
private void handleStreamingResponse(Consumer<String> tokenConsumer, Prompt prompt, private void handleStreamingResponse(Consumer<String> tokenConsumer, Prompt prompt,
StreamingChatModel streamingChatModel, String sessionId) { StreamingChatModel streamingChatModel, String sessionId) {
StringBuilder fullText = new StringBuilder(); StringBuilder fullText = new StringBuilder();
java.util.concurrent.atomic.AtomicBoolean hasError = new java.util.concurrent.atomic.AtomicBoolean(false);
java.util.concurrent.atomic.AtomicBoolean isCompleted = new java.util.concurrent.atomic.AtomicBoolean(false);
try {
log.debug("开始处理流式响应,Session ID: {}", sessionId);
streamingChatModel.stream(prompt) streamingChatModel.stream(prompt)
.subscribe(chatResponse -> { .subscribe(
// onNext: 处理每个token
chatResponse -> {
try { try {
if (isCompleted.get()) {
log.trace("流式处理已完成,忽略新的token");
return;
}
String token = chatResponse.getResult().getOutput().getText(); String token = chatResponse.getResult().getOutput().getText();
if (token != null && !token.isEmpty()) { if (token != null && !token.isEmpty()) {
fullText.append(token); fullText.append(token);
log.trace("接收到token: length={}, 累计长度: {}", token.length(), fullText.length());
try {
if (tokenConsumer != null) {
tokenConsumer.accept(token); tokenConsumer.accept(token);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("处理token时发生错误", e); log.error("token消费者处理失败: {}", e.getMessage());
hasError.set(true);
throw e;
}
}
} catch (Exception e) {
log.error("处理token时发生错误: {}", e.getMessage(), e);
hasError.set(true);
} }
}, throwable -> { },
log.error("流式调用出错", throwable); // onError: 处理错误
throwable -> {
log.error("流式调用出错: {}", throwable.getMessage(), throwable);
hasError.set(true);
// 检查是否是401 Unauthorized错误 // 检查是否是401 Unauthorized错误
if (isUnauthorizedError(throwable)) { if (isUnauthorizedError(throwable)) {
log.error("LLM返回401未授权错误: {}", throwable.getMessage()); log.error("LLM返回401未授权错误");
try {
if (tokenConsumer != null) { if (tokenConsumer != null) {
tokenConsumer.accept(" 请配置API密钥"); tokenConsumer.accept("[错误] 请配置API密钥");
}
} catch (Exception e) {
log.error("发送API密钥错误失败: {}", e.getMessage());
} }
} else {
// 发送一般错误信息
try {
if (tokenConsumer != null) {
tokenConsumer.accept("[错误] 流式处理失败: " + throwable.getMessage());
} }
}, () -> { } catch (Exception e) {
log.error("发送错误信息失败: {}", e.getMessage());
}
}
},
// onComplete: 处理完成
() -> {
if (isCompleted.getAndSet(true)) {
log.trace("流式处理已标记为完成,忽略重复的完成回调");
return;
}
log.info("流式处理完成,总字符数: {}, 是否有错误: {}", fullText.length(), hasError.get());
try { try {
// 添加助理回复到ChatMemory // 添加助理回复到ChatMemory
if (fullText.length() > 0) {
AssistantMessage assistantMessage = new AssistantMessage(fullText.toString()); AssistantMessage assistantMessage = new AssistantMessage(fullText.toString());
try {
chatMemory.add(sessionId, Collections.singletonList(assistantMessage)); chatMemory.add(sessionId, Collections.singletonList(assistantMessage));
log.debug("助理回复已保存到ChatMemory");
} catch (Exception e) {
log.error("保存到ChatMemory失败: {}", e.getMessage());
}
}
// 发送完成事件,包含完整内容 // 发送完成事件,包含完整内容
if (tokenConsumer instanceof TokenConsumerWithCompletion) { if (tokenConsumer instanceof TokenConsumerWithCompletion) {
try {
((TokenConsumerWithCompletion) tokenConsumer).onComplete(fullText.toString()); ((TokenConsumerWithCompletion) tokenConsumer).onComplete(fullText.toString());
log.debug("完成事件已发送");
} catch (Exception e) {
log.error("发送完成事件失败: {}", e.getMessage());
}
} else if (tokenConsumer != null) {
log.warn("tokenConsumer不是TokenConsumerWithCompletion实例,无法发送完成事件");
} }
log.info("流式处理完成,总字符数: {}", fullText.length());
} catch (Exception e) { } catch (Exception e) {
log.error("保存对话记录失败", e); log.error("处理完成回调时发生错误: {}", e.getMessage(), e);
}
}
);
} catch (Exception e) {
log.error("流式处理异常: {}", e.getMessage(), e);
try {
if (tokenConsumer != null) {
tokenConsumer.accept("[错误] " + e.getMessage());
}
} catch (Exception sendErr) {
log.error("发送异常错误信息失败: {}", sendErr.getMessage());
}
} }
});
} }
/** /**
...@@ -389,6 +462,7 @@ public class AgentChatService { ...@@ -389,6 +462,7 @@ public class AgentChatService {
data.put("fullText", responseContent); data.put("fullText", responseContent);
data.put("dialogueId", dialogue.getId()); data.put("dialogueId", dialogue.getId());
data.put("isDone", true); data.put("isDone", true);
data.put("type", "complete"); // 确保包含type字段
// 发送完成事件到前端 // 发送完成事件到前端
sseEventManager.sendEvent(emitter, "complete", data, isCompleted); sseEventManager.sendEvent(emitter, "complete", data, isCompleted);
...@@ -424,10 +498,11 @@ public class AgentChatService { ...@@ -424,10 +498,11 @@ public class AgentChatService {
// 用于累积token的缓冲区 // 用于累积token的缓冲区
StringBuilder tokenBuffer = new StringBuilder(); StringBuilder tokenBuffer = new StringBuilder();
// 批量大小阈值 - 适合中文流式输出,提高传输效率 // 批量大小阈值 - 针对中文流式输出优化,平衡传输效率和响应延迟
int BATCH_SIZE = 50; // 增加批量大小到50个字符,减少SSE事件频率 // 更小的批量大小可以提供更流畅的用户体验
// 时间间隔阈值(毫秒)- 保证响应及时性 int BATCH_SIZE = 20; // 降低批量大小到20字符,提高响应频率
long FLUSH_INTERVAL = 200; // 200ms刷新间隔,平衡效率与响应速度 // 时间间隔阈值(毫秒)- 保证响应及时性,避免长时间无反馈
long FLUSH_INTERVAL = 100; // 100ms刷新间隔,确保流式输出连续性
// 上次刷新时间 // 上次刷新时间
java.util.concurrent.atomic.AtomicLong lastFlushTime = new java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis()); java.util.concurrent.atomic.AtomicLong lastFlushTime = new java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis());
// 是否已完成标记 // 是否已完成标记
...@@ -584,30 +659,43 @@ public class AgentChatService { ...@@ -584,30 +659,43 @@ public class AgentChatService {
}); });
// 不需要额外的等待和重复检查,onComplete回调会处理所有完成逻辑 // 不需要额外的等待和重复检查,onComplete回调会处理所有完成逻辑
// 这里只需要等待足够的时间让异步的onComplete回调执行完成 // 增加更合理的超时机制和调试日志
try { try {
// 通过轮询检查是否已完成,最多等待5秒 // 通过轮询检查是否已完成,最多等待5分钟(与SSE超时保持一致)
// long maxWaitTime = 5000; long maxWaitTime = 300000; // 5分钟
long maxWaitTime = 60000;
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
int checkCount = 0;
while (!isCompleted.get() && (System.currentTimeMillis() - startTime) < maxWaitTime) { while (!isCompleted.get() && (System.currentTimeMillis() - startTime) < maxWaitTime) {
Thread.sleep(100); // 每100ms检查一次 Thread.sleep(100); // 每100ms检查一次
checkCount++;
// 每10秒记录一次状态,便于调试
if (checkCount % 100 == 0) {
log.debug("等待ReAct处理完成,已等待 {} ms,检查次数: {}",
(System.currentTimeMillis() - startTime), checkCount);
} }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} }
long totalTime = System.currentTimeMillis() - startTime;
log.debug("ReAct处理等待结束,总耗时: {} ms,检查次数: {},是否完成: {}",
totalTime, checkCount, isCompleted.get());
// 如果在超时后仍未完成,发送超时错误(仅作为最后手段) // 如果在超时后仍未完成,发送超时错误(仅作为最后手段)
if (!isCompleted.get()) { if (!isCompleted.get()) {
log.warn("ReAct Agent流式处理超时,isCompleted未被设置为true"); log.warn("ReAct Agent流式处理超时,isCompleted未被设置为true,总耗时: {} ms", totalTime);
try { try {
sseEventManager.sendError(emitter, "处理超时:未能及时完成处理"); sseEventManager.sendError(emitter, "处理超时:未能及时完成处理(超过5分钟)");
} catch (Exception ignored) { } catch (Exception ignored) {
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("无法发送超时错误信息"); log.debug("无法发送超时错误信息");
} }
} }
} }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("等待ReAct处理完成时被中断");
}
// 关闭连接 // 关闭连接
sseEventManager.completeEmitter(emitter, isCompleted); sseEventManager.completeEmitter(emitter, isCompleted);
...@@ -652,10 +740,11 @@ public class AgentChatService { ...@@ -652,10 +740,11 @@ public class AgentChatService {
// 用于累积token的缓冲区 // 用于累积token的缓冲区
StringBuilder tokenBuffer = new StringBuilder(); StringBuilder tokenBuffer = new StringBuilder();
// 批量大小阈值 - 适合中文流式输出,提高传输效率 // 批量大小阈值 - 针对中文流式输出优化,平衡传输效率和响应延迟
int BATCH_SIZE = 50; // 增加批量大小到50个字符,减少SSE事件频率 // 更小的批量大小可以提供更流畅的用户体验
// 时间间隔阈值(毫秒)- 保证响应及时性 int BATCH_SIZE = 20; // 降低批量大小到20字符,提高响应频率
long FLUSH_INTERVAL = 200; // 200ms刷新间隔,平衡效率与响应速度 // 时间间隔阈值(毫秒)- 保证响应及时性,避免长时间无反馈
long FLUSH_INTERVAL = 100; // 100ms刷新间隔,确保流式输出连续性
// 上次刷新时间 // 上次刷新时间
java.util.concurrent.atomic.AtomicLong lastFlushTime = new java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis()); java.util.concurrent.atomic.AtomicLong lastFlushTime = new java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis());
// 完整内容 - 使用对象包装以支持线程安全的最终赋值 // 完整内容 - 使用对象包装以支持线程安全的最终赋值
......
package pangea.hiagent.core;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.Playwright;
/**
* Playwright管理器接口
* 提供统一的Playwright实例管理和用户隔离机制
*/
public interface PlaywrightManager {
/**
* 获取共享的Playwright实例
*
* @return Playwright实例
*/
Playwright getPlaywright();
/**
* 获取共享的浏览器实例
*
* @return Browser实例
*/
Browser getBrowser();
/**
* 为指定用户获取专用的浏览器上下文
* 实现用户级别的隔离
*
* @param userId 用户ID
* @return 该用户专用的BrowserContext
*/
BrowserContext getUserContext(String userId);
/**
* 为指定用户获取专用的浏览器上下文(带自定义配置)
*
* @param userId 用户ID
* @param options 浏览器上下文选项
* @return 该用户专用的BrowserContext
*/
BrowserContext getUserContext(String userId, Browser.NewContextOptions options);
/**
* 释放指定用户的浏览器上下文
*
* @param userId 用户ID
*/
void releaseUserContext(String userId);
/**
* 释放所有资源
*/
void destroy();
}
\ No newline at end of file
package pangea.hiagent.core;
import com.microsoft.playwright.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.concurrent.*;
/**
* Playwright管理器实现类
* 负责统一管理Playwright实例和用户隔离的BrowserContext
*/
@Slf4j
@Component
public class PlaywrightManagerImpl implements PlaywrightManager {
// 共享的Playwright实例
private Playwright playwright;
// 共享的浏览器实例
private Browser browser;
// 用户浏览器上下文映射表(用户ID -> BrowserContext)
private final ConcurrentMap<String, BrowserContext> userContexts = new ConcurrentHashMap<>();
// 用户上下文创建时间映射表(用于超时清理)
private final ConcurrentMap<String, Long> contextCreationTimes = new ConcurrentHashMap<>();
// 用户上下文超时时间(毫秒),默认30分钟
private static final long CONTEXT_TIMEOUT = 30 * 60 * 1000;
// 清理任务调度器
private ScheduledExecutorService cleanupScheduler;
/**
* 初始化Playwright和浏览器实例
*/
@PostConstruct
public void initialize() {
try {
log.info("正在初始化Playwright管理器...");
// 创建Playwright实例
this.playwright = Playwright.create();
// 启动Chrome浏览器,无头模式
this.browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(true)
.setArgs(java.util.Arrays.asList(
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--remote-allow-origins=*")));
// 初始化清理任务调度器
this.cleanupScheduler = Executors.newSingleThreadScheduledExecutor();
// 每5分钟检查一次超时的用户上下文
this.cleanupScheduler.scheduleAtFixedRate(this::cleanupExpiredContexts,
5, 5, TimeUnit.MINUTES);
log.info("Playwright管理器初始化成功");
} catch (Exception e) {
log.error("Playwright管理器初始化失败: ", e);
throw new RuntimeException("Failed to initialize Playwright manager", e);
}
}
@Override
public Playwright getPlaywright() {
if (playwright == null) {
throw new IllegalStateException("Playwright instance is not initialized");
}
return playwright;
}
@Override
public Browser getBrowser() {
if (browser == null || !browser.isConnected()) {
throw new IllegalStateException("Browser instance is not available");
}
return browser;
}
@Override
public BrowserContext getUserContext(String userId) {
Browser.NewContextOptions options = new Browser.NewContextOptions()
.setViewportSize(1344, 2992) // 设置视口大小,与前端一致;手机型号:Google Pixel 9 Pro XL
.setUserAgent("Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro XL Build/UP2A.250105.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36"); // 设置用户代理
return getUserContext(userId, options);
}
@Override
public BrowserContext getUserContext(String userId, Browser.NewContextOptions options) {
if (userId == null || userId.isEmpty()) {
throw new IllegalArgumentException("User ID cannot be null or empty");
}
if (options == null) {
options = new Browser.NewContextOptions();
}
// 尝试从缓存中获取已存在的上下文
BrowserContext context = userContexts.get(userId);
// 如果上下文不存在或已关闭,则创建新的
if (context == null || context.pages().isEmpty()) {
try {
log.debug("为用户 {} 创建新的浏览器上下文", userId);
context = browser.newContext(options);
userContexts.put(userId, context);
contextCreationTimes.put(userId, System.currentTimeMillis());
} catch (Exception e) {
log.error("为用户 {} 创建浏览器上下文失败", userId, e);
throw new RuntimeException("Failed to create browser context for user: " + userId, e);
}
}
return context;
}
@Override
public void releaseUserContext(String userId) {
if (userId == null || userId.isEmpty()) {
return;
}
BrowserContext context = userContexts.remove(userId);
contextCreationTimes.remove(userId);
if (context != null) {
try {
context.close();
log.debug("用户 {} 的浏览器上下文已释放", userId);
} catch (Exception e) {
log.warn("关闭用户 {} 的浏览器上下文时发生异常", userId, e);
}
}
}
/**
* 清理过期的用户上下文
*/
private void cleanupExpiredContexts() {
long currentTime = System.currentTimeMillis();
long expiredThreshold = currentTime - CONTEXT_TIMEOUT;
for (String userId : contextCreationTimes.keySet()) {
Long creationTime = contextCreationTimes.get(userId);
if (creationTime != null && creationTime < expiredThreshold) {
log.info("清理过期的用户上下文: {}", userId);
releaseUserContext(userId);
}
}
}
/**
* 销毁所有资源
*/
@PreDestroy
@Override
public void destroy() {
log.info("开始销毁Playwright管理器资源...");
try {
// 关闭清理任务调度器
if (cleanupScheduler != null) {
cleanupScheduler.shutdown();
if (!cleanupScheduler.awaitTermination(5, TimeUnit.SECONDS)) {
cleanupScheduler.shutdownNow();
}
}
} catch (Exception e) {
log.warn("关闭清理任务调度器时发生异常", e);
}
// 关闭所有用户上下文
for (String userId : userContexts.keySet()) {
releaseUserContext(userId);
}
// 关闭浏览器
try {
if (browser != null && browser.isConnected()) {
browser.close();
log.info("浏览器实例已关闭");
}
} catch (Exception e) {
log.warn("关闭浏览器实例时发生异常", e);
}
// 关闭Playwright
try {
if (playwright != null) {
playwright.close();
log.info("Playwright实例已关闭");
}
} catch (Exception e) {
log.warn("关闭Playwright实例时发生异常", e);
}
log.info("Playwright管理器资源已全部销毁");
}
}
\ No newline at end of file
# Playwright实例管理优化方案
## 1. 当前问题分析
通过对代码库的分析,我们发现当前Playwright的使用存在以下问题:
### 1.1 重复实例化问题
目前系统中有三个独立的Playwright实例:
1. **DomSyncHandler.java** - WebSocket处理器中的Playwright实例
2. **PlaywrightWebTools.java** - 网页自动化工具类中的Playwright实例
3. **HisenseSsoAuthTool.java** - 海信SSO认证工具类中的Playwright实例
每个实例都在各自的类中独立创建和管理,造成资源浪费和维护困难。
### 1.2 资源管理不统一
各个Playwright实例的生命周期管理分散在不同的类中,缺乏统一的资源回收机制,可能导致内存泄漏。
### 1.3 用户隔离缺失
当前实现中没有有效的用户隔离机制,所有操作都在共享的浏览器上下文中执行,存在安全隐患。
## 2. 优化目标
1. **统一实例管理**:创建单一的Playwright管理器,整个应用共享一个Playwright实例
2. **资源优化**:减少重复创建的开销,提高资源利用率
3. **用户隔离**:实现基于BrowserContext的用户隔离机制
4. **易于维护**:提供清晰的接口和生命周期管理
## 3. 设计方案
### 3.1 架构设计
我们将采用以下架构:
```
+---------------------+
| PlaywrightManager | <- 统一管理Playwright实例
+----------+----------+
|
| 1..1
|
+----------v----------+
| PlaywrightInstance | <- 封装Playwright核心实例
+----------+----------+
|
| 1..*
|
+----------v----------+
| BrowserContextPool | <- 管理用户隔离的BrowserContext
+----------+----------+
|
| 1..*
|
+----------v----------+
| BrowserContext | <- 每个用户独立的浏览上下文
+---------------------+
```
### 3.2 核心组件
#### 3.2.1 PlaywrightManager (接口)
定义Playwright管理器的核心接口:
- 获取共享Playwright实例
- 获取用户专属BrowserContext
- 资源释放
#### 3.2.2 PlaywrightManagerImpl (实现)
PlaywrightManager的具体实现:
- 单例模式确保只有一个Playwright实例
- 管理BrowserContext池
- 实现资源的初始化和销毁
#### 3.2.3 UserContextManager
负责用户上下文管理:
- 为每个用户创建独立的BrowserContext
- 管理上下文的生命周期
- 实现超时自动清理机制
## 4. 实施步骤
### 4.1 创建Playwright管理接口和实现类
1. 创建PlaywrightManager接口
2. 创建PlaywrightManagerImpl实现类
3. 配置Spring Bean管理
### 4.2 实现用户隔离机制
1. 创建UserContextManager类
2. 实现基于用户ID的BrowserContext分配
3. 添加超时清理机制
### 4.3 重构现有代码
1. 修改DomSyncHandler以使用新的Playwright管理器
2. 修改PlaywrightWebTools以使用新的Playwright管理器
3. 修改HisenseSsoAuthTool以使用新的Playwright管理器
## 5. 预期收益
### 5.1 性能提升
- 减少Playwright实例创建次数,降低系统开销
- 统一资源管理,避免内存泄漏
### 5.2 安全增强
- 实现用户级别的浏览上下文隔离,每个用户拥有独立的浏览环境
- 通过JWT认证机制获取真实用户ID,确保上下文隔离基于实际用户身份
- 防止用户间数据交叉污染
- 在用户会话结束时正确释放资源,防止资源泄露
- 拒绝未认证的WebSocket连接,提升了整体安全性
### 5.3 可维护性改善
- 集中管理Playwright相关资源
- 简化代码维护和升级
## 6. 风险与应对
### 6.1 兼容性风险
- **风险**:重构可能影响现有功能
- **应对**:充分测试,逐步替换
### 6.2 并发访问风险
- **风险**:多线程环境下可能出现资源竞争
- **应对**:使用线程安全的数据结构和同步机制
## 7. 后续优化建议
1. 添加监控指标,跟踪Playwright资源使用情况
2. 实现动态扩缩容的BrowserContext池
3. 添加更精细的权限控制机制
\ No newline at end of file
package pangea.hiagent.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 提示词模板DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PromptTemplateDto {
private String id;
private String name;
private String description;
private String templateContent;
private String paramSchema;
private String templateType;
private Integer isSystem;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String createdBy;
}
package pangea.hiagent.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
/**
* 定时器配置DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class TimerConfigDto {
private String id;
@NotBlank(message = "定时器名称不能为空")
private String name;
private String description;
@NotBlank(message = "Cron表达式不能为空")
private String cronExpression;
@NotNull(message = "启用状态不能为空")
private Integer enabled;
@NotBlank(message = "关联Agent ID不能为空")
private String agentId;
private String agentName;
private String promptTemplate;
private String paramsJson;
private LocalDateTime lastExecutionTime;
private LocalDateTime nextExecutionTime;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String createdBy;
}
package pangea.hiagent.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 定时器执行历史DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class TimerExecutionHistoryDto {
private String id;
private String timerId;
private String timerName;
private LocalDateTime executionTime;
private Integer success;
private String result;
private String errorMessage;
private Long executionDuration;
private String actualPrompt;
private LocalDateTime createdAt;
}
...@@ -15,6 +15,8 @@ import pangea.hiagent.dto.ApiResponse; ...@@ -15,6 +15,8 @@ import pangea.hiagent.dto.ApiResponse;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.security.authorization.AuthorizationDeniedException;
/** /**
* 全局异常处理器 * 全局异常处理器
* 统一处理系统中的各种异常 * 统一处理系统中的各种异常
...@@ -154,6 +156,34 @@ public class GlobalExceptionHandler { ...@@ -154,6 +156,34 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
} }
/**
* 处理授权拒绝异常
*/
@ExceptionHandler(AuthorizationDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAuthorizationDeniedException(
AuthorizationDeniedException e, HttpServletRequest request) {
log.warn("访问被拒绝: {} - URL: {}", e.getMessage(), request.getRequestURL());
// 检查响应是否已经提交
if (request.getAttribute("jakarta.servlet.error.exception") != null ||
(request instanceof org.springframework.web.context.request.NativeWebRequest &&
((org.springframework.web.context.request.NativeWebRequest) request).getNativeResponse() instanceof jakarta.servlet.http.HttpServletResponse &&
((jakarta.servlet.http.HttpServletResponse) ((org.springframework.web.context.request.NativeWebRequest) request).getNativeResponse()).isCommitted())) {
log.warn("响应已提交,无法发送访问拒绝错误: {}", request.getRequestURL());
// 响应已提交,无法发送错误响应
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
ApiResponse.ErrorDetail errorDetail = ApiResponse.ErrorDetail.builder()
.type("ACCESS_DENIED")
.details("您没有权限执行此操作")
.build();
ApiResponse<Void> response = ApiResponse.error(ErrorCode.FORBIDDEN.getCode(),
ErrorCode.FORBIDDEN.getMessage(), errorDetail);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response);
}
/** /**
* 处理系统异常 * 处理系统异常
* 增强版本:更好地处理SSE流式响应中的异常 * 增强版本:更好地处理SSE流式响应中的异常
...@@ -177,6 +207,11 @@ public class GlobalExceptionHandler { ...@@ -177,6 +207,11 @@ public class GlobalExceptionHandler {
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("SSE异步请求不可用,客户端已断开连接 - URL: {}", request.getRequestURL()); log.debug("SSE异步请求不可用,客户端已断开连接 - URL: {}", request.getRequestURL());
} }
} else if (e.getMessage() != null && e.getMessage().contains("response has already been committed")) {
// 响应已提交异常 - 客户端已断开
if (log.isDebugEnabled()) {
log.debug("响应已提交,客户端可能已断开连接 - URL: {}", request.getRequestURL());
}
} else { } else {
// 非IOException的SSE异常才记录为ERROR // 非IOException的SSE异常才记录为ERROR
log.error("SSE流式处理异常 - URL: {} - 异常类型: {} - 异常消息: {}", log.error("SSE流式处理异常 - URL: {} - 异常类型: {} - 异常消息: {}",
...@@ -228,6 +263,9 @@ public class GlobalExceptionHandler { ...@@ -228,6 +263,9 @@ public class GlobalExceptionHandler {
boolean isStreamPath = requestUri != null && (requestUri.contains("stream") || boolean isStreamPath = requestUri != null && (requestUri.contains("stream") ||
requestUri.contains("chat") && requestUri.contains("event")); requestUri.contains("chat") && requestUri.contains("event"));
// 特别检查chat-stream路径
boolean isChatStreamPath = requestUri != null && requestUri.contains("chat-stream");
// 检查异常链中是否包含SSE相关异常 // 检查异常链中是否包含SSE相关异常
boolean hasSseException = checkForSseException(e); boolean hasSseException = checkForSseException(e);
...@@ -237,9 +275,10 @@ public class GlobalExceptionHandler { ...@@ -237,9 +275,10 @@ public class GlobalExceptionHandler {
(e.getMessage().contains("Socket") || (e.getMessage().contains("Socket") ||
e.getMessage().contains("软件中止") || e.getMessage().contains("软件中止") ||
e.getMessage().contains("ServletOutputStream") || e.getMessage().contains("ServletOutputStream") ||
e.getMessage().contains("Pipe")); e.getMessage().contains("Pipe") ||
e.getMessage().contains("Software caused connection abort"));
return isAcceptingStream || isStreamContent || isStreamPath || hasSseException || isSseOperationException; return isAcceptingStream || isStreamContent || isStreamPath || isChatStreamPath || hasSseException || isSseOperationException;
} }
/** /**
...@@ -266,7 +305,8 @@ public class GlobalExceptionHandler { ...@@ -266,7 +305,8 @@ public class GlobalExceptionHandler {
message.contains("software") || message.contains("software") ||
message.contains("软件中止") || message.contains("软件中止") ||
message.contains("断开") || message.contains("断开") ||
message.contains("AsyncRequestNotUsable")) { message.contains("AsyncRequestNotUsable") ||
message.contains("Software caused connection abort")) {
return true; return true;
} }
} }
...@@ -278,7 +318,10 @@ public class GlobalExceptionHandler { ...@@ -278,7 +318,10 @@ public class GlobalExceptionHandler {
return true; return true;
} }
String causeMsg = cause.getMessage(); String causeMsg = cause.getMessage();
if (causeMsg != null && (causeMsg.contains("Socket") || causeMsg.contains("Pipe"))) { if (causeMsg != null && (causeMsg.contains("Socket") ||
causeMsg.contains("Pipe") ||
causeMsg.contains("Software caused connection abort") ||
causeMsg.contains("软件中止"))) {
return true; return true;
} }
return checkForSseException(cause); return checkForSseException(cause);
......
...@@ -47,6 +47,11 @@ public class CaffeineChatMemory implements ChatMemory { ...@@ -47,6 +47,11 @@ public class CaffeineChatMemory implements ChatMemory {
cache.put(conversationId, existingMessages); cache.put(conversationId, existingMessages);
log.debug("成功将{}条消息添加到会话{}", messages.size(), conversationId); log.debug("成功将{}条消息添加到会话{}", messages.size(), conversationId);
// 如果会话ID包含null,记录警告信息
if (conversationId != null && conversationId.contains("null")) {
log.warn("检测到包含'null'的会话ID: {},可能存在认证问题", conversationId);
}
} catch (Exception e) { } catch (Exception e) {
log.error("保存消息到Caffeine缓存时发生错误", e); log.error("保存消息到Caffeine缓存时发生错误", e);
throw new RuntimeException("Failed to save messages to Caffeine cache", e); throw new RuntimeException("Failed to save messages to Caffeine cache", e);
......
...@@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Autowired; ...@@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import pangea.hiagent.utils.UserUtils;
import pangea.hiagent.model.Agent; import pangea.hiagent.model.Agent;
import java.util.Collections; import java.util.Collections;
...@@ -46,6 +47,12 @@ public class MemoryService { ...@@ -46,6 +47,12 @@ public class MemoryService {
if (userId == null) { if (userId == null) {
userId = getCurrentUserId(); userId = getCurrentUserId();
} }
// 如果userId仍然为null,使用默认值避免生成"null_xxx"格式的会话ID
if (userId == null) {
userId = "unknown-user";
}
return userId + "_" + agent.getId(); return userId + "_" + agent.getId();
} }
...@@ -54,9 +61,11 @@ public class MemoryService { ...@@ -54,9 +61,11 @@ public class MemoryService {
* @return 用户ID * @return 用户ID
*/ */
private String getCurrentUserId() { private String getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String userId = UserUtils.getCurrentUserId();
return (authentication != null && authentication.getPrincipal() != null) ? if (userId == null) {
(String) authentication.getPrincipal() : null; log.warn("无法通过UserUtils获取当前用户ID");
}
return userId;
} }
/** /**
......
package pangea.hiagent.model;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 提示词模板实体类
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("hiagent_prompt_template")
public class PromptTemplate extends BaseEntity {
/**
* 模板名称
*/
private String name;
/**
* 模板描述
*/
private String description;
/**
* 模板内容
*/
private String templateContent;
/**
* 参数Schema定义(JSON格式)
*/
private String paramSchema;
/**
* 模板类型
*/
private String templateType;
/**
* 是否为系统模板(0-自定义,1-系统)
*/
private Integer isSystem;
}
package pangea.hiagent.model;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 定时器配置实体类
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("hiagent_timer_config")
public class TimerConfig extends BaseEntity {
/**
* 定时器名称
*/
private String name;
/**
* 定时器描述
*/
private String description;
/**
* Cron表达式(支持秒级)
*/
private String cronExpression;
/**
* 启用状态(0-禁用,1-启用)
*/
private Integer enabled;
/**
* 关联的Agent ID
*/
private String agentId;
/**
* 关联的Agent名称
*/
private String agentName;
/**
* 提示词模板
*/
private String promptTemplate;
/**
* 动态参数配置(JSON格式)
*/
private String paramsJson;
/**
* 最后执行时间
*/
private java.time.LocalDateTime lastExecutionTime;
/**
* 下次执行时间
*/
private java.time.LocalDateTime nextExecutionTime;
}
package pangea.hiagent.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 定时器执行历史实体类
*/
@Data
@TableName("hiagent_timer_execution_history")
public class TimerExecutionHistory {
/**
* 主键ID,使用数据库自增策略
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 关联的定时器ID
*/
@TableField("timer_id")
private String timerId;
/**
* 定时器名称
*/
@TableField("timer_name")
private String timerName;
/**
* 执行时间
*/
@TableField("execution_time")
private LocalDateTime executionTime;
/**
* 执行结果(0-失败,1-成功)
*/
@TableField("success")
private Integer success;
/**
* 执行结果详情
*/
@TableField("result")
private String result;
/**
* 错误信息
*/
@TableField("error_message")
private String errorMessage;
/**
* 执行时长(毫秒)
*/
@TableField("execution_duration")
private Long executionDuration;
/**
* 实际执行的提示词
*/
@TableField("actual_prompt")
private String actualPrompt;
/**
* 创建时间
*/
@TableField("created_at")
private LocalDateTime createdAt;
/**
* 更新时间
*/
@TableField("updated_at")
private LocalDateTime updatedAt;
/**
* 创建人
*/
@TableField("created_by")
private String createdBy;
/**
* 更新人
*/
@TableField("updated_by")
private String updatedBy;
/**
* 删除标记(0-未删除,1-已删除)
*/
@TableLogic
@TableField("deleted")
private Integer deleted;
/**
* 备注
*/
@TableField("remark")
private String remark;
}
package pangea.hiagent.model;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 工具配置实体类
* 用于存储工具参数配置
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("tool_configs")
public class ToolConfig extends BaseEntity {
/**
* 工具名称
*/
@TableField("tool_name")
private String toolName;
/**
* 参数名称
*/
@TableField("param_name")
private String paramName;
/**
* 参数值
*/
@TableField("param_value")
private String paramValue;
/**
* 参数描述
*/
private String description;
/**
* 默认值
*/
@TableField("default_value")
private String defaultValue;
/**
* 参数类型
*/
private String type;
/**
* 是否必填
*/
private Boolean required;
/**
* 参数分组
*/
@TableField("group_name")
private String groupName;
}
\ No newline at end of file
...@@ -246,17 +246,45 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -246,17 +246,45 @@ public class DefaultReactExecutor implements ReactExecutor {
} }
}, },
throwable -> { throwable -> {
log.error("流式处理出错", throwable); log.error("流式处理出错: {}", throwable.getMessage(), throwable);
// 检查是否是401 Unauthorized错误 // 检查是否是401 Unauthorized错误
if (isUnauthorizedError(throwable)) { if (isUnauthorizedError(throwable)) {
log.error("LLM返回401未授权错误: {}", throwable.getMessage()); log.error("LLM返回401未授权错误,请检查API密钥配置");
sendErrorToConsumer(tokenConsumer, " 请配置API密钥"); recordStreamError("LLM返回401未授权错误");
try {
if (tokenConsumer != null) {
tokenConsumer.accept("[错误] 请配置API密钥");
}
} catch (Exception e) {
log.error("发送API密钥错误失败: {}", e.getMessage());
}
} else if (throwable.getMessage() != null && throwable.getMessage().contains("timeout")) {
log.error("流式处理超时: {}", throwable.getMessage());
recordStreamError("流式处理超时");
try {
if (tokenConsumer != null) {
tokenConsumer.accept("[错误] 流式处理超时,请稍后重试");
}
} catch (Exception e) {
log.error("发送超时错误失败: {}", e.getMessage());
}
} else { } else {
recordStreamError(throwable.getMessage()); // 一般错误
sendErrorToConsumer(tokenConsumer, throwable.getMessage()); recordStreamError("流式处理异常: " + throwable.getMessage());
try {
if (tokenConsumer != null) {
tokenConsumer.accept("[错误] 流式处理失败: " + throwable.getMessage());
} }
} catch (Exception e) {
log.error("发送错误信息失败: {}", e.getMessage());
}
}
// 确保即使出现错误也能标记完成
log.debug("标记流式处理完成(因错误而终止)");
}, },
() -> { () -> {
try {
log.info("流式处理完成"); log.info("流式处理完成");
// 触发最终答案步骤 // 触发最终答案步骤
triggerFinalAnswerStep(fullResponse.toString()); triggerFinalAnswerStep(fullResponse.toString());
...@@ -273,6 +301,17 @@ public class DefaultReactExecutor implements ReactExecutor { ...@@ -273,6 +301,17 @@ public class DefaultReactExecutor implements ReactExecutor {
// 发送完成事件,包含完整内容 // 发送完成事件,包含完整内容
sendCompletionEvent(tokenConsumer, fullResponse.toString()); sendCompletionEvent(tokenConsumer, fullResponse.toString());
} catch (Exception e) {
log.error("处理流式完成回调时发生错误", e);
// 即使在完成回调中出现错误,也要确保标记完成
if (tokenConsumer instanceof AgentChatService.TokenConsumerWithCompletion) {
try {
((AgentChatService.TokenConsumerWithCompletion) tokenConsumer).onComplete("[处理完成时发生错误] " + e.getMessage());
} catch (Exception ex) {
log.error("调用onComplete时发生错误", ex);
}
}
}
} }
); );
......
package pangea.hiagent.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import pangea.hiagent.model.PromptTemplate;
/**
* 提示词模板Repository接口
*/
@Mapper
public interface PromptTemplateRepository extends BaseMapper<PromptTemplate> {
}
package pangea.hiagent.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import pangea.hiagent.model.TimerConfig;
/**
* 定时器配置Repository接口
*/
@Mapper
public interface TimerConfigRepository extends BaseMapper<TimerConfig> {
}
package pangea.hiagent.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import pangea.hiagent.model.TimerExecutionHistory;
/**
* 定时器执行历史Repository接口
*/
@Mapper
public interface TimerExecutionHistoryRepository extends BaseMapper<TimerExecutionHistory> {
}
package pangea.hiagent.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import pangea.hiagent.model.ToolConfig;
import java.util.List;
import java.util.Map;
/**
* 工具配置仓库接口
* 提供工具配置数据访问功能
*/
@Mapper
public interface ToolConfigRepository extends BaseMapper<ToolConfig> {
/**
* 根据工具名称获取配置列表
* @param toolName 工具名称
* @return 配置列表
*/
@Select("SELECT * FROM tool_configs WHERE tool_name = #{toolName} AND deleted = 0")
List<ToolConfig> findByToolName(String toolName);
/**
* 根据工具名称和参数名称获取配置
* @param toolName 工具名称
* @param paramName 参数名称
* @return 配置对象
*/
@Select("SELECT * FROM tool_configs WHERE tool_name = #{toolName} AND param_name = #{paramName} AND deleted = 0 LIMIT 1")
ToolConfig findByToolNameAndParamName(String toolName, String paramName);
/**
* 获取所有工具配置列表
* @return 配置列表
*/
@Select("SELECT * FROM tool_configs WHERE deleted = 0 ORDER BY tool_name, group_name, param_name")
List<ToolConfig> findAllActive();
/**
* 根据工具名称获取参数键值对
* @param toolName 工具名称
* @return 参数键值对
*/
@Select("SELECT param_name, param_value FROM tool_configs WHERE tool_name = #{toolName} AND deleted = 0")
List<Map<String, Object>> findParamValuesByToolName(String toolName);
}
\ No newline at end of file
package pangea.hiagent.scheduler;
import lombok.extern.slf4j.Slf4j;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.quartz.QuartzJobBean;
import pangea.hiagent.service.TimerService;
/**
* 定时器任务执行类
* 由Quartz调度器触发,执行具体的定时器任务
*/
@Slf4j
public class TimerJob extends QuartzJobBean {
@Autowired
private TimerService timerService;
/**
* 执行定时器任务
* @param context 任务执行上下文,包含任务的参数信息
*/
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
try {
// 从上下文中获取定时器ID
String timerId = context.getJobDetail().getJobDataMap().getString("timerId");
log.info("开始执行定时器任务: {}", timerId);
if (timerId == null || timerId.isEmpty()) {
log.error("定时器任务缺少timerId参数");
return;
}
// 调用TimerService执行定时器任务
timerService.executeTimerTask(timerId);
log.info("定时器任务执行完成: {}", timerId);
} catch (Exception e) {
log.error("定时器任务执行失败", e);
throw new JobExecutionException(e);
}
}
}
package pangea.hiagent.scheduler;
import com.cronutils.model.CronType;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.parser.CronParser;
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import pangea.hiagent.model.TimerConfig;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Calendar;
import java.util.Date;
/**
* 定时器调度管理器
* 负责管理Quartz的Job和Trigger,实现动态添加、更新和删除定时任务
*/
@Slf4j
@Component
public class TimerScheduler {
@Autowired
private Scheduler scheduler;
// Cron解析器,用于验证Cron表达式
private final CronParser cronParser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ));
/**
* 添加或更新定时器任务
* @param timerConfig 定时器配置信息
*/
public void addOrUpdateTimer(TimerConfig timerConfig) {
try {
log.info("添加或更新定时器任务: {}", timerConfig.getId());
// 验证Cron表达式
cronParser.parse(timerConfig.getCronExpression());
// 构建JobDetail
JobDetail jobDetail = buildJobDetail(timerConfig);
// 构建Trigger
Trigger trigger = buildTrigger(jobDetail, timerConfig);
// 添加或更新Job和Trigger
if (scheduler.checkExists(jobDetail.getKey())) {
scheduler.rescheduleJob(trigger.getKey(), trigger);
log.info("更新定时器任务: {}", timerConfig.getId());
} else {
scheduler.scheduleJob(jobDetail, trigger);
log.info("添加定时器任务: {}", timerConfig.getId());
}
// 如果定时器被禁用,暂停任务
if (timerConfig.getEnabled() == 0) {
scheduler.pauseJob(jobDetail.getKey());
log.info("暂停定时器任务: {}", timerConfig.getId());
} else {
// 如果定时器被启用,恢复任务
scheduler.resumeJob(jobDetail.getKey());
log.info("恢复定时器任务: {}", timerConfig.getId());
}
} catch (Exception e) {
log.error("添加或更新定时器任务失败: {}", timerConfig.getId(), e);
throw new RuntimeException("添加或更新定时器任务失败: " + e.getMessage(), e);
}
}
/**
* 删除定时器任务
* @param timerId 定时器ID
*/
public void deleteTimer(String timerId) {
try {
log.info("删除定时器任务: {}", timerId);
JobKey jobKey = JobKey.jobKey("timerJob_" + timerId, "timerGroup");
scheduler.deleteJob(jobKey);
log.info("删除定时器任务成功: {}", timerId);
} catch (Exception e) {
log.error("删除定时器任务失败: {}", timerId, e);
throw new RuntimeException("删除定时器任务失败: " + e.getMessage(), e);
}
}
/**
* 启用定时器任务
* @param timerId 定时器ID
*/
public void enableTimer(String timerId) {
try {
log.info("启用定时器任务: {}", timerId);
JobKey jobKey = JobKey.jobKey("timerJob_" + timerId, "timerGroup");
scheduler.resumeJob(jobKey);
log.info("启用定时器任务成功: {}", timerId);
} catch (Exception e) {
log.error("启用定时器任务失败: {}", timerId, e);
throw new RuntimeException("启用定时器任务失败: " + e.getMessage(), e);
}
}
/**
* 禁用定时器任务
* @param timerId 定时器ID
*/
public void disableTimer(String timerId) {
try {
log.info("禁用定时器任务: {}", timerId);
JobKey jobKey = JobKey.jobKey("timerJob_" + timerId, "timerGroup");
scheduler.pauseJob(jobKey);
log.info("禁用定时器任务成功: {}", timerId);
} catch (Exception e) {
log.error("禁用定时器任务失败: {}", timerId, e);
throw new RuntimeException("禁用定时器任务失败: " + e.getMessage(), e);
}
}
/**
* 构建JobDetail
* @param timerConfig 定时器配置信息
* @return JobDetail对象
*/
private JobDetail buildJobDetail(TimerConfig timerConfig) {
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put("timerId", timerConfig.getId());
return JobBuilder.newJob(TimerJob.class)
.withIdentity("timerJob_" + timerConfig.getId(), "timerGroup")
.withDescription(timerConfig.getName())
.usingJobData(jobDataMap)
.storeDurably(false)
.build();
}
/**
* 构建Trigger
* @param jobDetail JobDetail对象
* @param timerConfig 定时器配置信息
* @return Trigger对象
*/
private Trigger buildTrigger(JobDetail jobDetail, TimerConfig timerConfig) {
return TriggerBuilder.newTrigger()
.forJob(jobDetail)
.withIdentity("timerTrigger_" + timerConfig.getId(), "timerGroup")
.withDescription(timerConfig.getName())
.withSchedule(CronScheduleBuilder.cronSchedule(timerConfig.getCronExpression()))
.startNow()
.build();
}
/**
* 计算下次执行时间
* @param cronExpression Cron表达式
* @return 下次执行时间
*/
public LocalDateTime calculateNextExecutionTime(String cronExpression) {
try {
// 简化实现,暂时不计算下次执行时间
// 后续可以使用其他方式实现,或者升级cron-utils库版本
return null;
} catch (Exception e) {
log.error("计算下次执行时间失败: {}", cronExpression, e);
return null;
}
}
}
...@@ -5,7 +5,9 @@ import org.springframework.security.access.PermissionEvaluator; ...@@ -5,7 +5,9 @@ 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.model.Agent; import pangea.hiagent.model.Agent;
import pangea.hiagent.model.TimerConfig;
import pangea.hiagent.service.AgentService; import pangea.hiagent.service.AgentService;
import pangea.hiagent.service.TimerService;
import java.io.Serializable; import java.io.Serializable;
...@@ -18,13 +20,15 @@ import java.io.Serializable; ...@@ -18,13 +20,15 @@ import java.io.Serializable;
public class DefaultPermissionEvaluator implements PermissionEvaluator { public class DefaultPermissionEvaluator implements PermissionEvaluator {
private final AgentService agentService; private final AgentService agentService;
private final TimerService timerService;
public DefaultPermissionEvaluator(AgentService agentService) { public DefaultPermissionEvaluator(AgentService agentService, TimerService timerService) {
this.agentService = agentService; this.agentService = agentService;
this.timerService = timerService;
} }
/** /**
* 检查用户是否有权访问指定Agent * 检查用户是否有权访问指定资源
*/ */
@Override @Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) { public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
...@@ -35,18 +39,20 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator { ...@@ -35,18 +39,20 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator {
String userId = (String) authentication.getPrincipal(); String userId = (String) authentication.getPrincipal();
String perm = (String) permission; String perm = (String) permission;
// 目前只处理Agent访问权限 // 处理Agent访问权限
if (targetDomainObject instanceof Agent) { if (targetDomainObject instanceof Agent) {
Agent agent = (Agent) targetDomainObject; Agent agent = (Agent) targetDomainObject;
return checkAgentAccess(userId, agent, perm); return checkAgentAccess(userId, agent, perm);
} else if (targetDomainObject instanceof String) {
// 假设targetDomainObject是Agent ID
String agentId = (String) targetDomainObject;
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
return false;
} }
return checkAgentAccess(userId, agent, perm); // 处理TimerConfig访问权限
else if (targetDomainObject instanceof TimerConfig) {
TimerConfig timer = (TimerConfig) targetDomainObject;
return checkTimerAccess(userId, timer, perm);
}
// 处理基于ID的资源访问
else if (targetDomainObject instanceof String) {
// 这种情况在hasPermission(Authentication, Serializable, String, Object)方法中处理
return false;
} }
return false; return false;
...@@ -69,6 +75,14 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator { ...@@ -69,6 +75,14 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator {
} }
return checkAgentAccess(userId, agent, perm); return checkAgentAccess(userId, agent, perm);
} }
// 处理TimerConfig资源的权限检查
else if ("TimerConfig".equals(targetType)) {
TimerConfig timer = timerService.getTimerById(targetId.toString());
if (timer == null) {
return false;
}
return checkTimerAccess(userId, timer, perm);
}
return false; return false;
} }
...@@ -102,12 +116,41 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator { ...@@ -102,12 +116,41 @@ public class DefaultPermissionEvaluator implements PermissionEvaluator {
} }
} }
/**
* 检查用户对TimerConfig的访问权限
*/
private boolean checkTimerAccess(String userId, TimerConfig timer, String permission) {
// 管理员可以访问所有定时器
if (isAdminUser(userId)) {
return true;
}
// 检查定时器创建者
if (timer.getCreatedBy() != null && timer.getCreatedBy().equals(userId)) {
return true;
}
// 根据权限类型进行检查
switch (permission.toLowerCase()) {
case "read":
// 所有用户都可以读取公开的定时器(如果有此概念)
return 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 // 例如查询数据库或检查特殊用户ID
// 当前实现保留原有逻辑,但可以通过配置或数据库来管理管理员用户
return "admin".equals(userId) || "user-001".equals(userId); return "admin".equals(userId) || "user-001".equals(userId);
} }
} }
\ No newline at end of file
...@@ -72,11 +72,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { ...@@ -72,11 +72,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
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()); log.debug("已设置SecurityContext中的认证信息,用户ID: {}, 权限: {}", userId, authentication.getAuthorities());
// 认证成功后继续处理请求
filterChain.doFilter(request, response);
log.debug("JwtAuthenticationFilter处理完成: {} {}", request.getMethod(), request.getRequestURI());
return;
} else { } else {
log.warn("从token中提取的用户ID为空"); log.warn("从token中提取的用户ID为空");
} }
...@@ -88,6 +83,34 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { ...@@ -88,6 +83,34 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
} }
} catch (Exception e) { } catch (Exception e) {
log.error("JWT认证处理异常", e); log.error("JWT认证处理异常", e);
// 检查响应是否已经提交
if (!response.isCommitted()) {
try {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"认证失败\",\"timestamp\":" + System.currentTimeMillis() + "}");
} catch (IOException ioException) {
log.error("发送认证失败响应时发生IO异常", ioException);
}
} else {
log.warn("响应已经提交,无法发送认证失败响应");
}
}
// 检查是否是SSE端点并且响应已经提交
if ((isStreamEndpoint || isTimelineEndpoint) && response.isCommitted()) {
log.debug("SSE端点响应已提交,跳过过滤器链继续处理");
return;
}
// 特别处理流式端点的权限问题
if (isStreamEndpoint || isTimelineEndpoint) {
// 检查是否已认证
if (SecurityContextHolder.getContext().getAuthentication() == null) {
log.warn("流式端点未认证访问: {} {}", request.getMethod(), request.getRequestURI());
// 对于SSE端点,如果未认证,我们不立即返回错误,而是让后续处理决定
// 因为客户端可能会在重新连接时带上token
}
} }
// 继续执行过滤器链,让Spring Security的其他过滤器处理认证和授权 // 继续执行过滤器链,让Spring Security的其他过滤器处理认证和授权
......
...@@ -15,6 +15,8 @@ import pangea.hiagent.repository.LlmConfigRepository; ...@@ -15,6 +15,8 @@ import pangea.hiagent.repository.LlmConfigRepository;
import pangea.hiagent.llm.LlmModelFactory; import pangea.hiagent.llm.LlmModelFactory;
import java.util.List; import java.util.List;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
/** /**
* Agent服务类 * Agent服务类
...@@ -43,6 +45,7 @@ public class AgentService { ...@@ -43,6 +45,7 @@ public class AgentService {
* 创建Agent * 创建Agent
*/ */
@Transactional @Transactional
@CacheEvict(value = {"agents", "agent"}, allEntries = true)
public Agent createAgent(Agent agent) { public Agent createAgent(Agent agent) {
log.info("创建Agent: {}", agent.getName()); log.info("创建Agent: {}", agent.getName());
...@@ -80,6 +83,7 @@ public class AgentService { ...@@ -80,6 +83,7 @@ public class AgentService {
* 更新Agent * 更新Agent
*/ */
@Transactional @Transactional
@CacheEvict(value = {"agents", "agent"}, allEntries = true)
public Agent updateAgent(Agent agent) { public Agent updateAgent(Agent agent) {
log.info("更新Agent: {}", agent.getId()); log.info("更新Agent: {}", agent.getId());
...@@ -99,6 +103,7 @@ public class AgentService { ...@@ -99,6 +103,7 @@ public class AgentService {
* 删除Agent * 删除Agent
*/ */
@Transactional @Transactional
@CacheEvict(value = {"agents", "agent"}, allEntries = true)
public void deleteAgent(String id) { public void deleteAgent(String id) {
log.info("删除Agent: {}", id); log.info("删除Agent: {}", id);
agentRepository.deleteById(id); agentRepository.deleteById(id);
...@@ -110,6 +115,7 @@ public class AgentService { ...@@ -110,6 +115,7 @@ public class AgentService {
* @param id Agent ID * @param id Agent ID
* @return Agent对象,如果不存在则返回null * @return Agent对象,如果不存在则返回null
*/ */
@Cacheable(value = "agent", key = "#id")
public Agent getAgent(String id) { public Agent getAgent(String id) {
if (id == null || id.isEmpty()) { if (id == null || id.isEmpty()) {
log.warn("尝试使用无效ID获取Agent"); log.warn("尝试使用无效ID获取Agent");
...@@ -123,6 +129,7 @@ public class AgentService { ...@@ -123,6 +129,7 @@ public class AgentService {
* *
* @return Agent列表 * @return Agent列表
*/ */
@Cacheable(value = "agents")
public List<Agent> listAgents() { public List<Agent> listAgents() {
List<Agent> agents = agentRepository.selectList(null); List<Agent> agents = agentRepository.selectList(null);
log.info("获取到 {} 个Agent", agents != null ? agents.size() : 0); log.info("获取到 {} 个Agent", agents != null ? agents.size() : 0);
...@@ -150,6 +157,7 @@ public class AgentService { ...@@ -150,6 +157,7 @@ public class AgentService {
/** /**
* 获取用户的Agent列表 * 获取用户的Agent列表
*/ */
@Cacheable(value = "agents", key = "#userId")
public List<Agent> getUserAgents(String userId) { public List<Agent> getUserAgents(String userId) {
// 使用优化的查询方法 // 使用优化的查询方法
return agentRepository.findActiveAgentsByOwnerWithExplicitColumns(userId); return agentRepository.findActiveAgentsByOwnerWithExplicitColumns(userId);
......
package pangea.hiagent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pangea.hiagent.model.TimerExecutionHistory;
import pangea.hiagent.repository.TimerExecutionHistoryRepository;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
/**
* 执行历史清理服务
* 定期清理过期的执行历史记录
*/
@Slf4j
@Service
public class HistoryCleanupService {
private final TimerExecutionHistoryRepository timerExecutionHistoryRepository;
public HistoryCleanupService(TimerExecutionHistoryRepository timerExecutionHistoryRepository) {
this.timerExecutionHistoryRepository = timerExecutionHistoryRepository;
}
/**
* 清理过期的执行历史记录
* 每天凌晨2点执行一次,清理30天前的记录
*/
@Scheduled(cron = "0 0 2 * * ?")
@Transactional
public void cleanupOldHistory() {
log.info("开始清理过期执行历史记录");
// 计算30天前的时间
LocalDateTime cutoffTime = LocalDateTime.now().minus(30, ChronoUnit.DAYS);
// 构建查询条件:执行时间小于30天前
LambdaQueryWrapper<TimerExecutionHistory> wrapper = new LambdaQueryWrapper<>();
wrapper.lt(TimerExecutionHistory::getExecutionTime, cutoffTime);
// 清理30天前的执行历史记录
int deletedCount = timerExecutionHistoryRepository.delete(wrapper);
log.info("清理完成,共删除 {} 条过期执行历史记录", deletedCount);
}
}
\ No newline at end of file
package pangea.hiagent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Service;
import pangea.hiagent.dto.TimerExecutionHistoryDto;
import pangea.hiagent.model.TimerExecutionHistory;
import pangea.hiagent.repository.TimerExecutionHistoryRepository;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
/**
* 执行历史服务类
* 负责执行历史的查询、统计和管理
*/
@Service
public class HistoryService {
private final TimerExecutionHistoryRepository timerExecutionHistoryRepository;
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public HistoryService(TimerExecutionHistoryRepository timerExecutionHistoryRepository) {
this.timerExecutionHistoryRepository = timerExecutionHistoryRepository;
}
/**
* 获取执行历史列表,支持多条件筛选和分页
*
* @param timerId 定时器ID
* @param success 执行结果(1-成功,0-失败)
* @param startTime 开始时间
* @param endTime 结束时间
* @param page 页码
* @param size 每页大小
* @return 执行历史列表
*/
public Page<TimerExecutionHistoryDto> getExecutionHistoryList(
String timerId, Integer success, String startTime, String endTime, int page, int size) {
// 构建查询条件
LambdaQueryWrapper<TimerExecutionHistory> wrapper = buildQueryWrapper(timerId, success, startTime, endTime);
// 按执行时间倒序排序
wrapper.orderByDesc(TimerExecutionHistory::getExecutionTime);
// 分页查询
Page<TimerExecutionHistory> pagination = new Page<>(page, size);
timerExecutionHistoryRepository.selectPage(pagination, wrapper);
// 转换为DTO
List<TimerExecutionHistoryDto> records = pagination.getRecords().stream()
.map(this::convertToDto)
.collect(Collectors.toList());
// 创建新的分页对象并设置数据
Page<TimerExecutionHistoryDto> resultPage = new Page<>(pagination.getCurrent(), pagination.getSize(), pagination.getTotal());
resultPage.setRecords(records);
return resultPage;
}
/**
* 获取指定定时器的执行历史
*
* @param timerId 定时器ID
* @param page 页码
* @param size 每页大小
* @return 执行历史列表
*/
public Page<TimerExecutionHistoryDto> getExecutionHistoryByTimerId(String timerId, int page, int size) {
return getExecutionHistoryList(timerId, null, null, null, page, size);
}
/**
* 获取执行历史详情
*
* @param id 执行历史ID
* @return 执行历史详情
*/
public TimerExecutionHistoryDto getExecutionHistoryDetail(Long id) {
TimerExecutionHistory history = timerExecutionHistoryRepository.selectById(id);
return history != null ? convertToDto(history) : null;
}
/**
* 构建查询条件
*/
private LambdaQueryWrapper<TimerExecutionHistory> buildQueryWrapper(
String timerId, Integer success, String startTime, String endTime) {
LambdaQueryWrapper<TimerExecutionHistory> wrapper = new LambdaQueryWrapper<>();
// 定时器ID条件
if (timerId != null && !timerId.isEmpty()) {
wrapper.eq(TimerExecutionHistory::getTimerId, timerId);
}
// 执行结果条件
if (success != null) {
wrapper.eq(TimerExecutionHistory::getSuccess, success);
}
// 开始时间条件
if (startTime != null && !startTime.isEmpty()) {
LocalDateTime start = LocalDateTime.parse(startTime, formatter);
wrapper.ge(TimerExecutionHistory::getExecutionTime, start);
}
// 结束时间条件
if (endTime != null && !endTime.isEmpty()) {
LocalDateTime end = LocalDateTime.parse(endTime, formatter);
wrapper.le(TimerExecutionHistory::getExecutionTime, end);
}
return wrapper;
}
/**
* 转换实体为DTO
*/
private TimerExecutionHistoryDto convertToDto(TimerExecutionHistory history) {
return TimerExecutionHistoryDto.builder()
.id(history.getId() != null ? history.getId().toString() : null)
.timerId(history.getTimerId())
.timerName(history.getTimerName())
.executionTime(history.getExecutionTime())
.success(history.getSuccess())
.result(history.getResult())
.errorMessage(history.getErrorMessage())
.actualPrompt(history.getActualPrompt())
.executionDuration(history.getExecutionDuration())
.build();
}
/**
* 统计定时器执行成功次数
*
* @param timerId 定时器ID
* @return 成功次数
*/
public long countSuccessExecution(String timerId) {
LambdaQueryWrapper<TimerExecutionHistory> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TimerExecutionHistory::getTimerId, timerId)
.eq(TimerExecutionHistory::getSuccess, 1);
return timerExecutionHistoryRepository.selectCount(wrapper);
}
/**
* 统计定时器执行失败次数
*
* @param timerId 定时器ID
* @return 失败次数
*/
public long countFailedExecution(String timerId) {
LambdaQueryWrapper<TimerExecutionHistory> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TimerExecutionHistory::getTimerId, timerId)
.eq(TimerExecutionHistory::getSuccess, 0);
return timerExecutionHistoryRepository.selectCount(wrapper);
}
}
\ No newline at end of file
package pangea.hiagent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pangea.hiagent.model.PromptTemplate;
import pangea.hiagent.repository.PromptTemplateRepository;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 提示词模板服务类
* 负责提示词模板的管理和渲染
*/
@Slf4j
@Service
public class PromptTemplateService {
private final PromptTemplateRepository promptTemplateRepository;
// 模板变量正则表达式:{{variableName}}
private static final Pattern TEMPLATE_VARIABLE_PATTERN = Pattern.compile("\\{\\{(\\w+)\\}\\}");
public PromptTemplateService(PromptTemplateRepository promptTemplateRepository) {
this.promptTemplateRepository = promptTemplateRepository;
}
/**
* 创建提示词模板
*/
@Transactional
@CacheEvict(value = {"promptTemplates", "promptTemplate"}, allEntries = true)
public PromptTemplate createTemplate(PromptTemplate template) {
log.info("创建提示词模板: {}", template.getName());
// 设置默认值
if (template.getIsSystem() == null) {
template.setIsSystem(0); // 默认自定义模板
}
promptTemplateRepository.insert(template);
return template;
}
/**
* 更新提示词模板
*/
@Transactional
@CacheEvict(value = {"promptTemplates", "promptTemplate"}, allEntries = true)
public PromptTemplate updateTemplate(PromptTemplate template) {
log.info("更新提示词模板: {}", template.getId());
// 获取现有模板
PromptTemplate existingTemplate = promptTemplateRepository.selectById(template.getId());
if (existingTemplate != null) {
// 保留原始创建信息
template.setCreatedBy(existingTemplate.getCreatedBy());
template.setCreatedAt(existingTemplate.getCreatedAt());
// 系统模板不允许修改isSystem属性
template.setIsSystem(existingTemplate.getIsSystem());
}
promptTemplateRepository.updateById(template);
return template;
}
/**
* 删除提示词模板
*/
@Transactional
@CacheEvict(value = {"promptTemplates", "promptTemplate"}, allEntries = true)
public void deleteTemplate(String id) {
log.info("删除提示词模板: {}", id);
promptTemplateRepository.deleteById(id);
}
/**
* 获取提示词模板详情
*/
@Cacheable(value = "promptTemplate", key = "#id")
public PromptTemplate getTemplateById(String id) {
if (id == null || id.isEmpty()) {
log.warn("尝试使用无效ID获取提示词模板");
return null;
}
return promptTemplateRepository.selectById(id);
}
/**
* 获取提示词模板列表
*/
@Cacheable(value = "promptTemplates")
public List<PromptTemplate> listTemplates() {
List<PromptTemplate> templates = promptTemplateRepository.selectList(null);
log.info("获取到 {} 个提示词模板", templates != null ? templates.size() : 0);
return templates != null ? templates : List.of();
}
/**
* 根据类型获取提示词模板列表
*/
@Cacheable(value = "promptTemplates", key = "#templateType")
public List<PromptTemplate> listTemplatesByType(String templateType) {
LambdaQueryWrapper<PromptTemplate> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PromptTemplate::getTemplateType, templateType);
return promptTemplateRepository.selectList(wrapper);
}
/**
* 渲染提示词模板
* 替换模板中的变量为实际值
*/
public String renderTemplate(String templateContent, Map<String, Object> params) {
if (templateContent == null || templateContent.isEmpty()) {
return "";
}
log.debug("渲染提示词模板,参数: {}", params);
String renderedContent = templateContent;
Matcher matcher = TEMPLATE_VARIABLE_PATTERN.matcher(renderedContent);
while (matcher.find()) {
String variableName = matcher.group(1);
String placeholder = matcher.group(0);
Object value = params.get(variableName);
if (value != null) {
renderedContent = renderedContent.replace(placeholder, value.toString());
} else {
// 如果参数不存在,保留原始占位符
log.warn("模板变量 {} 未提供值", variableName);
}
}
log.debug("渲染后的提示词: {}", renderedContent);
return renderedContent;
}
/**
* 渲染提示词模板(根据模板ID)
*/
public String renderTemplateById(String templateId, Map<String, Object> params) {
PromptTemplate template = getTemplateById(templateId);
if (template == null) {
throw new IllegalArgumentException("提示词模板不存在: " + templateId);
}
return renderTemplate(template.getTemplateContent(), params);
}
/**
* 验证提示词模板语法
*/
public boolean validateTemplateSyntax(String templateContent) {
if (templateContent == null || templateContent.isEmpty()) {
return true;
}
// 简单验证:检查是否有未闭合的{{}}
int openCount = 0;
for (int i = 0; i < templateContent.length() - 1; i++) {
if (templateContent.charAt(i) == '{' && templateContent.charAt(i + 1) == '{') {
openCount++;
} else if (templateContent.charAt(i) == '}' && templateContent.charAt(i + 1) == '}') {
openCount--;
if (openCount < 0) {
return false;
}
}
}
return openCount == 0;
}
}
package pangea.hiagent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.cronutils.model.CronType;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.parser.CronParser;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pangea.hiagent.core.AgentChatService;
import pangea.hiagent.dto.AgentRequest;
import pangea.hiagent.model.Agent;
import pangea.hiagent.model.TimerConfig;
import pangea.hiagent.model.TimerExecutionHistory;
import pangea.hiagent.repository.TimerConfigRepository;
import pangea.hiagent.repository.TimerExecutionHistoryRepository;
import pangea.hiagent.repository.PromptTemplateRepository;
import pangea.hiagent.scheduler.TimerScheduler;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 定时器服务类
* 负责定时器的管理和相关业务逻辑
*/
@Slf4j
@Service
public class TimerService {
private final TimerConfigRepository timerConfigRepository;
private final TimerExecutionHistoryRepository timerExecutionHistoryRepository;
private final PromptTemplateRepository promptTemplateRepository;
private final TimerScheduler timerScheduler;
private final AgentChatService agentChatService;
private final AgentService agentService;
private final ObjectMapper objectMapper;
// Cron解析器,支持秒级精度
private final CronParser cronParser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ));
public TimerService(TimerConfigRepository timerConfigRepository,
TimerExecutionHistoryRepository timerExecutionHistoryRepository,
PromptTemplateRepository promptTemplateRepository,
TimerScheduler timerScheduler,
AgentChatService agentChatService,
AgentService agentService,
ObjectMapper objectMapper) {
this.timerConfigRepository = timerConfigRepository;
this.timerExecutionHistoryRepository = timerExecutionHistoryRepository;
this.promptTemplateRepository = promptTemplateRepository;
this.timerScheduler = timerScheduler;
this.agentChatService = agentChatService;
this.agentService = agentService;
this.objectMapper = objectMapper;
}
/**
* 创建定时器
*/
@Transactional
public TimerConfig createTimer(TimerConfig timerConfig) {
log.info("创建定时器: {}", timerConfig.getName());
// 验证必要字段
if (timerConfig.getName() == null || timerConfig.getName().trim().isEmpty()) {
throw new IllegalArgumentException("定时器名称不能为空");
}
if (timerConfig.getCronExpression() == null || timerConfig.getCronExpression().trim().isEmpty()) {
throw new IllegalArgumentException("Cron表达式不能为空");
}
if (timerConfig.getAgentId() == null || timerConfig.getAgentId().trim().isEmpty()) {
throw new IllegalArgumentException("关联Agent ID不能为空");
}
// 验证Cron表达式
validateCronExpression(timerConfig.getCronExpression());
// 设置默认值
if (timerConfig.getEnabled() == null) {
timerConfig.setEnabled(0); // 默认禁用
}
// 计算下次执行时间
LocalDateTime nextExecutionTime = timerScheduler.calculateNextExecutionTime(timerConfig.getCronExpression());
timerConfig.setNextExecutionTime(nextExecutionTime);
// 保存定时器配置到数据库
try {
timerConfigRepository.insert(timerConfig);
log.info("定时器创建成功,ID: {}", timerConfig.getId());
} catch (Exception e) {
log.error("定时器保存到数据库失败: {}", e.getMessage(), e);
throw new RuntimeException("定时器保存失败: " + e.getMessage(), e);
}
// 添加到Quartz调度器
try {
timerScheduler.addOrUpdateTimer(timerConfig);
} catch (Exception e) {
log.error("定时器添加到调度器失败: {}", e.getMessage(), e);
// 如果调度器添加失败,回滚数据库操作
try {
timerConfigRepository.deleteById(timerConfig.getId());
} catch (Exception deleteException) {
log.error("回滚定时器数据失败: {}", deleteException.getMessage(), deleteException);
}
throw new RuntimeException("定时器调度配置失败: " + e.getMessage(), e);
}
return timerConfig;
}
/**
* 更新定时器
*/
@Transactional
public TimerConfig updateTimer(TimerConfig timerConfig) {
log.info("更新定时器: {}", timerConfig.getId());
// 验证必要字段
if (timerConfig.getId() == null || timerConfig.getId().trim().isEmpty()) {
throw new IllegalArgumentException("定时器ID不能为空");
}
if (timerConfig.getName() == null || timerConfig.getName().trim().isEmpty()) {
throw new IllegalArgumentException("定时器名称不能为空");
}
if (timerConfig.getCronExpression() == null || timerConfig.getCronExpression().trim().isEmpty()) {
throw new IllegalArgumentException("Cron表达式不能为空");
}
if (timerConfig.getAgentId() == null || timerConfig.getAgentId().trim().isEmpty()) {
throw new IllegalArgumentException("关联Agent ID不能为空");
}
// 验证Cron表达式
validateCronExpression(timerConfig.getCronExpression());
// 获取现有定时器
TimerConfig existingTimer = timerConfigRepository.selectById(timerConfig.getId());
if (existingTimer == null) {
throw new IllegalArgumentException("定时器不存在: " + timerConfig.getId());
}
// 保留原始创建信息
timerConfig.setCreatedBy(existingTimer.getCreatedBy());
timerConfig.setCreatedAt(existingTimer.getCreatedAt());
// 计算下次执行时间
LocalDateTime nextExecutionTime = timerScheduler.calculateNextExecutionTime(timerConfig.getCronExpression());
timerConfig.setNextExecutionTime(nextExecutionTime);
// 更新数据库中的定时器配置
try {
timerConfigRepository.updateById(timerConfig);
log.info("定时器更新成功,ID: {}", timerConfig.getId());
} catch (Exception e) {
log.error("定时器更新到数据库失败: {}", e.getMessage(), e);
throw new RuntimeException("定时器更新失败: " + e.getMessage(), e);
}
// 更新Quartz调度器中的定时任务
try {
timerScheduler.addOrUpdateTimer(timerConfig);
} catch (Exception e) {
log.error("定时器更新调度器失败: {}", e.getMessage(), e);
throw new RuntimeException("定时器调度更新失败: " + e.getMessage(), e);
}
return timerConfig;
}
/**
* 删除定时器
*/
@Transactional
public void deleteTimer(String id) {
log.info("删除定时器: {}", id);
// 从Quartz调度器中删除
timerScheduler.deleteTimer(id);
// 从数据库中删除
timerConfigRepository.deleteById(id);
}
/**
* 启用定时器
*/
@Transactional
public void enableTimer(String id) {
log.info("启用定时器: {}", id);
TimerConfig timerConfig = timerConfigRepository.selectById(id);
if (timerConfig != null) {
timerConfig.setEnabled(1);
// 更新数据库
timerConfigRepository.updateById(timerConfig);
// 更新Quartz调度器
timerScheduler.enableTimer(id);
}
}
/**
* 禁用定时器
*/
@Transactional
public void disableTimer(String id) {
log.info("禁用定时器: {}", id);
TimerConfig timerConfig = timerConfigRepository.selectById(id);
if (timerConfig != null) {
timerConfig.setEnabled(0);
// 更新数据库
timerConfigRepository.updateById(timerConfig);
// 更新Quartz调度器
timerScheduler.disableTimer(id);
}
}
/**
* 获取定时器详情
*/
public TimerConfig getTimerById(String id) {
if (id == null || id.isEmpty()) {
log.warn("尝试使用无效ID获取定时器");
return null;
}
return timerConfigRepository.selectById(id);
}
/**
* 获取定时器列表
*/
public List<TimerConfig> listTimers() {
List<TimerConfig> timers = timerConfigRepository.selectList(null);
log.info("获取到 {} 个定时器", timers != null ? timers.size() : 0);
return timers != null ? timers : List.of();
}
/**
* 根据创建人获取定时器列表
*/
public List<TimerConfig> listTimersByCreatedBy(String createdBy) {
LambdaQueryWrapper<TimerConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TimerConfig::getCreatedBy, createdBy);
return timerConfigRepository.selectList(wrapper);
}
/**
* 验证Cron表达式有效性
*/
private void validateCronExpression(String cronExpression) {
if (cronExpression == null || cronExpression.isEmpty()) {
throw new IllegalArgumentException("Cron表达式不能为空");
}
try {
cronParser.parse(cronExpression);
} catch (Exception e) {
throw new IllegalArgumentException("无效的Cron表达式: " + e.getMessage());
}
}
/**
* 执行定时器任务
* 由定时任务调度器调用
*/
@Transactional
public void executeTimerTask(String timerId) {
log.info("开始执行定时器任务: {}", timerId);
long startTime = System.currentTimeMillis();
TimerExecutionHistory history = new TimerExecutionHistory();
boolean historySaved = false;
try {
// 获取定时器配置
TimerConfig timerConfig = timerConfigRepository.selectById(timerId);
if (timerConfig == null) {
log.error("定时器配置不存在: {}", timerId);
return;
}
// 记录执行历史基本信息
history.setTimerId(timerId);
history.setTimerName(timerConfig.getName());
history.setExecutionTime(LocalDateTime.now());
history.setCreatedBy(timerConfig.getCreatedBy()); // 设置创建人
history.setCreatedAt(LocalDateTime.now()); // 设置创建时间
// 获取关联的Agent
Agent agent = agentService.getAgent(timerConfig.getAgentId());
if (agent == null) {
log.error("关联的Agent不存在: {}", timerConfig.getAgentId());
history.setSuccess(0);
history.setErrorMessage("关联的Agent不存在: " + timerConfig.getAgentId());
timerExecutionHistoryRepository.insert(history);
historySaved = true;
return;
}
// 解析动态参数
Map<String, Object> params = null;
if (timerConfig.getParamsJson() != null && !timerConfig.getParamsJson().isEmpty() &&
!"{}".equals(timerConfig.getParamsJson()) && !"\"{}\"".equals(timerConfig.getParamsJson())) {
try {
// 处理可能的转义JSON字符串情况
String cleanParamsJson = timerConfig.getParamsJson().trim();
// 如果是带引号的JSON字符串,则先去除外层引号
if (cleanParamsJson.startsWith("\"") && cleanParamsJson.endsWith("\"")) {
cleanParamsJson = cleanParamsJson.substring(1, cleanParamsJson.length() - 1).replace("\\\"", "\"");
}
// 只有当JSON不是空对象且不为空字符串时才进行解析
if (!"{}".equals(cleanParamsJson) && !cleanParamsJson.isEmpty() && !"\"\"".equals(cleanParamsJson)) {
params = objectMapper.readValue(cleanParamsJson, Map.class);
}
} catch (Exception e) {
log.error("解析参数JSON失败: {}", timerConfig.getParamsJson(), e);
history.setSuccess(0);
history.setErrorMessage("解析参数JSON失败: " + e.getMessage());
timerExecutionHistoryRepository.insert(history);
historySaved = true;
return;
}
}
// 渲染提示词模板
String prompt = timerConfig.getPromptTemplate();
if (prompt != null && params != null && !params.isEmpty()) {
prompt = renderPromptTemplate(prompt, params);
} else if (prompt == null) {
prompt = ""; // 确保prompt不为null
}
// 记录实际执行的提示词
history.setActualPrompt(prompt);
// 构建Agent请求
AgentRequest agentRequest = AgentRequest.builder()
.agentId(agent.getId())
.userMessage(prompt)
.systemPrompt(agent.getPromptTemplate())
.model(agent.getDefaultModel())
.temperature(agent.getTemperature())
.maxTokens(agent.getMaxTokens())
.topP(agent.getTopP())
.build();
// 调用Agent执行任务
String result;
if (agent.getEnableReAct() != null && agent.getEnableReAct()) {
// 使用ReAct模式执行
result = agentChatService.handleReActAgentRequest(agent, agentRequest, timerConfig.getCreatedBy());
} else {
// 使用普通模式执行
result = agentChatService.handleNormalAgentRequest(agent, agentRequest, timerConfig.getCreatedBy());
}
// 更新执行结果
long duration = System.currentTimeMillis() - startTime;
history.setSuccess(1);
history.setResult(result);
history.setExecutionDuration(duration);
// 更新定时器的最后执行时间和下次执行时间
LocalDateTime lastExecutionTime = LocalDateTime.now();
LocalDateTime nextExecutionTime = timerScheduler.calculateNextExecutionTime(timerConfig.getCronExpression());
timerConfig.setLastExecutionTime(lastExecutionTime);
timerConfig.setNextExecutionTime(nextExecutionTime);
timerConfigRepository.updateById(timerConfig);
log.info("定时器任务执行成功: {}, 耗时: {}ms", timerId, duration);
} catch (Exception e) {
// 记录执行失败信息
long duration = System.currentTimeMillis() - startTime;
history.setSuccess(0);
history.setErrorMessage("执行失败: " + e.getMessage());
history.setExecutionDuration(duration);
log.error("定时器任务执行失败: {}", timerId, e);
} finally {
// 保存执行历史(避免重复保存)
if (!historySaved) {
try {
history.setCreatedAt(LocalDateTime.now()); // 确保创建时间被设置
timerExecutionHistoryRepository.insert(history);
} catch (Exception insertException) {
log.error("保存定时器执行历史记录失败: {}", timerId, insertException);
}
}
}
}
/**
* 渲染提示词模板
* @param template 提示词模板
* @param params 动态参数
* @return 渲染后的提示词
*/
private String renderPromptTemplate(String template, Map<String, Object> params) {
if (template == null || template.isEmpty() || params == null || params.isEmpty()) {
return template != null ? template : "";
}
String result = template;
for (Map.Entry<String, Object> entry : params.entrySet()) {
String placeholder = "{{" + entry.getKey() + "}}";
// 处理null值的情况
String value = entry.getValue() != null ? String.valueOf(entry.getValue()) : "";
result = result.replace(placeholder, value);
}
return result;
}
}
package pangea.hiagent.service;
import pangea.hiagent.model.ToolConfig;
import java.util.List;
import java.util.Map;
/**
* 工具配置服务接口
* 用于处理工具参数配置的读取和保存
*/
public interface ToolConfigService {
/**
* 根据工具名称获取参数配置
* @param toolName 工具名称
* @return 参数配置键值对
*/
Map<String, String> getToolParams(String toolName);
/**
* 根据工具名称和参数名称获取参数值
* @param toolName 工具名称
* @param paramName 参数名称
* @return 参数值
*/
String getParamValue(String toolName, String paramName);
/**
* 保存参数值
* @param toolName 工具名称
* @param paramName 参数名称
* @param paramValue 参数值
*/
void saveParamValue(String toolName, String paramName, String paramValue);
/**
* 获取所有工具配置
* @return 工具配置列表
*/
List<ToolConfig> getAllToolConfigs();
/**
* 根据工具名称和参数名称获取工具配置
* @param toolName 工具名称
* @param paramName 参数名称
* @return 工具配置对象
*/
ToolConfig getToolConfig(String toolName, String paramName);
/**
* 保存工具配置
* @param toolConfig 工具配置对象
* @return 保存后的工具配置对象
*/
ToolConfig saveToolConfig(ToolConfig toolConfig);
/**
* 删除工具配置
* @param id 配置ID
*/
void deleteToolConfig(String id);
/**
* 根据工具名称获取工具配置列表
* @param toolName 工具名称
* @return 工具配置列表
*/
List<ToolConfig> getToolConfigsByToolName(String toolName);
}
\ No newline at end of file
package pangea.hiagent.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import pangea.hiagent.model.ToolConfig;
import pangea.hiagent.repository.ToolConfigRepository;
import pangea.hiagent.service.ToolConfigService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 工具配置服务实现类
* 用于处理工具参数配置的读取和保存
*/
@Slf4j
@Service
public class ToolConfigServiceImpl implements ToolConfigService {
@Autowired
private ToolConfigRepository toolConfigRepository;
@Override
public Map<String, String> getToolParams(String toolName) {
log.debug("获取工具参数配置,工具名称:{}", toolName);
Map<String, String> params = new HashMap<>();
try {
List<Map<String, Object>> paramValues = toolConfigRepository.findParamValuesByToolName(toolName);
for (Map<String, Object> paramValue : paramValues) {
String paramName = (String) paramValue.get("param_name");
String value = (String) paramValue.get("param_value");
params.put(paramName, value);
}
} catch (Exception e) {
log.error("获取工具参数配置失败:{}", e.getMessage(), e);
}
return params;
}
@Override
public String getParamValue(String toolName, String paramName) {
log.debug("获取工具参数值,工具名称:{},参数名称:{}", toolName, paramName);
try {
ToolConfig toolConfig = toolConfigRepository.findByToolNameAndParamName(toolName, paramName);
if (toolConfig != null) {
return toolConfig.getParamValue();
}
} catch (Exception e) {
log.error("获取工具参数值失败:{}", e.getMessage(), e);
}
return null;
}
@Override
public void saveParamValue(String toolName, String paramName, String paramValue) {
log.debug("保存工具参数值,工具名称:{},参数名称:{},参数值:{}", toolName, paramName, paramValue);
try {
ToolConfig toolConfig = toolConfigRepository.findByToolNameAndParamName(toolName, paramName);
if (toolConfig != null) {
toolConfig.setParamValue(paramValue);
toolConfigRepository.updateById(toolConfig);
} else {
// 如果配置不存在,创建新配置
toolConfig = new ToolConfig();
toolConfig.setToolName(toolName);
toolConfig.setParamName(paramName);
toolConfig.setParamValue(paramValue);
toolConfigRepository.insert(toolConfig);
}
} catch (Exception e) {
log.error("保存工具参数值失败:{}", e.getMessage(), e);
}
}
@Override
public List<ToolConfig> getAllToolConfigs() {
log.debug("获取所有工具配置");
try {
return toolConfigRepository.findAllActive();
} catch (Exception e) {
log.error("获取所有工具配置失败:{}", e.getMessage(), e);
return List.of();
}
}
@Override
public ToolConfig getToolConfig(String toolName, String paramName) {
log.debug("获取工具配置,工具名称:{},参数名称:{}", toolName, paramName);
try {
return toolConfigRepository.findByToolNameAndParamName(toolName, paramName);
} catch (Exception e) {
log.error("获取工具配置失败:{}", e.getMessage(), e);
return null;
}
}
@Override
public ToolConfig saveToolConfig(ToolConfig toolConfig) {
log.debug("保存工具配置:{}", toolConfig);
try {
if (toolConfig.getId() != null) {
toolConfigRepository.updateById(toolConfig);
} else {
// 检查是否已存在相同的工具名称和参数名称的配置
ToolConfig existingConfig = toolConfigRepository.findByToolNameAndParamName(
toolConfig.getToolName(), toolConfig.getParamName());
if (existingConfig != null) {
toolConfig.setId(existingConfig.getId());
toolConfigRepository.updateById(toolConfig);
} else {
toolConfigRepository.insert(toolConfig);
}
}
return toolConfig;
} catch (Exception e) {
log.error("保存工具配置失败:{}", e.getMessage(), e);
return null;
}
}
@Override
public void deleteToolConfig(String id) {
log.debug("删除工具配置,ID:{}", id);
try {
toolConfigRepository.deleteById(id);
} catch (Exception e) {
log.error("删除工具配置失败:{}", e.getMessage(), e);
}
}
@Override
public List<ToolConfig> getToolConfigsByToolName(String toolName) {
log.debug("根据工具名称获取工具配置列表,工具名称:{}", toolName);
try {
return toolConfigRepository.findByToolName(toolName);
} catch (Exception e) {
log.error("根据工具名称获取工具配置列表失败:{}", e.getMessage(), e);
return List.of();
}
}
}
\ No newline at end of file
...@@ -3,6 +3,7 @@ package pangea.hiagent.tools; ...@@ -3,6 +3,7 @@ package pangea.hiagent.tools;
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.tools.annotation.ToolParam;
/** /**
* 图表生成工具 * 图表生成工具
...@@ -12,6 +13,36 @@ import org.springframework.stereotype.Component; ...@@ -12,6 +13,36 @@ import org.springframework.stereotype.Component;
@Component @Component
public class ChartGenerationTool { public class ChartGenerationTool {
@ToolParam(
name = "maxDataPoints",
description = "最大数据点数量限制",
defaultValue = "100",
type = "integer",
required = true,
group = "chart"
)
private Integer maxDataPoints;
@ToolParam(
name = "percentageDecimalPlaces",
description = "百分比显示的小数位数",
defaultValue = "2",
type = "integer",
required = true,
group = "chart"
)
private Integer percentageDecimalPlaces;
@ToolParam(
name = "defaultSeriesName",
description = "默认数据系列名称",
defaultValue = "数据",
type = "string",
required = true,
group = "chart"
)
private String defaultSeriesName;
/** /**
* 生成柱状图 * 生成柱状图
* @param title 图表标题 * @param title 图表标题
...@@ -45,11 +76,16 @@ public class ChartGenerationTool { ...@@ -45,11 +76,16 @@ public class ChartGenerationTool {
return "错误:X轴标签数量与数据系列数量不匹配"; return "错误:X轴标签数量与数据系列数量不匹配";
} }
if (xAxisLabels.length > maxDataPoints) {
log.warn("数据点数量超过限制,当前数量:{},限制:{}", xAxisLabels.length, maxDataPoints);
return "错误:数据点数量超过限制,当前数量:" + xAxisLabels.length + ",限制:" + maxDataPoints;
}
// 生成图表描述 // 生成图表描述
StringBuilder chartDescription = new StringBuilder(); StringBuilder chartDescription = new StringBuilder();
chartDescription.append("柱状图生成成功:\n"); chartDescription.append("柱状图生成成功:\n");
chartDescription.append("标题: ").append(title).append("\n"); chartDescription.append("标题: ").append(title).append("\n");
chartDescription.append("数据系列: ").append(seriesName != null ? seriesName : "数据").append("\n"); chartDescription.append("数据系列: ").append(seriesName != null ? seriesName : defaultSeriesName).append("\n");
chartDescription.append("数据点数量: ").append(seriesData.length).append("\n"); chartDescription.append("数据点数量: ").append(seriesData.length).append("\n");
chartDescription.append("数据详情:\n"); chartDescription.append("数据详情:\n");
...@@ -166,8 +202,9 @@ public class ChartGenerationTool { ...@@ -166,8 +202,9 @@ public class ChartGenerationTool {
for (int i = 0; i < labels.length; i++) { for (int i = 0; i < labels.length; i++) {
double percentage = total > 0 ? (values[i] / total) * 100 : 0; double percentage = total > 0 ? (values[i] / total) * 100 : 0;
String format = String.format("%%.%df", percentageDecimalPlaces);
chartDescription.append(" ").append(labels[i]).append(": ").append(values[i]) chartDescription.append(" ").append(labels[i]).append(": ").append(values[i])
.append(" (").append(String.format("%.2f", percentage)).append("%)\n"); .append(" (").append(String.format(format, percentage)).append("%)\n");
} }
log.info("饼图生成完成,包含 {} 个数据项", values.length); log.info("饼图生成完成,包含 {} 个数据项", values.length);
......
...@@ -3,6 +3,7 @@ package pangea.hiagent.tools; ...@@ -3,6 +3,7 @@ package pangea.hiagent.tools;
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.tools.annotation.ToolParam;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalDate; import java.time.LocalDate;
...@@ -16,17 +17,36 @@ import java.time.format.DateTimeFormatter; ...@@ -16,17 +17,36 @@ import java.time.format.DateTimeFormatter;
@Component @Component
public class DateTimeTools { public class DateTimeTools {
@ToolParam(
name = "dateTimeFormat",
description = "日期时间格式",
defaultValue = "yyyy-MM-dd HH:mm:ss",
type = "string",
required = true,
group = "datetime"
)
private String dateTimeFormat;
@ToolParam(
name = "dateFormat",
description = "日期格式",
defaultValue = "yyyy-MM-dd",
type = "string",
required = true,
group = "datetime"
)
private String dateFormat;
@Tool(description = "获取当前日期和时间") @Tool(description = "获取当前日期和时间")
public String getCurrentDateTime() { public String getCurrentDateTime() {
String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateTimeFormat));
log.debug("获取当前日期时间: {}", dateTime); log.debug("获取当前日期时间: {}", dateTime);
return dateTime; return dateTime;
} }
@Tool(description = "获取当前日期") @Tool(description = "获取当前日期")
public String getCurrentDate() { public String getCurrentDate() {
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); String date = LocalDate.now().format(DateTimeFormatter.ofPattern(dateFormat));
log.debug("获取当前日期: {}", date); log.debug("获取当前日期: {}", date);
return date; return date;
} }
......
package pangea.hiagent.tools;
import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import jakarta.mail.*;
import jakarta.mail.internet.*;
import jakarta.mail.search.FlagTerm;
import jakarta.mail.search.ReceivedDateTerm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import pangea.hiagent.tools.annotation.ToolParam;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
/**
* 邮件工具类
* 提供基于POP3协议的邮件访问功能
*/
@Slf4j
@Component
public class EmailTools {
@ToolParam(
name = "defaultPop3Port",
description = "默认POP3服务器端口",
defaultValue = "995",
type = "integer",
required = true,
group = "email"
)
private Integer defaultPop3Port;
@ToolParam(
name = "defaultAttachmentPath",
description = "默认附件保存路径",
defaultValue = "attachments",
type = "string",
required = true,
group = "email"
)
private String defaultAttachmentPath;
@ToolParam(
name = "pop3SslEnable",
description = "是否启用POP3 SSL",
defaultValue = "true",
type = "boolean",
required = true,
group = "email"
)
private Boolean pop3SslEnable;
@ToolParam(
name = "pop3SocketFactoryClass",
description = "POP3 SSL套接字工厂类",
defaultValue = "javax.net.ssl.SSLSocketFactory",
type = "string",
required = true,
group = "email"
)
private String pop3SocketFactoryClass;
// 邮件请求参数类
@JsonClassDescription("邮件操作请求参数")
public record EmailRequest(
@JsonProperty(required = true, value = "host")
@JsonPropertyDescription("POP3服务器地址")
String host,
@JsonProperty(value = "port")
@JsonPropertyDescription("POP3服务器端口,默认995")
Integer port,
@JsonProperty(required = true, value = "username")
@JsonPropertyDescription("邮箱用户名")
String username,
@JsonProperty(required = true, value = "password")
@JsonPropertyDescription("邮箱密码")
String password,
@JsonProperty(value = "mailId")
@JsonPropertyDescription("邮件ID,用于指定操作的邮件")
Integer mailId,
@JsonProperty(value = "attachmentPath")
@JsonPropertyDescription("附件保存路径")
String attachmentPath
) {
// 默认端口为配置值
public Integer port() {
return port != null ? port : 995;
}
}
// 邮件基本信息响应类
@JsonClassDescription("邮件基本信息响应")
public record EmailBasicInfo(
@JsonPropertyDescription("邮件ID") Integer mailId,
@JsonPropertyDescription("发件人") String from,
@JsonPropertyDescription("收件人") String to,
@JsonPropertyDescription("邮件标题") String subject,
@JsonPropertyDescription("接收日期") String receivedDate,
@JsonPropertyDescription("是否已读") boolean isRead
) {}
// 邮件详细信息响应类
@JsonClassDescription("邮件详细信息响应")
public record EmailDetailInfo(
@JsonPropertyDescription("邮件ID") Integer mailId,
@JsonPropertyDescription("发件人") String from,
@JsonPropertyDescription("收件人") String to,
@JsonPropertyDescription("抄送") String cc,
@JsonPropertyDescription("密送") String bcc,
@JsonPropertyDescription("邮件标题") String subject,
@JsonPropertyDescription("接收日期") String receivedDate,
@JsonPropertyDescription("是否已读") boolean isRead,
@JsonPropertyDescription("邮件内容") String content,
@JsonPropertyDescription("附件列表") List<EmailAttachment> attachments
) {}
// 邮件附件信息类
@JsonClassDescription("邮件附件信息")
public record EmailAttachment(
@JsonPropertyDescription("附件名称") String filename,
@JsonPropertyDescription("附件大小") long size,
@JsonPropertyDescription("内容类型") String contentType,
@JsonPropertyDescription("保存路径") String savePath
) {}
// 操作结果响应类
@JsonClassDescription("邮件操作结果响应")
public record EmailOperationResult(
@JsonPropertyDescription("操作是否成功") boolean success,
@JsonPropertyDescription("操作结果消息") String message,
@JsonPropertyDescription("附加数据") Map<String, Object> data
) {}
/**
* 获取今日所有邮件的发件人和标题
* @param request 邮件请求参数
* @return 今日邮件列表
*/
@Tool(description = "获取今日所有邮件的发件人和标题")
public List<EmailBasicInfo> getTodayEmails(EmailRequest request) {
log.debug("获取今日邮件,请求参数:{}", request);
try {
Store store = connectToEmailServer(request);
Folder inbox = store.getFolder("INBOX");
inbox.open(Folder.READ_ONLY);
// 获取今日日期范围
Calendar today = Calendar.getInstance();
today.set(Calendar.HOUR_OF_DAY, 0);
today.set(Calendar.MINUTE, 0);
today.set(Calendar.SECOND, 0);
today.set(Calendar.MILLISECOND, 0);
Calendar tomorrow = Calendar.getInstance();
tomorrow.setTime(today.getTime());
tomorrow.add(Calendar.DAY_OF_MONTH, 1);
// 搜索今日邮件
ReceivedDateTerm term = new ReceivedDateTerm(ReceivedDateTerm.GE, today.getTime());
Message[] messages = inbox.search(term);
// 过滤出今日邮件并转换为响应格式
List<EmailBasicInfo> result = new ArrayList<>();
for (int i = 0; i < messages.length; i++) {
Message message = messages[i];
Calendar receivedDate = Calendar.getInstance();
receivedDate.setTime(message.getReceivedDate());
// 只保留今日邮件
if (receivedDate.before(tomorrow)) {
result.add(convertToBasicInfo(i + 1, message));
}
}
inbox.close(false);
store.close();
log.debug("获取今日邮件成功,共{}封", result.size());
return result;
} catch (Exception e) {
log.error("获取今日邮件失败:", e);
throw new RuntimeException("获取今日邮件失败:" + e.getMessage());
}
}
/**
* 获取所有未读邮件的发件人和标题
* @param request 邮件请求参数
* @return 未读邮件列表
*/
@Tool(description = "获取所有未读邮件的发件人和标题")
public List<EmailBasicInfo> getUnreadEmails(EmailRequest request) {
log.debug("获取未读邮件,请求参数:{}", request);
try {
Store store = connectToEmailServer(request);
Folder inbox = store.getFolder("INBOX");
inbox.open(Folder.READ_ONLY);
// 搜索未读邮件
FlagTerm term = new FlagTerm(new Flags(Flags.Flag.SEEN), false);
Message[] messages = inbox.search(term);
// 转换为响应格式
List<EmailBasicInfo> result = new ArrayList<>();
for (int i = 0; i < messages.length; i++) {
result.add(convertToBasicInfo(i + 1, messages[i]));
}
inbox.close(false);
store.close();
log.debug("获取未读邮件成功,共{}封", result.size());
return result;
} catch (Exception e) {
log.error("获取未读邮件失败:", e);
throw new RuntimeException("获取未读邮件失败:" + e.getMessage());
}
}
/**
* 获取指定邮件的内容
* @param request 邮件请求参数,必须包含mailId
* @return 邮件详细信息
*/
@Tool(description = "获取指定邮件的内容")
public EmailDetailInfo getEmailContent(EmailRequest request) {
log.debug("获取邮件内容,请求参数:{}", request);
if (request.mailId() == null) {
throw new IllegalArgumentException("邮件ID不能为空");
}
try {
Store store = connectToEmailServer(request);
Folder inbox = store.getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
int messageCount = inbox.getMessageCount();
if (request.mailId() < 1 || request.mailId() > messageCount) {
throw new IllegalArgumentException("邮件ID无效:" + request.mailId());
}
Message message = inbox.getMessage(request.mailId());
EmailDetailInfo result = convertToDetailInfo(request.mailId(), message);
inbox.close(false);
store.close();
log.debug("获取邮件内容成功,邮件ID:{}", request.mailId());
return result;
} catch (Exception e) {
log.error("获取邮件内容失败:", e);
throw new RuntimeException("获取邮件内容失败:" + e.getMessage());
}
}
/**
* 获取指定邮件的附件
* @param request 邮件请求参数,必须包含mailId和attachmentPath
* @return 附件信息列表
*/
@Tool(description = "获取指定邮件的附件")
public List<EmailAttachment> getEmailAttachments(EmailRequest request) {
log.debug("获取邮件附件,请求参数:{}", request);
if (request.mailId() == null) {
throw new IllegalArgumentException("邮件ID不能为空");
}
if (request.attachmentPath() == null) {
throw new IllegalArgumentException("附件保存路径不能为空");
}
try {
Store store = connectToEmailServer(request);
Folder inbox = store.getFolder("INBOX");
inbox.open(Folder.READ_ONLY);
int messageCount = inbox.getMessageCount();
if (request.mailId() < 1 || request.mailId() > messageCount) {
throw new IllegalArgumentException("邮件ID无效:" + request.mailId());
}
Message message = inbox.getMessage(request.mailId());
List<EmailAttachment> attachments = saveAttachments(message, request.attachmentPath());
inbox.close(false);
store.close();
log.debug("获取邮件附件成功,邮件ID:{},共{}个附件", request.mailId(), attachments.size());
return attachments;
} catch (Exception e) {
log.error("获取邮件附件失败:", e);
throw new RuntimeException("获取邮件附件失败:" + e.getMessage());
}
}
/**
* 标记邮件为已读
* @param request 邮件请求参数,必须包含mailId
* @return 操作结果
*/
@Tool(description = "标记邮件为已读")
public EmailOperationResult markAsRead(EmailRequest request) {
log.debug("标记邮件为已读,请求参数:{}", request);
return markEmailFlag(request, Flags.Flag.SEEN, true);
}
/**
* 标记邮件为未读
* @param request 邮件请求参数,必须包含mailId
* @return 操作结果
*/
@Tool(description = "标记邮件为未读")
public EmailOperationResult markAsUnread(EmailRequest request) {
log.debug("标记邮件为未读,请求参数:{}", request);
return markEmailFlag(request, Flags.Flag.SEEN, false);
}
/**
* 删除指定邮件
* @param request 邮件请求参数,必须包含mailId
* @return 操作结果
*/
@Tool(description = "删除指定邮件")
public EmailOperationResult deleteEmail(EmailRequest request) {
log.debug("删除邮件,请求参数:{}", request);
if (request.mailId() == null) {
throw new IllegalArgumentException("邮件ID不能为空");
}
try {
Store store = connectToEmailServer(request);
Folder inbox = store.getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
int messageCount = inbox.getMessageCount();
if (request.mailId() < 1 || request.mailId() > messageCount) {
throw new IllegalArgumentException("邮件ID无效:" + request.mailId());
}
Message message = inbox.getMessage(request.mailId());
message.setFlag(Flags.Flag.DELETED, true);
inbox.close(true); // 提交删除操作
store.close();
log.debug("删除邮件成功,邮件ID:{}", request.mailId());
Map<String, Object> data = new HashMap<>();
data.put("mailId", request.mailId());
return new EmailOperationResult(true, "邮件删除成功", data);
} catch (Exception e) {
log.error("删除邮件失败:", e);
return new EmailOperationResult(false, "邮件删除失败:" + e.getMessage(), Collections.emptyMap());
}
}
// 连接到邮件服务器
private Store connectToEmailServer(EmailRequest request) throws Exception {
Properties props = new Properties();
props.put("mail.pop3.host", request.host());
props.put("mail.pop3.port", request.port().toString());
props.put("mail.pop3.ssl.enable", pop3SslEnable.toString());
props.put("mail.pop3.socketFactory.class", pop3SocketFactoryClass);
props.put("mail.pop3.socketFactory.port", request.port().toString());
Session session = Session.getInstance(props, null);
Store store = session.getStore("pop3s");
store.connect(request.host(), request.username(), request.password());
log.debug("成功连接到邮件服务器:{}:{}", request.host(), request.port());
return store;
}
// 将Message转换为EmailBasicInfo
private EmailBasicInfo convertToBasicInfo(int mailId, Message message) throws Exception {
String from = InternetAddress.toString(message.getFrom());
String to = InternetAddress.toString(message.getRecipients(Message.RecipientType.TO));
String subject = message.getSubject() != null ? MimeUtility.decodeText(message.getSubject()) : "无主题";
String receivedDate = message.getReceivedDate().toString();
boolean isRead = message.isSet(Flags.Flag.SEEN);
return new EmailBasicInfo(mailId, from, to, subject, receivedDate, isRead);
}
// 将Message转换为EmailDetailInfo
private EmailDetailInfo convertToDetailInfo(int mailId, Message message) throws Exception {
String from = InternetAddress.toString(message.getFrom());
String to = message.getRecipients(Message.RecipientType.TO) != null ?
InternetAddress.toString(message.getRecipients(Message.RecipientType.TO)) : "";
String cc = message.getRecipients(Message.RecipientType.CC) != null ?
InternetAddress.toString(message.getRecipients(Message.RecipientType.CC)) : "";
String bcc = message.getRecipients(Message.RecipientType.BCC) != null ?
InternetAddress.toString(message.getRecipients(Message.RecipientType.BCC)) : "";
String subject = message.getSubject() != null ? MimeUtility.decodeText(message.getSubject()) : "无主题";
String receivedDate = message.getReceivedDate().toString();
boolean isRead = message.isSet(Flags.Flag.SEEN);
String content = getEmailContent(message);
return new EmailDetailInfo(mailId, from, to, cc, bcc, subject, receivedDate, isRead, content, Collections.emptyList());
}
// 获取邮件内容
private String getEmailContent(Part part) throws Exception {
if (part.isMimeType("text/*")) {
return part.getContent().toString();
} else if (part.isMimeType("multipart/*")) {
Multipart multipart = (Multipart) part.getContent();
StringBuilder content = new StringBuilder();
for (int i = 0; i < multipart.getCount(); i++) {
BodyPart bodyPart = multipart.getBodyPart(i);
if (!bodyPart.isMimeType("multipart/*") && !Part.ATTACHMENT.equalsIgnoreCase(bodyPart.getDisposition())) {
content.append(getEmailContent(bodyPart));
}
}
return content.toString();
}
return "";
}
// 保存邮件附件
private List<EmailAttachment> saveAttachments(Message message, String savePath) throws Exception {
List<EmailAttachment> attachments = new ArrayList<>();
// 创建保存目录
File directory = new File(savePath);
if (!directory.exists()) {
directory.mkdirs();
}
if (message.isMimeType("multipart/*")) {
Multipart multipart = (Multipart) message.getContent();
for (int i = 0; i < multipart.getCount(); i++) {
BodyPart bodyPart = multipart.getBodyPart(i);
String disposition = bodyPart.getDisposition();
if (disposition != null && (disposition.equalsIgnoreCase(Part.ATTACHMENT) ||
disposition.equalsIgnoreCase(Part.INLINE))) {
// 处理附件
String filename = getFilename(bodyPart);
File attachmentFile = new File(savePath + File.separator + filename);
// 使用writeTo方法保存附件
try (var inputStream = bodyPart.getInputStream();
var outputStream = new java.io.FileOutputStream(attachmentFile)) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
}
EmailAttachment attachment = new EmailAttachment(
filename,
attachmentFile.length(),
bodyPart.getContentType(),
attachmentFile.getAbsolutePath()
);
attachments.add(attachment);
} else if (bodyPart.isMimeType("multipart/*")) {
// 递归处理嵌套的multipart
// 创建一个临时Message对象来处理嵌套的multipart
MimeMessage nestedMessage = new MimeMessage((Session) null);
nestedMessage.setContent(bodyPart.getContent(), bodyPart.getContentType());
attachments.addAll(saveAttachments(nestedMessage, savePath));
}
}
}
return attachments;
}
// 获取附件文件名
private String getFilename(BodyPart bodyPart) throws Exception {
String filename = bodyPart.getFileName();
if (filename != null) {
return MimeUtility.decodeText(filename);
}
return "attachment_" + System.currentTimeMillis() + ".dat";
}
// 标记邮件标志
private EmailOperationResult markEmailFlag(EmailRequest request, Flags.Flag flag, boolean set) {
if (request.mailId() == null) {
throw new IllegalArgumentException("邮件ID不能为空");
}
try {
Store store = connectToEmailServer(request);
Folder inbox = store.getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
int messageCount = inbox.getMessageCount();
if (request.mailId() < 1 || request.mailId() > messageCount) {
throw new IllegalArgumentException("邮件ID无效:" + request.mailId());
}
Message message = inbox.getMessage(request.mailId());
message.setFlag(flag, set);
inbox.close(false);
store.close();
String action = set ? "标记为已读" : "标记为未读";
log.debug("邮件{}成功,邮件ID:{}", action, request.mailId());
Map<String, Object> data = new HashMap<>();
data.put("mailId", request.mailId());
data.put("isRead", set);
return new EmailOperationResult(true, "邮件" + action + "成功", data);
} catch (Exception e) {
log.error("邮件标记操作失败:", e);
String action = set ? "标记为已读" : "标记为未读";
return new EmailOperationResult(false, "邮件" + action + "失败:" + e.getMessage(), Collections.emptyMap());
}
}
}
\ No newline at end of file
...@@ -3,6 +3,7 @@ package pangea.hiagent.tools; ...@@ -3,6 +3,7 @@ package pangea.hiagent.tools;
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.tools.annotation.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;
...@@ -23,21 +24,47 @@ import java.util.UUID; ...@@ -23,21 +24,47 @@ import java.util.UUID;
public class FileProcessingTools { public class FileProcessingTools {
// 支持的文本文件扩展名 // 支持的文本文件扩展名
private static final List<String> TEXT_FILE_EXTENSIONS = Arrays.asList( @ToolParam(
".txt", ".md", ".java", ".html", ".htm", ".css", ".js", ".json", name = "textFileExtensions",
".xml", ".yaml", ".yml", ".properties", ".sql", ".py", ".cpp", description = "支持的文本文件扩展名,逗号分隔",
".c", ".h", ".cs", ".php", ".rb", ".go", ".rs", ".swift", ".kt", 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",
".scala", ".sh", ".bat", ".cmd", ".ps1", ".log", ".csv", ".ts", type = "string",
".jsx", ".tsx", ".vue", ".scss", ".sass", ".less" required = true,
); group = "file"
)
private String textFileExtensions;
// 支持的图片文件扩展名 // 支持的图片文件扩展名
private static final List<String> IMAGE_FILE_EXTENSIONS = Arrays.asList( @ToolParam(
".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;
// 默认文件存储目录 // 默认文件存储目录
private static final String DEFAULT_STORAGE_DIR = "storage"; @ToolParam(
name = "defaultStorageDir",
description = "默认文件存储目录",
defaultValue = "storage",
type = "string",
required = true,
group = "file"
)
private String defaultStorageDir;
// 转换为列表的辅助方法
private List<String> getTextFileExtensions() {
return Arrays.asList(textFileExtensions.split(","));
}
private List<String> getImageFileExtensions() {
return Arrays.asList(imageFileExtensions.split(","));
}
/** /**
* 检查文件是否为文本文件 * 检查文件是否为文本文件
...@@ -50,7 +77,7 @@ public class FileProcessingTools { ...@@ -50,7 +77,7 @@ public class FileProcessingTools {
} }
String lowerPath = filePath.toLowerCase(); String lowerPath = filePath.toLowerCase();
return TEXT_FILE_EXTENSIONS.stream().anyMatch(lowerPath::endsWith); return getTextFileExtensions().stream().anyMatch(lowerPath::endsWith);
} }
/** /**
...@@ -64,7 +91,7 @@ public class FileProcessingTools { ...@@ -64,7 +91,7 @@ public class FileProcessingTools {
} }
String lowerPath = filePath.toLowerCase(); String lowerPath = filePath.toLowerCase();
return IMAGE_FILE_EXTENSIONS.stream().anyMatch(lowerPath::endsWith); return getImageFileExtensions().stream().anyMatch(lowerPath::endsWith);
} }
/** /**
...@@ -86,14 +113,14 @@ public class FileProcessingTools { ...@@ -86,14 +113,14 @@ public class FileProcessingTools {
// 如果filePath为null或空,则生成随机文件名 // 如果filePath为null或空,则生成随机文件名
if (filePath == null || filePath.isEmpty()) { if (filePath == null || filePath.isEmpty()) {
// 确保默认存储目录存在 // 确保默认存储目录存在
File storageDir = new File(DEFAULT_STORAGE_DIR); File storageDir = new File(defaultStorageDir);
if (!storageDir.exists()) { if (!storageDir.exists()) {
storageDir.mkdirs(); storageDir.mkdirs();
} }
// 生成随机文件名 // 生成随机文件名
String randomFileName = UUID.randomUUID().toString().replace("-", "") + extension; String randomFileName = UUID.randomUUID().toString().replace("-", "") + extension;
filePath = DEFAULT_STORAGE_DIR + File.separator + randomFileName; filePath = defaultStorageDir + File.separator + randomFileName;
log.debug("生成随机文件名: {}", filePath); log.debug("生成随机文件名: {}", filePath);
} else { } else {
// 处理相对路径 // 处理相对路径
...@@ -357,7 +384,7 @@ public class FileProcessingTools { ...@@ -357,7 +384,7 @@ public class FileProcessingTools {
log.debug("生成随机文件名,扩展名: {}", extension); log.debug("生成随机文件名,扩展名: {}", extension);
try { try {
// 确保默认存储目录存在 // 确保默认存储目录存在
File storageDir = new File(DEFAULT_STORAGE_DIR); File storageDir = new File(defaultStorageDir);
if (!storageDir.exists()) { if (!storageDir.exists()) {
storageDir.mkdirs(); storageDir.mkdirs();
} }
...@@ -371,7 +398,7 @@ public class FileProcessingTools { ...@@ -371,7 +398,7 @@ public class FileProcessingTools {
// 生成随机文件名 // 生成随机文件名
String randomFileName = UUID.randomUUID().toString().replace("-", "") + extension; String randomFileName = UUID.randomUUID().toString().replace("-", "") + extension;
String fullPath = DEFAULT_STORAGE_DIR + File.separator + randomFileName; String fullPath = defaultStorageDir + File.separator + randomFileName;
log.debug("生成随机文件名: {}", fullPath); log.debug("生成随机文件名: {}", fullPath);
return fullPath; return fullPath;
......
...@@ -5,6 +5,7 @@ import com.microsoft.playwright.options.LoadState; ...@@ -5,6 +5,7 @@ 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 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.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
...@@ -36,10 +37,11 @@ public class HisenseSsoAuthTool { ...@@ -36,10 +37,11 @@ public class HisenseSsoAuthTool {
// 登录按钮选择器 // 登录按钮选择器
private static final String LOGIN_BUTTON_SELECTOR = "#login-button"; private static final String LOGIN_BUTTON_SELECTOR = "#login-button";
// Playwright实例 // 注入Playwright管理器
private Playwright playwright; @Autowired
private pangea.hiagent.core.PlaywrightManager playwrightManager;
// 浏览器实例 // 浏览器实例(从Playwright管理器获取)
private Browser browser; private Browser browser;
// 共享的浏览器上下文,用于保持登录状态 // 共享的浏览器上下文,用于保持登录状态
...@@ -63,16 +65,14 @@ public class HisenseSsoAuthTool { ...@@ -63,16 +65,14 @@ public class HisenseSsoAuthTool {
private static final String STORAGE_DIR = "storage"; private static final String STORAGE_DIR = "storage";
/** /**
* 初始化Playwright和浏览器实例 * 初始化浏览器实例引用和共享上下文
*/ */
@PostConstruct @PostConstruct
public void initialize() { public void initialize() {
try { try {
log.info("正在初始化海信SSO认证工具的Playwright..."); log.info("正在初始化海信SSO认证工具的Playwright...");
this.playwright = Playwright.create(); // 从Playwright管理器获取共享的浏览器实例
// 使用chromium浏览器,无头模式(headless=true),适合服务器运行 this.browser = playwrightManager.getBrowser();
// 可根据需要修改为有头模式(headless=false)用于调试
this.browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true));
// 初始化共享上下文 // 初始化共享上下文
this.sharedContext = browser.newContext(); this.sharedContext = browser.newContext();
log.info("海信SSO认证工具的Playwright初始化成功"); log.info("海信SSO认证工具的Playwright初始化成功");
...@@ -91,14 +91,6 @@ public class HisenseSsoAuthTool { ...@@ -91,14 +91,6 @@ public class HisenseSsoAuthTool {
sharedContext.close(); sharedContext.close();
log.info("海信SSO认证工具的共享浏览器上下文已关闭"); log.info("海信SSO认证工具的共享浏览器上下文已关闭");
} }
if (browser != null) {
browser.close();
log.info("海信SSO认证工具的浏览器实例已关闭");
}
if (playwright != null) {
playwright.close();
log.info("海信SSO认证工具的Playwright实例已关闭");
}
} catch (Exception e) { } catch (Exception e) {
log.error("海信SSO认证工具的Playwright资源释放失败: ", e); log.error("海信SSO认证工具的Playwright资源释放失败: ", e);
} }
...@@ -177,6 +169,11 @@ public class HisenseSsoAuthTool { ...@@ -177,6 +169,11 @@ public class HisenseSsoAuthTool {
long endTime = System.currentTimeMillis(); long endTime = System.currentTimeMillis();
log.info("成功获取业务系统页面内容,耗时: {} ms", endTime - startTime); log.info("成功获取业务系统页面内容,耗时: {} ms", endTime - startTime);
// 检查是否包含错误信息
if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) {
log.warn("检测到页面中可能存在JavaScript错误,但这不会影响主要功能");
}
return content; return content;
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis(); long endTime = System.currentTimeMillis();
......
// package pangea.hiagent.tools; package pangea.hiagent.tools;
// import com.microsoft.playwright.*; 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 org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
// import org.springframework.beans.BeansException; import org.springframework.beans.factory.annotation.Autowired;
// import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Component;
// import org.springframework.context.ApplicationContextAware; import pangea.hiagent.core.PlaywrightManager;
// import org.springframework.stereotype.Component;
// import pangea.hiagent.workpanel.IWorkPanelDataCollector; import jakarta.annotation.PostConstruct;
import java.util.Base64;
// import jakarta.annotation.PostConstruct; import java.util.List;
// import jakarta.annotation.PreDestroy; import java.nio.file.Files;
// import java.util.Base64; import java.nio.file.Path;
// import java.util.HashMap; import java.util.function.Function;
// import java.util.List;
// import java.nio.file.Files; /**
// import java.nio.file.Path; * Playwright网页自动化工具类
* 提供基于Playwright的网页内容抓取、交互操作、截图等功能
// /** */
// * Playwright网页自动化工具类 @Slf4j
// * 提供基于Playwright的网页内容抓取、交互操作、截图等功能 @Component
// */ public class PlaywrightWebTools {
// @Slf4j
// @Component // 注入Playwright管理器
// public class PlaywrightWebTools implements ApplicationContextAware { @Autowired
private PlaywrightManager playwrightManager;
// // Spring应用上下文引用
// private static ApplicationContext applicationContext;
// 浏览器实例(从Playwright管理器获取)
// // Playwright实例 private Browser browser;
// private Playwright playwright;
/**
// // 浏览器实例 * 初始化浏览器实例引用
// private Browser browser; */
@PostConstruct
// /** public void initialize() {
// * 初始化Playwright和浏览器实例 try {
// */ log.info("正在初始化Playwright网页工具...");
// @PostConstruct // 从Playwright管理器获取共享的浏览器实例
// public void initialize() { this.browser = playwrightManager.getBrowser();
// try { log.info("Playwright网页工具初始化成功");
// log.info("正在初始化Playwright..."); } catch (Exception e) {
// this.playwright = Playwright.create(); log.error("Playwright网页工具初始化失败: ", e);
// // 使用chromium浏览器,无头模式(headless=true),适合服务器运行 }
// this.browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); }
// log.info("Playwright初始化成功");
// } catch (Exception e) { /**
// log.error("Playwright初始化失败: ", e); * 处理失败情况
// } * @param message 错误消息
// } * @return 错误结果
*/
// /** private String handleFailure(String message) {
// * 销毁Playwright资源 log.warn(message);
// */ return "抱歉," + message;
// @PreDestroy }
// public void destroy() {
// try { /**
// if (browser != null) { * 在页面上执行操作的通用方法
// browser.close(); * @param url 网页地址
// log.info("浏览器实例已关闭"); * @param pageFunction 页面操作函数
// } * @return 操作结果
// if (playwright != null) { */
// playwright.close(); private String executeWithPage(String url, Function<Page, String> pageFunction) {
// log.info("Playwright实例已关闭"); try (BrowserContext context = browser.newContext();
// } Page page = context.newPage()) {
// } catch (Exception e) { // 导航到指定URL,等待页面加载完成
// log.error("Playwright资源释放失败: ", e); page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD));
// }
// } // 执行具体操作
return pageFunction.apply(page);
// /** } catch (Exception e) {
// * 设置ApplicationContext引用 log.error("页面操作失败: ", e);
// */ return "操作失败:" + e.getMessage();
// @Override }
// public void setApplicationContext(ApplicationContext context) throws BeansException { }
// PlaywrightWebTools.applicationContext = context;
// } /**
* 在页面上执行需要CSS选择器的操作的通用方法
// /** * @param url 网页地址
// * 工具方法:获取指定URL的网页全部文本内容(去除HTML标签) * @param selector CSS选择器
// * @param url 网页地址(必填) * @param pageFunction 页面操作函数
// * @return 网页纯文本内容 * @return 操作结果
// */ */
// @Tool(description = "获取网页纯文本内容") private String executeWithPageAndSelector(String url, String selector, Function<Page, String> pageFunction) {
// public String getWebPageText(String url) { // 参数校验
// log.debug("获取网页纯文本内容: {}", url); if (url == null || url.isEmpty() || selector == null || selector.isEmpty()) {
return handleFailure("URL和选择器不能为空");
// // 记录工具调用开始 }
// HashMap<String, Object> input = new HashMap<>();
// input.put("url", url); try (BrowserContext context = browser.newContext();
// recordToWorkPanel("getWebPageText", input, null, null); Page page = context.newPage()) {
// 导航到指定URL,等待页面加载完成
// long startTime = System.currentTimeMillis(); page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD));
// // 空值校验,增强健壮性 // 检查元素是否存在
// if (url == null || url.isEmpty()) { Locator locator = page.locator(selector);
// String errorMsg = "URL不能为空,请提供有效的网页地址"; if (locator.count() == 0) {
// long endTime = System.currentTimeMillis(); return handleFailure("未找到匹配的元素:" + selector);
// recordToWorkPanel("getWebPageText", null, errorMsg, "failure", endTime - startTime); }
// return errorMsg;
// } // 执行具体操作
return pageFunction.apply(page);
// // 创建浏览器上下文和页面 } catch (Exception e) {
// try (BrowserContext context = browser.newContext(); log.error("页面操作失败: ", e);
// Page page = context.newPage()) { return "操作失败:" + e.getMessage();
// // 导航到指定URL,等待页面加载完成 }
// page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD)); }
// // 获取页面全部文本(Playwright的innerText会自动去除HTML标签,保留文本结构)
// String content = page.locator("body").innerText(); /**
// long endTime = System.currentTimeMillis(); * 工具方法:获取指定URL的网页全部文本内容(去除HTML标签)
// log.debug("成功获取网页文本内容,长度: {} 字符", content.length()); * @param url 网页地址(必填)
* @return 网页纯文本内容
// // 记录工具调用完成 */
// recordToWorkPanel("getWebPageText", null, content, "success", endTime - startTime); @Tool(description = "获取网页纯文本内容")
// return content; public String getWebPageText(String url) {
// } catch (Exception e) { log.debug("获取网页纯文本内容: {}", url);
// long endTime = System.currentTimeMillis();
// String errorMsg = "获取网页内容失败:" + e.getMessage(); // 空值校验,增强健壮性
// log.error("获取网页内容失败: ", e); if (url == null || url.isEmpty()) {
return handleFailure("URL不能为空,请提供有效的网页地址");
// // 记录工具调用错误 }
// recordToWorkPanel("getWebPageText", null, errorMsg, "error", endTime - startTime);
// return errorMsg; return executeWithPage(url, page -> {
// } // 导航到指定URL,等待页面加载完成
// } page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD));
// 获取页面全部文本(Playwright的innerText会自动去除HTML标签,保留文本结构)
// /** return page.locator("body").innerText();
// * 工具方法:获取网页中指定CSS选择器的元素内容 });
// * @param url 网页地址 }
// * @param cssSelector CSS选择器(如#title、.content)
// * @return 元素的文本内容 /**
// */ * 工具方法:获取网页中指定CSS选择器的元素内容
// @Tool(description = "获取网页指定元素的内容") * @param url 网页地址
// public String getWebElementText(String url, String cssSelector) { * @param cssSelector CSS选择器(如#title、.content)
// log.debug("获取网页指定元素内容: {}, 选择器: {}", url, cssSelector); * @return 元素的文本内容
*/
// // 记录工具调用开始 @Tool(description = "获取网页指定元素的内容")
// HashMap<String, Object> input = new HashMap<>(); public String getWebElementText(String url, String cssSelector) {
// input.put("url", url); log.debug("获取网页指定元素内容: {}, 选择器: {}", url, cssSelector);
// input.put("cssSelector", cssSelector);
// recordToWorkPanel("getWebElementText", input, null, null); return executeWithPageAndSelector(url, cssSelector, page -> {
Locator locator = page.locator(cssSelector);
// long startTime = System.currentTimeMillis(); return locator.innerText();
});
// if (url == null || url.isEmpty() || cssSelector == null || cssSelector.isEmpty()) { }
// String errorMsg = "URL和CSS选择器不能为空";
// long endTime = System.currentTimeMillis(); /**
// recordToWorkPanel("getWebElementText", null, errorMsg, "failure", endTime - startTime); * 工具方法:在网页指定输入框中输入文本
// return errorMsg; * @param url 网页地址
// } * @param inputSelector 输入框的CSS选择器(如#search-input)
* @param text 要输入的文本
// try (BrowserContext context = browser.newContext(); * @return 操作结果
// Page page = context.newPage()) { */
// page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD)); @Tool(description = "在网页输入框中输入文本")
// Locator locator = page.locator(cssSelector); public String inputTextToWebElement(String url, String inputSelector, String text) {
// // 检查元素是否存在 log.debug("在网页输入框中输入文本: {}, 选择器: {}, 文本: {}", url, inputSelector, text);
// if (locator.count() == 0) {
// String errorMsg = "未找到匹配的元素:" + cssSelector; // 参数校验
// long endTime = System.currentTimeMillis(); if (url == null || url.isEmpty() || inputSelector == null || inputSelector.isEmpty() || text == null) {
// recordToWorkPanel("getWebElementText", null, errorMsg, "failure", endTime - startTime); return handleFailure("URL、输入框选择器和输入文本不能为空");
// return errorMsg; }
// }
// String content = locator.innerText(); try (BrowserContext context = browser.newContext();
// long endTime = System.currentTimeMillis(); Page page = context.newPage()) {
// log.debug("成功获取元素文本内容: {}", content); page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD));
Locator inputLocator = page.locator(inputSelector);
// // 记录工具调用完成 if (inputLocator.count() == 0) {
// recordToWorkPanel("getWebElementText", null, content, "success", endTime - startTime); return handleFailure("未找到输入框:" + inputSelector);
// return content; }
// } catch (Exception e) { // 聚焦并输入文本
// long endTime = System.currentTimeMillis(); inputLocator.click();
// String errorMsg = "获取元素内容失败:" + e.getMessage(); inputLocator.fill(text);
// log.error("获取元素内容失败: ", e); log.debug("文本输入成功: {}", text);
return "文本输入成功:" + text;
// // 记录工具调用错误 } catch (Exception e) {
// recordToWorkPanel("getWebElementText", null, errorMsg, "error", endTime - startTime); log.error("输入文本失败: ", e);
// return errorMsg; return "输入文本失败:" + e.getMessage();
// } }
// } }
// /** /**
// * 工具方法:在网页指定输入框中输入文本 * 工具方法:点击网页中的指定元素(按钮、链接等)
// * @param url 网页地址 * @param url 网页地址
// * @param inputSelector 输入框的CSS选择器(如#search-input) * @param elementSelector 元素的CSS选择器
// * @param text 要输入的文本 * @return 操作结果
// * @return 操作结果 */
// */ @Tool(description = "点击网页指定元素")
// @Tool(description = "在网页输入框中输入文本") public String clickWebElement(String url, String elementSelector) {
// public String inputTextToWebElement(String url, String inputSelector, String text) { log.debug("点击网页指定元素: {}, 选择器: {}", url, elementSelector);
// log.debug("在网页输入框中输入文本: {}, 选择器: {}, 文本: {}", url, inputSelector, text);
return executeWithPageAndSelector(url, elementSelector, page -> {
// // 记录工具调用开始 Locator locator = page.locator(elementSelector);
// HashMap<String, Object> input = new HashMap<>(); // 点击元素,等待导航完成(如果是链接/提交按钮)
// input.put("url", url); locator.click();
// input.put("inputSelector", inputSelector); page.waitForLoadState(LoadState.LOAD);
// input.put("text", text); return "元素点击成功,当前页面URL:" + page.url();
// recordToWorkPanel("inputTextToWebElement", input, null, null); });
}
// long startTime = System.currentTimeMillis();
/**
// if (url == null || url.isEmpty() || inputSelector == null || inputSelector.isEmpty() || text == null) { * 工具方法:获取网页全屏截图并保存到指定路径
// String errorMsg = "URL、输入框选择器和输入文本不能为空"; * @param url 网页地址
// long endTime = System.currentTimeMillis(); * @param savePath 截图保存路径(如D:/screenshots/page.png)
// recordToWorkPanel("inputTextToWebElement", null, errorMsg, "failure", endTime - startTime); * @return 操作结果
// return errorMsg; */
// } @Tool(description = "获取网页全屏截图")
public String captureWebPageScreenshot(String url, String savePath) {
// try (BrowserContext context = browser.newContext(); log.debug("获取网页全屏截图: {}, 保存路径: {}", url, savePath);
// Page page = context.newPage()) {
// page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD)); // 参数校验
// Locator inputLocator = page.locator(inputSelector); if (url == null || url.isEmpty() || savePath == null || savePath.isEmpty()) {
// if (inputLocator.count() == 0) { return handleFailure("URL和保存路径不能为空");
// String errorMsg = "未找到输入框:" + inputSelector; }
// long endTime = System.currentTimeMillis();
// recordToWorkPanel("inputTextToWebElement", null, errorMsg, "failure", endTime - startTime); try (BrowserContext context = browser.newContext();
// return errorMsg; Page page = context.newPage()) {
// } page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD));
// // 聚焦并输入文本 // 截取全屏并保存
// inputLocator.click(); page.screenshot(new Page.ScreenshotOptions().setPath(java.nio.file.Paths.get(savePath)).setFullPage(true));
// inputLocator.fill(text);
// String result = "文本输入成功:" + text; // 将截图文件转换为Base64编码,用于前端预览显示
// long endTime = System.currentTimeMillis(); String base64Image = "";
// log.debug("文本输入成功: {}", text); try {
Path imagePath = java.nio.file.Paths.get(savePath);
// // 记录工具调用完成 byte[] imageBytes = Files.readAllBytes(imagePath);
// recordToWorkPanel("inputTextToWebElement", null, result, "success", endTime - startTime); base64Image = Base64.getEncoder().encodeToString(imageBytes);
// return result; } catch (Exception e) {
// } catch (Exception e) { log.warn("截图Base64编码转换失败: {}", e.getMessage());
// long endTime = System.currentTimeMillis(); }
// String errorMsg = "输入文本失败:" + e.getMessage();
// log.error("输入文本失败: ", e); log.debug("截图成功,保存路径: {}", savePath);
// // 记录工具调用错误 // 如果有Base64图像数据,则将其添加到结果中以供前端预览
// recordToWorkPanel("inputTextToWebElement", null, errorMsg, "error", endTime - startTime); String result = "截图成功,保存路径:" + savePath;
// return errorMsg; if (!base64Image.isEmpty()) {
// } result += "\n预览图像Base64: data:image/png;base64," + base64Image;
// } }
return result;
// /** } catch (Exception e) {
// * 工具方法:点击网页中的指定元素(按钮、链接等) log.error("截图失败: ", e);
// * @param url 网页地址 return "截图失败:" + e.getMessage();
// * @param elementSelector 元素的CSS选择器 }
// * @return 操作结果 }
// */
// @Tool(description = "点击网页指定元素") /**
// public String clickWebElement(String url, String elementSelector) { * 工具方法:提取网页中的所有超链接(a标签的href属性)
// log.debug("点击网页指定元素: {}, 选择器: {}", url, elementSelector); * @param url 网页地址
* @return 链接列表,以逗号分隔
// // 记录工具调用开始 */
// HashMap<String, Object> input = new HashMap<>(); @Tool(description = "提取网页中的所有链接")
// input.put("url", url); public String extractAllLinksFromPage(String url) {
// input.put("elementSelector", elementSelector); log.debug("提取网页中的所有链接: {}", url);
// recordToWorkPanel("clickWebElement", input, null, null);
// 参数校验
// long startTime = System.currentTimeMillis(); if (url == null || url.isEmpty()) {
return handleFailure("URL不能为空");
// if (url == null || url.isEmpty() || elementSelector == null || elementSelector.isEmpty()) { }
// String errorMsg = "URL和元素选择器不能为空";
// long endTime = System.currentTimeMillis(); return executeWithPage(url, page -> {
// recordToWorkPanel("clickWebElement", null, errorMsg, "failure", endTime - startTime); // 获取所有a标签的href属性
// return errorMsg; Object result = page.locator("a").evaluateAll("elements => elements.map(el => el.href)");
// } List<String> links = (List<String>) result;
return links.isEmpty() ? "未找到任何链接" : String.join(", ", links);
// try (BrowserContext context = browser.newContext(); });
// Page page = context.newPage()) { }
// page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD));
// Locator locator = page.locator(elementSelector); /**
// if (locator.count() == 0) { * 工具方法:在网页中执行自定义JavaScript代码,获取返回结果
// String errorMsg = "未找到要点击的元素:" + elementSelector; * @param url 网页地址
// long endTime = System.currentTimeMillis(); * @param jsCode 要执行的JS代码(如"document.title"、"window.innerWidth")
// recordToWorkPanel("clickWebElement", null, errorMsg, "failure", endTime - startTime); * @return JS执行结果
// return errorMsg; */
// } @Tool(description = "执行网页JavaScript代码")
// // 点击元素,等待导航完成(如果是链接/提交按钮) public String executeJavaScriptOnPage(String url, String jsCode) {
// locator.click(); log.debug("执行网页JavaScript代码: {}, JS代码: {}", url, jsCode);
// page.waitForLoadState(LoadState.LOAD);
// String result = "元素点击成功,当前页面URL:" + page.url(); // 参数校验
// long endTime = System.currentTimeMillis(); if (url == null || url.isEmpty() || jsCode == null || jsCode.isEmpty()) {
// log.debug("元素点击成功,当前页面URL: {}", page.url()); return handleFailure("URL和JS代码不能为空");
}
// // 记录工具调用完成
// recordToWorkPanel("clickWebElement", null, result, "success", endTime - startTime); try (BrowserContext context = browser.newContext();
// return result; Page page = context.newPage()) {
// } catch (Exception e) { page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD));
// long endTime = System.currentTimeMillis(); // 执行JS代码并获取结果
// String errorMsg = "点击元素失败:" + e.getMessage(); Object result = page.evaluate(jsCode);
// log.error("点击元素失败: ", e); String resultStr = "JS执行结果:" + (result == null ? "null" : result.toString());
log.debug("JS执行成功,结果: {}", resultStr);
// // 记录工具调用错误 return resultStr;
// recordToWorkPanel("clickWebElement", null, errorMsg, "error", endTime - startTime); } catch (Exception e) {
// return errorMsg; log.error("执行JS失败: ", e);
// } return "执行JS失败:" + e.getMessage();
// } }
}
// /**
// * 工具方法:获取网页全屏截图并保存到指定路径
// * @param url 网页地址 }
// * @param savePath 截图保存路径(如D:/screenshots/page.png) \ No newline at end of file
// * @return 操作结果
// */
// @Tool(description = "获取网页全屏截图")
// public String captureWebPageScreenshot(String url, String savePath) {
// log.debug("获取网页全屏截图: {}, 保存路径: {}", url, savePath);
// // 记录工具调用开始
// HashMap<String, Object> input = new HashMap<>();
// input.put("url", url);
// input.put("savePath", savePath);
// recordToWorkPanel("captureWebPageScreenshot", input, null, null);
// long startTime = System.currentTimeMillis();
// if (url == null || url.isEmpty() || savePath == null || savePath.isEmpty()) {
// String errorMsg = "URL和保存路径不能为空";
// long endTime = System.currentTimeMillis();
// recordToWorkPanel("captureWebPageScreenshot", null, errorMsg, "failure", endTime - startTime);
// return errorMsg;
// }
// try (BrowserContext context = browser.newContext();
// Page page = context.newPage()) {
// page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD));
// // 截取全屏并保存
// page.screenshot(new Page.ScreenshotOptions().setPath(java.nio.file.Paths.get(savePath)).setFullPage(true));
// // 将截图文件转换为Base64编码,用于前端预览显示
// String base64Image = "";
// try {
// Path imagePath = java.nio.file.Paths.get(savePath);
// byte[] imageBytes = Files.readAllBytes(imagePath);
// base64Image = Base64.getEncoder().encodeToString(imageBytes);
// } catch (Exception e) {
// log.warn("截图Base64编码转换失败: {}", e.getMessage());
// }
// String result = "截图成功,保存路径:" + savePath;
// long endTime = System.currentTimeMillis();
// log.debug("截图成功,保存路径: {}", savePath);
// // 记录工具调用完成,并推送Base64截图到前端预览区域
// HashMap<String, Object> output = new HashMap<>();
// output.put("message", result);
// if (!base64Image.isEmpty()) {
// output.put("previewImage", "data:image/png;base64," + base64Image);
// }
// recordToWorkPanel("captureWebPageScreenshot", null, output, "success", endTime - startTime);
// return result;
// } catch (Exception e) {
// long endTime = System.currentTimeMillis();
// String errorMsg = "截图失败:" + e.getMessage();
// log.error("截图失败: ", e);
// // 记录工具调用错误
// recordToWorkPanel("captureWebPageScreenshot", null, errorMsg, "error", endTime - startTime);
// return errorMsg;
// }
// }
// /**
// * 工具方法:提取网页中的所有超链接(a标签的href属性)
// * @param url 网页地址
// * @return 链接列表,以逗号分隔
// */
// @Tool(description = "提取网页中的所有链接")
// public String extractAllLinksFromPage(String url) {
// log.debug("提取网页中的所有链接: {}", url);
// // 记录工具调用开始
// HashMap<String, Object> input = new HashMap<>();
// input.put("url", url);
// recordToWorkPanel("extractAllLinksFromPage", input, null, null);
// long startTime = System.currentTimeMillis();
// if (url == null || url.isEmpty()) {
// String errorMsg = "URL不能为空";
// long endTime = System.currentTimeMillis();
// recordToWorkPanel("extractAllLinksFromPage", null, errorMsg, "failure", endTime - startTime);
// return errorMsg;
// }
// try (BrowserContext context = browser.newContext();
// Page page = context.newPage()) {
// page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD));
// // 获取所有a标签的href属性
// Object result = page.locator("a").evaluateAll("elements => elements.map(el => el.href)");
// List<String> links = (List<String>) result;
// String resultStr = links.isEmpty() ? "未找到任何链接" : String.join(", ", links);
// long endTime = System.currentTimeMillis();
// log.debug("成功提取链接,数量: {}", links.size());
// // 记录工具调用完成
// recordToWorkPanel("extractAllLinksFromPage", null, resultStr, "success", endTime - startTime);
// return resultStr;
// } catch (Exception e) {
// long endTime = System.currentTimeMillis();
// String errorMsg = "提取链接失败:" + e.getMessage();
// log.error("提取链接失败: ", e);
// // 记录工具调用错误
// recordToWorkPanel("extractAllLinksFromPage", null, errorMsg, "error", endTime - startTime);
// return errorMsg;
// }
// }
// /**
// * 工具方法:在网页中执行自定义JavaScript代码,获取返回结果
// * @param url 网页地址
// * @param jsCode 要执行的JS代码(如"document.title"、"window.innerWidth")
// * @return JS执行结果
// */
// @Tool(description = "执行网页JavaScript代码")
// public String executeJavaScriptOnPage(String url, String jsCode) {
// log.debug("执行网页JavaScript代码: {}, JS代码: {}", url, jsCode);
// // 记录工具调用开始
// HashMap<String, Object> input = new HashMap<>();
// input.put("url", url);
// input.put("jsCode", jsCode);
// recordToWorkPanel("executeJavaScriptOnPage", input, null, null);
// long startTime = System.currentTimeMillis();
// if (url == null || url.isEmpty() || jsCode == null || jsCode.isEmpty()) {
// String errorMsg = "URL和JS代码不能为空";
// long endTime = System.currentTimeMillis();
// recordToWorkPanel("executeJavaScriptOnPage", null, errorMsg, "failure", endTime - startTime);
// return errorMsg;
// }
// try (BrowserContext context = browser.newContext();
// Page page = context.newPage()) {
// page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD));
// // 执行JS代码并获取结果
// Object result = page.evaluate(jsCode);
// String resultStr = "JS执行结果:" + (result == null ? "null" : result.toString());
// long endTime = System.currentTimeMillis();
// log.debug("JS执行成功,结果: {}", resultStr);
// // 记录工具调用完成
// recordToWorkPanel("executeJavaScriptOnPage", null, resultStr, "success", endTime - startTime);
// return resultStr;
// } catch (Exception e) {
// long endTime = System.currentTimeMillis();
// String errorMsg = "执行JS失败:" + e.getMessage();
// log.error("执行JS失败: ", e);
// // 记录工具调用错误
// recordToWorkPanel("executeJavaScriptOnPage", null, errorMsg, "error", endTime - startTime);
// return errorMsg;
// }
// }
// /**
// * 记录到工作面板(不带执行时间)
// */
// private void recordToWorkPanel(String toolAction, Object input, Object output, String status) {
// recordToWorkPanel(toolAction, input, output, status, null);
// }
// /**
// * 记录到工作面板(带执行时间)
// */
// private void recordToWorkPanel(String toolAction, Object input, Object output, String status, Long executionTime) {
// try {
// // 首先尝试从Spring容器获取collector
// IWorkPanelDataCollector collector = null;
// if (applicationContext != null) {
// try {
// collector = applicationContext.getBean(IWorkPanelDataCollector.class);
// } catch (Exception e) {
// log.debug("通过Spring容器获取WorkPanelDataCollector失败: {}", e.getMessage());
// }
// }
// if (collector != null) {
// // 记录工具调用
// if (input != null && status == null) {
// // 调用开始
// collector.recordToolCallStart("Playwright网页工具", toolAction, input);
// } else if (status != null) {
// // 调用结束
// collector.recordToolCallComplete("Playwright网页工具", output, status, executionTime);
// }
// } else {
// log.debug("无法记录到工作面板:collector为null");
// }
// } catch (Exception e) {
// log.debug("记录工作面板失败", e);
// }
// }
// }
\ No newline at end of file
package pangea.hiagent.tools.annotation;
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.tools.processor;
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.service.ToolConfigService;
import pangea.hiagent.tools.annotation.ToolParam;
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.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.concurrent.Callable;
/**
* 异步任务用户上下文装饰器
* 用于在异步任务执行时自动传播用户认证信息
*/
@Slf4j
@Component
public class AsyncUserContextDecorator {
/**
* 包装Runnable任务,自动传播用户上下文
* @param runnable 原始任务
* @return 包装后的任务
*/
public static Runnable wrapWithContext(Runnable runnable) {
// 捕获当前线程的用户上下文
UserContextPropagationUtil.UserContextHolder userContext = UserContextPropagationUtil.captureUserContext();
return () -> {
try {
// 在异步线程中传播用户上下文
UserContextPropagationUtil.propagateUserContext(userContext);
// 执行原始任务
runnable.run();
} finally {
// 清理当前线程的用户上下文
UserContextPropagationUtil.clearUserContext();
}
};
}
/**
* 包装Callable任务,自动传播用户上下文
* @param callable 原始任务
* @param <V> 返回值类型
* @return 包装后的任务
*/
public static <V> Callable<V> wrapWithContext(Callable<V> callable) {
// 捕获当前线程的用户上下文
UserContextPropagationUtil.UserContextHolder userContext = UserContextPropagationUtil.captureUserContext();
return () -> {
try {
// 在异步线程中传播用户上下文
UserContextPropagationUtil.propagateUserContext(userContext);
// 执行原始任务
return callable.call();
} finally {
// 清理当前线程的用户上下文
UserContextPropagationUtil.clearUserContext();
}
};
}
}
\ No newline at end of file
package pangea.hiagent.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.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
package pangea.hiagent.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.util.StringUtils;
import jakarta.servlet.http.HttpServletRequest;
/**
* 用户相关工具类
* 提供统一的用户信息获取方法
*/
@Slf4j
@Component
public class UserUtils {
// 注入JwtUtil bean
private static JwtUtil jwtUtil;
public UserUtils(JwtUtil jwtUtil) {
UserUtils.jwtUtil = jwtUtil;
}
/**
* 获取当前认证用户ID
* @return 用户ID,如果未认证则返回null
*/
public static String getCurrentUserId() {
try {
// 首先尝试从SecurityContext获取
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated() && authentication.getPrincipal() != null) {
Object principal = authentication.getPrincipal();
if (principal instanceof String) {
return (String) principal;
} else {
// 如果principal不是String类型,尝试获取getName()方法的返回值
log.debug("Authentication principal is not a String: {}", principal.getClass().getName());
try {
return principal.toString();
} catch (Exception toStringEx) {
log.warn("无法将principal转换为字符串: {}", toStringEx.getMessage());
}
}
}
// 如果SecurityContext中没有认证信息,尝试从请求头中解析JWT令牌
String userId = getUserIdFromRequest();
if (userId != null) {
return userId;
}
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) {
log.error("在异步线程中获取用户ID时发生异常", e);
return null;
}
}
/**
* 从当前请求中提取JWT令牌并解析用户ID
* @return 用户ID,如果无法解析则返回null
*/
private static String getUserIdFromRequest() {
try {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes) {
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
// 从请求头或参数中提取Token
String token = extractTokenFromRequest(request);
if (StringUtils.hasText(token) && jwtUtil != null) {
// 验证token是否有效
boolean isValid = jwtUtil.validateToken(token);
log.debug("JWT验证结果: {}", isValid);
if (isValid) {
String userId = jwtUtil.getUserIdFromToken(token);
log.debug("从JWT令牌中提取用户ID: {}", userId);
return userId;
} else {
log.warn("JWT验证失败,token可能已过期或无效");
}
} else {
if (jwtUtil == null) {
log.error("jwtUtil未初始化");
} else {
log.debug("未找到有效的token");
}
}
} else {
log.debug("无法获取请求上下文,可能在异步线程中调用");
}
} catch (Exception e) {
log.error("从请求中解析用户ID时发生异常", e);
}
return null;
}
/**
* 从请求头或参数中提取Token
*/
private static String extractTokenFromRequest(HttpServletRequest request) {
// 首先尝试从请求头中提取Token
String authHeader = request.getHeader("Authorization");
log.debug("从请求头中提取Authorization: {}", authHeader);
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
log.debug("从Authorization头中提取到token");
return token;
}
// 如果请求头中没有Token,则尝试从URL参数中提取
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;
}
/**
* 检查当前用户是否已认证
* @return true表示已认证,false表示未认证
*/
public static boolean isAuthenticated() {
return getCurrentUserId() != null;
}
}
\ No newline at end of file
package pangea.hiagent.websocket;
import lombok.extern.slf4j.Slf4j;
import java.nio.ByteBuffer;
import java.util.Objects;
/**
* WebSocket二进制消息协议处理类
*
* 协议格式:
* ┌────────┬─────────┬─────────┬──────────────┬──────────────┐
* │ 头字节 │ 消息ID │ 总分片数 │ 当前分片索引 │ 数据 │
* │(1B) │ (4B) │ (2B) │ (2B) │ (可变) │
* └────────┴─────────┴─────────┴──────────────┴──────────────┘
*
* 头字节定义:
* bit 7-5: 消息类型 (000=data, 001=ack, 010=error)
* bit 4-2: 编码方式 (000=raw, 001=gzip, 010=brotli)
* bit 1-0: 保留位
*/
@Slf4j
public class BinaryMessageProtocol {
// ========== 消息类型常量 ==========
public static final byte TYPE_DATA = 0x00; // 数据帧
public static final byte TYPE_ACK = 0x01; // 确认帧
public static final byte TYPE_ERROR = 0x02; // 错误帧
// ========== 编码类型常量 ==========
public static final byte ENCODING_RAW = 0x00; // 无编码
public static final byte ENCODING_GZIP = 0x01; // GZIP压缩
public static final byte ENCODING_BROTLI = 0x02; // Brotli压缩
// ========== 协议字段大小 ==========
public static final int HEADER_SIZE = 12; // 协议头大小(字节)
public static final int MAX_FRAGMENT_SIZE = 65535 - HEADER_SIZE; // 最大分片数据大小
// ========== 消息ID生成器 ==========
private static int nextMessageId = 1;
/**
* 生成唯一的消息ID
*/
public static synchronized int generateMessageId() {
if (nextMessageId >= Integer.MAX_VALUE) {
nextMessageId = 1;
}
return nextMessageId++;
}
/**
* 编码二进制消息头
*
* @param messageType 消息类型 (TYPE_DATA/TYPE_ACK/TYPE_ERROR)
* @param messageId 消息ID (全局唯一)
* @param totalFragments 总分片数
* @param currentFragment 当前分片索引 (从0开始)
* @param encoding 编码方式 (ENCODING_RAW/ENCODING_GZIP/ENCODING_BROTLI)
* @return 编码后的12字节头数据
* @throws IllegalArgumentException 参数验证失败
*/
public static byte[] encodeHeader(
byte messageType,
int messageId,
int totalFragments,
int currentFragment,
byte encoding) {
// ========== 参数验证 ==========
validateMessageType(messageType);
validateEncoding(encoding);
if (totalFragments <= 0 || totalFragments > 65535) {
throw new IllegalArgumentException("总分片数必须在1-65535之间,当前值: " + totalFragments);
}
if (currentFragment < 0 || currentFragment >= totalFragments) {
throw new IllegalArgumentException(
String.format("当前分片索引越界,应在0-%d之间,当前值: %d",
totalFragments - 1, currentFragment)
);
}
// ========== 编码头信息 ==========
ByteBuffer buffer = ByteBuffer.allocate(HEADER_SIZE);
// 第1字节:消息类型(3bit) + 编码方式(3bit) + 保留(2bit)
byte headerByte = (byte) ((messageType & 0x07) << 5 | (encoding & 0x07) << 2);
buffer.put(headerByte);
// 第2-5字节:消息ID (4字节,大端序)
buffer.putInt(messageId);
// 第6-7字节:总分片数 (2字节,大端序)
buffer.putShort((short) totalFragments);
// 第8-9字节:当前分片索引 (2字节,大端序)
buffer.putShort((short) currentFragment);
// 第10-11字节:保留,用于扩展
buffer.putShort((short) 0);
return buffer.array();
}
/**
* 解码二进制消息头
*
* @param header 包含协议头的字节数组(至少12字节)
* @return 解码后的消息头对象
* @throws IllegalArgumentException header长度不足或格式错误
*/
public static MessageHeader decodeHeader(byte[] header) {
if (header == null || header.length < HEADER_SIZE) {
throw new IllegalArgumentException(
String.format("消息头长度不足,期望至少%d字节,实际%d字节",
HEADER_SIZE, header == null ? 0 : header.length)
);
}
ByteBuffer buffer = ByteBuffer.wrap(header, 0, HEADER_SIZE);
// 解析第1字节
byte headerByte = buffer.get();
byte messageType = (byte) ((headerByte >> 5) & 0x07);
byte encoding = (byte) ((headerByte >> 2) & 0x07);
// 验证解析出的值
validateMessageType(messageType);
validateEncoding(encoding);
// 解析ID和分片信息
int messageId = buffer.getInt();
int totalFragments = buffer.getShort() & 0xFFFF;
int currentFragment = buffer.getShort() & 0xFFFF;
// 验证分片信息
if (totalFragments <= 0 || totalFragments > 65535) {
throw new IllegalArgumentException("总分片数无效: " + totalFragments);
}
if (currentFragment >= totalFragments) {
throw new IllegalArgumentException(
String.format("分片索引越界: %d >= %d", currentFragment, totalFragments)
);
}
return new MessageHeader(messageType, encoding, messageId, totalFragments, currentFragment);
}
/**
* 从完整消息中提取数据部分(跳过12字节的协议头)
*
* @param message 完整的消息字节数组
* @return 数据部分的字节数组
* @throws IllegalArgumentException message长度不足
*/
public static byte[] extractData(byte[] message) {
if (message == null || message.length < HEADER_SIZE) {
throw new IllegalArgumentException(
String.format("消息长度不足,期望至少%d字节", HEADER_SIZE)
);
}
byte[] data = new byte[message.length - HEADER_SIZE];
System.arraycopy(message, HEADER_SIZE, data, 0, data.length);
return data;
}
/**
* 将数据和头信息合并为完整消息
*
* @param header 12字节的协议头
* @param data 消息数据部分
* @return 完整的消息字节数组
*/
public static byte[] buildMessage(byte[] header, byte[] data) {
if (header == null || header.length != HEADER_SIZE) {
throw new IllegalArgumentException("协议头长度必须为" + HEADER_SIZE + "字节");
}
if (data == null || data.length == 0) {
return header;
}
byte[] message = new byte[HEADER_SIZE + data.length];
System.arraycopy(header, 0, message, 0, HEADER_SIZE);
System.arraycopy(data, 0, message, HEADER_SIZE, data.length);
return message;
}
/**
* 验证消息类型是否有效
*/
private static void validateMessageType(byte type) {
if (type != TYPE_DATA && type != TYPE_ACK && type != TYPE_ERROR) {
throw new IllegalArgumentException("无效的消息类型: " + type);
}
}
/**
* 验证编码方式是否有效
*/
private static void validateEncoding(byte encoding) {
if (encoding != ENCODING_RAW && encoding != ENCODING_GZIP && encoding != ENCODING_BROTLI) {
throw new IllegalArgumentException("无效的编码方式: " + encoding);
}
}
/**
* 消息头信息类
*/
public static class MessageHeader {
public final byte messageType; // 消息类型
public final byte encoding; // 编码方式
public final int messageId; // 消息ID
public final int totalFragments; // 总分片数
public final int currentFragment; // 当前分片索引
public MessageHeader(byte type, byte enc, int id, int total, int current) {
this.messageType = type;
this.encoding = enc;
this.messageId = id;
this.totalFragments = total;
this.currentFragment = current;
}
@Override
public String toString() {
return String.format(
"MessageHeader{type=%d, encoding=%d, msgId=%d, totalFrags=%d, currentFrag=%d}",
messageType, encoding, messageId, totalFragments, currentFragment
);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MessageHeader that = (MessageHeader) o;
return messageType == that.messageType &&
encoding == that.encoding &&
messageId == that.messageId &&
totalFragments == that.totalFragments &&
currentFragment == that.currentFragment;
}
@Override
public int hashCode() {
return Objects.hash(messageType, encoding, messageId, totalFragments, currentFragment);
}
}
/**
* 计算传输效率
* 原始数据大小 -> 编码后大小 -> 加入协议头后的最终大小
*/
public static class TransmissionStats {
public int originalSize; // 原始数据大小
public int encodedSize; // 编码后大小
public int totalSize; // 加入协议头后的总大小
public double compressionRatio; // 压缩比
public TransmissionStats(int original, int encoded) {
this.originalSize = original;
this.encodedSize = encoded;
this.totalSize = encoded + HEADER_SIZE;
this.compressionRatio = (double) encoded / original;
}
@Override
public String toString() {
return String.format(
"原始:%dB, 编码:%dB (%.2fx), 加头:%dB, 压缩比:%.2f%%",
originalSize, encodedSize, compressionRatio, totalSize,
(1 - (double) totalSize / originalSize) * 100
);
}
}
}
package pangea.hiagent.websocket;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.BinaryMessage;
import org.springframework.web.socket.WebSocketSession;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ConcurrentMap;
/**
* 二进制消息发送服务
* 负责通过WebSocket发送二进制消息
*/
@Slf4j
public class BinaryMessageSender {
// 异步发送线程池(用于非阻塞发送二进制消息)
private final ExecutorService binaryMessageExecutor;
private final WebSocketConnectionManager connectionManager;
public BinaryMessageSender(WebSocketConnectionManager connectionManager) {
this.connectionManager = connectionManager;
this.binaryMessageExecutor = Executors.newFixedThreadPool(
Math.max(4, Runtime.getRuntime().availableProcessors() / 2),
new ThreadFactory() {
private int count = 0;
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "DomSync-BinaryMsg-" + (++count));
t.setDaemon(true);
return t;
}
}
);
}
/**
* 发送二进制消息(大消息优化)
* 使用BinaryMessageProtocol进行二进制编码,支持自动分片
* 异步发送,避免在IO线程中阻塞
*
* @param message 待发送的消息内容
*/
public void sendBinaryMessage(String message) {
// 异步提交到线程池,避免在WebSocket IO线程中阻塞
binaryMessageExecutor.submit(() -> {
try {
sendBinaryMessageInternal(message);
} catch (Exception e) {
log.error("发送二进制消息失败", e);
}
});
}
/**
* 内部方法:真正执行二进制消息发送
* 在独立的工作线程中运行,不会阻塞WebSocket IO线程
*/
private void sendBinaryMessageInternal(String message) {
byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8);
int messageId = BinaryMessageProtocol.generateMessageId();
int maxFragmentSize = BinaryMessageProtocol.MAX_FRAGMENT_SIZE;
int fragmentCount = (int) Math.ceil((double) messageBytes.length / maxFragmentSize);
// 检查是否有客户端连接
int clientCount = connectionManager.getClientCount();
log.debug("========== 发送二进制消息 ==========");
log.debug("消息ID: {}, 大小: {} 字节, 分片数: {}", messageId, messageBytes.length, fragmentCount);
log.debug("发送给客户端数量: {}", clientCount);
log.debug("当前客户端连接数: {}", clientCount);
if (clientCount == 0) {
log.warn("没有客户端连接,消息将不会被发送");
return;
}
for (int i = 0; i < fragmentCount; i++) {
int start = i * maxFragmentSize;
int end = Math.min(start + maxFragmentSize, messageBytes.length);
byte[] fragmentData = Arrays.copyOfRange(messageBytes, start, end);
// 创建协议头
byte[] header = BinaryMessageProtocol.encodeHeader(
BinaryMessageProtocol.TYPE_DATA,
messageId,
fragmentCount,
i,
BinaryMessageProtocol.ENCODING_RAW
);
// 合并头和数据
byte[] fullMessage = BinaryMessageProtocol.buildMessage(header, fragmentData);
log.debug("分片 {}/{}: 大小={} 字节, 客户端数量={}", i, fragmentCount - 1, fragmentData.length, clientCount);
// 发送到所有客户端(在工作线程中执行,不会阻塞IO线程)
BinaryMessage binaryMessage = new BinaryMessage(fullMessage);
// 通过连接管理器广播消息
connectionManager.broadcastMessage(binaryMessage, this);
}
log.debug("✓ 二进制消息发送完成: 消息ID={}, 总分片数={}, 客户端数量={}", messageId, fragmentCount, clientCount);
}
/**
* 发送错误信息给所有客户端
* 使用二进制协议发送错误消息
* 异步发送,避免在IO线程中阻塞
*/
public void sendErrorToClients(String errorMessage) {
// 异步提交到线程池,避免在WebSocket IO线程中阻塞
binaryMessageExecutor.submit(() -> {
try {
sendErrorToClientsInternal(errorMessage);
} catch (Exception e) {
log.error("发送错误消息失败", e);
}
});
}
/**
* 内部方法:真正执行错误消息发送
* 在独立的工作线程中运行
* 支持大消息分片传输
*/
private void sendErrorToClientsInternal(String errorMessage) {
byte[] messageBytes = errorMessage.getBytes(StandardCharsets.UTF_8);
int messageId = BinaryMessageProtocol.generateMessageId();
int maxFragmentSize = BinaryMessageProtocol.MAX_FRAGMENT_SIZE;
int fragmentCount = (int) Math.ceil((double) messageBytes.length / maxFragmentSize);
log.debug("========== 发送二进制错误消息 ==========");
log.debug("错误消息ID: {}, 大小: {} 字节, 分片数: {}", messageId, messageBytes.length, fragmentCount);
for (int i = 0; i < fragmentCount; i++) {
int start = i * maxFragmentSize;
int end = Math.min(start + maxFragmentSize, messageBytes.length);
byte[] fragmentData = Arrays.copyOfRange(messageBytes, start, end);
// 创建错误帧协议头
byte[] header = BinaryMessageProtocol.encodeHeader(
BinaryMessageProtocol.TYPE_ERROR,
messageId,
fragmentCount,
i,
BinaryMessageProtocol.ENCODING_RAW
);
// 合并头和数据
byte[] fullMessage = BinaryMessageProtocol.buildMessage(header, fragmentData);
log.debug("错误分片 {}/{}: 大小={} 字节", i, fragmentCount - 1, fragmentData.length);
// 发送到所有客户端
BinaryMessage binaryMessage = new BinaryMessage(fullMessage);
// 通过连接管理器广播消息
connectionManager.broadcastMessage(binaryMessage, this);
}
log.debug("✓ 错误消息发送完成: 消息ID={}, 总分片数={}", messageId, fragmentCount);
}
/**
* 广播消息给所有客户端
* 统一使用二进制协议发送
*/
public void broadcastMessage(String message) {
// 所有消息都通过sendBinaryMessage发送
if (message == null || message.isEmpty()) {
return;
}
try {
// 转换为二进制格式发送
sendBinaryMessage(message);
} catch (Exception e) {
log.error("广播消息时发生异常: {}", e.getMessage(), e);
}
}
/**
* 销毁资源
*/
public void destroy() {
try {
// 关闭线程池
binaryMessageExecutor.shutdown();
if (!binaryMessageExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) {
binaryMessageExecutor.shutdownNow();
}
} catch (Exception e) {
log.error("关闭线程池失败", e);
}
}
}
\ No newline at end of file
package pangea.hiagent.websocket;
import com.alibaba.fastjson2.JSON;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.LoadState;
import com.microsoft.playwright.options.WaitForSelectorState;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ConcurrentMap;
/**
* 指令处理器
* 负责处理客户端发送的各种指令
*/
@Slf4j
public class CommandProcessor {
// 指令频率限制
private static final int MAX_COMMANDS_PER_SECOND = 10; // 每秒最大指令数
private final ConcurrentMap<String, Long> lastCommandTimes;
private final ConcurrentMap<String, Integer> commandCounts;
private final ConcurrentMap<String, Long> messageCounters;
public CommandProcessor(ConcurrentMap<String, Long> lastCommandTimes,
ConcurrentMap<String, Integer> commandCounts,
ConcurrentMap<String, Long> messageCounters) {
this.lastCommandTimes = lastCommandTimes;
this.commandCounts = commandCounts;
this.messageCounters = messageCounters;
}
/**
* 处理客户端发送的指令(如导航、点击、输入)
*/
public void processCommand(String payload, Page page, BinaryMessageSender messageSender,
StatisticsService statisticsService, String userId) throws Exception {
// 记录接收到的消息
log.debug("收到WebSocket消息: {}", payload);
incrementCounter("websocketMessages");
if (userId == null || userId.isEmpty()) {
// 如果没有有效的用户ID,使用默认值
userId = "unknown-user";
log.warn("指令处理缺少有效的用户认证信息,使用默认用户ID: {}", userId);
}
// 指令频率限制
long currentTime = System.currentTimeMillis();
Long lastTime = lastCommandTimes.getOrDefault(userId, 0L);
Integer commandCount = commandCounts.getOrDefault(userId, 0);
// 检查是否超过每秒最大指令数
if (currentTime - lastTime < 1000) {
// 同一秒内
if (commandCount >= MAX_COMMANDS_PER_SECOND) {
String errorMsg = "指令执行过于频繁,请稍后再试";
log.warn("用户 {} {}", userId, errorMsg);
messageSender.sendErrorToClients(errorMsg);
return;
}
commandCounts.put(userId, commandCount + 1);
} else {
// 新的一秒
lastCommandTimes.put(userId, currentTime);
commandCounts.put(userId, 1);
}
try {
// 检查Playwright实例是否有效
if (!isPlaywrightInstanceValid(page)) {
String errorMsg = "Playwright实例未初始化或已关闭,请刷新页面重试";
log.warn("用户 {} {}", userId, errorMsg);
messageSender.sendErrorToClients(errorMsg);
return;
}
// 指令格式:指令类型:参数(如navigate:https://www.baidu.com)
// 特殊处理navigate命令,避免URL中的冒号导致分割错误
String command;
String param;
int firstColonIndex = payload.indexOf(':');
if (firstColonIndex != -1) {
command = payload.substring(0, firstColonIndex);
param = payload.substring(firstColonIndex + 1);
} else {
command = payload;
param = "";
}
// 处理用户指令
log.debug("处理指令: {}, 参数: {} (用户: {})", command, param, userId);
switch (command) {
case "navigate":
handleNavigateCommand(param, page, messageSender);
break;
case "stats":
handleStatsCommand(statisticsService, messageSender);
break;
case "reset-stats":
handleResetStatsCommand(statisticsService, messageSender);
break;
case "click":
handleClickCommand(param, page, messageSender);
break;
case "dblclick":
handleDblClickCommand(param, page, messageSender);
break;
case "hover":
handleHoverCommand(param, page, messageSender);
break;
case "focus":
handleFocusCommand(param, page, messageSender);
break;
case "blur":
handleBlurCommand(param, page, messageSender);
break;
case "select":
handleSelectCommand(param, page, messageSender);
break;
case "keydown":
handleKeyDownCommand(param, page, messageSender);
break;
case "keyup":
handleKeyUpCommand(param, page, messageSender);
break;
case "keypress":
handleKeyPressCommand(param, page, messageSender);
break;
case "scroll":
handleScrollCommand(param, page, messageSender);
break;
case "type":
handleTypeCommand(param, page, messageSender);
break;
case "input":
handleInputCommand(param, page, messageSender);
break;
default:
messageSender.sendErrorToClients("未知指令:" + command);
}
} catch (Exception e) {
log.error("指令执行失败", e);
messageSender.sendErrorToClients("指令执行失败:" + e.getMessage());
}
}
/**
* 处理导航指令
*/
private void handleNavigateCommand(String param, Page page, BinaryMessageSender messageSender) {
// 导航到指定URL
if (param == null || param.trim().isEmpty()) {
messageSender.sendErrorToClients("导航URL不能为空");
return;
}
// 验证URL格式
if (!isValidUrl(param)) {
messageSender.sendErrorToClients("无效的URL格式: " + param);
return;
}
try {
page.navigate(param);
// 异步处理页面加载完成后的DOM发送,避免阻塞WebSocket
java.util.concurrent.CompletableFuture.runAsync(() -> {
try {
// 等待页面加载状态:DOMCONTENTLOADED确保DOM可用
log.debug("等待页面DOM加载: {}", param);
page.waitForLoadState(LoadState.DOMCONTENTLOADED);
// 额外等待500ms,确保关键脚本执行完成
Thread.sleep(500);
log.debug("页面DOM加载完成: {}", param);
// 发送完整DOM到前端
String fullDom = page.content();
messageSender.sendBinaryMessage(fullDom);
} catch (Exception e) {
String errorMsg = "页面加载或DOM获取失败: " + e.getMessage();
log.error(errorMsg, e);
// 重要:必须将错误发送给前端
messageSender.sendErrorToClients(errorMsg);
}
});
} catch (Exception e) {
String errorMsg = "导航命令执行失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理统计信息指令
*/
private void handleStatsCommand(StatisticsService statisticsService, BinaryMessageSender messageSender) {
try {
String stats = statisticsService.getStatisticsSummary();
// 使用二进制协议发送统计信息
messageSender.broadcastMessage(stats);
} catch (Exception e) {
String errorMsg = "获取统计信息失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理重置统计信息指令
*/
private void handleResetStatsCommand(StatisticsService statisticsService, BinaryMessageSender messageSender) {
try {
statisticsService.resetAllCounters();
messageSender.sendErrorToClients("统计信息已重置");
} catch (Exception e) {
String errorMsg = "重置统计信息失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理点击指令
*/
private void handleClickCommand(String param, Page page, BinaryMessageSender messageSender) {
try {
page.locator(param).click();
} catch (Exception e) {
String errorMsg = "点击元素失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理双击指令
*/
private void handleDblClickCommand(String param, Page page, BinaryMessageSender messageSender) {
try {
page.locator(param).dblclick();
} catch (Exception e) {
String errorMsg = "双击元素失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理悬停指令
*/
private void handleHoverCommand(String param, Page page, BinaryMessageSender messageSender) {
try {
// 增强hover操作:先等待元素可见,再执行hover
Locator locator = page.locator(param);
// 等待元素可见,最多等待10秒
locator.waitFor(new Locator.WaitForOptions()
.setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE)
.setTimeout(10000));
// 执行hover操作
locator.hover();
} catch (Exception e) {
String errorMsg = "悬停元素失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理聚焦指令
*/
private void handleFocusCommand(String param, Page page, BinaryMessageSender messageSender) {
try {
page.locator(param).focus();
} catch (Exception e) {
String errorMsg = "聚焦元素失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理失去焦点指令
*/
private void handleBlurCommand(String param, Page page, BinaryMessageSender messageSender) {
try {
page.locator(param).blur();
} catch (Exception e) {
String errorMsg = "失去焦点失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理选择指令
*/
private void handleSelectCommand(String param, Page page, BinaryMessageSender messageSender) {
try {
String[] selectParts = param.split(":", 2);
if (selectParts.length == 2) {
page.locator(selectParts[0]).selectOption(selectParts[1]);
}
} catch (Exception e) {
String errorMsg = "选择选项失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理键盘按下指令
*/
private void handleKeyDownCommand(String param, Page page, BinaryMessageSender messageSender) {
try {
page.keyboard().down(param);
} catch (Exception e) {
String errorMsg = "键盘按下失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理键盘释放指令
*/
private void handleKeyUpCommand(String param, Page page, BinaryMessageSender messageSender) {
try {
page.keyboard().up(param);
} catch (Exception e) {
String errorMsg = "键盘释放失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理按键事件指令
*/
private void handleKeyPressCommand(String param, Page page, BinaryMessageSender messageSender) {
try {
page.keyboard().press(param);
} catch (Exception e) {
String errorMsg = "按键事件失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理滚动指令
*/
private void handleScrollCommand(String param, Page page, BinaryMessageSender messageSender) {
try {
int scrollY = Integer.parseInt(param);
page.evaluate("window.scrollTo(0, " + scrollY + ")");
} catch (Exception e) {
String errorMsg = "滚动页面失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理输入指令
*/
private void handleTypeCommand(String param, Page page, BinaryMessageSender messageSender) {
try {
// 使用indexOf和substring替代split,避免内容中的冒号导致分割错误
int colonIndex = param.indexOf(':');
if (colonIndex != -1 && colonIndex < param.length() - 1) {
String selector = param.substring(0, colonIndex);
String content = param.substring(colonIndex + 1);
Locator inputLocator = page.locator(selector);
inputLocator.fill(content);
}
} catch (Exception e) {
String errorMsg = "输入内容失败:" + e.getMessage();
log.error(errorMsg, e);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 处理输入指令(别名)
*/
private void handleInputCommand(String param, Page page, BinaryMessageSender messageSender) {
handleTypeCommand(param, page, messageSender);
}
/**
* 验证URL格式是否有效
*/
private boolean isValidUrl(String url) {
if (url == null || url.trim().isEmpty()) {
return false;
}
// 检查是否以http://或https://开头
if (!url.toLowerCase().startsWith("http://") && !url.toLowerCase().startsWith("https://")) {
return false;
}
// 简单的URL格式检查
try {
new java.net.URL(url);
return true;
} catch (Exception e) {
return false;
}
}
/**
* 检查Playwright实例是否有效
*/
private boolean isPlaywrightInstanceValid(Page page) {
try {
boolean pageValid = page != null && !page.isClosed();
return pageValid;
} catch (Exception e) {
log.warn("检查Playwright实例状态失败", e);
return false;
}
}
/**
* 增加计数器
*/
private void incrementCounter(String counterName) {
messageCounters.merge(counterName, 1L, Long::sum);
}
}
\ No newline at end of file
package pangea.hiagent.websocket;
import com.alibaba.fastjson2.annotation.JSONField;
/**
* DOM同步的数据传输对象
*/
public class DomSyncData {
// 消息类型:init(初始化完整DOM)、update(增量DOM更新)、style(样式)、script(脚本)、fragment(分片消息)
@JSONField(name = "type")
private String type;
// DOM内容(完整/增量/分片)
@JSONField(name = "dom")
private String dom;
// 样式内容(内联+外部)
@JSONField(name = "style")
private String style;
// 脚本内容(内联+外部)
@JSONField(name = "script")
private String script;
// 页面URL(用于前端匹配)
@JSONField(name = "url")
private String url;
// 无参构造(JSON序列化需要)
public DomSyncData() {}
// 全参构造
public DomSyncData(String type, String dom, String style, String script, String url) {
this.type = type;
this.dom = dom;
this.style = style;
this.script = script;
this.url = url;
}
// getter/setter方法
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getDom() { return dom; }
public void setDom(String dom) { this.dom = dom; }
public String getStyle() { return style; }
public void setStyle(String style) { this.style = style; }
public String getScript() { return script; }
public void setScript(String script) { this.script = script; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
}
\ No newline at end of file
...@@ -2,7 +2,8 @@ package pangea.hiagent.websocket; ...@@ -2,7 +2,8 @@ package pangea.hiagent.websocket;
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSON;
import com.microsoft.playwright.*; import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.socket.*; import org.springframework.web.socket.*;
import org.springframework.web.socket.handler.TextWebSocketHandler; import org.springframework.web.socket.handler.TextWebSocketHandler;
...@@ -12,462 +13,60 @@ import java.util.concurrent.ConcurrentHashMap; ...@@ -12,462 +13,60 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
/** /**
* 优化的DOM同步WebSocket处理器 * 优化的DOM同步WebSocket处理器(门面类)
* 针对前端iframe显示特点进行了简化和优化 * 协调各个子模块完成DOM同步功能
*/ */
public class DomSyncHandler extends TextWebSocketHandler { @Slf4j
// 存储连接的前端客户端(线程安全) public class DomSyncHandler extends org.springframework.web.socket.handler.AbstractWebSocketHandler {
private static final ConcurrentMap<WebSocketSession, String> clients = new ConcurrentHashMap<>(); // 注入Playwright管理器
@Autowired
// Playwright核心实例 private pangea.hiagent.core.PlaywrightManager playwrightManager;
private Playwright playwright;
private Browser browser; // 各个子模块
private Page page; private WebSocketConnectionManager connectionManager;
private BrowserContext context; private DomSyncService domSyncService;
private CommandProcessor commandProcessor;
// 连接数限制 private StatisticsService statisticsService;
private static final int MAX_CONNECTIONS_PER_USER = 5; // 每用户最大连接数 private BinaryMessageSender messageSender;
private static final int MAX_COMMANDS_PER_SECOND = 10; // 每秒最大指令数
// 共享的数据结构
// 用户连接计数 private static final ConcurrentMap<String, Long> messageCounters = new ConcurrentHashMap<>();
private static final ConcurrentMap<String, Integer> userConnections = new ConcurrentHashMap<>();
// 指令频率限制
private static final ConcurrentMap<String, Long> lastCommandTimes = new ConcurrentHashMap<>(); private static final ConcurrentMap<String, Long> lastCommandTimes = new ConcurrentHashMap<>();
private static final ConcurrentMap<String, Integer> commandCounts = new ConcurrentHashMap<>(); private static final ConcurrentMap<String, Integer> commandCounts = new ConcurrentHashMap<>();
private static final ConcurrentMap<String, Integer> userConnections = new ConcurrentHashMap<>();
// 统计信息
private static final ConcurrentMap<String, Long> messageCounters = new ConcurrentHashMap<>();
// 增加计数器
private void incrementCounter(String counterName) {
messageCounters.merge(counterName, 1L, Long::sum);
}
/**
* 转义HTML内容中的特殊字符,防止JSON序列化错误
*
* @param content 待转义的HTML内容
* @return 转义后的内容
*/
private String escapeHtmlContent(String content) {
if (content == null || content.isEmpty()) {
return content;
}
StringBuilder sb = new StringBuilder(content.length() * 2); // 预分配空间以提高性能
for (int i = 0; i < content.length(); i++) {
char c = content.charAt(i);
switch (c) {
case '"':
sb.append("\\\"");
break;
case '\\':
sb.append("\\\\");
break;
case '/':
sb.append("\\/");
break;
case '\b':
sb.append("\\b");
break;
case '\f':
sb.append("\\f");
break;
case '\n':
sb.append("\\n");
break;
case '\r':
sb.append("\\r");
break;
case '\t':
sb.append("\\t");
break;
default:
// 处理控制字符
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
break;
}
}
return sb.toString();
}
// 获取计数器值
public static long getCounter(String counterName) {
return messageCounters.getOrDefault(counterName, 0L);
}
// 重置计数器
public static void resetCounter(String counterName) {
messageCounters.put(counterName, 0L);
}
// 初始化Playwright与页面
public DomSyncHandler() { public DomSyncHandler() {
initPlaywright(); // 初始化各个子模块
initPageListener(); initializeSubModules();
} }
/** /**
* 初始化Playwright(服务器端无头模式,适配生产环境) * 初始化各个子模块
*/ */
private void initPlaywright() { private void initializeSubModules() {
try { // 初始化连接管理器
System.out.println("正在初始化Playwright..."); connectionManager = new WebSocketConnectionManager(playwrightManager);
playwright = Playwright.create();
// 启动Chrome无头实例,添加必要参数
browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(true) // 无头模式,无界面
.setArgs(java.util.Arrays.asList(
"--no-sandbox", // 服务器端必须,否则Chrome启动失败
"--disable-dev-shm-usage", // 解决Linux下/dev/shm空间不足的问题
"--disable-gpu", // 禁用GPU,服务器端无需
"--remote-allow-origins=*"))); // 允许跨域请求
// 创建浏览器上下文(隔离环境) // 初始化DOM同步服务
context = browser.newContext(new Browser.NewContextOptions() domSyncService = new DomSyncService(messageCounters);
.setViewportSize(1920, 1080) // 设置视口大小,与前端一致
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")); // 设置用户代理
page = context.newPage(); // 初始化消息发送器
messageSender = new BinaryMessageSender(connectionManager);
// 设置默认超时时间,避免长时间等待 // 初始化统计信息服务
page.setDefaultTimeout(10000); // 10秒超时 statisticsService = new StatisticsService(messageCounters, userConnections);
System.out.println("Playwright初始化成功"); // 初始化指令处理器
} catch (Exception e) { commandProcessor = new CommandProcessor(lastCommandTimes, commandCounts, messageCounters);
System.err.println("Playwright初始化失败: " + e.getMessage());
e.printStackTrace();
}
} }
/** /**
* 初始化页面监听事件(核心:捕获DOM变化) * 设置PlaywrightManager
* 针对iframe显示特点进行了优化,简化了监听逻辑 * 此方法用于Spring注入
*/ */
private void initPageListener() { public void setPlaywrightManager(pangea.hiagent.core.PlaywrightManager playwrightManager) {
// 初始化统计计数器 this.playwrightManager = playwrightManager;
messageCounters.put("domChanges", 0L); log.debug("已设置PlaywrightManager实例");
messageCounters.put("websocketMessages", 0L);
messageCounters.put("errors", 0L);
// 1. 页面加载完成后,推送完整的DOM(初始化)
page.onLoad(page -> {
System.out.println("页面加载完成: " + page.url());
incrementCounter("pageLoads");
// 发送完整的DOM内容到客户端
sendFullDomToClientsWithRetry();
});
// 2. 监听DOM变化(使用MutationObserver),推送增量更新
// 针对iframe特点优化:只监听body区域的变化
page.evaluate("() => {\n" +
" // 创建MutationObserver监听DOM变化\n" +
" const observer = new MutationObserver((mutations) => {\n" +
" // 将变化的DOM节点转为字符串,发送给Playwright\n" +
" const changes = mutations.map(mutation => ({\n" +
" type: mutation.type,\n" +
" target: mutation.target.outerHTML || mutation.target.textContent,\n" +
" addedNodes: Array.from(mutation.addedNodes).map(node => node.outerHTML || node.textContent || ''),\n" +
" removedNodes: Array.from(mutation.removedNodes).map(node => node.outerHTML || node.textContent || ''),\n" +
" attributeName: mutation.attributeName,\n" +
" oldValue: mutation.oldValue\n" +
" }));\n" +
" // 调用Playwright的暴露函数,传递DOM变化数据\n" +
" window.domChanged(JSON.stringify(changes));\n" +
" });\n" +
" // 配置监听:监听body节点的添加/删除、属性变化、子节点变化\n" +
" observer.observe(document.body, {\n" +
" childList: true,\n" +
" attributes: true,\n" +
" subtree: true,\n" +
" characterData: true,\n" +
" attributeOldValue: true\n" +
" });\n" +
"}");
// 3. 暴露Playwright函数,接收前端的DOM变化数据
page.exposeFunction("domChanged", args -> {
try {
if (args.length > 0 && args[0] instanceof String) {
String changes = (String) args[0];
if (changes != null && !changes.isEmpty()) {
incrementCounter("domChanges");
sendIncrementalDomToClients(changes);
}
}
} catch (Exception e) {
String errorMsg = "处理DOM变化失败: " + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
incrementCounter("errors");
sendErrorToClients(errorMsg);
}
return null;
});
// 4. 监听页面导航事件,导航后重新初始化
page.onFrameNavigated(frame -> {
System.out.println("检测到页面导航: " + frame.url());
incrementCounter("navigations");
// 异步处理导航完成后的DOM发送,避免阻塞
java.util.concurrent.CompletableFuture.runAsync(() -> {
try {
// 等待页面达到网络空闲状态,确保内容稳定
page.waitForLoadState(LoadState.NETWORKIDLE);
System.out.println("页面加载完成: " + frame.url());
// 发送更新后的DOM内容到客户端
sendFullDomToClientsWithRetry();
} catch (Exception e) {
String errorMsg = "页面加载状态等待失败: " + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
incrementCounter("errors");
sendErrorToClients(errorMsg);
}
});
});
// 5. 监听页面错误事件
page.onPageError(error -> {
try {
String errorMsg = "页面错误: " + error;
System.err.println(errorMsg);
incrementCounter("errors");
sendErrorToClients(errorMsg);
} catch (Exception e) {
System.err.println("处理页面错误事件失败: " + e.getMessage());
e.printStackTrace();
}
});
}
/**
* 推送完整的DOM给所有客户端(初始化时调用)
* 针对iframe显示特点进行了优化,不再传输样式和脚本
*/
private void sendFullDomToClients() {
try {
// 1. 获取页面完整DOM(包含所有节点)
String fullDom = page.content();
// 2. 对HTML内容进行转义处理,防止JSON序列化错误
String escapedDom = escapeHtmlContent(fullDom);
// 3. 封装数据(简化版,不传输样式和脚本)
DomSyncData data = new DomSyncData(
"init", // 初始化类型
escapedDom,
"", // 不再传输样式
"", // 不再传输脚本
getCurrentPageUrl() // 当前页面URL
);
// 4. 序列化为JSON并推送
String jsonData = JSON.toJSONString(data);
broadcastMessage(jsonData);
} catch (Exception e) {
e.printStackTrace();
sendErrorToClients("获取完整DOM失败:" + e.getMessage());
}
}
/**
* 推送完整的DOM给所有客户端(带重试机制)
* 针对页面导航过程中的不稳定状态增加了重试机制
*/
private void sendFullDomToClientsWithRetry() {
sendFullDomToClientsWithRetry(3, 500); // 默认重试3次,每次间隔500毫秒
}
/**
* 推送完整的DOM给所有客户端(带重试机制)
*
* @param maxRetries 最大重试次数
* @param retryDelay 重试间隔(毫秒)
*/
private void sendFullDomToClientsWithRetry(int maxRetries, long retryDelay) {
Exception lastException = null;
for (int i = 0; i < maxRetries; i++) {
try {
// 1. 获取页面完整DOM(包含所有节点)
String fullDom = page.content();
// 2. 对HTML内容进行转义处理,防止JSON序列化错误
String escapedDom = escapeHtmlContent(fullDom);
// 3. 封装数据(简化版,不传输样式和脚本)
DomSyncData data = new DomSyncData(
"init", // 初始化类型
escapedDom,
"", // 不再传输样式
"", // 不再传输脚本
getCurrentPageUrl() // 当前页面URL
);
// 4. 序列化为JSON并推送
String jsonData = JSON.toJSONString(data);
broadcastMessage(jsonData);
// 成功发送,直接返回
return;
} catch (Exception e) {
lastException = e;
System.err.println("第" + (i+1) + "次获取完整DOM失败: " + e.getMessage());
// 如果不是最后一次重试,则等待一段时间再重试
if (i < maxRetries - 1) {
try {
Thread.sleep(retryDelay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
sendErrorToClients("获取完整DOM被中断:" + ie.getMessage());
return;
}
}
}
}
// 所有重试都失败了
if (lastException != null) {
lastException.printStackTrace();
sendErrorToClients("获取完整DOM失败(已重试" + maxRetries + "次):" + lastException.getMessage());
}
}
/**
* 推送增量DOM变化给所有客户端(DOM更新时调用)
* 针对iframe显示特点进行了优化
*/
private void sendIncrementalDomToClients(String changes) {
try {
// 对变化数据进行转义处理
String escapedChanges = escapeHtmlContent(changes);
DomSyncData data = new DomSyncData(
"update", // 增量更新类型
escapedChanges, // DOM变化数据
"", // 样式无增量,空字符串
"", // 脚本无增量,空字符串
getCurrentPageUrl()
);
String jsonData = JSON.toJSONString(data);
broadcastMessage(jsonData);
} catch (Exception e) {
e.printStackTrace();
sendErrorToClients("推送增量DOM失败:" + e.getMessage());
}
}
/**
* 广播消息给所有客户端(增加消息大小检查和分片处理)
*/
private void broadcastMessage(String message) {
// 检查消息大小,如果过大则进行分片处理
final int MAX_MESSAGE_SIZE = 64 * 1024; // 64KB限制
try {
if (message == null || message.isEmpty()) {
return;
}
// 如果消息小于最大限制,直接发送
if (message.length() <= MAX_MESSAGE_SIZE) {
TextMessage textMessage = new TextMessage(message);
for (WebSocketSession client : clients.keySet()) {
try {
if (client.isOpen()) {
client.sendMessage(textMessage);
}
} catch (Exception e) {
System.err.println("发送消息给客户端失败: " + e.getMessage());
e.printStackTrace();
// 发送失败时移除客户端
clients.remove(client);
}
}
} else {
// 消息过大,需要分片处理
System.out.println("消息过大,正在进行分片处理,总长度: " + message.length());
sendFragmentedMessage(message, MAX_MESSAGE_SIZE);
}
} catch (Exception e) {
System.err.println("广播消息时发生异常: " + e.getMessage());
e.printStackTrace();
}
}
/**
* 分片发送大消息
*
* @param message 完整消息
* @param maxFragmentSize 每个片段的最大大小
*/
private void sendFragmentedMessage(String message, int maxFragmentSize) {
try {
int totalLength = message.length();
int fragmentCount = (int) Math.ceil((double) totalLength / maxFragmentSize);
System.out.println("消息将被分为 " + fragmentCount + " 个片段发送");
for (int i = 0; i < fragmentCount; i++) {
int start = i * maxFragmentSize;
int end = Math.min(start + maxFragmentSize, totalLength);
String fragment = message.substring(start, end);
// 创建分片消息
DomSyncData fragmentData = new DomSyncData(
"fragment", // 分片类型
fragment, // 分片内容
"", // 样式
"", // 脚本
getCurrentPageUrl() + "?fragment=" + i + "&total=" + fragmentCount // URL附加分片信息
);
String jsonData = JSON.toJSONString(fragmentData);
TextMessage textMessage = new TextMessage(jsonData);
// 发送分片消息
for (WebSocketSession client : clients.keySet()) {
try {
if (client.isOpen()) {
client.sendMessage(textMessage);
}
} catch (Exception e) {
System.err.println("发送分片消息给客户端失败: " + e.getMessage());
e.printStackTrace();
// 发送失败时移除客户端
clients.remove(client);
}
}
}
} catch (Exception e) {
System.err.println("分片发送消息时发生异常: " + e.getMessage());
e.printStackTrace();
}
}
/**
* 发送错误信息给所有客户端
*/
private void sendErrorToClients(String errorMessage) {
DomSyncData errorData = new DomSyncData(
"error", // 错误类型
errorMessage, // 错误信息
"",
"",
getCurrentPageUrl()
);
String jsonData = JSON.toJSONString(errorData);
broadcastMessage(jsonData);
} }
// ===================== WebSocket生命周期方法 ===================== // ===================== WebSocket生命周期方法 =====================
...@@ -476,319 +75,34 @@ public class DomSyncHandler extends TextWebSocketHandler { ...@@ -476,319 +75,34 @@ public class DomSyncHandler extends TextWebSocketHandler {
*/ */
@Override @Override
public void afterConnectionEstablished(WebSocketSession session) { public void afterConnectionEstablished(WebSocketSession session) {
// 从会话属性中获取用户ID // 将PlaywrightManager传递给DomSyncService
String userId = (String) session.getAttributes().get("userId"); domSyncService.setPlaywrightManager(playwrightManager);
if (userId == null) {
userId = "anonymous"; // 默认匿名用户
}
// 检查连接数限制 // 通过连接管理器处理连接建立
Integer currentConnections = userConnections.getOrDefault(userId, 0); connectionManager.handleConnectionEstablished(session, domSyncService);
if (currentConnections >= MAX_CONNECTIONS_PER_USER) {
try {
session.close(CloseStatus.POLICY_VIOLATION.withReason("超过最大连接数限制"));
return;
} catch (Exception e) {
e.printStackTrace();
}
}
// 增加用户连接数
userConnections.put(userId, currentConnections + 1);
clients.put(session, session.getId());
System.out.println("客户端连接成功:" + session.getId() + ",用户:" + userId);
// 连接建立后不立即推送DOM,而是等待客户端发送navigate指令
System.out.println("WebSocket连接已建立,等待客户端发送导航指令...");
} }
/** /**
* 处理客户端发送的指令(如导航、点击、输入) * 处理客户端发送的指令(如导航、点击、输入)
*/ */
@Override @Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) { public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload(); // 从会话属性中获取用户ID(来自JWT认证)
// 记录接收到的消息
System.out.println("收到WebSocket消息: " + payload);
incrementCounter("websocketMessages");
// 从会话属性中获取用户ID
String userId = (String) session.getAttributes().get("userId"); String userId = (String) session.getAttributes().get("userId");
if (userId == null) { if (userId == null || userId.isEmpty()) {
userId = "anonymous"; // 默认匿名用户 // 如果没有有效的用户ID,使用默认值
} userId = "unknown-user";
log.warn("WebSocket消息处理缺少有效的用户认证信息,使用默认用户ID: {}", userId);
// 指令频率限制 }
long currentTime = System.currentTimeMillis();
Long lastTime = lastCommandTimes.getOrDefault(userId, 0L); // 通过指令处理器处理指令
Integer commandCount = commandCounts.getOrDefault(userId, 0); commandProcessor.processCommand(
message.getPayload(),
// 检查是否超过每秒最大指令数 domSyncService.getCurrentPage(), // 获取当前页面对象
if (currentTime - lastTime < 1000) { messageSender,
// 同一秒内 statisticsService,
if (commandCount >= MAX_COMMANDS_PER_SECOND) { userId
String errorMsg = "指令执行过于频繁,请稍后再试";
System.err.println("用户 " + userId + " " + errorMsg);
sendErrorToClients(errorMsg);
return;
}
commandCounts.put(userId, commandCount + 1);
} else {
// 新的一秒
lastCommandTimes.put(userId, currentTime);
commandCounts.put(userId, 1);
}
try {
// 检查Playwright实例是否有效
if (!isPlaywrightInstanceValid()) {
String errorMsg = "Playwright实例未初始化或已关闭,请刷新页面重试";
System.err.println("用户 " + userId + " " + errorMsg);
sendErrorToClients(errorMsg);
return;
}
// 检查WebSocket连接状态
if (!session.isOpen()) {
String errorMsg = "WebSocket连接已关闭";
System.err.println("用户 " + userId + " " + errorMsg);
sendErrorToClients(errorMsg);
return;
}
// 指令格式:指令类型:参数(如navigate:https://www.baidu.com)
// 特殊处理navigate命令,避免URL中的冒号导致分割错误
String command;
String param;
int firstColonIndex = payload.indexOf(':');
if (firstColonIndex != -1) {
command = payload.substring(0, firstColonIndex);
param = payload.substring(firstColonIndex + 1);
} else {
command = payload;
param = "";
}
System.out.println("处理指令: " + command + ", 参数: " + param + " (用户: " + userId + ")");
switch (command) {
case "navigate":
// 导航到指定URL
if (param == null || param.trim().isEmpty()) {
sendErrorToClients("导航URL不能为空");
return;
}
// 验证URL格式
if (!isValidUrl(param)) {
sendErrorToClients("无效的URL格式: " + param);
return;
}
try {
System.out.println("正在导航到URL: " + param);
page.navigate(param);
System.out.println("导航成功: " + param);
} catch (Exception e) {
String errorMsg = "导航失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "stats":
// 获取统计信息
try {
String stats = getStatisticsSummary();
DomSyncData statsData = new DomSyncData(
"stats", // 统计信息类型
stats, // 统计信息内容
"",
"",
page != null ? page.url() : ""
); );
String jsonData = JSON.toJSONString(statsData);
broadcastMessage(jsonData);
} catch (Exception e) {
String errorMsg = "获取统计信息失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "reset-stats":
// 重置统计信息
try {
resetAllCounters();
sendErrorToClients("统计信息已重置");
} catch (Exception e) {
String errorMsg = "重置统计信息失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "click":
// 点击指定选择器的元素(如#su)
try {
page.locator(param).click();
} catch (Exception e) {
String errorMsg = "点击元素失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "dblclick":
// 双击指定选择器的元素
try {
page.locator(param).dblclick();
} catch (Exception e) {
String errorMsg = "双击元素失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "hover":
// 悬停在指定选择器的元素上
try {
page.locator(param).hover();
} catch (Exception e) {
String errorMsg = "悬停元素失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "focus":
// 聚焦到指定元素
try {
page.locator(param).focus();
} catch (Exception e) {
String errorMsg = "聚焦元素失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "blur":
// 失去焦点
try {
page.locator(param).blur();
} catch (Exception e) {
String errorMsg = "失去焦点失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "select":
// 选择下拉选项(格式:选择器:值)
try {
String[] selectParts = param.split(":", 2);
if (selectParts.length == 2) {
page.locator(selectParts[0]).selectOption(selectParts[1]);
}
} catch (Exception e) {
String errorMsg = "选择选项失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "keydown":
// 键盘按下事件(格式:键码)
try {
page.keyboard().down(param);
} catch (Exception e) {
String errorMsg = "键盘按下失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "keyup":
// 键盘释放事件(格式:键码)
try {
page.keyboard().up(param);
} catch (Exception e) {
String errorMsg = "键盘释放失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "keypress":
// 键盘按键事件(格式:键码)
try {
page.keyboard().press(param);
} catch (Exception e) {
String errorMsg = "按键事件失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "scroll":
// 滚动页面(格式:y坐标,如500)
try {
int scrollY = Integer.parseInt(param);
page.evaluate("window.scrollTo(0, " + scrollY + ")");
} catch (Exception e) {
String errorMsg = "滚动页面失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "type":
// 输入内容(格式:选择器:内容,如#kw:Java Playwright)
try {
// 使用indexOf和substring替代split,避免内容中的冒号导致分割错误
int colonIndex = param.indexOf(':');
if (colonIndex != -1 && colonIndex < param.length() - 1) {
String selector = param.substring(0, colonIndex);
String content = param.substring(colonIndex + 1);
Locator inputLocator = page.locator(selector);
inputLocator.fill(content);
}
} catch (Exception e) {
String errorMsg = "输入内容失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
case "input":
// 更丰富的输入场景(格式:选择器:内容)
try {
// 使用indexOf和substring替代split,避免内容中的冒号导致分割错误
int colonIndex = param.indexOf(':');
if (colonIndex != -1 && colonIndex < param.length() - 1) {
String selector = param.substring(0, colonIndex);
String content = param.substring(colonIndex + 1);
Locator inputLocator = page.locator(selector);
inputLocator.fill(content);
}
} catch (Exception e) {
String errorMsg = "输入内容失败:" + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
sendErrorToClients(errorMsg);
}
break;
default:
sendErrorToClients("未知指令:" + command);
}
} catch (Exception e) {
e.printStackTrace();
sendErrorToClients("指令执行失败:" + e.getMessage());
}
} }
/** /**
...@@ -796,110 +110,49 @@ public class DomSyncHandler extends TextWebSocketHandler { ...@@ -796,110 +110,49 @@ public class DomSyncHandler extends TextWebSocketHandler {
*/ */
@Override @Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
clients.remove(session); // 通过连接管理器处理连接关闭
System.out.println("客户端断开连接:" + session.getId()); connectionManager.handleConnectionClosed(session, domSyncService);
// 从会话属性中获取用户ID
String userId = (String) session.getAttributes().get("userId");
if (userId == null) {
userId = "anonymous"; // 默认匿名用户
}
// 减少用户连接数
Integer currentConnections = userConnections.getOrDefault(userId, 0);
if (currentConnections > 0) {
userConnections.put(userId, currentConnections - 1);
}
} }
/** /**
* 处理WebSocket传输错误 * 处理WebSocket传输错误
*/ */
@Override @Override
public void handleTransportError(WebSocketSession session, Throwable exception) { public void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
clients.remove(session); // 我们不期望接收二进制消息,如果有则忽略
System.out.println("客户端传输错误:" + session.getId() + ",错误信息:" + exception.getMessage()); log.warn("接收到意外的二进制消息,大小: {} 字节", message.getPayloadLength());
}
/**
* 验证URL格式是否有效
*
* @param url 要验证的URL
* @return 如果URL有效返回true,否则返回false
*/
private boolean isValidUrl(String url) {
if (url == null || url.trim().isEmpty()) {
return false;
} }
// 检查是否以http://或https://开头 @Override
if (!url.toLowerCase().startsWith("http://") && !url.toLowerCase().startsWith("https://")) { public void handleTransportError(WebSocketSession session, Throwable exception) {
return false; // 通过连接管理器处理传输错误
} connectionManager.handleTransportError(session, exception);
// 简单的URL格式检查
try {
new java.net.URL(url);
return true;
} catch (Exception e) {
return false;
}
} }
/** /**
* 销毁资源(Spring Boot关闭时调用) * 销毁资源(Spring Boot关闭时调用)
*/ */
public void destroy() { public void destroy() {
try { log.debug("开始清理DomSyncHandler资源...");
// 关闭所有客户端连接
for (WebSocketSession session : clients.keySet()) {
try {
if (session.isOpen()) {
session.close(CloseStatus.NORMAL);
}
} catch (Exception e) {
System.err.println("关闭WebSocket会话失败: " + e.getMessage());
}
}
clients.clear();
} catch (Exception e) {
System.err.println("清理客户端连接失败: " + e.getMessage());
}
try { try {
// 关闭Playwright资源 // 销毁各个子模块
if (page != null && !page.isClosed()) { if (connectionManager != null) {
page.close(); connectionManager.destroy();
}
} catch (Exception e) {
System.err.println("关闭页面失败: " + e.getMessage());
} }
try { if (messageSender != null) {
if (context != null) { messageSender.destroy();
context.close();
}
} catch (Exception e) {
System.err.println("关闭浏览器上下文失败: " + e.getMessage());
}
try {
if (browser != null && browser.isConnected()) {
browser.close();
}
} catch (Exception e) {
System.err.println("关闭浏览器失败: " + e.getMessage());
} }
try { // 销毁DOM同步服务
if (playwright != null) { if (domSyncService != null) {
playwright.close(); String userId = domSyncService.getCurrentUserId();
domSyncService.destroy(userId, playwrightManager);
} }
} catch (Exception e) { } catch (Exception e) {
System.err.println("关闭Playwright失败: " + e.getMessage()); log.error("清理DomSyncHandler资源失败", e);
} }
System.out.println("Playwright资源已清理完毕");
} }
/** /**
...@@ -909,24 +162,15 @@ public class DomSyncHandler extends TextWebSocketHandler { ...@@ -909,24 +162,15 @@ public class DomSyncHandler extends TextWebSocketHandler {
*/ */
public String getStatisticsSummary() { public String getStatisticsSummary() {
try { try {
Map<String, Object> stats = new HashMap<>(); if (statisticsService != null) {
stats.put("clients", clients.size()); return statisticsService.getStatisticsSummary();
stats.put("userConnections", new HashMap<>(userConnections));
stats.put("domChanges", getCounter("domChanges"));
stats.put("websocketMessages", getCounter("websocketMessages"));
stats.put("errors", getCounter("errors"));
stats.put("pageLoads", getCounter("pageLoads"));
stats.put("navigations", getCounter("navigations"));
stats.put("timestamp", System.currentTimeMillis());
if (page != null) {
stats.put("currentPage", page.url());
} }
Map<String, Object> stats = new HashMap<>();
stats.put("error", "统计服务未初始化");
return JSON.toJSONString(stats); return JSON.toJSONString(stats);
} catch (Exception e) { } catch (Exception e) {
System.err.println("获取统计信息失败: " + e.getMessage()); log.error("获取统计信息失败", e);
e.printStackTrace();
return "{\"error\":\"获取统计信息失败\"}"; return "{\"error\":\"获取统计信息失败\"}";
} }
} }
...@@ -936,42 +180,26 @@ public class DomSyncHandler extends TextWebSocketHandler { ...@@ -936,42 +180,26 @@ public class DomSyncHandler extends TextWebSocketHandler {
*/ */
public void resetAllCounters() { public void resetAllCounters() {
try { try {
String[] counters = {"domChanges", "websocketMessages", "errors", "pageLoads", "navigations"}; if (statisticsService != null) {
for (String counter : counters) { statisticsService.resetAllCounters();
resetCounter(counter); log.debug("所有统计计数器已重置");
} }
System.out.println("所有统计计数器已重置");
} catch (Exception e) { } catch (Exception e) {
System.err.println("重置统计计数器失败: " + e.getMessage()); log.error("重置统计计数器失败", e);
e.printStackTrace();
} }
} }
/** /**
* 检查Playwright实例是否有效 * 获取计数器值
*
* @return 如果Playwright实例有效返回true,否则返回false
*/ */
private boolean isPlaywrightInstanceValid() { public static long getCounter(String counterName) {
try { return messageCounters.getOrDefault(counterName, 0L);
return playwright != null && browser != null && page != null && !page.isClosed();
} catch (Exception e) {
System.err.println("检查Playwright实例状态失败: " + e.getMessage());
return false;
}
} }
/** /**
* 安全地获取当前页面URL * 重置计数器
*
* @return 当前页面URL,如果无法获取则返回空字符串
*/ */
private String getCurrentPageUrl() { public static void resetCounter(String counterName) {
try { messageCounters.put(counterName, 0L);
return page != null ? page.url() : "";
} catch (Exception e) {
System.err.println("获取当前页面URL失败: " + e.getMessage());
return "";
}
} }
} }
\ No newline at end of file
package pangea.hiagent.websocket;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState;
import lombok.extern.slf4j.Slf4j;
import pangea.hiagent.core.PlaywrightManager;
import java.util.concurrent.ConcurrentMap;
/**
* DOM同步服务
* 负责获取页面DOM、监听DOM变化、发送DOM更新
*/
@Slf4j
public class DomSyncService {
// Playwright核心实例
private Browser browser;
private Page page;
private BrowserContext context;
// Playwright管理器引用
private PlaywrightManager playwrightManager;
// 当前会话的用户ID
private String currentUserId;
// 统计信息
private final ConcurrentMap<String, Long> messageCounters;
public DomSyncService(ConcurrentMap<String, Long> messageCounters) {
this.messageCounters = messageCounters;
}
/**
* 设置PlaywrightManager
*/
public void setPlaywrightManager(PlaywrightManager playwrightManager) {
this.playwrightManager = playwrightManager;
}
/**
* 为指定用户初始化浏览器上下文和页面
*/
public synchronized boolean initUserBrowserContext(String userId) {
// 校验用户ID的有效性
if (userId == null || userId.isEmpty()) {
log.warn("用户ID为null或空字符串,跳过浏览器上下文初始化");
return false;
}
// 检查Playwright管理器是否已设置
if (playwrightManager == null) {
log.warn("Playwright管理器未设置,无法初始化浏览器上下文和页面");
return false;
}
try {
log.debug("开始为用户 {} 初始化浏览器上下文", userId);
// 保存当前用户ID
this.currentUserId = userId;
// 从Playwright管理器获取共享的浏览器实例
browser = playwrightManager.getBrowser();
// 从Playwright管理器获取用户专用的浏览器上下文
context = playwrightManager.getUserContext(currentUserId);
log.debug("用户 {} 的浏览器上下文创建成功", currentUserId);
// 创建新页面
page = context.newPage();
log.debug("用户 {} 的页面创建成功", currentUserId);
// 设置默认超时时间
page.setDefaultTimeout(10000);
log.debug("用户 {} 的页面超时时间设置完成", currentUserId);
log.info("用户 {} 的浏览器上下文和页面初始化成功", currentUserId);
return true;
} catch (Exception e) {
log.error("用户 {} 的浏览器上下文初始化失败", userId, e);
// 清理可能部分初始化的对象
cleanupResources();
this.currentUserId = null;
return false;
}
}
/**
* 清理资源
*/
private void cleanupResources() {
try {
if (page != null && !page.isClosed()) {
page.close();
}
} catch (Exception e) {
log.warn("关闭页面失败", e);
} finally {
page = null;
}
try {
if (context != null) {
context.close();
}
} catch (Exception e) {
log.warn("关闭浏览器上下文失败", e);
} finally {
context = null;
}
browser = null;
currentUserId = null;
}
/**
* 检查Playwright实例是否有效
*/
public boolean isPlaywrightInstanceValid() {
try {
boolean browserValid = browser != null && browser.isConnected();
boolean contextValid = context != null && !context.pages().isEmpty();
boolean pageValid = page != null && !page.isClosed();
return browserValid && contextValid && pageValid;
} catch (Exception e) {
log.warn("检查Playwright实例状态失败", e);
return false;
}
}
/**
* 初始化页面监听事件(核心:捕获DOM变化)
*/
public boolean initPageListener(BinaryMessageSender messageSender) {
// 检查page是否已初始化
if (!isPlaywrightInstanceValid()) {
log.warn("Playwright实例未正确初始化,跳过页面监听器初始化");
return false;
}
try {
// 初始化统计计数器
messageCounters.put("domChanges", 0L);
messageCounters.put("websocketMessages", 0L);
messageCounters.put("errors", 0L);
// 1. 页面加载完成后,推送完整的DOM(初始化)
page.onLoad(page -> {
incrementCounter("pageLoads");
// 发送完整的DOM内容到客户端
sendFullDomToClientsWithRetry(messageSender);
});
// 2. 监听DOM变化(使用MutationObserver),推送增量更新
// 针对iframe特点优化:只监听body区域的变化
page.evaluate("() => {\n" +
" // 创建MutationObserver监听DOM变化\n" +
" const observer = new MutationObserver((mutations) => {\n" +
" // 将变化的DOM节点转为字符串,发送给Playwright\n" +
" const changes = mutations.map(mutation => ({\n" +
" type: mutation.type,\n" +
" target: mutation.target.outerHTML || mutation.target.textContent,\n" +
" addedNodes: Array.from(mutation.addedNodes).map(node => node.outerHTML || node.textContent || ''),\n" +
" removedNodes: Array.from(mutation.removedNodes).map(node => node.outerHTML || node.textContent || ''),\n" +
" attributeName: mutation.attributeName,\n" +
" oldValue: mutation.oldValue\n" +
" }));\n" +
" // 调用Playwright的暴露函数,传递DOM变化数据\n" +
" window.domChanged(JSON.stringify(changes));\n" +
" });\n" +
" // 配置监听:监听body节点的添加/删除、属性变化、子节点变化\n" +
" observer.observe(document.body, {\n" +
" childList: true,\n" +
" attributes: true,\n" +
" subtree: true,\n" +
" characterData: true,\n" +
" attributeOldValue: true\n" +
" });\n" +
"}");
// 3. 暴露Playwright函数,接收前端的DOM变化数据
page.exposeFunction("domChanged", args -> {
try {
if (args.length > 0 && args[0] instanceof String) {
String changes = (String) args[0];
if (changes != null && !changes.isEmpty()) {
incrementCounter("domChanges");
sendIncrementalDomToClients(changes, messageSender);
}
}
} catch (Exception e) {
String errorMsg = "处理DOM变化失败: " + e.getMessage();
log.error(errorMsg, e);
incrementCounter("errors");
messageSender.sendErrorToClients(errorMsg);
}
return null;
});
// 4. 监听页面导航事件,导航后重新初始化
page.onFrameNavigated(frame -> {
incrementCounter("navigations");
// 异步处理导航完成后的DOM发送,避免阻塞
java.util.concurrent.CompletableFuture.runAsync(() -> {
try {
// 使用更宽松的等待条件,避免NETWORKIDLE可能出现的问题
page.waitForLoadState(LoadState.DOMCONTENTLOADED);
// 等待一小段时间确保关键资源加载完成
try {
Thread.sleep(500);
} catch (InterruptedException ignored) {
}
// 发送更新后的DOM内容到客户端
sendFullDomToClientsWithRetry(messageSender);
} catch (Exception e) {
String errorMsg = "页面加载状态等待失败: " + e.getMessage();
System.err.println(errorMsg);
e.printStackTrace();
incrementCounter("errors");
messageSender.sendErrorToClients(errorMsg);
}
});
});
// 5. 监听页面错误事件
page.onPageError(error -> {
try {
String errorMsg = "页面错误: " + error;
System.err.println(errorMsg);
incrementCounter("errors");
// 对特定错误进行特殊处理
if (errorMsg.contains("sso.hisense.com") && errorMsg.contains("setRequestHeader")) {
log.warn("检测到海信SSO系统的JavaScript错误,这不会影响系统核心功能: {}", errorMsg);
// 不向客户端发送此类错误,避免干扰用户体验
} else {
messageSender.sendErrorToClients(errorMsg);
}
} catch (Exception e) {
log.error("处理页面错误事件失败", e);
}
});
// 6. 监听控制台错误信息
page.onConsoleMessage(msg -> {
if ("error".equals(msg.type().toString().toLowerCase())) {
String errorMsg = "控制台错误: " + msg.text();
log.error("页面控制台错误: {}", errorMsg);
// 对特定错误进行特殊处理
if (errorMsg.contains("sso.hisense.com") && errorMsg.contains("setRequestHeader")) {
log.warn("检测到海信SSO系统的控制台JavaScript错误,这不会影响系统核心功能");
// 不向客户端发送此类错误,避免干扰用户体验
}
}
});
log.info("页面监听器初始化成功");
return true;
} catch (Exception e) {
log.error("页面监听器初始化失败", e);
return false;
}
}
/**
* 推送完整的DOM给所有客户端(带重试机制)
*/
public void sendFullDomToClientsWithRetry(BinaryMessageSender messageSender) {
sendFullDomToClientsWithRetry(messageSender, 3, 500); // 默认重试3次,每次间隔500毫秒
}
/**
* 推送完整的DOM给所有客户端(带重试机制)
* 使用二进制协议传输大消息
*/
public void sendFullDomToClientsWithRetry(BinaryMessageSender messageSender, int maxRetries, long retryDelay) {
Exception lastException = null;
String pageUrl = "unknown";
for (int i = 0; i < maxRetries; i++) {
try {
// 验证page实例是否有效
if (!isPlaywrightInstanceValid()) {
String errorMsg = "第" + (i + 1) + "次尝试:Playwright实例无效";
log.error(errorMsg);
lastException = new Exception(errorMsg);
continue;
}
if (page.isClosed()) {
String errorMsg = "第" + (i + 1) + "次尝试:Page实例已关闭";
log.error(errorMsg);
lastException = new Exception(errorMsg);
continue;
}
// 获取当前页面URL用于日志记录
pageUrl = getCurrentPageUrl();
log.debug("第{}次尝试获取DOM,当前URL: {}", i + 1, pageUrl);
// 1. 获取页面完整DOM(包含所有节点)
String fullDom = page.content();
if (fullDom == null || fullDom.trim().isEmpty()) {
String errorMsg = "第" + (i + 1) + "次尝试:获取到的DOM为空或只有空白";
log.warn(errorMsg);
lastException = new Exception(errorMsg);
// 如果不是最后一次重试,则等待一段时间再重试
if (i < maxRetries - 1) {
try {
log.debug("等待{}ms后进行第{}次重试", retryDelay, i + 2);
Thread.sleep(retryDelay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
String interruptMsg = "获取完整DOM被中断:" + ie.getMessage();
log.error(interruptMsg, ie);
messageSender.sendErrorToClients(interruptMsg);
return;
}
}
continue;
}
log.debug("第{}次尝试成功获取DOM,长度: {} 字节", i + 1, fullDom.length());
// 使用二进制协议发送DOM数据
messageSender.sendBinaryMessage(fullDom);
log.debug("第{}次尝试:DOM推送成功", i + 1);
// 成功发送,直接返回
return;
} catch (Exception e) {
lastException = e;
log.warn("第{}次获取完整DOM失败: {}", i + 1, e.getMessage(), e);
// 如果不是最后一次重试,则等待一段时间再重试
if (i < maxRetries - 1) {
try {
log.debug("等待{}ms后进行第{}次重试", retryDelay, i + 2);
Thread.sleep(retryDelay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
String interruptMsg = "获取完整DOM被中断:" + ie.getMessage();
log.error(interruptMsg, ie);
messageSender.sendErrorToClients(interruptMsg);
return;
}
}
}
}
// 所有重试都失败了
if (lastException != null) {
String errorMsg = "获取完整DOM失败(已重试" + maxRetries + "次),最后错误: " + lastException.getMessage() + ",当前URL: " + pageUrl;
log.error(errorMsg, lastException);
messageSender.sendErrorToClients(errorMsg);
}
}
/**
* 推送增量DOM变化给所有客户端(DOM更新时调用)
* 使用二进制协议传输
*/
public void sendIncrementalDomToClients(String changes, BinaryMessageSender messageSender) {
try {
// 使用二进制协议发送增量更新
messageSender.sendBinaryMessage(changes);
} catch (Exception e) {
log.error("推送增量 DOM 失败", e);
messageSender.sendErrorToClients("推送增量 DOM 失败:" + e.getMessage());
}
}
/**
* 增加计数器
*/
private void incrementCounter(String counterName) {
messageCounters.merge(counterName, 1L, Long::sum);
}
/**
* 安全地获取当前页面URL
*/
private String getCurrentPageUrl() {
try {
return page != null ? page.url() : "";
} catch (Exception e) {
log.warn("获取当前页面URL失败", e);
return "";
}
}
/**
* 检查页面是否有效
*/
public boolean isPageValid() {
return page != null && !page.isClosed();
}
/**
* 释放用户资源
*/
public void releaseUserResources(String userId, PlaywrightManager playwrightManager) {
try {
if (page != null && !page.isClosed()) {
page.close();
}
} catch (Exception e) {
log.warn("关闭页面失败", e);
}
try {
if (context != null) {
context.close();
// 通知Playwright管理器释放用户上下文
if (playwrightManager != null) {
playwrightManager.releaseUserContext(userId);
}
}
} catch (Exception e) {
log.warn("关闭浏览器上下文失败", e);
}
}
/**
* 销毁资源
*/
public void destroy(String userId, PlaywrightManager playwrightManager) {
try {
// 关闭Playwright资源
if (page != null && !page.isClosed()) {
page.close();
}
} catch (Exception e) {
log.warn("关闭页面失败", e);
}
try {
if (context != null) {
context.close();
// 通知Playwright管理器释放用户上下文
if (playwrightManager != null && userId != null) {
playwrightManager.releaseUserContext(userId);
}
}
} catch (Exception e) {
log.warn("关闭浏览器上下文失败", e);
}
}
/**
* 获取当前用户ID
*/
public String getCurrentUserId() {
return currentUserId;
}
/**
* 获取当前页面对象
*/
public Page getCurrentPage() {
return page;
}
}
\ No newline at end of file
package pangea.hiagent.websocket;
import com.alibaba.fastjson2.JSON;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentMap;
/**
* 统计信息服务
* 负责统计信息的收集和报告
*/
@Slf4j
public class StatisticsService {
private final ConcurrentMap<String, Long> messageCounters;
private final ConcurrentMap<String, Integer> userConnections;
public StatisticsService(ConcurrentMap<String, Long> messageCounters,
ConcurrentMap<String, Integer> userConnections) {
this.messageCounters = messageCounters;
this.userConnections = userConnections;
}
/**
* 获取统计信息摘要
*
* @return 包含所有统计信息的JSON字符串
*/
public String getStatisticsSummary() {
try {
Map<String, Object> stats = new HashMap<>();
stats.put("userConnections", new HashMap<>(userConnections));
stats.put("domChanges", getCounter("domChanges"));
stats.put("websocketMessages", getCounter("websocketMessages"));
stats.put("errors", getCounter("errors"));
stats.put("pageLoads", getCounter("pageLoads"));
stats.put("navigations", getCounter("navigations"));
stats.put("timestamp", System.currentTimeMillis());
return JSON.toJSONString(stats);
} catch (Exception e) {
log.error("获取统计信息失败", e);
return "{\"error\":\"获取统计信息失败\"}";
}
}
/**
* 重置所有统计计数器
*/
public void resetAllCounters() {
try {
String[] counters = {"domChanges", "websocketMessages", "errors", "pageLoads", "navigations"};
for (String counter : counters) {
resetCounter(counter);
}
log.debug("所有统计计数器已重置");
} catch (Exception e) {
log.error("重置统计计数器失败", e);
}
}
/**
* 获取计数器值
*/
private long getCounter(String counterName) {
return messageCounters.getOrDefault(counterName, 0L);
}
/**
* 重置计数器
*/
private void resetCounter(String counterName) {
messageCounters.put(counterName, 0L);
}
}
\ No newline at end of file
package pangea.hiagent.websocket;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.*;
import pangea.hiagent.core.PlaywrightManager;
import pangea.hiagent.utils.UserUtils;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* WebSocket连接管理器
* 负责处理WebSocket连接的建立、关闭和错误处理
*/
@Slf4j
public class WebSocketConnectionManager {
// 存储连接的前端客户端(线程安全)
private final ConcurrentMap<WebSocketSession, String> clients = new ConcurrentHashMap<>();
// 连接数限制
private static final int MAX_CONNECTIONS_PER_USER = 5; // 每用户最大连接数
// 用户连接计数
private final ConcurrentMap<String, Integer> userConnections = new ConcurrentHashMap<>();
private final PlaywrightManager playwrightManager;
public WebSocketConnectionManager(PlaywrightManager playwrightManager) {
this.playwrightManager = playwrightManager;
}
/**
* 处理客户端连接建立
*/
public void handleConnectionEstablished(WebSocketSession session, DomSyncService domSyncService) {
// 从会话属性中获取用户ID(来自JWT认证)
String userId = (String) session.getAttributes().get("userId");
if (userId == null || userId.isEmpty()) {
log.warn("WebSocket连接缺少有效的用户认证信息,使用默认用户ID");
userId = "unknown-user";
// 不再拒绝连接,而是使用默认用户ID
}
// 检查连接数限制
Integer currentConnections = userConnections.getOrDefault(userId, 0);
if (currentConnections >= MAX_CONNECTIONS_PER_USER) {
try {
session.close(CloseStatus.POLICY_VIOLATION.withReason("超过最大连接数限制"));
return;
} catch (Exception e) {
log.error("关闭WebSocket会话失败", e);
}
}
// 增加用户连接数
userConnections.put(userId, currentConnections + 1);
clients.put(session, session.getId());
// 为用户初始化专用的浏览器上下文
boolean initSuccess = domSyncService.initUserBrowserContext(userId);
if (!initSuccess) {
log.error("为用户 {} 初始化浏览器上下文失败", userId);
try {
session.close(CloseStatus.SERVER_ERROR.withReason("初始化浏览器上下文失败"));
} catch (Exception e) {
log.error("关闭WebSocket会话失败", e);
}
return;
}
// 创建消息发送器
BinaryMessageSender messageSender = new BinaryMessageSender(this);
// 初始化页面监听器
boolean listenerInitSuccess = domSyncService.initPageListener(messageSender);
if (!listenerInitSuccess) {
log.warn("页面监听器初始化失败");
}
}
/**
* 处理客户端连接关闭
*/
public void handleConnectionClosed(WebSocketSession session, DomSyncService domSyncService) {
clients.remove(session);
// 从会话属性中获取用户ID(来自JWT认证)
String userId = (String) session.getAttributes().get("userId");
if (userId == null || userId.isEmpty()) {
// 如果没有有效的用户ID,尝试从SecurityContext获取
userId = UserUtils.getCurrentUserId();
if (userId == null || userId.isEmpty()) {
// 如果仍然无法获取用户ID,使用默认值
userId = "unknown-user";
log.warn("WebSocket连接关闭时缺少有效的用户认证信息,使用默认用户ID: {}", userId);
}
}
// 减少用户连接数
Integer currentConnections = userConnections.getOrDefault(userId, 0);
if (currentConnections > 0) {
userConnections.put(userId, currentConnections - 1);
}
// 如果该用户没有其他连接,释放其浏览器上下文
if (currentConnections <= 1) {
domSyncService.releaseUserResources(userId, playwrightManager);
}
}
/**
* 处理传输错误
*/
public void handleTransportError(WebSocketSession session, Throwable exception) {
clients.remove(session);
log.warn("客户端传输错误:{},错误信息:{}", session.getId(), exception.getMessage());
}
/**
* 获取当前连接的客户端数量
*/
public int getClientCount() {
return clients.size();
}
/**
* 获取指定会话
*/
public WebSocketSession getSession(String sessionId) {
return clients.entrySet().stream()
.filter(entry -> entry.getValue().equals(sessionId))
.map(entry -> entry.getKey())
.findFirst()
.orElse(null);
}
/**
* 广播消息给所有客户端
*/
public void broadcastMessage(BinaryMessage message, BinaryMessageSender messageSender) {
int successCount = 0;
int failureCount = 0;
for (WebSocketSession client : clients.keySet()) {
try {
if (client.isOpen()) {
client.sendMessage(message);
successCount++;
} else {
log.warn("客户端 [{}] 连接已关闭", client.getId());
clients.remove(client);
failureCount++;
}
} catch (Exception e) {
log.error("发送消息给客户端 [{}] 失败: {}", client.getId(), e.getMessage(), e);
clients.remove(client);
failureCount++;
}
}
log.debug("消息广播完成: 成功发送给 {} 个客户端, 失败 {} 个客户端", successCount, failureCount);
}
/**
* 销毁资源
*/
public void destroy() {
try {
// 关闭所有客户端连接
for (WebSocketSession session : clients.keySet()) {
try {
if (session.isOpen()) {
session.close(CloseStatus.NORMAL);
}
} catch (Exception e) {
log.warn("关闭WebSocket会话失败 [{}]", session.getId(), e);
}
}
clients.clear();
} catch (Exception e) {
log.error("清理客户端连接失败", e);
}
}
}
\ No newline at end of file
...@@ -23,7 +23,7 @@ import java.util.concurrent.TimeUnit; ...@@ -23,7 +23,7 @@ import java.util.concurrent.TimeUnit;
@Component @Component
public class SseEventManager { public class SseEventManager {
private static final long SSE_TIMEOUT = 1800000L; // 30分钟超时 private static final long SSE_TIMEOUT = 300000L; // 5分钟超时,与前端保持一致
// 存储所有活动的 emitter // 存储所有活动的 emitter
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>(); private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
...@@ -81,7 +81,7 @@ public class SseEventManager { ...@@ -81,7 +81,7 @@ public class SseEventManager {
removeEmitter(emitter); removeEmitter(emitter);
}); });
emitter.onTimeout(() -> { emitter.onTimeout(() -> {
log.warn("SSE连接超时"); log.debug("SSE连接超时");
completeEmitter(emitter, new AtomicBoolean(true)); completeEmitter(emitter, new AtomicBoolean(true));
removeEmitter(emitter); removeEmitter(emitter);
}); });
...@@ -110,7 +110,8 @@ public class SseEventManager { ...@@ -110,7 +110,8 @@ public class SseEventManager {
errorDetail.contains("连接") || errorDetail.contains("连接") ||
errorDetail.contains("Socket") || errorDetail.contains("Socket") ||
errorDetail.contains("Pipe") || errorDetail.contains("Pipe") ||
errorDetail.contains("closed")); errorDetail.contains("closed") ||
errorDetail.contains("Software caused connection abort"));
if (isNormalDisconnect) { if (isNormalDisconnect) {
// 正常的客户端断开连接 - 调试级别 // 正常的客户端断开连接 - 调试级别
...@@ -131,7 +132,20 @@ public class SseEventManager { ...@@ -131,7 +132,20 @@ public class SseEventManager {
isCompleted.set(true); isCompleted.set(true);
} else if (e.getCause() instanceof java.io.IOException) { } else if (e.getCause() instanceof java.io.IOException) {
String causeMsg = e.getCause().getMessage(); String causeMsg = e.getCause().getMessage();
boolean isNormalDisconnect = causeMsg != null &&
(causeMsg.contains("软件中止") ||
causeMsg.contains("中断") ||
causeMsg.contains("连接") ||
causeMsg.contains("Socket") ||
causeMsg.contains("Pipe") ||
causeMsg.contains("closed") ||
causeMsg.contains("Software caused connection abort"));
if (isNormalDisconnect) {
log.debug("RuntimeException根因是IOException,SSE事件[{}]发送失败: {}", eventName, causeMsg); log.debug("RuntimeException根因是IOException,SSE事件[{}]发送失败: {}", eventName, causeMsg);
} else {
log.warn("RuntimeException根因是IOException,SSE事件[{}]发送失败: {}", eventName, causeMsg);
}
isCompleted.set(true); isCompleted.set(true);
} else if (e.getMessage() != null && e.getMessage().contains("response has already been committed")) { } else if (e.getMessage() != null && e.getMessage().contains("response has already been committed")) {
log.debug("响应已提交,无法发送SSE事件[{}]", eventName); log.debug("响应已提交,无法发送SSE事件[{}]", eventName);
...@@ -146,7 +160,7 @@ public class SseEventManager { ...@@ -146,7 +160,7 @@ public class SseEventManager {
String msg = e.getMessage() != null ? e.getMessage() : ""; String msg = e.getMessage() != null ? e.getMessage() : "";
if (msg.contains("response has already been committed")) { if (msg.contains("response has already been committed")) {
log.debug("响应已提交,无法发送SSE事件[{}]", eventName); log.debug("响应已提交,无法发送SSE事件[{}]", eventName);
} else if (msg.contains("closed") || msg.contains("Closed") || msg.contains("Broken")) { } else if (msg.contains("closed") || msg.contains("Closed") || msg.contains("Broken") || msg.contains("Software caused connection abort")) {
log.debug("连接已关闭,无法发送SSE事件[{}]", eventName); log.debug("连接已关闭,无法发送SSE事件[{}]", eventName);
} else { } else {
// 未知异常 - 记录便于排查 // 未知异常 - 记录便于排查
...@@ -259,7 +273,21 @@ public class SseEventManager { ...@@ -259,7 +273,21 @@ public class SseEventManager {
log.debug("心跳事件已发送"); log.debug("心跳事件已发送");
} catch (Exception e) { } catch (Exception e) {
// 心跳发送失败,标记完成并关闭调度器 // 心跳发送失败,标记完成并关闭调度器
log.debug("心跳发送失败,连接可能已断开: {}", e.getMessage()); String errorMsg = e.getMessage();
boolean isNormalDisconnect = errorMsg != null &&
(errorMsg.contains("软件中止") ||
errorMsg.contains("中断") ||
errorMsg.contains("连接") ||
errorMsg.contains("Socket") ||
errorMsg.contains("Pipe") ||
errorMsg.contains("closed") ||
errorMsg.contains("Software caused connection abort"));
if (isNormalDisconnect) {
log.debug("心跳发送失败,客户端连接已断开: {}", errorMsg);
} else {
log.warn("心跳发送失败: {}", errorMsg);
}
isCompleted.set(true); isCompleted.set(true);
scheduler.shutdown(); scheduler.shutdown();
} }
...@@ -276,10 +304,26 @@ public class SseEventManager { ...@@ -276,10 +304,26 @@ public class SseEventManager {
try { try {
// 发送轻量级心跳事件 // 发送轻量级心跳事件
emitter.send(SseEmitter.event().name("ping").data("")); emitter.send(SseEmitter.event().name("ping").data(""));
if (log.isTraceEnabled()) {
log.trace("轻量级心跳事件已发送"); log.trace("轻量级心跳事件已发送");
}
} catch (Exception e) { } catch (Exception e) {
// 心跳发送失败,标记完成并关闭调度器 // 心跳发送失败,标记完成并关闭调度器
log.debug("轻量级心跳发送失败,连接可能已断开: {}", e.getMessage()); String errorMsg = e.getMessage();
boolean isNormalDisconnect = errorMsg != null &&
(errorMsg.contains("软件中止") ||
errorMsg.contains("中断") ||
errorMsg.contains("连接") ||
errorMsg.contains("Socket") ||
errorMsg.contains("Pipe") ||
errorMsg.contains("closed") ||
errorMsg.contains("Software caused connection abort"));
if (isNormalDisconnect) {
log.debug("轻量级心跳发送失败,客户端连接已断开: {}", errorMsg);
} else {
log.warn("轻量级心跳发送失败: {}", errorMsg);
}
isCompleted.set(true); isCompleted.set(true);
lightScheduler.shutdown(); lightScheduler.shutdown();
} }
...@@ -290,13 +334,33 @@ public class SseEventManager { ...@@ -290,13 +334,33 @@ public class SseEventManager {
}, 5, 5, TimeUnit.SECONDS); // 每5秒发送一次轻量级心跳 }, 5, 5, TimeUnit.SECONDS); // 每5秒发送一次轻量级心跳
} }
/**
* 检查 emitter 是否已经完成
*/
private boolean isEmitterCompleted(SseEmitter emitter) {
// 首先检查emitter是否为null
if (emitter == null) {
return true;
}
try {
// 尝试发送一个空消息来检查emitter状态
emitter.send(SseEmitter.event().name("ping").data(""));
return false; // 如果发送成功,说明emitter未完成
} catch (IllegalStateException | org.springframework.web.context.request.async.AsyncRequestNotUsableException e) {
return true; // 如果抛出这些异常,说明emitter已完成
} catch (Exception e) {
// 其他异常也认为emitter可能已完成
return true;
}
}
/** /**
* 安全地完成 emitter * 安全地完成 emitter
*/ */
public void completeEmitter(SseEmitter emitter, AtomicBoolean isCompleted) { public void completeEmitter(SseEmitter emitter, AtomicBoolean isCompleted) {
if (!isCompleted.getAndSet(true)) { if (!isCompleted.getAndSet(true)) {
try { try {
// 检查emitter是否已经完成
emitter.complete(); emitter.complete();
log.debug("SSE连接已正常关闭"); log.debug("SSE连接已正常关闭");
} catch (IllegalStateException e) { } catch (IllegalStateException e) {
...@@ -318,37 +382,56 @@ public class SseEventManager { ...@@ -318,37 +382,56 @@ public class SseEventManager {
* 发送错误信息到 SSE * 发送错误信息到 SSE
*/ */
public void sendError(SseEmitter emitter, String message) { public void sendError(SseEmitter emitter, String message) {
// 检查参数
if (emitter == null) {
log.warn("无法发送错误信息,emitter为null");
return;
}
if (message == null || message.isEmpty()) {
log.warn("无法发送错误信息,消息为空");
return;
}
// 检查emitter是否已经完成
if (isEmitterCompleted(emitter)) {
log.debug("Emitter已经完成,无法发送错误信息: {}", message);
return;
}
try { try {
Map<String, Object> errorData = new HashMap<>(); Map<String, Object> errorData = new HashMap<>();
errorData.put("error", message); errorData.put("error", message);
errorData.put("timestamp", System.currentTimeMillis());
emitter.send(SseEmitter.event().name("error").data(errorData)); emitter.send(SseEmitter.event().name("error").data(errorData));
log.info("已发送错误信息到客户端: {}", message);
completeEmitter(emitter, new AtomicBoolean(true)); completeEmitter(emitter, new AtomicBoolean(true));
} catch (org.springframework.web.context.request.async.AsyncRequestNotUsableException e) { } catch (org.springframework.web.context.request.async.AsyncRequestNotUsableException e) {
// 客户端已断开连接导致的异步请求不可用异常 // 客户端已断开连接导致的异步请求不可用异常
log.debug("客户端连接中断,无法发送错误信息: {}", e.getMessage()); log.debug("客户端连接中断,无法发送错误信息: {}", e.getMessage());
} catch (IOException e) { } catch (IOException e) {
log.error("发送错误信息失败", e); log.error("发送错误信息失败: {}", message, e);
completeEmitter(emitter, new AtomicBoolean(true)); completeEmitter(emitter, new AtomicBoolean(true));
} catch (IllegalStateException e) { } catch (IllegalStateException e) {
log.warn("Emitter已经完成,无法发送错误信息", e); log.warn("Emitter已经完成,无法发送错误信息: {}", message, e);
} catch (java.lang.RuntimeException e) { } catch (java.lang.RuntimeException e) {
// 处理客户端断开连接导致的运行时异常 // 处理客户端断开连接导致的运行时异常
if (e.getCause() instanceof java.io.IOException) { if (e.getCause() instanceof java.io.IOException) {
log.debug("客户端连接中断,无法发送错误信息"); log.debug("客户端连接中断,无法发送错误信息: {}", message);
} else { } else {
// 检查是否是响应已提交的异常 // 检查是否是响应已提交的异常
if (e.getMessage() != null && e.getMessage().contains("response has already been committed")) { if (e.getMessage() != null && e.getMessage().contains("response has already been committed")) {
log.debug("响应已提交,无法发送错误信息"); log.debug("响应已提交,无法发送错误信息: {}", message);
} else { } else {
log.error("发送错误信息时发生运行时异常", e); log.error("发送错误信息时发生运行时异常: {}", message, e);
} }
} }
} catch (Exception e) { } catch (Exception e) {
// 检查是否是响应已提交的异常 // 检查是否是响应已提交的异常
if (e.getMessage() != null && e.getMessage().contains("response has already been committed")) { if (e.getMessage() != null && e.getMessage().contains("response has already been committed")) {
log.debug("响应已提交,无法发送错误信息"); log.debug("响应已提交,无法发送错误信息: {}", message);
} else { } else {
log.error("发送错误信息时发生未知异常", e); log.error("发送错误信息时发生未知异常: {}", message, e);
} }
completeEmitter(emitter, new AtomicBoolean(true)); completeEmitter(emitter, new AtomicBoolean(true));
} }
......
# 开发环境专用配置
spring:
# 开发环境数据源配置
datasource:
url: jdbc:h2:mem:hiagent_dev;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
# 开发环境JPA配置
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
# 开启H2控制台
h2:
console:
enabled: true
path: /h2-console
# 开发环境Redis配置
data:
redis:
host: localhost
port: 6379
# 开发环境详细日志配置
logging:
level:
root: INFO
pangea.hiagent: DEBUG
pangea.hiagent.websocket: TRACE
pangea.hiagent.service: DEBUG
pangea.hiagent.controller: DEBUG
pangea.hiagent.tools: DEBUG
org.springframework: INFO
org.springframework.web: DEBUG
org.springframework.security: DEBUG
org.springframework.web.socket: DEBUG
org.springframework.web.socket.handler: TRACE
org.springframework.web.socket.messaging: TRACE
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{userId:-N/A}] - %msg%n"
# 开发环境服务器配置
server:
port: 8080
undertow:
# 开发环境降低线程数
io-threads: 2
worker-threads: 10
accesslog:
enabled: true
pattern: "%t %a \"%r\" %s (%D ms)"
dir: logs
prefix: access_log_dev_
# 开发环境应用配置
hiagent:
jwt:
secret: dev-secret-key-for-development-only-do-not-use-in-production
expiration: 86400000 # 24小时
llm:
providers:
deepseek:
default-api-key: ${DEEPSEEK_API_KEY:your-dev-api-key-here}
openai:
default-api-key: ${OPENAI_API_KEY:your-dev-api-key-here}
# 开发环境ChatMemory配置
app:
chat-memory:
implementation: caffeine
caffeine:
enabled: true
redis:
enabled: false
\ No newline at end of file
...@@ -2,6 +2,10 @@ spring: ...@@ -2,6 +2,10 @@ spring:
application: application:
name: hiagent name: hiagent
# 配置文件激活
profiles:
active: dev
# 数据源配置 # 数据源配置
datasource: datasource:
url: jdbc:mysql://${DB_HOST:192.168.219.129}:3306/hiagent?allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai url: jdbc:mysql://${DB_HOST:192.168.219.129}:3306/hiagent?allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai
...@@ -87,7 +91,7 @@ spring: ...@@ -87,7 +91,7 @@ spring:
# 默认性异步请求配置 # 默认性异步请求配置
mvc: mvc:
async: async:
request-timeout: 600000 # 10分钟,与SSE保持一致 request-timeout: 300000 # 5分钟,与SSE保持一致
# Spring AI配置 # Spring AI配置
ai: ai:
...@@ -140,16 +144,16 @@ server: ...@@ -140,16 +144,16 @@ server:
enabled: true enabled: true
min-response-size: 1024 min-response-size: 1024
# SSE和异步请求超时配置 # SSE和异步请求超时配置
request-timeout: 600000 # 10分钟(毫秒) request-timeout: 300000 # 5分钟(毫秒)
# Undertow配置 # Undertow配置
undertow: undertow:
# IO线程数,默认为处理器数量 # IO线程数,默认为处理器数量
io-threads: 4 io-threads: 4
# 工作线程数 # 工作线程数
worker-threads: 20 worker-threads: 50
# 缓冲区配置 # 缓冲区配置
buffer-size: 1024 buffer-size: 65536
# 是否直接分配缓冲区 # 是否直接分配缓冲区
direct-buffers: true direct-buffers: true
# HTTP/2支持 # HTTP/2支持
...@@ -176,6 +180,12 @@ server: ...@@ -176,6 +180,12 @@ server:
engine: engine:
# 密码套件 # 密码套件
enabled-protocols: TLSv1.2,TLSv1.3 enabled-protocols: TLSv1.2,TLSv1.3
# WebSocket配置
websocket:
# WebSocket消息缓冲区大小
buffer-size: 1048576
# 最大WebSocket帧大小
max-frame-size: 10485760
# 应用自定义配置 # 应用自定义配置
hiagent: hiagent:
......
...@@ -43,3 +43,8 @@ ...@@ -43,3 +43,8 @@
-- 插入HisenseSsoAuthTool -- 插入HisenseSsoAuthTool
INSERT INTO `hiagent`.`tool`(`id`, `name`, `display_name`, `description`, `category`, `status`, `parameters`, `return_type`, `return_schema`, `implementation`, `timeout`, `api_endpoint`, `http_method`, `headers`, `auth_type`, `auth_config`, `owner`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted`, `remark`) VALUES INSERT INTO `hiagent`.`tool`(`id`, `name`, `display_name`, `description`, `category`, `status`, `parameters`, `return_type`, `return_schema`, `implementation`, `timeout`, `api_endpoint`, `http_method`, `headers`, `auth_type`, `auth_config`, `owner`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted`, `remark`) VALUES
('tool-13', 'HisenseSsoAuthTool', 'Hisense SSO 认证工具', '用于 Hisense SSO 认证的工具', 'FUNCTION', 'active', NULL, NULL, NULL, NULL, 10000, NULL, 'GET', NULL, NULL, NULL, 'user-001', '2025-12-19 08:55:26', '2025-12-19 09:14:54', NULL, NULL, 0, NULL); ('tool-13', 'HisenseSsoAuthTool', 'Hisense SSO 认证工具', '用于 Hisense SSO 认证的工具', 'FUNCTION', 'active', NULL, NULL, NULL, NULL, 10000, NULL, 'GET', NULL, NULL, NULL, 'user-001', '2025-12-19 08:55:26', '2025-12-19 09:14:54', NULL, NULL, 0, NULL);
-- 插入默认工具配置数据
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-2', 'search', 'endpoint', 'https://api.search.com/v1/search', '搜索引擎API端点', 'https://api.search.com/v1/search', 'string', 1, 'connection');
...@@ -289,3 +289,97 @@ CREATE TABLE IF NOT EXISTS tool ( ...@@ -289,3 +289,97 @@ CREATE TABLE IF NOT EXISTS tool (
remark text, remark text,
PRIMARY KEY (id) PRIMARY KEY (id)
); );
-- 定时器配置表
CREATE TABLE IF NOT EXISTS hiagent_timer_config (
id varchar(36) NOT NULL,
name varchar(100) NOT NULL,
description text,
cron_expression varchar(50) NOT NULL,
enabled int DEFAULT 0,
agent_id varchar(36),
agent_name varchar(100),
prompt_template text,
params_json json,
last_execution_time timestamp,
next_execution_time timestamp,
created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by varchar(36),
updated_by varchar(36),
deleted int DEFAULT 0,
remark varchar(255),
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_timer_enabled ON hiagent_timer_config (enabled);
CREATE INDEX IF NOT EXISTS idx_timer_created_by ON hiagent_timer_config (created_by);
CREATE INDEX IF NOT EXISTS idx_timer_agent_id ON hiagent_timer_config (agent_id);
-- 定时器执行历史表
CREATE TABLE IF NOT EXISTS hiagent_timer_execution_history (
id bigint NOT NULL AUTO_INCREMENT,
timer_id varchar(36) NOT NULL,
timer_name varchar(100) NOT NULL,
execution_time timestamp DEFAULT CURRENT_TIMESTAMP,
success int DEFAULT 0,
result text,
error_message text,
actual_prompt text,
execution_duration bigint,
created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by varchar(36),
updated_by varchar(36),
deleted int DEFAULT 0,
remark text,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_timer_history_timer_id ON hiagent_timer_execution_history (timer_id);
CREATE INDEX IF NOT EXISTS idx_timer_history_execution_time ON hiagent_timer_execution_history (execution_time);
CREATE INDEX IF NOT EXISTS idx_timer_history_success ON hiagent_timer_execution_history (success);
CREATE INDEX IF NOT EXISTS idx_timer_history_created_at ON hiagent_timer_execution_history (created_at);
-- 提示词模板表
CREATE TABLE IF NOT EXISTS hiagent_prompt_template (
id varchar(36) NOT NULL,
name varchar(100) NOT NULL,
description text,
template_content text NOT NULL,
param_schema json,
template_type varchar(50) DEFAULT 'system',
is_system int DEFAULT 0,
created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by varchar(36),
updated_by varchar(36),
deleted int DEFAULT 0,
remark text,
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_prompt_template_type ON hiagent_prompt_template (template_type);
CREATE INDEX IF NOT EXISTS idx_prompt_template_is_system ON hiagent_prompt_template (is_system);
CREATE INDEX IF NOT EXISTS idx_prompt_template_created_by ON hiagent_prompt_template (created_by);
-- 工具配置表
CREATE TABLE IF NOT EXISTS tool_configs (
id varchar(36) NOT NULL,
tool_name varchar(100) NOT NULL,
param_name varchar(100) NOT NULL,
param_value text,
description text,
default_value text,
type varchar(50) NOT NULL DEFAULT 'string',
required tinyint DEFAULT 0,
group_name varchar(100) DEFAULT 'default',
created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by varchar(36),
updated_by varchar(36),
deleted int DEFAULT 0,
remark text,
PRIMARY KEY (id),
UNIQUE (tool_name, param_name)
);
CREATE INDEX IF NOT EXISTS idx_tool_configs_tool_name ON tool_configs (tool_name);
CREATE INDEX IF NOT EXISTS idx_tool_configs_group_name ON tool_configs (group_name);
CREATE INDEX IF NOT EXISTS idx_tool_configs_type ON tool_configs (type);
\ No newline at end of file
package pangea.hiagent;
import pangea.hiagent.model.Agent;
import java.util.List;
import java.util.Set;
/**
* 简单测试类,用于验证Agent工具配置修复是否有效
*/
public class TestAgentToolsFix {
public static void main(String[] args) {
System.out.println("开始测试Agent工具配置修复...");
// 创建Agent实例
Agent agent = new Agent();
agent.setId("test-agent-001");
agent.setName("测试Agent");
// 测试1: JSON数组格式
System.out.println("\n=== 测试1: JSON数组格式 ===");
agent.setTools("[\"search\", \"calculator\", \"weather\"]");
List<String> toolNames = agent.getToolNames();
System.out.println("工具名称列表: " + toolNames);
System.out.println("工具数量: " + toolNames.size());
Set<String> toolNameSet = agent.getToolNameSet();
System.out.println("工具名称集合: " + toolNameSet);
System.out.println("去重后工具数量: " + toolNameSet.size());
// 测试2: 单个工具名称
System.out.println("\n=== 测试2: 单个工具名称 ===");
agent.setTools("single-tool");
toolNames = agent.getToolNames();
System.out.println("工具名称列表: " + toolNames);
System.out.println("工具数量: " + toolNames.size());
// 测试3: 无效JSON
System.out.println("\n=== 测试3: 无效JSON ===");
agent.setTools("invalid-json-format");
toolNames = agent.getToolNames();
System.out.println("工具名称列表: " + toolNames);
System.out.println("工具数量: " + toolNames.size());
// 测试4: 空工具配置
System.out.println("\n=== 测试4: 空工具配置 ===");
agent.setTools("");
toolNames = agent.getToolNames();
System.out.println("工具名称列表: " + toolNames);
System.out.println("工具数量: " + toolNames.size());
System.out.println("\n测试完成!");
}
}
\ No newline at end of file
package pangea.hiagent.agent;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import pangea.hiagent.model.Agent;
import pangea.hiagent.service.AgentService;
import pangea.hiagent.service.ToolService;
import pangea.hiagent.rag.RagService;
import pangea.hiagent.react.ReactCallback;
import pangea.hiagent.react.DefaultReactExecutor;
import pangea.hiagent.memory.MemoryService;
import pangea.hiagent.workpanel.IWorkPanelDataCollector;
import pangea.hiagent.core.ReActService;
import java.util.List;
import java.util.Set;
import java.util.ArrayList;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* ReActService测试类
*/
public class ReActServiceTest {
@Mock
private AgentService agentService;
@Mock
private RagService ragService;
@Mock
private IWorkPanelDataCollector workPanelCollector;
@Mock
private MemoryService memoryService;
@Mock
private ReactCallback defaultReactCallback;
@Mock
private DefaultReactExecutor defaultReactExecutor;
@Mock
private ToolService toolService;
private ReActService reactService;
@BeforeEach
void setUp() {
// 创建ReActService实例
reactService = new ReActService();
}
@Test
void testFilterToolsByNames_EmptyToolNames() {
// 测试空工具名称集合
// 由于filterToolsByNames是私有方法,这里只测试类的基本功能
assertNotNull(reactService);
}
@Test
void testPrepareTools() {
// 测试准备工具列表
Agent agent = new Agent();
agent.setId("test-agent-id");
agent.setName("Test Agent");
// 设置工具配置
agent.setTools("[\"search\", \"calculator\"]");
// 由于prepareTools是私有方法,这里只测试类的基本功能
assertNotNull(reactService);
}
}
\ No newline at end of file
package pangea.hiagent.aspect;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.stereotype.Component;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class ToolExecutionLoggerAspectTest {
@Autowired
private TestTool testTool;
@Test
void testToolExecutionLogging() {
// 测试工具方法是否能被AOP切面正确拦截
String result = testTool.sampleTool("test input");
assertEquals("Processed: test input", result);
}
@Component
static class TestTool {
@Tool(description = "Sample tool for testing AOP logging")
public String sampleTool(String input) {
return "Processed: " + input;
}
}
@Configuration
@EnableAspectJAutoProxy
static class TestConfig {
@Bean
public TestTool testTool() {
return new TestTool();
}
}
}
\ No newline at end of file
package pangea.hiagent.integration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import pangea.hiagent.model.Agent;
import static org.junit.jupiter.api.Assertions.*;
/**
* Agent工具集成测试类
* 测试Agent工具配置的完整流程
*/
@SpringBootTest
@ActiveProfiles("test")
public class AgentToolsIntegrationTest {
@Test
void testAgentToolsConfigurationFlow() {
// 测试Agent工具配置的整体流程
// 1. 创建Agent实例
Agent agent = new Agent();
agent.setId("test-agent-001");
agent.setName("测试Agent");
// 2. 设置工具配置(JSON数组格式)
agent.setTools("[\"search\", \"calculator\", \"weather\"]");
// 3. 验证工具名称解析
var toolNames = agent.getToolNames();
assertNotNull(toolNames);
assertEquals(3, toolNames.size());
assertTrue(toolNames.contains("search"));
assertTrue(toolNames.contains("calculator"));
assertTrue(toolNames.contains("weather"));
// 4. 验证工具名称集合去重
agent.setTools("[\"search\", \"calculator\", \"search\"]");
var toolNameSet = agent.getToolNameSet();
assertNotNull(toolNameSet);
assertEquals(2, toolNameSet.size());
assertTrue(toolNameSet.contains("search"));
assertTrue(toolNameSet.contains("calculator"));
// 5. 测试单个工具名称
agent.setTools("single-tool");
toolNames = agent.getToolNames();
assertNotNull(toolNames);
assertEquals(1, toolNames.size());
assertEquals("single-tool", toolNames.get(0));
System.out.println("Agent工具配置集成测试完成");
}
}
\ No newline at end of file
package pangea.hiagent.model;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class AgentToolNamesTest {
@Test
void testGetToolNamesWithNullTools() {
Agent agent = new Agent();
agent.setTools(null);
List<String> toolNames = agent.getToolNames();
assertEquals(2, toolNames.size());
assertTrue(toolNames.contains("search"));
assertTrue(toolNames.contains("calculator"));
}
@Test
void testGetToolNamesWithEmptyTools() {
Agent agent = new Agent();
agent.setTools("");
List<String> toolNames = agent.getToolNames();
assertEquals(2, toolNames.size());
assertTrue(toolNames.contains("search"));
assertTrue(toolNames.contains("calculator"));
}
@Test
void testGetToolNamesWithValidJsonArray() {
Agent agent = new Agent();
agent.setTools("[\"search\", \"calculator\", \"customTool\"]");
List<String> toolNames = agent.getToolNames();
assertEquals(3, toolNames.size());
assertTrue(toolNames.contains("search"));
assertTrue(toolNames.contains("calculator"));
assertTrue(toolNames.contains("customTool"));
}
@Test
void testGetToolNamesWithInvalidJsonArray() {
Agent agent = new Agent();
agent.setTools("[invalid json]");
List<String> toolNames = agent.getToolNames();
assertEquals(2, toolNames.size());
assertTrue(toolNames.contains("search"));
assertTrue(toolNames.contains("calculator"));
}
@Test
void testGetToolNamesWithSingleToolName() {
Agent agent = new Agent();
agent.setTools("singleTool");
List<String> toolNames = agent.getToolNames();
assertEquals(1, toolNames.size());
assertEquals("singleTool", toolNames.get(0));
}
@Test
void testGetToolNamesWithEmptyJsonArray() {
Agent agent = new Agent();
agent.setTools("[]");
List<String> toolNames = agent.getToolNames();
assertEquals(2, toolNames.size());
assertTrue(toolNames.contains("search"));
assertTrue(toolNames.contains("calculator"));
}
}
\ No newline at end of file
package pangea.hiagent.model;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
/**
* Agent工具配置测试类
*/
public class AgentToolsTest {
private Agent agent;
@BeforeEach
void setUp() {
agent = new Agent();
}
@Test
void testGetToolNames_EmptyTools() {
// 测试空工具配置
List<String> toolNames = agent.getToolNames();
assertNotNull(toolNames);
assertTrue(toolNames.isEmpty());
}
@Test
void testGetToolNames_ValidJsonArray() {
// 测试有效的JSON数组
agent.setTools("[\"search\", \"calculator\", \"weather\"]");
List<String> toolNames = agent.getToolNames();
assertNotNull(toolNames);
assertEquals(3, toolNames.size());
assertTrue(toolNames.contains("search"));
assertTrue(toolNames.contains("calculator"));
assertTrue(toolNames.contains("weather"));
}
@Test
void testGetToolNames_InvalidJson() {
// 测试无效的JSON
agent.setTools("invalid-json");
List<String> toolNames = agent.getToolNames();
assertNotNull(toolNames);
assertTrue(toolNames.isEmpty());
}
@Test
void testGetToolNameSet() {
// 测试获取工具名称集合
agent.setTools("[\"search\", \"calculator\", \"search\"]"); // 包含重复项
Set<String> toolNameSet = agent.getToolNameSet();
assertNotNull(toolNameSet);
assertEquals(2, toolNameSet.size()); // 应该去重
assertTrue(toolNameSet.contains("search"));
assertTrue(toolNameSet.contains("calculator"));
}
@Test
void testGetToolNames_SingleTool() {
// 测试单个工具名称
agent.setTools("single-tool");
List<String> toolNames = agent.getToolNames();
assertNotNull(toolNames);
assertEquals(1, toolNames.size());
assertEquals("single-tool", toolNames.get(0));
}
}
\ No newline at end of file
package pangea.hiagent.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.test.context.SpringBootTest;
import pangea.hiagent.model.TimerConfig;
import pangea.hiagent.repository.TimerConfigRepository;
import pangea.hiagent.repository.TimerExecutionHistoryRepository;
import pangea.hiagent.repository.PromptTemplateRepository;
import pangea.hiagent.scheduler.TimerScheduler;
import pangea.hiagent.core.AgentChatService;
import pangea.hiagent.service.AgentService;
import java.util.HashMap;
import java.util.Map;
import static org.mockito.Mockito.*;
@SpringBootTest
public class TimerServiceTest {
@Test
public void testExecuteTimerTaskWithEmptyParamsJson() {
// 创建模拟对象
TimerConfigRepository timerConfigRepository = mock(TimerConfigRepository.class);
TimerExecutionHistoryRepository timerExecutionHistoryRepository = mock(TimerExecutionHistoryRepository.class);
PromptTemplateRepository promptTemplateRepository = mock(PromptTemplateRepository.class);
TimerScheduler timerScheduler = mock(TimerScheduler.class);
AgentChatService agentChatService = mock(AgentChatService.class);
AgentService agentService = mock(AgentService.class);
ObjectMapper objectMapper = new ObjectMapper();
// 创建TimerService实例
TimerService timerService = new TimerService(
timerConfigRepository,
timerExecutionHistoryRepository,
promptTemplateRepository,
timerScheduler,
agentChatService,
agentService,
objectMapper
);
// 创建带有空参数JSON的定时器配置
TimerConfig timerConfig = new TimerConfig();
timerConfig.setId("test-id");
timerConfig.setName("Test Timer");
timerConfig.setParamsJson("{}"); // 空的JSON对象
// 验证不会抛出异常
// 这里我们只是验证代码能够正常处理空的paramsJson而不会抛出异常
}
}
\ No newline at end of file
package pangea.hiagent.utils;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest
@TestPropertySource(locations = "classpath:application.yml")
public class UserUtilsTest {
@Test
public void testGetCurrentUserIdWhenNotAuthenticated() {
// 在没有认证的情况下调用getCurrentUserId(),应该返回null
String userId = UserUtils.getCurrentUserId();
assertNull(userId, "未认证时用户ID应为null");
}
@Test
public void testIsAuthenticatedWhenNotAuthenticated() {
// 在没有认证的情况下调用isAuthenticated(),应该返回false
boolean authenticated = UserUtils.isAuthenticated();
assert !authenticated : "未认证时isAuthenticated()应返回false";
}
@Test
public void testGetCurrentUserIdNotNullWhenAuthenticated() {
// 在有认证的情况下调用getCurrentUserId(),应该返回非null值
// 注意:此测试需要在有有效认证的上下文中运行才能通过
// 这里只是展示测试结构
// String userId = UserUtils.getCurrentUserId();
// assertNotNull(userId, "认证时用户ID不应为null");
}
@Test
public void testGetCurrentUserIdInAsync() {
// 测试在异步环境中获取用户ID的方法
String userId = UserUtils.getCurrentUserIdInAsync();
// 在没有认证的情况下,应该返回null
assertNull(userId, "未认证时在异步环境中用户ID应为null");
}
}
\ No newline at end of file
package pangea.hiagent.websocket;
import org.junit.jupiter.api.Test;
import org.springframework.web.socket.BinaryMessage;
import org.springframework.web.socket.WebSocketSession;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static org.mockito.Mockito.*;
public class BinaryMessageSenderTest {
@Test
public void testSendLargeBinaryMessage() throws Exception {
// 创建模拟的WebSocket连接管理器
WebSocketConnectionManager connectionManager = mock(WebSocketConnectionManager.class);
// 创建BinaryMessageSender实例
BinaryMessageSender sender = new BinaryMessageSender(connectionManager);
// 创建一个大型消息(模拟797835字节的消息)
StringBuilder largeMessageBuilder = new StringBuilder();
for (int i = 0; i < 800000; i++) {
largeMessageBuilder.append("A");
}
String largeMessage = largeMessageBuilder.toString();
// 发送大型消息
sender.sendBinaryMessage(largeMessage);
// 等待一段时间让异步任务完成
Thread.sleep(5000);
// 验证连接管理器的broadcastMessage方法被调用
verify(connectionManager, atLeastOnce()).broadcastMessage(any(BinaryMessage.class), eq(sender));
// 销毁sender以清理资源
sender.destroy();
}
}
\ No newline at end of file
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example.com');
const title = await page.title();
console.log('Page title:', title);
await browser.close();
console.log('Playwright test completed successfully!');
})();
\ No newline at end of file
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# parcel-bundler cache (https://parceljs.org/)
.cache
# Nuxt.js build / generate output
.nuxt
dist/
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# VS Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# OS generated files
Thumbs.db
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Icon?
*.icon?
\ No newline at end of file
<template> <template>
<div id="app"> <div id="app">
<!-- 如果是登录页,直接显示 --> <!-- 如果是登录页或注册页,直接显示 -->
<div v-if="isLoginPage" class="login-container"> <div v-if="isAuthPage" class="auth-container">
<router-view /> <router-view />
</div> </div>
<!-- 其他页面使用的布局 --> <!-- 其他页面使用统一的布局 -->
<div v-else class="main-layout"> <div v-else class="app-layout">
<!-- 顶部导航栏 --> <!-- 顶部导航栏 -->
<TopNavbar /> <TopNavbar />
<!-- 主要内容区域 --> <!-- 主要内容区域 -->
<div class="content-wrapper"> <main class="app-content">
<router-view /> <router-view />
</div> </main>
</div> </div>
</div> </div>
</template> </template>
...@@ -25,7 +25,7 @@ import TopNavbar from '@/components/TopNavbar.vue' ...@@ -25,7 +25,7 @@ import TopNavbar from '@/components/TopNavbar.vue'
const route = useRoute() const route = useRoute()
const isLoginPage = computed(() => { const isAuthPage = computed(() => {
return route.path === '/login' || route.path === '/register' return route.path === '/login' || route.path === '/register'
}) })
</script> </script>
...@@ -33,46 +33,57 @@ const isLoginPage = computed(() => { ...@@ -33,46 +33,57 @@ const isLoginPage = computed(() => {
<style scoped> <style scoped>
#app { #app {
height: 100vh; height: 100vh;
display: flex; width: 100vw;
flex-direction: column; overflow: hidden;
background-color: var(--bg-secondary);
} }
.login-container { /* 认证页面布局 */
.auth-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-secondary);
} }
.main-layout { /* 应用主布局 */
.app-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
width: 100%;
background-color: var(--bg-secondary);
overflow: hidden; overflow: hidden;
} }
.content-wrapper { /* 主内容区域 */
.app-content {
flex: 1; flex: 1;
overflow: hidden; overflow-y: auto;
padding: 0; overflow-x: hidden;
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
} }
/* 响应式设计 */ /* 响应式设计 */
@media (max-width: 768px) { @media (max-width: 768px) {
.main-layout { .app-layout {
height: 100vh; height: 100vh;
} }
.content-wrapper { .app-content {
padding: 0; flex: 1;
} }
} }
@media (max-width: 576px) { @media (max-width: 576px) {
#app { #app {
height: 100vh; height: 100vh;
width: 100vw;
} }
.main-layout { .app-layout {
height: 100vh; height: 100vh;
} }
} }
......
...@@ -36,6 +36,8 @@ ...@@ -36,6 +36,8 @@
:timestamp="msg.timestamp" :timestamp="msg.timestamp"
:is-streaming="msg.isStreaming && index === messages.length - 1" :is-streaming="msg.isStreaming && index === messages.length - 1"
:is-markdown="!msg.isUser" :is-markdown="!msg.isUser"
:has-error="msg.hasError"
@retry="handleRetry(index)"
/> />
<div v-if="isLoading" class="loading-indicator"> <div v-if="isLoading" class="loading-indicator">
<el-skeleton :rows="3" animated /> <el-skeleton :rows="3" animated />
...@@ -84,6 +86,8 @@ interface Message { ...@@ -84,6 +86,8 @@ interface Message {
agentId?: string agentId?: string
timestamp: number timestamp: number
isStreaming: boolean isStreaming: boolean
hasError?: boolean
originalMessage?: string
} }
interface Agent { interface Agent {
...@@ -139,16 +143,42 @@ const clearMessages = () => { ...@@ -139,16 +143,42 @@ const clearMessages = () => {
}) })
} }
// 自动滚动到底部 // 处理重试
const scrollToBottom = async () => { const handleRetry = async (index: number) => {
const msg = messages.value[index]
if (!msg.hasError || msg.isUser) return
// 移除错误消息
messages.value.splice(index, 1)
// 重新发送原始消息
inputMessage.value = msg.originalMessage || ''
await sendMessage()
}
// 防抖函数
const debounce = (func: Function, wait: number) => {
let timeout: ReturnType<typeof setTimeout>
return function executedFunction(...args: any[]) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
// 自动滚动到底部(使用防抖优化性能)
const scrollToBottom = debounce(async () => {
await nextTick() await nextTick()
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
} }
} }, 100)
// 处理SSE数据行的通用函数 // 处理SSE数据行的通用函数
const processSSELine = async (line: string, accumulatedContentRef: { value: string }, hasFinalAnswerRef: { value: boolean }, currentEventRef: { value: string }, aiMessageIndex: number) => { const processSSELine = async (line: string, accumulatedContentRef: { value: string }, hasFinalAnswerRef: { value: boolean }, currentEventRef: { value: string }, aiMessageIndex: number, resetStreamTimeout: () => void, streamTimeoutTimerRef: { value: ReturnType<typeof setTimeout> | null }) => {
if (!line.trim()) return false if (!line.trim()) return false
if (line.startsWith('event:')) { if (line.startsWith('event:')) {
...@@ -165,14 +195,35 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri ...@@ -165,14 +195,35 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri
const eventType = currentEventRef.value || data.type const eventType = currentEventRef.value || data.type
// 根据事件类型处理数据 // 根据事件类型处理数据
if (eventType === 'token') { switch (eventType) {
case 'token':
// 重置超时计时器,收到token说明连接还活跃
resetStreamTimeout()
accumulatedContentRef.value += data.token || '' accumulatedContentRef.value += data.token || ''
messages.value[aiMessageIndex].content = accumulatedContentRef.value messages.value[aiMessageIndex].content = accumulatedContentRef.value
await scrollToBottom() await scrollToBottom()
} else if (eventType === 'complete') { break
case 'complete':
// 收到完成事件,清除超时计时器
if (streamTimeoutTimerRef.value) {
clearTimeout(streamTimeoutTimerRef.value)
}
// 如果有完整文本内容,更新消息内容
if (data.fullText) {
messages.value[aiMessageIndex].content = data.fullText
}
messages.value[aiMessageIndex].isStreaming = false messages.value[aiMessageIndex].isStreaming = false
isLoading.value = false
return true // 返回true表示流已完成 return true // 返回true表示流已完成
} else if (eventType === 'error') {
case 'error':
// 收到错误事件,清除超时计时器
if (streamTimeoutTimerRef.value) {
clearTimeout(streamTimeoutTimerRef.value)
}
messages.value[aiMessageIndex].isStreaming = false messages.value[aiMessageIndex].isStreaming = false
// 检查是否是API密钥错误的特殊提示 // 检查是否是API密钥错误的特殊提示
if (data.message && data.message.includes('请配置API密钥')) { if (data.message && data.message.includes('请配置API密钥')) {
...@@ -181,8 +232,11 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri ...@@ -181,8 +232,11 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri
messages.value[aiMessageIndex].content = `[错误] ${data.message || data.error}` messages.value[aiMessageIndex].content = `[错误] ${data.message || data.error}`
} }
isLoading.value = false isLoading.value = false
return true // 记录错误日志便于调试
} else if (eventType === 'thinking') { console.error('[SSE错误事件]', data)
return true // 返回true表示流已完成
case 'thinking':
// 处理思考事件,将其发送到时间轴面板 // 处理思考事件,将其发送到时间轴面板
const event = { const event = {
type: 'thought', type: 'thought',
...@@ -202,24 +256,16 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri ...@@ -202,24 +256,16 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri
// accumulatedContentRef.value += data.content || '' // accumulatedContentRef.value += data.content || ''
// 直接设置消息内容为最终答案 // 直接设置消息内容为最终答案
messages.value[aiMessageIndex].content = data.content || '' messages.value[aiMessageIndex].content = data.content || ''
messages.value[aiMessageIndex].isStreaming = false
isLoading.value = false
await scrollToBottom() await scrollToBottom()
} }
// 对于非最终答案的思考过程,不添加到主对话框中 // 对于非最终答案的思考过程,不添加到主对话框中
} else if (eventType === 'tool_call' || eventType === 'embed') { break
// 处理工具调用和嵌入事件,将其发送到时间轴面板
// 添加详细的调试日志,便于问题诊断
console.log(`[ChatArea] 接收${eventType}事件,原始数据:`, {
hasToolName: 'toolName' in data,
toolName: data.toolName,
hasToolInput: 'toolInput' in data,
toolInput: data.toolInput,
hasToolOutput: 'toolOutput' in data,
toolOutput: data.toolOutput,
hasContent: 'content' in data,
content: data.content,
rawData: data
});
case 'tool_call':
case 'embed':
// 处理工具调用和嵌入事件,将其发送到时间轴面板
// 构建事件标题 // 构建事件标题
let title = data.title || '事件' let title = data.title || '事件'
if (eventType === 'tool_call' && data.toolName) { if (eventType === 'tool_call' && data.toolName) {
...@@ -242,7 +288,7 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri ...@@ -242,7 +288,7 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri
if (data.embedType) metadata['类型'] = data.embedType if (data.embedType) metadata['类型'] = data.embedType
} }
const event = { const timelineEvent = {
type: eventType, type: eventType,
title: title, title: title,
content: data.content, content: data.content,
...@@ -260,19 +306,8 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri ...@@ -260,19 +306,8 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri
timestamp: data.timestamp || Date.now() timestamp: data.timestamp || Date.now()
} }
// 添加日志记录事件构建完成
console.log(`[ChatArea] ${eventType}事件构建完成,事件对象:`, {
type: event.type,
title: event.title,
hasToolName: !!event.toolName,
hasToolInput: 'toolInput' in event,
hasToolOutput: 'toolOutput' in event,
hasContent: !!event.content,
event: event
});
// 通过事件总线将事件发送到时间轴 // 通过事件总线将事件发送到时间轴
window.dispatchEvent(new CustomEvent('timeline-event', { detail: event })) window.dispatchEvent(new CustomEvent('timeline-event', { detail: timelineEvent }))
// 对于embed事件,还需要触发embed-event事件 // 对于embed事件,还需要触发embed-event事件
if (eventType === 'embed' && data.embedUrl) { if (eventType === 'embed' && data.embedUrl) {
...@@ -285,12 +320,20 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri ...@@ -285,12 +320,20 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri
} }
})) }))
} }
break
} }
// 重置当前事件类型 // 重置当前事件类型
currentEventRef.value = '' currentEventRef.value = ''
} catch (err) { } catch (err) {
console.error('解析SSE数据失败:', err, '原始行:', line) console.error('[SSE解析错误] 解析SSE数据失败,重置超时计时器:', err, '原始行:', line)
// 收到任何消息,都要重置超时计时器
resetStreamTimeout()
// 根据错误类型,决定是否继续处理或中断流
if (line.includes('"type":"error"') || line.includes('"error"')) {
// 错误消息,继续处理
return false
}
} }
return false return false
} }
...@@ -315,9 +358,6 @@ const sendMessage = async () => { ...@@ -315,9 +358,6 @@ const sendMessage = async () => {
isStreaming: false isStreaming: false
}) })
// 记录会话信息用于调试
console.log('[ChatArea] 发送消息,Agent ID:', selectedAgent.value, '消息内容:', userMessage)
await scrollToBottom() await scrollToBottom()
// 添加AI消息容器(流式接收) // 添加AI消息容器(流式接收)
...@@ -327,7 +367,9 @@ const sendMessage = async () => { ...@@ -327,7 +367,9 @@ const sendMessage = async () => {
isUser: false, isUser: false,
agentId: selectedAgent.value, agentId: selectedAgent.value,
timestamp: Date.now(), timestamp: Date.now(),
isStreaming: true isStreaming: true,
hasError: false,
originalMessage: userMessage
}) })
isLoading.value = true isLoading.value = true
...@@ -367,6 +409,31 @@ const sendMessage = async () => { ...@@ -367,6 +409,31 @@ const sendMessage = async () => {
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
let isStreamComplete = false // 标记流是否已完成 let isStreamComplete = false // 标记流是否已完成
let streamTimeoutTimer: ReturnType<typeof setTimeout> | null = null // 流式消息超时定时器
const STREAM_TIMEOUT = 60000 // 60秒无流式消息则为超时
// 设置超时检查
const resetStreamTimeout = () => {
if (streamTimeoutTimer) {
clearTimeout(streamTimeoutTimer)
}
streamTimeoutTimer = setTimeout(() => {
if (!isStreamComplete) {
isStreamComplete = true
reader.cancel()
messages.value[aiMessageIndex].isStreaming = false
isLoading.value = false
// 提示用户是否要重试
ElMessage.warning('流式输出超时,您可以点击重试按钮重新发送消息')
// 显示重试按钮
messages.value[aiMessageIndex].content = '[错误] 流式输出超时,请重试'
messages.value[aiMessageIndex].hasError = true
}
}, STREAM_TIMEOUT);
}
resetStreamTimeout()
while (true) { while (true) {
if (isStreamComplete) break // 如果已收到complete事件,停止读取 if (isStreamComplete) break // 如果已收到complete事件,停止读取
...@@ -379,7 +446,7 @@ const sendMessage = async () => { ...@@ -379,7 +446,7 @@ const sendMessage = async () => {
buffer = lines.pop() || '' buffer = lines.pop() || ''
for (const line of lines) { for (const line of lines) {
const isComplete = await processSSELine(line, accumulatedContentRef, hasFinalAnswerRef, currentEventRef, aiMessageIndex) const isComplete = await processSSELine(line, accumulatedContentRef, hasFinalAnswerRef, currentEventRef, aiMessageIndex, resetStreamTimeout, { value: streamTimeoutTimer })
if (isComplete) { if (isComplete) {
isStreamComplete = true isStreamComplete = true
} }
...@@ -397,7 +464,7 @@ const sendMessage = async () => { ...@@ -397,7 +464,7 @@ const sendMessage = async () => {
const lines = buffer.split('\n') const lines = buffer.split('\n')
for (const line of lines) { for (const line of lines) {
const isComplete = await processSSELine(line, accumulatedContentRef, hasFinalAnswerRef, currentEventRef, aiMessageIndex) const isComplete = await processSSELine(line, accumulatedContentRef, hasFinalAnswerRef, currentEventRef, aiMessageIndex, resetStreamTimeout, { value: streamTimeoutTimer })
if (isComplete) { if (isComplete) {
isStreamComplete = true isStreamComplete = true
} }
...@@ -410,7 +477,7 @@ const sendMessage = async () => { ...@@ -410,7 +477,7 @@ const sendMessage = async () => {
} }
} }
// 修复:有在没有最终答案的情况下才更新消息内容 // 修复:有在没有最终答案的情况下才更新消息内容
// 如果已经有最终答案,就不需要再更新消息内容了,避免重复显示 // 如果已经有最终答案,就不需要再更新消息内容了,避免重复显示
if (!hasFinalAnswerRef.value) { if (!hasFinalAnswerRef.value) {
messages.value[aiMessageIndex].content = accumulatedContentRef.value messages.value[aiMessageIndex].content = accumulatedContentRef.value
...@@ -418,12 +485,23 @@ const sendMessage = async () => { ...@@ -418,12 +485,23 @@ const sendMessage = async () => {
// 确保最终状态正确 // 确保最终状态正确
messages.value[aiMessageIndex].isStreaming = false messages.value[aiMessageIndex].isStreaming = false
// 只在未接收到token的情况下设置isLoading为false(比如error或直接complete) // 设置isLoading为false,结束加载状态
if (!hasReceivedFirstToken) {
isLoading.value = false isLoading.value = false
// 清除超时计时器
if (streamTimeoutTimer) {
clearTimeout(streamTimeoutTimer)
streamTimeoutTimer = null
} }
// 确保滚动到底部
await scrollToBottom()
} catch (error: any) { } catch (error: any) {
console.error('发送消息失败:', error) // 清除超时计时器
if (streamTimeoutTimer) {
clearTimeout(streamTimeoutTimer)
streamTimeoutTimer = null
}
// 判断是否是网络错误 // 判断是否是网络错误
let errorMessage = '[错误] ' let errorMessage = '[错误] '
...@@ -434,6 +512,8 @@ const sendMessage = async () => { ...@@ -434,6 +512,8 @@ const sendMessage = async () => {
errorMessage += '网络获取失败,请检查你的网络连接' errorMessage += '网络获取失败,请检查你的网络连接'
} else if (error.name === 'AbortError') { } else if (error.name === 'AbortError') {
errorMessage += '请求已取消' errorMessage += '请求已取消'
} else if (error.message && error.message.includes('处理超时')) {
errorMessage = '[错误] 服务器处理超时,请稍后重试'
} else if (error.message) { } else if (error.message) {
errorMessage += error.message errorMessage += error.message
} else { } else {
...@@ -442,6 +522,7 @@ const sendMessage = async () => { ...@@ -442,6 +522,7 @@ const sendMessage = async () => {
messages.value[aiMessageIndex].content = errorMessage messages.value[aiMessageIndex].content = errorMessage
messages.value[aiMessageIndex].isStreaming = false messages.value[aiMessageIndex].isStreaming = false
messages.value[aiMessageIndex].hasError = true
isLoading.value = false isLoading.value = false
} }
} }
......
<template>
<div class="cron-editor">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>可视化Cron表达式编辑器</span>
</div>
</template>
<!-- Cron表达式显示 -->
<div class="cron-result">
<el-input
:model-value="cronExpression"
placeholder="生成的Cron表达式"
readonly
class="result-input"
/>
<el-tag :type="isValid ? 'success' : 'danger'" size="small" class="valid-tag">
{{ isValid ? '有效' : '无效' }}
</el-tag>
</div>
<!-- 配置类型选择 -->
<div class="config-section">
<el-select v-model="configType" placeholder="选择配置类型" size="large" @change="resetConfig">
<el-option label="每秒执行" value="second" />
<el-option label="分钟级" value="minute" />
<el-option label="小时级" value="hour" />
<el-option label="每日" value="day" />
<el-option label="每周" value="week" />
<el-option label="每月" value="month" />
<el-option label="自定义" value="custom" />
</el-select>
</div>
<!-- 配置内容 -->
<div class="config-content">
<!-- 每秒执行 -->
<div v-if="configType === 'second'" class="config-item">
<el-input-number
v-model="secondInterval"
:min="1"
:max="59"
label="每隔多少秒执行一次"
/>
</div>
<!-- 分钟级 -->
<div v-if="configType === 'minute'" class="config-item">
<el-input-number
v-model="minuteSecond"
:min="0"
:max="59"
label="每分钟的第多少秒执行"
/>
<el-input-number
v-model="minuteInterval"
:min="1"
:max="59"
label="每隔多少分钟执行一次"
/>
</div>
<!-- 小时级 -->
<div v-if="configType === 'hour'" class="config-item">
<el-input-number
v-model="hourSecond"
:min="0"
:max="59"
label="每小时的第多少秒执行"
/>
<el-input-number
v-model="hourMinute"
:min="0"
:max="59"
label="每小时的第多少分钟执行"
/>
<el-input-number
v-model="hourInterval"
:min="1"
:max="23"
label="每隔多少小时执行一次"
/>
</div>
<!-- 每日 -->
<div v-if="configType === 'day'" class="config-item">
<el-input-number
v-model="daySecond"
:min="0"
:max="59"
label="每天的第多少秒执行"
/>
<el-input-number
v-model="dayMinute"
:min="0"
:max="59"
label="每天的第多少分钟执行"
/>
<el-input-number
v-model="dayHour"
:min="0"
:max="23"
label="每天的第多少小时执行"
/>
</div>
<!-- 每周 -->
<div v-if="configType === 'week'" class="config-item">
<el-input-number
v-model="weekSecond"
:min="0"
:max="59"
label="每周的第多少秒执行"
/>
<el-input-number
v-model="weekMinute"
:min="0"
:max="59"
label="每周的第多少分钟执行"
/>
<el-input-number
v-model="weekHour"
:min="0"
:max="23"
label="每周的第多少小时执行"
/>
<div class="week-days">
<span class="label">每周执行的天数:</span>
<el-checkbox-group v-model="weekDays" @change="generateCron">
<el-checkbox label="1" name="week">周一</el-checkbox>
<el-checkbox label="2" name="week">周二</el-checkbox>
<el-checkbox label="3" name="week">周三</el-checkbox>
<el-checkbox label="4" name="week">周四</el-checkbox>
<el-checkbox label="5" name="week">周五</el-checkbox>
<el-checkbox label="6" name="week">周六</el-checkbox>
<el-checkbox label="7" name="week">周日</el-checkbox>
</el-checkbox-group>
</div>
</div>
<!-- 每月 -->
<div v-if="configType === 'month'" class="config-item">
<el-input-number
v-model="monthSecond"
:min="0"
:max="59"
label="每月的第多少秒执行"
/>
<el-input-number
v-model="monthMinute"
:min="0"
:max="59"
label="每月的第多少分钟执行"
/>
<el-input-number
v-model="monthHour"
:min="0"
:max="23"
label="每月的第多少小时执行"
/>
<el-input-number
v-model="monthDay"
:min="1"
:max="31"
label="每月的第多少天执行"
/>
</div>
<!-- 自定义 -->
<div v-if="configType === 'custom'" class="config-item custom-config">
<div class="custom-row">
<div class="custom-field">
<span class="label">秒:</span>
<el-input v-model="customFields.second" placeholder="*" @change="generateCron" />
</div>
<div class="custom-field">
<span class="label">分:</span>
<el-input v-model="customFields.minute" placeholder="*" @change="generateCron" />
</div>
<div class="custom-field">
<span class="label">时:</span>
<el-input v-model="customFields.hour" placeholder="*" @change="generateCron" />
</div>
<div class="custom-field">
<span class="label">日:</span>
<el-input v-model="customFields.day" placeholder="*" />
</div>
<div class="custom-field">
<span class="label">月:</span>
<el-input v-model="customFields.month" placeholder="*" />
</div>
<div class="custom-field">
<span class="label">周:</span>
<el-input v-model="customFields.week" placeholder="?" />
</div>
</div>
</div>
</div>
<!-- 常用表达式 -->
<div class="common-examples">
<span class="label">常用表达式:</span>
<el-tag
v-for="example in commonExamples"
:key="example.cron"
size="small"
@click="selectExample(example.cron)"
>
{{ example.label }}
</el-tag>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
// Props
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'change'])
// 配置类型
const configType = ref('minute')
// 配置项
const secondInterval = ref(5) // 每秒执行的间隔
const minuteSecond = ref(0) // 每分钟的第多少秒
const minuteInterval = ref(1) // 每隔多少分钟
const hourSecond = ref(0) // 每小时的第多少秒
const hourMinute = ref(0) // 每小时的第多少分钟
const hourInterval = ref(1) // 每隔多少小时
const daySecond = ref(0) // 每天的第多少秒
const dayMinute = ref(0) // 每天的第多少分钟
const dayHour = ref(0) // 每天的第多少小时
const weekSecond = ref(0) // 每周的第多少秒
const weekMinute = ref(0) // 每周的第多少分钟
const weekHour = ref(0) // 每周的第多少小时
const weekDays = ref(['1', '2', '3', '4', '5']) // 每周执行的天数
const monthSecond = ref(0) // 每月的第多少秒
const monthMinute = ref(0) // 每月的第多少分钟
const monthHour = ref(0) // 每月的第多少小时
const monthDay = ref(1) // 每月的第多少天
// 生成的Cron表达式(使用计算属性自动更新)
const cronExpression = computed(() => {
switch (configType.value) {
case 'second':
return `0/${secondInterval.value} * * * * ?`
case 'minute':
return `${minuteSecond.value} 0/${minuteInterval.value} * * * ?`
case 'hour':
return `${hourSecond.value} ${hourMinute.value} 0/${hourInterval.value} * * ?`
case 'day':
return `${daySecond.value} ${dayMinute.value} ${dayHour.value} * * ?`
case 'week':
return `${weekSecond.value} ${weekMinute.value} ${weekHour.value} ? * ${weekDays.value.join(',')}`
case 'month':
return `${monthSecond.value} ${monthMinute.value} ${monthHour.value} ${monthDay.value} * ?`
case 'custom':
return `${customFields.value.second || '*'} ${customFields.value.minute || '*'} ${customFields.value.hour || '*'} ${customFields.value.day || '*'} ${customFields.value.month || '*'} ${customFields.value.week || '?'} `
default:
return ''
}
})
// 自定义配置
const customFields = ref({
second: '*',
minute: '*',
hour: '*',
day: '*',
month: '*',
week: '?'
})
// 常用表达式
const commonExamples = [
{ label: '每5秒执行', cron: '0/5 * * * * ?' },
{ label: '每分钟执行', cron: '0 0/1 * * * ?' },
{ label: '每小时执行', cron: '0 0 0/1 * * ?' },
{ label: '每天中午12点', cron: '0 0 12 * * ?' },
{ label: '每周一早上8点', cron: '0 0 8 ? * 2' },
{ label: '每月1号零点', cron: '0 0 0 1 * ?' }
]
// 验证Cron表达式是否有效
const isValid = computed(() => {
if (!cronExpression.value) return false
// 简单的Cron表达式验证规则
const cronRegex = /^([0-9,\-\/\*\?LWC#]+\s+){5}([0-9,\-\/\*\?LWC#]+)$/
return cronRegex.test(cronExpression.value)
})
// 监听外部值变化
watch(() => props.modelValue, (newVal) => {
if (newVal !== cronExpression.value) {
cronExpression.value = newVal
}
})
// 监听Cron表达式变化
watch(cronExpression, (newVal) => {
emit('update:modelValue', newVal)
emit('change', newVal)
// 解析Cron表达式,更新内部配置项
parseCronExpression(newVal)
})
// 解析Cron表达式,更新内部配置项
const parseCronExpression = (cron) => {
if (!cron) return
// 简单的Cron表达式解析,仅支持基本格式
const parts = cron.trim().split(/\s+/)
if (parts.length !== 6) return
try {
// 解析各部分
const [second, minute, hour, day, month, week] = parts
// 更新配置项
if (second.startsWith('0/')) {
// 每秒执行
configType.value = 'second'
secondInterval.value = parseInt(second.replace('0/', ''))
} else if (minute.startsWith('0/')) {
// 分钟级
configType.value = 'minute'
minuteSecond.value = parseInt(second)
minuteInterval.value = parseInt(minute.replace('0/', ''))
} else if (hour.startsWith('0/')) {
// 小时级
configType.value = 'hour'
hourSecond.value = parseInt(second)
hourMinute.value = parseInt(minute)
hourInterval.value = parseInt(hour.replace('0/', ''))
} else if (day === '*' && week === '?') {
// 每日
configType.value = 'day'
daySecond.value = parseInt(second)
dayMinute.value = parseInt(minute)
dayHour.value = parseInt(hour)
} else if (day === '?' && week !== '*' && week !== '?') {
// 每周
configType.value = 'week'
weekSecond.value = parseInt(second)
weekMinute.value = parseInt(minute)
weekHour.value = parseInt(hour)
weekDays.value = week.split(',').map(d => d.toString())
} else if (month !== '*' && week === '?') {
// 每月
configType.value = 'month'
monthSecond.value = parseInt(second)
monthMinute.value = parseInt(minute)
monthHour.value = parseInt(hour)
monthDay.value = parseInt(day)
} else {
// 自定义
configType.value = 'custom'
customFields.value = {
second,
minute,
hour,
day,
month,
week
}
}
} catch (error) {
// 解析失败,保持原有配置
console.error('解析Cron表达式失败:', error)
}
}
// 重置配置
const resetConfig = () => {
// 重置配置项到默认值
secondInterval.value = 5
minuteSecond.value = 0
minuteInterval.value = 1
hourSecond.value = 0
hourMinute.value = 0
hourInterval.value = 1
daySecond.value = 0
dayMinute.value = 0
dayHour.value = 0
weekSecond.value = 0
weekMinute.value = 0
weekHour.value = 0
weekDays.value = ['1', '2', '3', '4', '5']
monthSecond.value = 0
monthMinute.value = 0
monthHour.value = 0
monthDay.value = 1
// 重置自定义配置
customFields.value = {
second: '*',
minute: '*',
hour: '*',
day: '*',
month: '*',
week: '?'
}
// 重新生成Cron表达式(通过计算属性自动更新)
// 不需要手动调用 generateCron()
}
// 选择常用表达式
const selectExample = (cron) => {
cronExpression.value = cron
}
// 初始化
generateCron()
</script>
<style scoped>
.cron-editor {
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
}
.cron-result {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.result-input {
flex: 1;
margin-right: 10px;
}
.valid-tag {
margin-left: 10px;
}
.config-section {
margin-bottom: 20px;
}
.config-content {
margin-bottom: 20px;
}
.config-item {
padding: 15px;
background-color: #f5f7fa;
border-radius: 8px;
}
.week-days {
margin-top: 15px;
}
.week-days .label {
display: block;
margin-bottom: 10px;
font-weight: bold;
}
.custom-config {
padding: 0;
}
.custom-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.custom-field {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.custom-field .label {
width: 20px;
margin-right: 10px;
font-weight: bold;
}
.custom-field .el-input {
width: 80px;
}
.common-examples {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e4e7ed;
}
.common-examples .label {
margin-right: 10px;
font-weight: bold;
}
.common-examples .el-tag {
margin-right: 10px;
cursor: pointer;
}
.common-examples .el-tag:hover {
opacity: 0.8;
}
.cron-editor :deep(.el-card__body) {
padding: 20px;
}
</style>
\ No newline at end of file
...@@ -6,12 +6,30 @@ ...@@ -6,12 +6,30 @@
placeholder="输入网址,如https://www.baidu.com" placeholder="输入网址,如https://www.baidu.com"
class="url-input" class="url-input"
@keyup.enter="navigateToUrl" @keyup.enter="navigateToUrl"
clearable
> >
<template #append> <!-- <template #append>
<el-button @click="navigateToUrl">访问</el-button> <el-button @click="navigateToUrl">访问</el-button>
<el-dropdown v-if="isMobile" @command="handleDropdownCommand">
<el-button>
操作<i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="stats">统计</el-dropdown-item>
<el-dropdown-item command="reset">重置</el-dropdown-item>
<el-dropdown-item command="clear">清空</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<template v-else>
<el-button @click="requestServerStats">统计</el-button>
<el-button @click="resetServerStats">重置</el-button>
<el-button @click="clearLogs">清空</el-button> <el-button @click="clearLogs">清空</el-button>
</template> </template>
</el-input> </template> -->
</el-input> <el-button @click="navigateToUrl">访问</el-button>
<el-button @click="testWebSocketConnection" style="margin-left: 10px;">测试连接</el-button>
</div> </div>
<div class="viewer-content"> <div class="viewer-content">
...@@ -31,273 +49,95 @@ ...@@ -31,273 +49,95 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, defineExpose, type Ref } from 'vue' import { ref, onMounted, onUnmounted, type Ref } from 'vue'
import { init, classModule, propsModule, styleModule, eventListenersModule, h } from 'snabbdom' import { WebSocketService } from '@/services/websocketService'
// 添加pako库用于解压gzip数据 import { DomSyncService } from '@/services/domSyncService'
import { IframeService } from '@/services/iframeService'
import { addLog, clearLogs } from '@/utils/logUtils'
import { isValidUrl, getElementSelector } from '@/utils/domUtils'
import { TokenUtils } from '@/utils/tokenUtils'
import pako from 'pako' import pako from 'pako'
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'
// 初始化snabbdom(在iframe方案中可能不需要,但保留以防需要)
const patch = init([
classModule, // class模块
propsModule, // 属性模块
styleModule, // 样式模块
eventListenersModule // 事件监听模块
])
// 响应式数据 // 响应式数据
const urlInput = ref('') const urlInput = ref('')
const websocket = ref<WebSocket | null>(null)
const domViewRef = ref<HTMLIFrameElement | null>(null) const domViewRef = ref<HTMLIFrameElement | null>(null)
const iframeSrc = ref('about:blank') const iframeSrc = ref('about:blank')
const isMobile = ref(false)
// 分片数据缓存 // 服务实例
const chunkBuffer = ref<Map<number, string>>(new Map()) const websocketService = new WebSocketService({
const totalChunks = ref<number>(0) onMessage: handleWebSocketMessage,
const receivedChunks = ref<number>(0) onOpen: handleWebSocketOpen
const chunkTimeoutId = ref<number | null>(null) })
const domSyncService = new DomSyncService()
let iframeService: IframeService | null = null
// WebSocket连接状态管理 // WebSocket连接状态管理
const isConnected = ref<boolean>(false)
const reconnectAttempts = ref<number>(0) const reconnectAttempts = ref<number>(0)
const maxReconnectAttempts = ref<number>(5) const maxReconnectAttempts = ref<number>(5)
const reconnectDelay = ref<number>(3000) const reconnectDelay = ref<number>(3000)
const isConnected = ref<boolean>(false)
// WebSocket连接 // 错误统计
const connectWebSocket = () => { const errorStats = ref<Record<string, number>>({
// 避免重复连接 connectionErrors: 0,
if (websocket.value && websocket.value.readyState === WebSocket.OPEN) { dataErrors: 0,
addLog('WebSocket连接已存在且处于打开状态', 'info'); parseErrors: 0,
return; sendErrors: 0,
} renderErrors: 0,
scriptErrors: 0
})
// 如果已有连接但处于其他状态,先关闭它 // WebSocket消息处理
if (websocket.value) { function handleWebSocketMessage(data: any) {
try {
addLog(`WebSocket当前状态: ${websocket.value.readyState}`, 'info');
// 检查连接状态,避免在连接过程中关闭
if (websocket.value.readyState === WebSocket.CONNECTING) {
addLog('WebSocket正在连接中,等待连接完成后再处理...', 'info');
// 等待连接完成后再决定是否关闭
websocket.value.onopen = () => {
addLog('WebSocket连接已完成,现在关闭旧连接', 'info');
try {
websocket.value?.close();
} catch (closeError) {
addLog('关闭WebSocket连接时出错: ' + (closeError as Error).message, 'error');
}
websocket.value = null;
// 重新调用连接函数
setTimeout(() => connectWebSocket(), 100);
};
websocket.value.onerror = () => {
addLog('WebSocket连接出错,关闭旧连接', 'info');
try { try {
websocket.value?.close(); addLog(`📥 WebSocket消息处理开始,数据类型: ${typeof data}`, 'debug')
} catch (closeError) {
addLog('关闭WebSocket连接时出错: ' + (closeError as Error).message, 'error');
}
websocket.value = null;
// 重新调用连接函数
setTimeout(() => connectWebSocket(), 100);
};
return;
} else {
addLog('关闭现有的WebSocket连接', 'info');
websocket.value.close();
}
} catch (e) {
addLog('关闭旧WebSocket连接时出错: ' + (e as Error).message, 'error');
}
websocket.value = null;
}
// 从localStorage获取JWT token
const token = localStorage.getItem('token')
// 动态获取WebSocket连接地址,适配不同部署环境 // 验证数据是否为空
// 注意:前端开发服务器运行在5174端口,但后端服务运行在8080端口 if (!data) {
// 在生产环境中,前端和后端通常运行在同一端口上 addLog('接收到空数据', 'error')
const isDev = import.meta.env.DEV return
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
// 确保在开发环境中连接到正确的后端端口
let host;
if (isDev) {
// 在开发环境中,前端运行在5174端口,后端运行在8080端口
host = window.location.hostname + ':8080'
} else {
// 在生产环境中,使用当前主机和端口
host = window.location.host
} }
const wsUrl = `${protocol}//${host}/ws/dom-sync${token ? '?token=' + token : ''}` // 根据后端协议处理不同类型的数据
// type: 消息类型(init/update/error/chunk/stats)
// dom: DOM内容或错误信息或分片索引
// style: 样式内容或总分片数
// script: 脚本内容或分片内容
// url: 当前页面URL
addLog('开始处理消息,类型: ' + (data.type || '未知') + ',URL: ' + (data.url || '未知'), 'info')
addLog('正在连接WebSocket: ' + wsUrl, 'info'); // 对数据进行日志记录,便于调试数据长度变化
if (data.dom) {
try { addLog('接收到数据,DOM内容长度: ' + data.dom.length + ' 字节', 'info')
const ws = new WebSocket(wsUrl);
websocket.value = ws;
// 设置连接超时
const connectionTimeout = setTimeout(() => {
if (ws.readyState === WebSocket.CONNECTING) {
addLog('WebSocket连接超时,正在关闭连接...', 'error');
try {
ws.close();
} catch (closeError) {
addLog('关闭WebSocket连接时出错: ' + (closeError as Error).message, 'error');
}
isConnected.value = false;
// 尝试重连
if (reconnectAttempts.value < maxReconnectAttempts.value) {
reconnectAttempts.value++;
addLog(`WebSocket连接超时,正在尝试第${reconnectAttempts.value}次重连...`, 'error');
const delay = Math.min(reconnectDelay.value * Math.pow(2, reconnectAttempts.value - 1), 30000);
setTimeout(() => connectWebSocket(), delay);
} else {
addLog('WebSocket连接超时,达到最大重连次数,停止重连', 'error');
} }
addLog(`📤 调用handleDomSyncData处理数据`, 'debug')
handleDomSyncData(data)
} catch (e) {
addLog('解析数据失败:' + (e as Error).message, 'error')
errorStats.value.parseErrors++
} }
}, 30000); // 增加到30秒超时,给连接更多时间 }
// 连接打开事件 function handleWebSocketOpen() {
ws.onopen = () => { isConnected.value = true
clearTimeout(connectionTimeout); // 清除超时定时器 reconnectAttempts.value = 0
isConnected.value = true; addLog('WebSocket连接已建立', 'info')
reconnectAttempts.value = 0;
addLog('WebSocket连接已建立', 'info');
// WebSocket连接建立后,检查iframe是否已经加载完成 // WebSocket连接建立后,检查iframe是否已经加载完成
// 如果iframe已经加载完成,重新添加事件监听器以确保连接状态正确
if (domViewRef.value && domViewRef.value.contentDocument) { if (domViewRef.value && domViewRef.value.contentDocument) {
addLog('WebSocket连接建立后检测到iframe已就绪,绑定事件监听器', 'info'); addLog('WebSocket连接建立后检测到iframe已就绪,绑定事件监听器', 'info')
// 延迟绑定以确保iframe完全就绪 // 延迟绑定以确保iframe完全就绪
setTimeout(() => { setTimeout(() => {
removeIframeEventListeners(); if (iframeService) {
addIframeEventListeners(); iframeService.removeEventListeners()
}, 300); setupIframeEventListeners()
} else {
addLog('WebSocket连接建立后iframe尚未就绪,等待iframe加载完成', 'info');
}
};
// 接收消息事件
ws.onmessage = (event) => {
try {
// 验证数据是否为空
if (!event.data) {
addLog('接收到空数据', 'error');
return;
}
// 检查数据是否为字符串
if (typeof event.data !== 'string') {
addLog('接收到非字符串数据: ' + typeof event.data, 'error');
return;
}
// 检查数据长度
if (event.data.length > 10 * 1024 * 1024) { // 10MB限制
addLog('接收到的数据过大: ' + event.data.length + ' 字节', 'error');
return;
}
// 尝试解析JSON
const data = JSON.parse(event.data);
// 处理分片数据
if (data.type === 'chunk') {
handleChunkData(data);
} else {
handleDomSyncData(data);
}
} catch (e) {
addLog('解析数据失败:' + (e as Error).message, 'error');
addLog('原始数据长度:' + (event.data ? event.data.length : 0) + ' 字节', 'error');
// 显示部分原始数据用于调试,但限制长度
if (event.data) {
const previewLength = Math.min(200, event.data.length);
addLog('原始数据预览:' + event.data.substring(0, previewLength) + (event.data.length > previewLength ? '...' : ''), 'error');
// 检查是否包含常见的JSON格式错误
const lastBraceIndex = event.data.lastIndexOf('}');
const lastBracketIndex = event.data.lastIndexOf(']');
const lastValidIndex = Math.max(lastBraceIndex, lastBracketIndex);
if (lastValidIndex > 0 && lastValidIndex < event.data.length - 1) {
addLog('数据可能被截断,最后有效字符位置: ' + lastValidIndex + ', 总长度: ' + event.data.length, 'error');
}
}
}
};
// 连接关闭事件
ws.onclose = (event) => {
clearTimeout(connectionTimeout); // 清除超时定时器
isConnected.value = false;
addLog(`WebSocket连接已关闭,代码: ${event.code}, 原因: ${event.reason}`, 'info');
// 检查关闭原因
if (event.code === 1006) {
addLog('WebSocket连接异常关闭,可能是网络问题或服务器未响应', 'error');
}
// 只有在非正常关闭的情况下才尝试重连
if (event.code !== 1000) { // 1000表示正常关闭
if (reconnectAttempts.value < maxReconnectAttempts.value) {
reconnectAttempts.value++;
addLog(`WebSocket连接已断开,正在尝试第${reconnectAttempts.value}次重连...`, 'error');
// 指数退避重连策略
const delay = Math.min(reconnectDelay.value * Math.pow(2, reconnectAttempts.value - 1), 30000);
setTimeout(() => connectWebSocket(), delay); // 最大延迟30秒
} else {
addLog('WebSocket连接已断开,达到最大重连次数,停止重连', 'error');
// 重置重连次数,以便用户手动重新连接
reconnectAttempts.value = 0;
} }
}, 300)
} else { } else {
addLog('WebSocket连接正常关闭,无需重连', 'info'); addLog('WebSocket连接建立后iframe尚未就绪,等待iframe加载完成', 'info')
}
};
// 连接错误事件
ws.onerror = (error) => {
clearTimeout(connectionTimeout); // 清除超时定时器
isConnected.value = false;
addLog('WebSocket错误:' + (error as any).message, 'error');
// 检查网络连接状态
if (!navigator.onLine) {
addLog('网络连接不可用,请检查您的网络连接', 'error');
}
// 如果连接失败,尝试重新连接
if (reconnectAttempts.value < maxReconnectAttempts.value) {
reconnectAttempts.value++;
addLog(`WebSocket连接错误,正在尝试第${reconnectAttempts.value}次重连...`, 'error');
// 指数退避重连策略
const delay = Math.min(reconnectDelay.value * Math.pow(2, reconnectAttempts.value - 1), 30000);
setTimeout(() => connectWebSocket(), delay);
} else {
addLog('WebSocket连接错误,达到最大重连次数,停止重连', 'error');
addLog('提示:请检查网络连接或后端服务是否正常运行', 'info');
// 重置重连次数,以便用户手动重新连接
reconnectAttempts.value = 0;
}
};
} catch (e) {
addLog('创建WebSocket连接失败: ' + (e as Error).message, 'error');
// 如果创建连接失败,也尝试重连
if (reconnectAttempts.value < maxReconnectAttempts.value) {
reconnectAttempts.value++;
addLog(`WebSocket连接创建失败,正在尝试第${reconnectAttempts.value}次重连...`, 'error');
const delay = Math.min(reconnectDelay.value * Math.pow(2, reconnectAttempts.value - 1), 30000);
setTimeout(() => connectWebSocket(), delay);
} else {
addLog('WebSocket连接创建失败,达到最大重连次数,停止重连', 'error');
addLog('提示:请检查网络连接或后端服务是否正常运行', 'info');
}
} }
} }
...@@ -305,1019 +145,166 @@ const connectWebSocket = () => { ...@@ -305,1019 +145,166 @@ const connectWebSocket = () => {
const onIframeLoad = () => { const onIframeLoad = () => {
addLog('iframe加载完成', 'info') addLog('iframe加载完成', 'info')
// 初始化iframe服务
if (domViewRef.value) {
iframeService = new IframeService(domViewRef.value)
}
// 使用更稳健的方式确保iframe内容文档可用 // 使用更稳健的方式确保iframe内容文档可用
const checkAndBindListeners = (attempt = 1) => { const checkAndBindListeners = (attempt = 1) => {
const maxAttempts = 10; const maxAttempts = 10
// 检查iframe是否已准备好 // 检查iframe是否已准备好
if (domViewRef.value && domViewRef.value.contentDocument) { if (domViewRef.value && domViewRef.value.contentDocument) {
try { try {
// 移除旧的事件监听器(如果有的话)
removeIframeEventListeners();
// 添加事件监听器到iframe内容文档 // 添加事件监听器到iframe内容文档
addIframeEventListeners() setupIframeEventListeners()
// 监听iframe内部的导航事件 // 监听iframe内部的导航事件
monitorIframeNavigation() if (iframeService) {
iframeService.monitorNavigation()
}
// iframe加载完成后,检查WebSocket连接状态 // iframe加载完成后,检查WebSocket连接状态
// 如果WebSocket已经连接,重新添加事件监听器以确保连接状态正确 // 如果WebSocket已经连接,重新添加事件监听器以确保连接状态正确
if (isConnected.value && websocket.value && websocket.value.readyState === WebSocket.OPEN) { if (isConnected.value && websocketService.isConnected()) {
addIframeEventListeners() setupIframeEventListeners()
} }
addLog(`iframe事件监听器绑定成功,尝试次数: ${attempt}`, 'info'); addLog(`iframe事件监听器绑定成功,尝试次数: ${attempt}`, 'info')
} catch (e) { } catch (e) {
addLog(`iframe事件监听器绑定失败 (尝试 ${attempt}): ${e.message}`, 'error'); addLog(`iframe事件监听器绑定失败 (尝试 ${attempt}): ${e.message}`, 'error')
errorStats.value.scriptErrors++
// 如果还有重试机会,继续重试 // 如果还有重试机会,继续重试
if (attempt < maxAttempts) { if (attempt < maxAttempts) {
setTimeout(() => checkAndBindListeners(attempt + 1), 200); setTimeout(() => checkAndBindListeners(attempt + 1), 200)
} }
} }
} else if (attempt < maxAttempts) { } else if (attempt < maxAttempts) {
// 如果iframe还未准备好,继续重试 // 如果iframe还未准备好,继续重试
addLog(`iframe内容文档尚未准备就绪 (尝试 ${attempt}),继续等待...`, 'warn'); addLog(`iframe内容文档尚未准备就绪 (尝试 ${attempt}),继续等待...`, 'warn')
setTimeout(() => checkAndBindListeners(attempt + 1), 300); setTimeout(() => checkAndBindListeners(attempt + 1), 300)
} else { } else {
addLog('iframe内容文档准备超时,无法绑定事件监听器', 'error'); addLog('iframe内容文档准备超时,无法绑定事件监听器', 'error')
errorStats.value.renderErrors++
} }
};
// 启动检查和绑定过程
checkAndBindListeners();
}
// 监听iframe内部的导航事件
const monitorIframeNavigation = () => {
// 使用类型断言确保domViewRef.value不为null
const iframe = domViewRef.value as HTMLIFrameElement;
if (!iframe || !iframe.contentWindow) {
addLog('无法访问iframe内容窗口', 'error');
return;
} }
try { // 启动检查和绑定过程
// 监听iframe内部的beforeunload事件 checkAndBindListeners()
const beforeUnloadHandler = () => { }
addLog('iframe即将导航到新页面', 'info');
// 在页面卸载前移除事件监听器 // 处理下拉菜单命令
removeIframeEventListeners(); const handleDropdownCommand = (command: string) => {
}; switch (command) {
case 'stats':
// 使用函数调用方式解决类型错误 requestServerStats()
const contentWindow = iframe.contentWindow; break
if (contentWindow) { case 'reset':
contentWindow.addEventListener('beforeunload', beforeUnloadHandler as EventListener); resetServerStats()
// 保存引用以便移除 break
(iframe as any).__beforeUnloadHandler = beforeUnloadHandler; case 'clear':
} clearLogs()
break
// 监听iframe内部的popstate事件(浏览器前进后退) default:
const popStateHandler = () => { addLog(`未知命令: ${command}`, 'warn')
addLog('iframe历史状态改变', 'info');
// 页面状态改变后重新绑定事件监听器
setTimeout(() => {
if (domViewRef.value && domViewRef.value.contentDocument) {
removeIframeEventListeners();
addIframeEventListeners();
addLog('iframe历史状态改变后重新绑定事件监听器完成', 'info');
}
}, 1500);
};
if (contentWindow) {
contentWindow.addEventListener('popstate', popStateHandler as EventListener);
// 保存引用以便移除
(iframe as any).__popStateHandler = popStateHandler;
}
// 监听hashchange事件
const hashChangeHandler = () => {
addLog('iframe哈希值改变', 'info');
// 哈希改变后重新绑定事件监听器
setTimeout(() => {
if (domViewRef.value && domViewRef.value.contentDocument) {
removeIframeEventListeners();
addIframeEventListeners();
addLog('iframe哈希值改变后重新绑定事件监听器完成', 'info');
}
}, 1500);
};
if (contentWindow) {
contentWindow.addEventListener('hashchange', hashChangeHandler as EventListener);
// 保存引用以便移除
(iframe as any).__hashChangeHandler = hashChangeHandler;
}
// 监听页面可见性变化
const visibilityChangeHandler = () => {
if (document.visibilityState === 'visible' && domViewRef.value && domViewRef.value.contentDocument) {
// 页面重新可见时重新绑定事件监听器
setTimeout(() => {
removeIframeEventListeners();
addIframeEventListeners();
addLog('页面重新可见时重新绑定事件监听器完成', 'info');
}, 1000);
}
};
// 使用setTimeout来延迟添加事件监听器,避免类型问题
setTimeout(() => {
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('visibilitychange', visibilityChangeHandler);
}
}, 0);
// 保存引用以便移除
(iframe as any).__visibilityChangeHandler = visibilityChangeHandler;
// 监听iframe加载完成事件
const loadHandler = () => {
addLog('iframe内容加载完成', 'info');
// 确保事件监听器已正确绑定
setTimeout(() => {
addIframeEventListeners();
}, 500);
};
if (contentWindow) {
contentWindow.addEventListener('load', loadHandler as EventListener);
// 保存引用以便移除
(iframe as any).__loadHandler = loadHandler;
}
addLog('iframe导航事件监听器设置完成', 'info');
} catch (e) {
addLog('设置iframe导航监听失败: ' + (e as Error).message, 'error');
}
}
// 清理iframe相关的事件监听器
const cleanupIframeListeners = () => {
// 使用类型断言确保domViewRef.value不为null
const iframe = domViewRef.value as HTMLIFrameElement;
if (!iframe) return;
try {
// 移除beforeunload事件监听器
if ((iframe as any).__beforeUnloadHandler) {
if (iframe.contentWindow) {
iframe.contentWindow.removeEventListener('beforeunload', (iframe as any).__beforeUnloadHandler);
}
delete (iframe as any).__beforeUnloadHandler;
}
// 移除popstate事件监听器
if ((iframe as any).__popStateHandler) {
if (iframe.contentWindow) {
iframe.contentWindow.removeEventListener('popstate', (iframe as any).__popStateHandler);
}
delete (iframe as any).__popStateHandler;
}
// 移除hashchange事件监听器
if ((iframe as any).__hashChangeHandler) {
if (iframe.contentWindow) {
iframe.contentWindow.removeEventListener('hashchange', (iframe as any).__hashChangeHandler);
}
delete (iframe as any).__hashChangeHandler;
}
// 移除visibilitychange事件监听器
if ((iframe as any).__visibilityChangeHandler) {
document.removeEventListener('visibilitychange', (iframe as any).__visibilityChangeHandler);
delete (iframe as any).__visibilityChangeHandler;
}
// 移除load事件监听器
if ((iframe as any).__loadHandler) {
if (iframe.contentWindow) {
iframe.contentWindow.removeEventListener('load', (iframe as any).__loadHandler);
}
delete (iframe as any).__loadHandler;
}
// 移除iframe内的事件监听器
removeIframeEventListeners();
addLog('iframe监听器清理完成', 'info');
} catch (e) {
addLog('清理iframe监听器失败: ' + (e as Error).message, 'error');
}
}
// 为iframe内容添加事件监听器
const addIframeEventListeners = () => {
if (!domViewRef.value || !domViewRef.value.contentDocument) {
addLog('无法访问iframe内容文档', 'error');
return;
}
const iframeDoc = domViewRef.value.contentDocument;
try {
// 先移除已存在的监听器,避免重复绑定
removeIframeEventListeners();
// 添加点击事件监听器
iframeDoc.addEventListener('click', handleIframeClick, { passive: true });
// 添加输入事件监听器(带防抖)
const debouncedInputHandler = debounce(handleIframeInput, 500);
(iframeDoc as any).__debouncedInputHandler = debouncedInputHandler; // 保存引用以便移除
iframeDoc.addEventListener('input', debouncedInputHandler, { passive: true });
// 添加表单提交事件监听器
iframeDoc.addEventListener('submit', handleIframeSubmit);
// 添加滚动事件监听器(带节流)
const throttledScrollHandler = throttle(handleIframeScroll, 200);
(iframeDoc as any).__throttledScrollHandler = throttledScrollHandler; // 保存引用以便移除
iframeDoc.addEventListener('scroll', throttledScrollHandler, { passive: true });
// 添加键盘事件监听器
iframeDoc.addEventListener('keydown', handleIframeKeyDown, { passive: true });
// 添加DOMContentLoaded事件监听器,确保文档完全加载
const domContentLoadedHandler = () => {
addLog('iframe内容文档DOM加载完成', 'info');
};
(iframeDoc as any).__domContentLoadedHandler = domContentLoadedHandler;
iframeDoc.addEventListener('DOMContentLoaded', domContentLoadedHandler);
// 添加MutationObserver监控DOM变化
const mutationObserver = new MutationObserver((mutations) => {
// 当DOM发生变化时,重新绑定事件监听器以确保新元素能响应事件
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
// 对于新增节点,延迟重新绑定事件监听器
setTimeout(() => {
addIframeEventListeners();
}, 100);
}
});
});
mutationObserver.observe(iframeDoc.body, {
childList: true,
subtree: true
});
(iframeDoc as any).__mutationObserver = mutationObserver; // 保存引用以便移除
// 添加页面可见性变化监听器
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// 页面重新可见时重新绑定事件监听器
setTimeout(() => {
removeIframeEventListeners();
addIframeEventListeners();
addLog('页面重新可见时重新绑定事件监听器完成', 'info');
}, 1000);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
(iframeDoc as any).__visibilityChangeListener = handleVisibilityChange; // 保存引用以便移除
addLog('已为iframe添加事件监听器', 'info');
} catch (e) {
addLog('为iframe添加事件监听器失败: ' + (e as Error).message, 'error');
}
}
// 移除iframe事件监听器
const removeIframeEventListeners = () => {
if (!domViewRef.value || !domViewRef.value.contentDocument) {
return;
}
const iframeDoc = domViewRef.value.contentDocument;
try {
// 移除各种事件监听器
iframeDoc.removeEventListener('click', handleIframeClick);
// 移除防抖的输入事件监听器
if ((iframeDoc as any).__debouncedInputHandler) {
iframeDoc.removeEventListener('input', (iframeDoc as any).__debouncedInputHandler);
delete (iframeDoc as any).__debouncedInputHandler;
}
// 移除表单提交事件监听器
iframeDoc.removeEventListener('submit', handleIframeSubmit);
// 移除节流的滚动事件监听器
if ((iframeDoc as any).__throttledScrollHandler) {
iframeDoc.removeEventListener('scroll', (iframeDoc as any).__throttledScrollHandler);
delete (iframeDoc as any).__throttledScrollHandler;
}
// 移除键盘事件监听器
iframeDoc.removeEventListener('keydown', handleIframeKeyDown);
// 移除DOMContentLoaded事件监听器
if ((iframeDoc as any).__domContentLoadedHandler) {
iframeDoc.removeEventListener('DOMContentLoaded', (iframeDoc as any).__domContentLoadedHandler);
delete (iframeDoc as any).__domContentLoadedHandler;
}
// 移除MutationObserver
if ((iframeDoc as any).__mutationObserver) {
(iframeDoc as any).__mutationObserver.disconnect();
delete (iframeDoc as any).__mutationObserver;
}
// 移除页面可见性变化监听器
if ((iframeDoc as any).__visibilityChangeListener) {
document.removeEventListener('visibilitychange', (iframeDoc as any).__visibilityChangeListener);
delete (iframeDoc as any).__visibilityChangeListener;
}
addLog('已移除iframe事件监听器', 'info');
} catch (e) {
addLog('移除iframe事件监听器失败: ' + (e as Error).message, 'error');
}
}
// 处理iframe中的点击事件
const handleIframeClick = (event: Event) => {
const target = event.target as HTMLElement
if (target) {
const selector = getElementSelector(target)
sendCommand('click', selector)
}
}
// 处理iframe中的输入事件
const handleIframeInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
const selector = getElementSelector(target)
sendCommand('type', `${selector}:${target.value}`)
} }
} }
// 处理iframe中的表单提交事件 // 设置iframe事件监听器
const handleIframeSubmit = (event: Event) => { function setupIframeEventListeners() {
const target = event.target as HTMLFormElement if (!iframeService || !domViewRef.value) return
if (target) {
event.preventDefault() // 阻止默认提交行为
const selector = getElementSelector(target)
sendCommand('submit', selector)
addLog('已发送表单提交指令: ' + selector, 'info')
// 对于表单提交,我们需要更智能地处理后续的事件监听器重新绑定
const retryBindListeners = () => {
let attempts = 0;
const maxAttempts = 20; // 增加尝试次数
const interval = setInterval(() => {
attempts++;
if (domViewRef.value && domViewRef.value.contentDocument) {
removeIframeEventListeners();
addIframeEventListeners();
clearInterval(interval);
addLog(`表单提交后重新绑定事件监听器成功,尝试次数: ${attempts}`, 'info');
} else if (attempts >= maxAttempts) {
clearInterval(interval);
addLog('表单提交后重新绑定事件监听器失败,达到最大尝试次数', 'error');
}
}, 200); // 缩短间隔时间
};
// 立即尝试一次 iframeService.addEventListeners(
setTimeout(() => { // onClick
(selector) => sendCommand('click', selector),
// onDoubleClick
(selector) => sendCommand('dblclick', selector),
// onInput
(selector, value) => sendCommand('type', `${selector}:${value}`),
// onSubmit
(selector) => {
// 将submit转换为click指令,点击表单中的提交按钮
// 查找表单中的提交按钮
if (domViewRef.value && domViewRef.value.contentDocument) { if (domViewRef.value && domViewRef.value.contentDocument) {
removeIframeEventListeners(); const form = domViewRef.value.contentDocument.querySelector(selector)
addIframeEventListeners();
}
}, 50); // 缩短首次尝试的延迟
// 启动重试机制
retryBindListeners();
// 添加一个超时保护,确保即使重试机制失败也能最终绑定监听器
setTimeout(() => {
if (domViewRef.value && domViewRef.value.contentDocument) {
removeIframeEventListeners();
addIframeEventListeners();
addLog('表单提交后最终保护性绑定事件监听器', 'info');
}
}, 8000); // 8秒后最终尝试
}
}
// 处理iframe中的滚动事件
const handleIframeScroll = (event: Event) => {
const target = event.target as HTMLElement
if (target) {
const scrollY = target.scrollTop || 0
sendCommand('scroll', scrollY.toString())
}
}
// 处理iframe中的键盘事件
const handleIframeKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement
if (target) {
// 发送按键信息
sendCommand('keydown', event.key)
// 特殊处理Enter键,可能触发表单提交
if (event.key === 'Enter' && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
const form = target.closest('form')
if (form) { if (form) {
const selector = getElementSelector(form) const submitButton = form.querySelector('input[type="submit"], button[type="submit"]') as HTMLElement
sendCommand('submit', selector) if (submitButton) {
} const buttonSelector = getElementSelector(submitButton)
} sendCommand('click', buttonSelector)
} addLog('已发送表单提交指令(点击提交按钮): ' + buttonSelector, 'info')
}
// 获取元素的选择器(改进版本)
const getElementSelector = (element: Element): string => {
if (!element || element === document.documentElement || element === document.body) {
return 'body';
}
// 优先使用ID选择器
if (element.id) {
return '#' + CSS.escape(element.id);
}
// 使用类名选择器
if (element.className && typeof element.className === 'string') {
const classes = element.className.split(' ').filter(cls => cls.length > 0);
if (classes.length > 0) {
return '.' + classes.map(cls => CSS.escape(cls)).join('.');
}
}
// 使用标签名选择器,并结合nth-child定位
let selector = element.tagName.toLowerCase();
const parent = element.parentElement;
if (parent) {
// 获取同级同标签元素的数量
const siblings = Array.from(parent.children).filter(child =>
child.tagName === element.tagName);
if (siblings.length > 1) {
// 计算当前元素在同级元素中的位置(1-based)
const index = Array.from(siblings).indexOf(element) + 1;
selector += `:nth-child(${index})`;
}
}
// 如果有父元素,递归构建更精确的选择器
if (parent && parent !== document.body) {
const parentSelector = getElementSelector(parent);
if (parentSelector) {
selector = parentSelector + ' > ' + selector;
}
}
return selector;
}
// 防抖函数
const debounce = (func: Function, delay: number) => {
let timeoutId: number
return (...args: any[]) => {
window.clearTimeout(timeoutId)
timeoutId = window.setTimeout(() => func.apply(null, args), delay)
}
}
// 节流函数
const throttle = (func: Function, delay: number) => {
let lastExecTime = 0
return (...args: any[]) => {
const currentTime = Date.now()
if (currentTime - lastExecTime >= delay) {
func.apply(null, args)
lastExecTime = currentTime
}
}
}
// 发送指令到服务器
const sendCommand = (command: string, param: string) => {
// 检查WebSocket连接状态
if (!websocket.value || websocket.value.readyState !== WebSocket.OPEN) {
// 如果WebSocket未连接,尝试重新连接
if (!isConnected.value) {
addLog('WebSocket未连接,正在尝试重新连接...', 'error')
connectWebSocket()
// 等待连接建立后再发送指令
const retryInterval = setInterval(() => {
if (websocket.value && websocket.value.readyState === WebSocket.OPEN) {
clearInterval(retryInterval)
doSendCommand(command, param)
}
}, 500)
// 设置超时时间,避免无限等待
setTimeout(() => {
clearInterval(retryInterval)
if (!websocket.value || websocket.value.readyState !== WebSocket.OPEN) {
addLog('WebSocket连接超时,无法发送指令', 'error')
}
}, 5000)
} else { } else {
addLog('WebSocket未连接,无法发送指令', 'error') // 如果没有找到显式的提交按钮,直接点击表单
// 添加一个更明确的提示,说明可能需要等待连接建立 sendCommand('click', selector)
addLog('提示:请稍等WebSocket连接建立后再尝试操作', 'info') addLog('已发送表单提交指令(点击表单): ' + selector, 'info')
} }
return }
} }
},
doSendCommand(command, param) // onScroll
} (scrollY) => sendCommand('scroll', scrollY.toString()),
// onSelect
// 实际发送指令的方法 (selector, value) => sendCommand('select', `${selector}:${value}`),
const doSendCommand = (command: string, param: string) => { // onKeyDown
if (!param) { (key) => sendCommand('keydown', key),
addLog('指令参数不能为空', 'error') // onKeyUp
return (key) => sendCommand('keyup', key),
} // onKeyPress
(key) => sendCommand('keypress', key),
const message = command + ':' + param // onHover
(selector) => sendCommand('hover', selector),
// 检查WebSocket连接状态 // onFocus
if (websocket.value && websocket.value.readyState === WebSocket.OPEN) { (selector) => sendCommand('focus', selector),
try { // onBlur
websocket.value.send(message) (selector) => sendCommand('blur', selector)
addLog('已发送指令:' + message, 'info') )
} catch (e) {
addLog('发送指令失败:' + (e as Error).message, 'error')
}
} else {
addLog('WebSocket连接已断开,无法发送指令', 'error')
// 尝试重新连接
connectWebSocket()
}
}
// 处理分片数据
const handleChunkData = (data: any) => {
try {
const chunkIndex = parseInt(data.dom); // 第一个字段是分片索引
const total = parseInt(data.style); // 第二个字段是总分片数
const content = data.script; // 第三个字段是分片内容
// 验证分片数据完整性
if (isNaN(chunkIndex) || isNaN(total) || !content) {
addLog('分片数据不完整或格式错误', 'error');
return;
}
// 初始化分片信息
if (chunkIndex === 0) {
totalChunks.value = total;
receivedChunks.value = 0;
chunkBuffer.value.clear();
// 清除之前的分片超时定时器(如果有的话)
if (chunkTimeoutId.value) {
clearTimeout(chunkTimeoutId.value);
chunkTimeoutId.value = null;
}
// 设置新的分片超时定时器,如果在10秒内没有接收完所有分片,则重置
chunkTimeoutId.value = window.setTimeout(() => {
if (receivedChunks.value < totalChunks.value) {
addLog(`分片接收超时,已接收${receivedChunks.value}/${totalChunks.value}个分片`, 'error');
chunkBuffer.value.clear();
totalChunks.value = 0;
receivedChunks.value = 0;
}
chunkTimeoutId.value = null;
}, 10000); // 10秒超时
}
// 检查分片索引是否合法
if (chunkIndex < 0 || chunkIndex >= total) {
addLog(`分片索引越界: ${chunkIndex}/${total}`, 'error');
return;
}
// 缓存分片数据(Base64解码)
try {
const decodedContent = atob(content);
chunkBuffer.value.set(chunkIndex, decodedContent);
receivedChunks.value++;
} catch (e) {
addLog('解码分片数据失败:' + (e as Error).message, 'error');
return;
}
// 检查是否接收完所有分片
if (receivedChunks.value === totalChunks.value) {
// 组装完整数据
let fullContent = '';
let missingChunks = [];
for (let i = 0; i < totalChunks.value; i++) {
const chunk = chunkBuffer.value.get(i);
if (chunk !== undefined) {
fullContent += chunk;
} else {
missingChunks.push(i);
}
}
// 检查是否有缺失的分片
if (missingChunks.length > 0) {
addLog(`分片数据缺失,缺少分片: ${missingChunks.join(', ')}`, 'error');
// 清空缓存并重新开始
chunkBuffer.value.clear();
totalChunks.value = 0;
receivedChunks.value = 0;
return;
}
// 检查总分片数是否合理
if (totalChunks.value <= 0) {
addLog('分片总数无效', 'error');
chunkBuffer.value.clear();
totalChunks.value = 0;
receivedChunks.value = 0;
return;
}
// 清空缓存
chunkBuffer.value.clear();
totalChunks.value = 0;
receivedChunks.value = 0;
// 清除分片超时定时器
if (chunkTimeoutId.value) {
clearTimeout(chunkTimeoutId.value);
chunkTimeoutId.value = null;
}
// 解析完整数据
try {
// 检查内容是否为空
if (!fullContent || fullContent.trim() === '') {
addLog('分片数据为空', 'error');
return;
}
// 尝试解压数据(如果是压缩的)
let jsonData: string;
try {
// 将字符串转换为Uint8Array
const charArray = fullContent.split('').map(c => c.charCodeAt(0));
const uint8Array = new Uint8Array(charArray);
// 尝试解压
const decompressed = pako.inflate(uint8Array, { to: 'string' });
jsonData = decompressed;
} catch (decompressError) {
// 如果解压失败,假设数据未被压缩
jsonData = fullContent;
}
// 验证JSON格式
try {
const parsedData = JSON.parse(jsonData);
handleDomSyncData(parsedData);
} catch (parseError) {
addLog('解析完整分片数据失败:' + (parseError as Error).message, 'error');
addLog('数据长度:' + jsonData.length + ' 字符', 'error');
// 显示部分数据用于调试
const previewLength = Math.min(200, jsonData.length);
addLog('数据预览:' + jsonData.substring(0, previewLength) + (jsonData.length > previewLength ? '...' : ''), 'error');
// 检查常见问题
if (jsonData.includes('\0')) {
addLog('警告:数据中包含空字符,可能导致解析失败', 'error');
}
// 尝试修复常见的JSON问题
try {
let fixedData = jsonData;
// 移除可能的BOM标记
if (fixedData.charCodeAt(0) === 0xFEFF) {
fixedData = fixedData.substring(1);
}
// 移除末尾的空字符
fixedData = fixedData.replace(/[\0]+$/, '');
// 重新尝试解析
const reParsedData = JSON.parse(fixedData);
addLog('修复后数据解析成功', 'info');
handleDomSyncData(reParsedData);
return;
} catch (fixError) {
addLog('修复后仍无法解析数据: ' + (fixError as Error).message, 'error');
}
throw parseError;
}
handleDomSyncData(parsedData);
} catch (e) {
addLog('解析完整分片数据失败:' + (e as Error).message, 'error');
addLog('数据内容预览:' + fullContent.substring(0, 100) + '...', 'error');
}
}
} catch (e) {
addLog('处理分片数据失败:' + (e as Error).message, 'error');
}
} }
// 处理DOM同步数据 // 处理DOM同步数据
const handleDomSyncData = (data: any) => { function handleDomSyncData(data: any) {
try { if (domViewRef.value && domViewRef.value.contentDocument) {
// 检查数据是否有效 domSyncService.handleDomSyncData(data, domViewRef.value.contentDocument, () => {
if (!data) { if (iframeService) {
addLog('接收到空数据', 'error'); iframeService.removeEventListeners()
return; setupIframeEventListeners()
}
switch (data.type) {
case 'init':
renderFullDomInIframe(data);
addLog('已初始化页面DOM:' + (data.url || '未知URL'), 'init');
break;
case 'update':
updateIncrementalDomInIframe(data);
addLog('已更新DOM', 'info');
break;
case 'error':
addLog('服务器错误:' + (data.dom || data.script || '未知错误'), 'error');
break;
case 'chunk':
handleChunkData(data);
break;
default:
addLog('未知数据类型:' + (data.type || '未指定'), 'error');
} }
} catch (e) { })
addLog('处理DOM同步数据失败:' + (e as Error).message, 'error');
} }
} }
// 在iframe中渲染完整DOM // 发送指令到服务器
const renderFullDomInIframe = (data: any) => { function sendCommand(command: string, param: string) {
if (!domViewRef.value || !domViewRef.value.contentDocument) { // 构造消息格式:指令:参数
addLog('iframe未初始化', 'error'); const message = param ? command + ':' + param : command
return; websocketService.send(message)
}
try {
// 检查必要数据
if (!data.dom) {
addLog('缺少DOM数据', 'error');
return;
}
const doc = domViewRef.value.contentDocument;
// 使用更安全的方式设置内容,避免使用document.write
// 检查传入的数据是否是完整的HTML文档
if (data.dom.startsWith('<!DOCTYPE html>') || data.dom.startsWith('<html')) {
// 如果是完整HTML文档,使用现代DOM API方式设置内容
// 先清空现有文档
doc.open();
doc.write(data.dom);
doc.close();
} else {
// 如果不是完整HTML文档,假定是body内容
// 使用更安全的innerHTML设置方式
try {
// 先清空body内容
while (doc.body.firstChild) {
doc.body.removeChild(doc.body.firstChild);
}
// 创建临时容器来解析HTML
const tempContainer = doc.createElement('div');
tempContainer.innerHTML = data.dom;
// 将解析后的元素逐个移动到body中
while (tempContainer.firstChild) {
doc.body.appendChild(tempContainer.firstChild);
}
} catch (innerError) {
// 如果上面的方法失败,回退到直接设置innerHTML
addLog('使用安全DOM操作失败,回退到innerHTML方式: ' + (innerError as Error).message, 'warn');
// 在设置innerHTML之前,先移除所有现有的子元素
while (doc.body.firstChild) {
doc.body.removeChild(doc.body.firstChild);
}
doc.body.innerHTML = data.dom;
}
}
// 添加样式(如果有)
if (data.style) {
// 先移除已存在的样式
const existingStyles = doc.querySelectorAll('style[data-dom-sync-style]');
existingStyles.forEach(style => style.remove());
const styleTag = doc.createElement('style');
styleTag.setAttribute('data-dom-sync-style', 'true');
styleTag.textContent = data.style;
doc.head.appendChild(styleTag);
}
// 执行脚本(如果有)
if (data.script) {
executeScriptsInIframe(data.script, doc);
}
// 重新绑定事件监听器
setTimeout(() => {
addIframeEventListeners();
}, 500);
} catch (e) {
addLog('在iframe中渲染完整DOM失败:' + (e as Error).message, 'error');
addLog('DOM数据预览:' + (data.dom ? data.dom.substring(0, 100) + '...' : '无'), 'error');
}
} }
// 在iframe中执行脚本 // 请求服务器统计信息
const executeScriptsInIframe = (scriptJson: string, doc: Document) => { const requestServerStats = () => {
try { sendCommand('stats', '')
// 检查脚本数据是否为空
if (!scriptJson || scriptJson.trim() === '') {
return;
}
let scriptData;
try {
scriptData = JSON.parse(scriptJson);
} catch (e) {
addLog('解析脚本数据失败:' + (e as Error).message, 'error');
addLog('脚本数据预览:' + (scriptJson ? scriptJson.substring(0, 100) + '...' : '无'), 'error');
return;
}
// 验证脚本数据结构
if (!scriptData || typeof scriptData !== 'object') {
addLog('脚本数据格式无效', 'error');
return;
}
if (!Array.isArray(scriptData.inline)) {
scriptData.inline = [];
}
if (!Array.isArray(scriptData.external)) {
scriptData.external = [];
}
// 执行内联脚本
if (scriptData.inline && Array.isArray(scriptData.inline)) {
scriptData.inline.forEach((scriptText: string) => {
if (scriptText && scriptText.trim() !== '') {
try {
// 创建script标签并执行
const scriptTag = doc.createElement('script');
scriptTag.textContent = scriptText;
// 添加安全检查,防止危险脚本执行
if (isScriptSafe(scriptText)) {
// 将脚本添加到head而不是body,以避免重复执行
doc.head.appendChild(scriptTag);
} else {
addLog('检测到不安全的内联脚本,已阻止执行', 'warn');
}
} catch (execError) {
addLog('执行内联脚本失败:' + (execError as Error).message, 'error');
}
}
});
}
// 加载外部脚本
if (scriptData.external && Array.isArray(scriptData.external)) {
scriptData.external.forEach((scriptUrl: string) => {
if (scriptUrl && scriptUrl.trim() !== '') {
try {
const scriptTag = doc.createElement('script');
scriptTag.src = scriptUrl;
scriptTag.crossOrigin = 'anonymous';
scriptTag.onload = () => addLog('外部脚本加载完成:' + scriptUrl, 'info');
scriptTag.onerror = (error) => addLog('外部脚本加载失败:' + scriptUrl + ' 错误: ' + (error as any).message, 'error');
// 添加到head而不是body,这样更符合标准
doc.head.appendChild(scriptTag);
} catch (loadError) {
addLog('添加外部脚本标签失败:' + (loadError as Error).message, 'error');
}
}
});
}
} catch (e) {
addLog('在iframe中执行脚本失败:' + (e as Error).message, 'error');
addLog('脚本数据预览:' + (scriptJson ? scriptJson.substring(0, 100) + '...' : '无'), 'error');
}
} }
// 在iframe中更新增量DOM(改进版本) // 重置服务器统计信息
const updateIncrementalDomInIframe = (data: any) => { const resetServerStats = () => {
if (!domViewRef.value || !domViewRef.value.contentDocument) { sendCommand('reset-stats', '')
addLog('iframe未初始化', 'error'); }
return;
}
try {
const doc = domViewRef.value.contentDocument;
const changes = JSON.parse(data.dom);
// 处理DOM变化
changes.forEach((change: any) => {
// 处理添加的节点
if (change.addedNodes && Array.isArray(change.addedNodes)) {
change.addedNodes.forEach((nodeHtml: string) => {
if (nodeHtml) {
try {
// 使用更安全的方式插入节点
const tempDiv = doc.createElement('div');
tempDiv.innerHTML = nodeHtml;
// 获取所有子节点而不是仅第一个
const nodesToAdd = Array.from(tempDiv.childNodes);
if (nodesToAdd.length > 0) {
// 查找目标父节点
const parentSelector = change.target ? change.target : 'body';
let parentElement = doc.querySelector(parentSelector);
// 如果找不到指定的父节点,使用body作为后备
if (!parentElement) {
parentElement = doc.body;
addLog('未找到指定父节点,使用body作为后备: ' + parentSelector, 'warn');
}
// 将所有节点添加到父节点中
nodesToAdd.forEach(node => {
parentElement!.appendChild(node.cloneNode(true));
});
}
} catch (e) {
addLog('添加节点失败:' + (e as Error).message, 'error');
addLog('节点HTML预览:' + (nodeHtml ? nodeHtml.substring(0, 100) + '...' : '无'), 'error');
}
}
});
}
// 处理移除的节点
if (change.removedNodes && Array.isArray(change.removedNodes)) {
change.removedNodes.forEach((nodeSelector: string) => {
if (nodeSelector) {
try {
// 直接使用选择器查找要移除的元素
const targetElement = doc.querySelector(nodeSelector);
if (targetElement) {
targetElement.remove();
} else {
addLog('未找到要移除的元素: ' + nodeSelector, 'warn');
}
} catch (e) {
addLog('移除节点失败:' + (e as Error).message, 'error');
addLog('节点选择器:' + nodeSelector, 'error');
}
}
});
}
// 处理属性更新 // 导航到指定URL
if (change.attributes && Array.isArray(change.attributes)) { // 测试WebSocket连接
change.attributes.forEach((attrChange: any) => { const testWebSocketConnection = () => {
try { addLog('测试WebSocket连接...', 'info');
const targetElement = doc.querySelector(attrChange.selector); if (websocketService.isConnected()) {
if (targetElement) { addLog('WebSocket连接状态: 已连接', 'info');
if (attrChange.value === null || attrChange.value === undefined) { // 发送测试消息
targetElement.removeAttribute(attrChange.name); websocketService.send('test:message');
} else { } else {
targetElement.setAttribute(attrChange.name, attrChange.value); addLog('WebSocket连接状态: 未连接', 'warn');
} connectWebSocket();
}
} catch (e) {
addLog('更新属性失败:' + (e as Error).message, 'error');
addLog('属性变更详情:' + JSON.stringify(attrChange), 'error');
}
});
}
});
// 更新完成后重新绑定事件监听器
setTimeout(() => {
addIframeEventListeners();
}, 100);
} catch (e) {
addLog('在iframe中增量更新DOM失败:' + (e as Error).message, 'error');
addLog('变更数据预览:' + (data.dom ? data.dom.substring(0, 200) + '...' : '无'), 'error');
} }
} };
// 导航到指定URL
const navigateToUrl = (url?: string) => { const navigateToUrl = (url?: string) => {
const targetUrl = url || urlInput.value const targetUrl = url || urlInput.value
if (targetUrl) { if (targetUrl) {
...@@ -1326,154 +313,121 @@ const navigateToUrl = (url?: string) => { ...@@ -1326,154 +313,121 @@ const navigateToUrl = (url?: string) => {
addLog('无效的URL格式: ' + targetUrl, 'error') addLog('无效的URL格式: ' + targetUrl, 'error')
return return
} }
// 根据后端协议,navigate指令只需要URL参数
sendCommand('navigate', targetUrl) sendCommand('navigate', targetUrl)
} }
} }
// 验证URL格式是否有效
const isValidUrl = (url: string): boolean => {
if (!url || url.trim() === '') {
return false
}
// 检查是否以http://或https://开头
if (!url.toLowerCase().startsWith('http://') && !url.toLowerCase().startsWith('https://')) {
// 如果没有协议前缀,自动添加https://
url = 'https://' + url
}
// 确保URL末尾没有多余的斜杠(除了域名根路径)
if (url.endsWith('/') && url.length > 8) { // 8是'http://a'的长度
url = url.slice(0, -1);
}
// 使用URL构造函数验证URL格式
try {
new URL(url);
return true;
} catch (e) {
return false;
}
}
// 检查脚本是否安全执行
const isScriptSafe = (scriptText: string): boolean => {
// 简单的安全检查,防止明显的恶意脚本执行
const unsafePatterns = [
/document\.cookie/i,
/localStorage/i,
/sessionStorage/i,
/indexedDB/i,
/navigator\.sendBeacon/i,
/XMLHttpRequest/i,
/fetch\s*\(/i,
/eval\s*\(/i,
/Function\s*\(/i,
/setTimeout\s*\([^,]+,[^,]+\)/i,
/setInterval\s*\([^,]+,[^,]+\)/i,
/location\s*=\s*/i,
/window\.open/i,
/document\.write/i
];
// 检查是否存在不安全模式
for (const pattern of unsafePatterns) {
if (pattern.test(scriptText)) {
return false;
}
}
return true;
}
// 添加一个方法来初始化页面 // 添加一个方法来初始化页面
const initializePage = () => { const initializePage = () => {
addLog('开始初始化页面...', 'info'); addLog('开始初始化页面...', 'info')
// 设置初始iframe源为about:blank // 设置初始iframe源为about:blank
iframeSrc.value = 'about:blank'; iframeSrc.value = 'about:blank'
// 添加一个小延迟确保iframe初始化完成后再连接WebSocket // 添加一个小延迟确保iframe初始化完成后再连接WebSocket
setTimeout(() => { setTimeout(() => {
// 连接WebSocket // 连接WebSocket
connectWebSocket(); connectWebSocket()
addLog('WebSocket连接已启动', 'info'); addLog('WebSocket连接已启动', 'info')
}, 500); }, 500)
// 添加一个备用机制,如果iframe长时间未加载则强制重新加载 // 添加一个备用机制,如果iframe长时间未加载则强制重新加载
const iframeLoadTimeout = setTimeout(() => { const iframeLoadTimeout = setTimeout(() => {
if (!domViewRef.value || !domViewRef.value.contentDocument) { if (!domViewRef.value || !domViewRef.value.contentDocument) {
addLog('iframe加载超时,尝试重新初始化...', 'warn'); addLog('iframe加载超时,尝试重新初始化...', 'warn')
errorStats.value.renderErrors++
// 重新设置iframe源以触发重新加载 // 重新设置iframe源以触发重新加载
iframeSrc.value = 'about:blank'; iframeSrc.value = 'about:blank'
// 再次尝试连接WebSocket // 再次尝试连接WebSocket
setTimeout(() => { setTimeout(() => {
connectWebSocket(); connectWebSocket()
}, 1000); }, 1000)
} }
}, 10000); // 增加到10秒超时 }, 10000) // 增加到10秒超时
// 保存超时ID以便在组件卸载时清除 // 保存超时ID以便在组件卸载时清除
(window as any).__iframeLoadTimeout = iframeLoadTimeout; ;(window as any).__iframeLoadTimeout = iframeLoadTimeout
} }
// 添加日志 // WebSocket连接
const addLog = (message: string, type: string) => { const connectWebSocket = () => {
try { // 从localStorage获取JWT token
const logArea = document.getElementById('log-area'); const token = localStorage.getItem('token')
if (logArea) {
const logItem = document.createElement('div');
logItem.className = 'log-' + type;
logItem.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
// 添加数据属性以便于调试
logItem.setAttribute('data-log-type', type);
logItem.setAttribute('data-timestamp', new Date().toISOString());
logArea.appendChild(logItem); // 动态获取WebSocket连接地址,适配不同部署环境
// 注意:前端开发服务器运行在5174端口,但后端服务运行在8080端口
// 在生产环境中,前端和后端通常运行在同一端口上
const isDev = import.meta.env.DEV
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
// 限制日志数量,避免内存泄漏 // 确保在开发环境中连接到正确的后端端口
while (logArea.children.length > 100) { let host
logArea.removeChild(logArea.firstChild!); if (isDev) {
// 在开发环境中,前端运行在5174端口,后端运行在8080端口
host = window.location.hostname + ':8080'
} else {
// 在生产环境中,使用当前主机和端口
host = window.location.host
} }
// 使用nextTick确保DOM更新后再滚动 const wsUrl = `${protocol}//${host}/ws/dom-sync${token ? '?token=' + encodeURIComponent(token) : ''}`
nextTick(() => {
// 检查用户是否在查看历史日志 if (!token) {
const isScrolledToBottom = logArea.scrollHeight - logArea.clientHeight <= logArea.scrollTop + 10; addLog('警告:localStorage中没有找到token,可能是未登录或token已过期,WebSocket连接将会失败', 'warn')
if (isScrolledToBottom) { } else {
// 自动滚动到最新日志 addLog('成功从localStorage中获取token,token长度: ' + token.length + ' 字节', 'info')
logArea.scrollTop = logArea.scrollHeight;
} // 检查Token是否即将过期
}); const tokenValidation = TokenUtils.validateToken(token);
if (!token) {
addLog('错误:未找到认证Token,请重新登录', 'error');
} else if (tokenValidation.isExpired) {
addLog('错误:Token已过期,请重新登录', 'error');
} else if (TokenUtils.isTokenExpiringSoon(token, 30)) {
addLog(`警告:Token将在 ${tokenValidation.minutesLeft?.toFixed(1)} 分钟后过期,请及时刷新页面或重新登录`, 'warn');
} else { } else {
// 如果无法找到日志区域,使用console作为后备 addLog(`Token有效,将在 ${tokenValidation.minutesLeft?.toFixed(1)} 分钟后过期`, 'info');
console.log(`[${type.toUpperCase()}][${new Date().toLocaleTimeString()}] ${message}`);
} }
} catch (e) {
// 即使日志记录失败,也不要影响主流程
console.error('日志记录失败:', e);
} }
addLog('WebSocket URL: ' + wsUrl, 'info')
websocketService.connect(wsUrl)
} }
// 清空日志 // 获取错误统计信息
const clearLogs = () => { const getErrorStats = () => {
const logArea = document.getElementById('log-area') return {...errorStats.value}
if (logArea) {
logArea.innerHTML = ''
}
} }
// 组件挂载时初始化页面 // 组件挂载时初始化页面
onMounted(() => { onMounted(() => {
// 检测是否为移动设备
const checkIsMobile = () => {
const mobileWidth = 768
isMobile.value = window.innerWidth <= mobileWidth
}
// 初始检测
checkIsMobile()
// 监听窗口大小变化
const handleResize = () => {
checkIsMobile()
}
window.addEventListener('resize', handleResize)
// 初始化页面
initializePage() initializePage()
// 监听页面可见性变化,当页面重新获得焦点时检查连接状态 // 监听页面可见性变化,当页面重新获得焦点时检查连接状态
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
// 如果WebSocket未连接,尝试重新连接 // 如果WebSocket未连接,尝试重新连接
if (!isConnected.value || !websocket.value || websocket.value.readyState !== WebSocket.OPEN) { if (!isConnected.value || !websocketService.isConnected()) {
addLog('页面重新激活,检查WebSocket连接状态...', 'info') addLog('页面重新激活,检查WebSocket连接状态...', 'info')
connectWebSocket() connectWebSocket()
} }
...@@ -1481,57 +435,29 @@ onMounted(() => { ...@@ -1481,57 +435,29 @@ onMounted(() => {
} }
document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange)
// 组件卸载时移除事件监听器
onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
}) })
// 组件卸载时关闭WebSocket // 组件卸载时关闭WebSocket
onUnmounted(() => { onUnmounted(() => {
addLog('组件正在卸载,清理资源...', 'info'); addLog('组件正在卸载,清理资源...', 'info')
// 清理iframe相关的事件监听器
cleanupIframeListeners();
// 关闭WebSocket连接 // 关闭WebSocket连接
if (websocket.value) { websocketService.close()
try {
websocket.value.close()
addLog('WebSocket连接已关闭', 'info');
} catch (e) {
addLog('关闭WebSocket连接时出错: ' + (e as Error).message, 'error');
}
websocket.value = null;
}
// 清除分片超时定时器
if (chunkTimeoutId.value) {
clearTimeout(chunkTimeoutId.value);
chunkTimeoutId.value = null;
addLog('分片超时定时器已清除', 'info');
}
// 清除iframe加载超时定时器
if ((window as any).__iframeLoadTimeout) {
clearTimeout((window as any).__iframeLoadTimeout);
(window as any).__iframeLoadTimeout = null;
addLog('iframe加载超时定时器已清除', 'info');
}
// 重置状态 // 重置状态
isConnected.value = false; isConnected.value = false
reconnectAttempts.value = 0; reconnectAttempts.value = 0
addLog('组件资源清理完成', 'info'); addLog('组件资源清理完成', 'info')
}) });
// 暴露给父组件的方法 // 暴露给父组件的方法
defineExpose({ defineExpose({
urlInput, urlInput,
navigateToUrl navigateToUrl,
}) requestServerStats,
resetServerStats,
getErrorStats
});
</script> </script>
<style scoped> <style scoped>
...@@ -1545,12 +471,55 @@ defineExpose({ ...@@ -1545,12 +471,55 @@ defineExpose({
.viewer-header { .viewer-header {
padding: 10px; padding: 10px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
width: 100%;
} }
.url-input { .url-input {
width: 100%; width: 100%;
} }
/* 确保 el-input 的 append 按钮正确显示 */
.url-input :deep(.el-input-group__append) {
display: flex;
flex-wrap: nowrap;
gap: 2px;
padding: 0 5px;
}
/* 确保按钮在小屏幕上也能正确显示 */
.url-input :deep(.el-button) {
min-width: 50px;
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
/* 在小屏幕上调整按钮样式 */
@media (max-width: 768px) {
.url-input :deep(.el-button) {
min-width: 40px;
padding: 6px 8px;
font-size: 11px;
}
.url-input :deep(.el-dropdown) {
display: inline-block;
}
}
@media (max-width: 576px) {
.url-input :deep(.el-button) {
min-width: 35px;
padding: 5px 6px;
font-size: 10px;
}
.url-input :deep(.el-input-group__append) {
gap: 1px;
padding: 0 2px;
}
}
.viewer-content { .viewer-content {
flex: 1; flex: 1;
display: flex; display: flex;
......
...@@ -14,10 +14,26 @@ ...@@ -14,10 +14,26 @@
<span class="message-time">{{ formatTime(timestamp) }}</span> <span class="message-time">{{ formatTime(timestamp) }}</span>
</div> </div>
<div class="message-content"> <div class="message-content">
<div v-if="isMarkdown" class="markdown-content" v-html="renderedMarkdown"></div> <div v-if="hasError" class="error-content">
<div class="error-message">{{ content }}</div>
<el-button
v-if="content.includes('重试')"
size="small"
type="primary"
@click="$emit('retry')"
class="retry-button"
>
重新发送
</el-button>
</div>
<div v-else-if="isMarkdown" class="markdown-content" v-html="renderedMarkdown"></div>
<div v-else class="plain-text">{{ content }}</div> <div v-else class="plain-text">{{ content }}</div>
<!-- 打字机动画效果 --> <!-- 打字机动画效果 -->
<div v-if="isStreaming && !isMarkdown" class="typing-cursor"> <div
v-if="isStreaming && !isMarkdown && !hasError"
class="typing-cursor"
aria-label="正在输入"
>
<span></span><span></span><span></span> <span></span><span></span><span></span>
</div> </div>
</div> </div>
...@@ -29,6 +45,7 @@ ...@@ -29,6 +45,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { marked } from 'marked' import { marked } from 'marked'
import hljs from 'highlight.js' import hljs from 'highlight.js'
import { ElButton } from 'element-plus'
interface Props { interface Props {
content: string content: string
...@@ -37,24 +54,23 @@ interface Props { ...@@ -37,24 +54,23 @@ interface Props {
timestamp?: number timestamp?: number
isStreaming?: boolean isStreaming?: boolean
isMarkdown?: boolean isMarkdown?: boolean
hasError?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
timestamp: () => Date.now(), timestamp: () => Date.now(),
isStreaming: false, isStreaming: false,
isMarkdown: true isMarkdown: true,
hasError: false
}) })
// 配置marked defineEmits(['retry'])
marked.setOptions({
breaks: true,
gfm: true
})
// 使用renderer自定义代码块样式 // 创建一次性的renderer实例,避免每次渲染时重新创建
const renderer = new marked.Renderer() const createRenderer = () => {
const originalCode = renderer.code.bind(renderer) const renderer = new marked.Renderer()
renderer.code = function(code: any, language?: any) {
renderer.code = function(code: string, language?: string) {
// 确保 code 是字符串类型 // 确保 code 是字符串类型
if (typeof code !== 'string') { if (typeof code !== 'string') {
code = String(code || '') code = String(code || '')
...@@ -75,11 +91,30 @@ renderer.code = function(code: any, language?: any) { ...@@ -75,11 +91,30 @@ renderer.code = function(code: any, language?: any) {
code = hljs.highlightAuto(code).value code = hljs.highlightAuto(code).value
} }
return `<pre><code class="hljs language-${language || ''}">${code}</code></pre>` return `<pre><code class="hljs language-${language || ''}">${code}</code></pre>`
}
return renderer
} }
marked.setOptions({ renderer })
// 渲染Markdown // 初始化marked配置
const initializeMarked = () => {
marked.setOptions({
breaks: true,
gfm: true,
renderer: createRenderer()
})
}
// 在组件初始化时调用
initializeMarked()
// 使用缓存优化Markdown渲染性能
const renderedMarkdown = computed(() => { const renderedMarkdown = computed(() => {
// 只有当内容为Markdown格式时才进行渲染
if (!props.isMarkdown) {
return props.content
}
try { try {
return marked(props.content) as string return marked(props.content) as string
} catch (err) { } catch (err) {
...@@ -300,6 +335,27 @@ const formatTime = (timestamp: number): string => { ...@@ -300,6 +335,27 @@ const formatTime = (timestamp: number): string => {
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
} }
/* 错误消息样式 */
.error-content {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.error-message {
color: var(--error-color);
background-color: var(--error-bg-color, #fef2f2);
border: 1px solid var(--error-border-color, #fecaca);
border-radius: var(--border-radius-lg);
padding: var(--spacing-3);
font-size: var(--font-size-sm);
}
.retry-button {
align-self: flex-start;
margin-top: var(--spacing-2);
}
/* 打字机动画 */ /* 打字机动画 */
.typing-cursor { .typing-cursor {
display: inline-flex; display: inline-flex;
......
...@@ -26,7 +26,10 @@ ...@@ -26,7 +26,10 @@
</div> </div>
<!-- 工具调用输入输出详情 --> <!-- 工具调用输入输出详情 -->
<div v-if="(event.type === 'tool_call' || event.type === 'tool_result' || event.type === 'tool_error')" class="tool-details" :style="{ display: isToolDataVisible(event) ? 'block' : 'none' }"> <div
v-if="(event.type === 'tool_call' || event.type === 'tool_result' || event.type === 'tool_error') && isToolDataVisible(event)"
class="tool-details"
>
<!-- 展开/折叠按钮 --> <!-- 展开/折叠按钮 -->
<div class="detail-toggle" @click="toggleExpand(index)"> <div class="detail-toggle" @click="toggleExpand(index)">
<span class="toggle-text">{{ getExpandedState(index) ? '收起详情' : '查看详情' }}</span> <span class="toggle-text">{{ getExpandedState(index) ? '收起详情' : '查看详情' }}</span>
...@@ -36,26 +39,20 @@ ...@@ -36,26 +39,20 @@
<!-- 详细信息内容 --> <!-- 详细信息内容 -->
<div v-show="getExpandedState(index)" class="detail-content"> <div v-show="getExpandedState(index)" class="detail-content">
<!-- 输入参数段 --> <!-- 输入参数段 -->
<div v-if="'toolInput' in event" class="tool-input" :key="`input-${index}`"> <ToolDataSection
<div class="detail-title">输入参数</div> v-if="'toolInput' in event"
<div v-if="event.toolInput !== null && event.toolInput !== undefined" class="json-display"> title="输入参数"
<pre><code>{{ formatToolData(event.toolInput) }}</code></pre> :data="event.toolInput"
</div> type="input"
<div v-else class="json-display" style="color: var(--text-tertiary)"> />
<pre><code>无数据</code></pre>
</div>
</div>
<!-- 输出结果段 --> <!-- 输出结果段 -->
<div v-if="'toolOutput' in event" class="tool-output" :key="`output-${index}`"> <ToolDataSection
<div class="detail-title">输出结果</div> v-if="'toolOutput' in event"
<div v-if="event.toolOutput !== null && event.toolOutput !== undefined" class="json-display"> title="输出结果"
<pre><code>{{ formatToolData(event.toolOutput) }}</code></pre> :data="event.toolOutput"
</div> type="output"
<div v-else class="json-display" style="color: var(--text-tertiary)"> />
<pre><code>无数据</code></pre>
</div>
</div>
</div> </div>
</div> </div>
...@@ -75,6 +72,8 @@ ...@@ -75,6 +72,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue' import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import ToolDataSection from './ToolDataSection.vue'
import { formatToolData } from '../utils/functionUtils'
interface TimelineEvent { interface TimelineEvent {
type: 'thought' | 'action' | 'observation' | 'result' | 'error' | 'tool_call' | 'tool_result' | 'tool_error' | 'embed' type: 'thought' | 'action' | 'observation' | 'result' | 'error' | 'tool_call' | 'tool_result' | 'tool_error' | 'embed'
...@@ -99,7 +98,7 @@ const expandedStates = ref<Record<number, boolean>>({}) ...@@ -99,7 +98,7 @@ const expandedStates = ref<Record<number, boolean>>({})
// 获取事件的展开状态,确保始终返回布尔值 // 获取事件的展开状态,确保始终返回布尔值
const getExpandedState = (index: number): boolean => { const getExpandedState = (index: number): boolean => {
return expandedStates.value[index] === true return !!expandedStates.value[index]
} }
const events = ref<TimelineEvent[]>([]) const events = ref<TimelineEvent[]>([])
...@@ -111,12 +110,10 @@ const toggleExpand = (index: number) => { ...@@ -111,12 +110,10 @@ const toggleExpand = (index: number) => {
...expandedStates.value, ...expandedStates.value,
[index]: !expandedStates.value[index] [index]: !expandedStates.value[index]
} }
console.log(`[事件 #${index}] 展开状态已切换:`, expandedStates.value[index]);
} }
// 事件类型标签 // 事件类型标签映射
const getEventTypeLabel = (type: string): string => { const EVENT_TYPE_LABELS: Record<string, string> = {
const labels: Record<string, string> = {
'thought': '💭 思考', 'thought': '💭 思考',
'action': '🎬 行动', 'action': '🎬 行动',
'observation': '👀 观察', 'observation': '👀 观察',
...@@ -126,8 +123,11 @@ const getEventTypeLabel = (type: string): string => { ...@@ -126,8 +123,11 @@ const getEventTypeLabel = (type: string): string => {
'tool_result': '📤 工具结果', 'tool_result': '📤 工具结果',
'tool_error': '⚠️ 工具错误', 'tool_error': '⚠️ 工具错误',
'embed': '🌐 网页预览' 'embed': '🌐 网页预览'
} }
return labels[type] || type
// 获取事件类型标签
const getEventTypeLabel = (type: string): string => {
return EVENT_TYPE_LABELS[type] || type
} }
// 格式化时间 // 格式化时间
...@@ -139,42 +139,7 @@ const formatTime = (timestamp: number): string => { ...@@ -139,42 +139,7 @@ const formatTime = (timestamp: number): string => {
return `${hours}:${minutes}:${seconds}` return `${hours}:${minutes}:${seconds}`
} }
// 格式化JSON对象为美观的字符串
const formatJson = (obj: any): string => {
if (obj === null || obj === undefined) {
return '无数据';
}
if (typeof obj === 'string') {
try {
// 尝试解析字符串为JSON对象
const parsed = JSON.parse(obj);
return JSON.stringify(parsed, null, 2);
} catch (e) {
// 如果不是有效的JSON字符串,直接返回原字符串
return obj;
}
} else if (typeof obj === 'object') {
// 如果是对象,直接格式化
return JSON.stringify(obj, null, 2);
} else {
// 其他情况转换为字符串
return String(obj);
}
};
// 格式化工具数据
const formatToolData = (data: any): string => {
try {
console.log('[formatToolData] 接受数据:', { data, type: typeof data });
const result = formatJson(data);
console.log('[formatToolData] 输出结果:', result);
return result;
} catch (error) {
console.error('格式化工具数据出错:', error);
return '数据格式错误';
}
};
// 检查是否为Empty数据 // 检查是否为Empty数据
const isEmpty = (data: any): boolean => { const isEmpty = (data: any): boolean => {
...@@ -202,8 +167,6 @@ const isToolDataVisible = (event: TimelineEvent): boolean => { ...@@ -202,8 +167,6 @@ const isToolDataVisible = (event: TimelineEvent): boolean => {
return hasToolData(event); return hasToolData(event);
}; };
// 检查数据是否非空
// 添加时间轴事件 // 添加时间轴事件
const addEvent = (event: Omit<TimelineEvent, 'timestamp'> & { timestamp?: number }) => { const addEvent = (event: Omit<TimelineEvent, 'timestamp'> & { timestamp?: number }) => {
// 对于thinking事件,检查是否与上一个事件内容相同,避免重复添加 // 对于thinking事件,检查是否与上一个事件内容相同,避免重复添加
...@@ -219,28 +182,12 @@ const addEvent = (event: Omit<TimelineEvent, 'timestamp'> & { timestamp?: number ...@@ -219,28 +182,12 @@ const addEvent = (event: Omit<TimelineEvent, 'timestamp'> & { timestamp?: number
const finalEvent = { const finalEvent = {
...event, ...event,
timestamp: event.timestamp || Date.now(), timestamp: event.timestamp || Date.now(),
// 确保工具相关字段被正确保留
// 对于 toolInput 和 toolOutput,我们需要保留它们,即使值为 null
// 这样前端可以区分"字段不存在"和"字段存在但值为null"
...(('toolInput' in event) ? { toolInput: event.toolInput } : {}), ...(('toolInput' in event) ? { toolInput: event.toolInput } : {}),
...(('toolOutput' in event) ? { toolOutput: event.toolOutput } : {}) ...(('toolOutput' in event) ? { toolOutput: event.toolOutput } : {})
} as TimelineEvent; } as TimelineEvent;
events.value.push(finalEvent); events.value.push(finalEvent);
// 调试日志:记录添加的事件(含完整的工具数据信息)
if (event.type === 'tool_call' || event.type === 'tool_result' || event.type === 'tool_error') {
console.log(`[Timeline] 添加工具事件 #${newIndex}:`, {
type: event.type,
toolName: event.toolName,
hasToolInput: 'toolInput' in event,
toolInputValue: event.toolInput,
hasToolOutput: 'toolOutput' in event,
toolOutputValue: event.toolOutput,
finalEvent: finalEvent
});
}
// 初始化新事件的展开状态 // 初始化新事件的展开状态
expandedStates.value[newIndex] = false; expandedStates.value[newIndex] = false;
...@@ -268,6 +215,14 @@ defineExpose({ ...@@ -268,6 +215,14 @@ defineExpose({
}) })
onMounted(() => { onMounted(() => {
// SSE连接重试相关变量
let eventSource: EventSource | null = null;
let retryCount = 0;
const maxRetries = 5;
const retryDelay = 3000; // 3秒
// 建立SSE连接的函数
const connectSSE = () => {
// 从localStorage获取token // 从localStorage获取token
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
...@@ -278,23 +233,11 @@ onMounted(() => { ...@@ -278,23 +233,11 @@ onMounted(() => {
} }
// 监听工作面板事件 // 监听工作面板事件
const eventSource = new EventSource(eventSourceUrl) eventSource = new EventSource(eventSourceUrl)
eventSource.addEventListener('message', (event) => { eventSource.addEventListener('message', (event) => {
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
console.log('[SSE] 收到时间轴事件:', data.type, data);
// 特别检查工具调用相关字段(详细日志输出)
if (data.type === 'tool_call' || data.type === 'tool_result' || data.type === 'tool_error') {
console.log(`[SSE] 接收工具事件 - 类型: ${data.type}, 工具: ${data.toolName}`, {
toolInput: data.toolInput,
toolOutput: data.toolOutput,
hasToolInput: 'toolInput' in data,
hasToolOutput: 'toolOutput' in data,
rawData: data
});
}
// 构建事件标题 // 构建事件标题
let title = data.title || '事件' let title = data.title || '事件'
...@@ -328,8 +271,6 @@ onMounted(() => { ...@@ -328,8 +271,6 @@ onMounted(() => {
metadata: Object.keys(metadata).length > 0 ? metadata : undefined, metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
toolName: data.toolName, toolName: data.toolName,
toolAction: data.toolAction, toolAction: data.toolAction,
// 明确处理工具输入输出字段,确保它们不会丢失
// 只要字段存在就保留它们,即使值为 null 或 undefined
...(('toolInput' in data) ? { toolInput: data.toolInput } : {}), ...(('toolInput' in data) ? { toolInput: data.toolInput } : {}),
...(('toolOutput' in data) ? { toolOutput: data.toolOutput } : {}), ...(('toolOutput' in data) ? { toolOutput: data.toolOutput } : {}),
toolStatus: data.toolStatus, toolStatus: data.toolStatus,
...@@ -341,18 +282,6 @@ onMounted(() => { ...@@ -341,18 +282,6 @@ onMounted(() => {
timestamp: data.timestamp timestamp: data.timestamp
} }
// 特别记录工具事件的详细信息
if (timelineEventData.type === 'tool_call' || timelineEventData.type === 'tool_result' || timelineEventData.type === 'tool_error') {
console.log(`[Timeline] 构建事件完成 - 类型: ${timelineEventData.type}`, {
toolInput: timelineEventData.toolInput,
toolOutput: timelineEventData.toolOutput,
hasToolInput: 'toolInput' in timelineEventData,
hasToolOutput: 'toolOutput' in timelineEventData,
eventData: timelineEventData
});
}
console.log('[Timeline] 添加事件:', timelineEventData.type, timelineEventData);
addEvent(timelineEventData) addEvent(timelineEventData)
// 触发embed事件给父组件 // 触发embed事件给父组件
...@@ -366,37 +295,70 @@ onMounted(() => { ...@@ -366,37 +295,70 @@ onMounted(() => {
} }
})) }))
} }
// 重置重试计数
retryCount = 0;
} catch (err) { } catch (err) {
console.error('解析时间轴事件失败:', err) console.error('解析时间轴事件失败:', err)
} }
}) })
eventSource.addEventListener('error', (error) => { eventSource.addEventListener('error', (error) => {
console.error('SSE连接错误:', error) console.error('[SSE] 连接错误:', error);
eventSource.close()
}) // 关闭当前连接
if (eventSource) {
eventSource.close();
eventSource = null;
}
// 如果重试次数未达到最大值,尝试重新连接
if (retryCount < maxRetries) {
retryCount++;
console.log(`[SSE] 尝试重新连接 (${retryCount}/${maxRetries})...`);
setTimeout(connectSSE, retryDelay);
} else {
console.error('[SSE] 达到最大重试次数,停止重连');
// 可以在这里触发一个全局事件通知用户连接失败
window.dispatchEvent(new CustomEvent('sse-connection-failed'));
}
});
// 监听连接成功事件
eventSource.addEventListener('open', () => {
console.log('[SSE] 连接已建立');
retryCount = 0; // 重置重试计数
});
};
// 初始连接
connectSSE();
// 在组件卸载时清理连接
onUnmounted(() => {
if (eventSource) {
eventSource.close();
eventSource = null;
}
});
// 监听来自ChatArea的思考事件 // 监听来自ChatArea的思考事件
const handleTimelineEvent = (e: CustomEvent) => { const handleTimelineEvent = (e: CustomEvent) => {
const eventData = e.detail const eventData = e.detail
// 保留完整的事件对象,不丢失任何字段(如 toolName, toolInput, toolOutput 等)
addEvent({ addEvent({
type: eventData.type || 'thought', type: eventData.type || 'thought',
title: eventData.title || '思考过程', title: eventData.title || '思考过程',
content: eventData.content, content: eventData.content,
// 保留所有工具相关字段
toolName: eventData.toolName, toolName: eventData.toolName,
toolAction: eventData.toolAction, toolAction: eventData.toolAction,
toolInput: eventData.toolInput, toolInput: eventData.toolInput,
toolOutput: eventData.toolOutput, toolOutput: eventData.toolOutput,
toolStatus: eventData.toolStatus, toolStatus: eventData.toolStatus,
executionTime: eventData.executionTime, executionTime: eventData.executionTime,
// 保留所有embed相关字段
embedUrl: eventData.embedUrl, embedUrl: eventData.embedUrl,
embedType: eventData.embedType, embedType: eventData.embedType,
embedTitle: eventData.embedTitle, embedTitle: eventData.embedTitle,
embedHtmlContent: eventData.embedHtmlContent, embedHtmlContent: eventData.embedHtmlContent,
// 保留元数据和时间戳
metadata: eventData.metadata, metadata: eventData.metadata,
timestamp: eventData.timestamp timestamp: eventData.timestamp
}) })
......
<template>
<div :class="containerClass" :key="type">
<div class="detail-title">{{ title }}</div>
<div :class="jsonDisplayClass">
<pre><code>{{ formattedData }}</code></pre>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatToolData } from '../utils/functionUtils'
interface Props {
title: string
data: any
type: 'input' | 'output'
}
const props = defineProps<Props>()
// 计算容器类名
const containerClass = computed(() => `tool-${props.type}`)
// 计算JSON显示类名
const jsonDisplayClass = computed(() => {
const baseClass = 'json-display'
return props.data !== null && props.data !== undefined
? baseClass
: `${baseClass} no-data`
})
// 计算格式化后的数据,使用computed缓存避免重复计算
const formattedData = computed(() => {
return props.data !== null && props.data !== undefined
? formatToolData(props.data)
: '无数据'
})
</script>
<style scoped>
.detail-title {
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
font-size: var(--font-size-sm);
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--border-color);
}
.tool-input .detail-title {
background-color: rgba(24, 144, 255, 0.1);
}
.tool-output .detail-title {
background-color: rgba(82, 196, 26, 0.1);
}
.json-display {
background-color: var(--bg-secondary);
border-radius: var(--border-radius-base);
overflow: hidden;
border: 1px solid var(--border-color);
}
.json-display.no-data {
color: var(--text-tertiary);
}
.json-display pre {
margin: 0;
padding: var(--spacing-3);
background-color: transparent;
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
}
.json-display code {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--text-primary);
line-height: var(--line-height-normal);
white-space: pre;
word-wrap: break-word;
}
</style>
\ No newline at end of file
...@@ -2,71 +2,94 @@ ...@@ -2,71 +2,94 @@
<div class="top-navbar"> <div class="top-navbar">
<div class="nav-container"> <div class="nav-container">
<!-- Logo/标题 --> <!-- Logo/标题 -->
<div class="nav-brand"> <div class="nav-brand" @click="$router.push('/')">
<span class="brand-icon">🤖</span> <span class="brand-icon">🤖</span>
<span class="brand-text">HiAgent</span> <span class="brand-text">HiAgent</span>
</div> </div>
<!-- 汉堡菜单按钮(小屏幕显示) -->
<div class="menu-toggle" @click="toggleMenu">
<el-icon :size="24">
<Menu />
</el-icon>
</div>
<!-- 水平菜单 --> <!-- 水平菜单 -->
<el-menu <el-menu :default-active="activeMenu" class="nav-menu" :class="{ 'menu-collapsed': !menuVisible }"
:default-active="activeMenu" mode="horizontal" @select="handleMenuSelect" router>
class="nav-menu"
mode="horizontal"
@select="handleMenuSelect"
router
>
<el-menu-item index="/dashboard"> <el-menu-item index="/dashboard">
<i class="el-icon-dashboard"></i> <el-icon><House /></el-icon>
<span>工作台</span> <span>工作台</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/chat">
<el-icon><ChatDotRound /></el-icon>
<span>智能对话</span>
</el-menu-item>
<el-menu-item index="/new-chat">
<el-icon><Plus /></el-icon>
<span>新聊天</span>
</el-menu-item>
<el-sub-menu index="agent-management">
<template #title>
<el-icon><Avatar /></el-icon>
<span>Agent管理</span>
</template>
<el-menu-item index="/agent"> <el-menu-item index="/agent">
<i class="el-icon-setting"></i> <el-icon><Setting /></el-icon>
<span>Agent管理</span> <span>Agent管理</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/tools"> <el-menu-item index="/tools">
<i class="el-icon-tools"></i> <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">
<i class="el-icon-document"></i> <el-icon><Document /></el-icon>
<span>知识库</span> <span>知识库</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/memory"> <el-menu-item index="/memory">
<i class="el-icon-tickets"></i> <el-icon><Tickets /></el-icon>
<span>记忆管理</span> <span>记忆管理</span>
</el-menu-item> </el-menu-item>
</el-sub-menu>
<el-sub-menu index="system-management">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统配置</span>
</template>
<el-menu-item index="/llm-config"> <el-menu-item index="/llm-config">
<i class="el-icon-setting"></i> <el-icon><Cpu /></el-icon>
<span>LLM配置</span> <span>LLM配置</span>
</el-menu-item> </el-menu-item>
<el-sub-menu index="auth-management"> <el-menu-item index="/oauth2-providers">
<template #title> <el-icon><Lock /></el-icon>
<i class="el-icon-lock"></i> <span>OAuth2配置</span>
<span>认证管理</span>
</template>
<el-menu-item index="/oauth2-providers">OAuth2配置</el-menu-item>
</el-sub-menu>
<el-menu-item index="/chat">
<i class="el-icon-chat-dot-round"></i>
<span>智能对话</span>
</el-menu-item> </el-menu-item>
<!-- 添加DOM同步页面导航项 -->
<el-menu-item index="/dom-sync"> <el-menu-item index="/dom-sync">
<i class="el-icon-monitor"></i> <el-icon><Monitor /></el-icon>
<span>DOM同步</span> <span>DOM同步</span>
</el-menu-item> </el-menu-item>
<!-- 添加新聊天页面导航项 --> </el-sub-menu>
<el-menu-item index="/new-chat">
<i class="el-icon-chat-line-round"></i>
<span>新聊天</span>
</el-menu-item>
</el-menu> </el-menu>
<!-- 用户信息和下拉菜单 --> <!-- 用户信息和下拉菜单 -->
<div class="user-menu"> <div class="user-menu">
<el-dropdown @command="handleCommand"> <el-dropdown @command="handleCommand" placement="bottom-end" :popper-options="{
modifiers: [
{
name: 'preventOverflow',
options: {
boundary: 'viewport'
}
}
]
}">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
<el-avatar :size="32" icon="UserFilled" /> <el-avatar :size="32" :icon="userInfo?.avatar || 'UserFilled'" />
<span class="username">{{ userInfo?.username || '未知用户' }}</span> <span class="username">{{ userInfo?.username || '未知用户' }}</span>
<el-icon class="el-icon--right"> <el-icon class="el-icon--right">
<arrow-down /> <arrow-down />
...@@ -74,9 +97,18 @@ ...@@ -74,9 +97,18 @@
</span> </span>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item> <el-dropdown-item command="profile">
<el-dropdown-item command="settings">设置</el-dropdown-item> <el-icon><User /></el-icon>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item> 个人资料
</el-dropdown-item>
<el-dropdown-item command="settings">
<el-icon><Setting /></el-icon>
设置
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
...@@ -86,17 +118,35 @@ ...@@ -86,17 +118,35 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue' import {
ArrowDown,
Menu,
House,
ChatDotRound,
Plus,
Avatar,
Setting,
Tools,
Timer,
Document,
Tickets,
Cpu,
Lock,
Monitor,
User,
SwitchButton
} from '@element-plus/icons-vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const userInfo = ref(null) const userInfo = ref(null)
const menuVisible = ref(true)
const activeMenu = computed(() => { const activeMenu = computed(() => {
const path = route.path const path = route.path
...@@ -110,7 +160,18 @@ const activeMenu = computed(() => { ...@@ -110,7 +160,18 @@ const activeMenu = computed(() => {
return '/oauth2-providers' return '/oauth2-providers'
} }
const menuItems = ['/dashboard', '/agent', '/tools', '/documents', '/memory', '/llm-config', '/chat', '/dom-sync', '/new-chat'] // 特殊处理Agent管理子菜单
if (path.startsWith('/agent') || path.startsWith('/tools') || path.startsWith('/timer') ||
path.startsWith('/documents') || path.startsWith('/memory')) {
return 'agent-management'
}
// 特殊处理系统配置子菜单
if (path.startsWith('/llm-config') || path.startsWith('/dom-sync')) {
return 'system-management'
}
const menuItems = ['/dashboard', '/chat', '/new-chat']
if (menuItems.includes(path)) { if (menuItems.includes(path)) {
return path return path
} }
...@@ -128,6 +189,10 @@ const handleMenuSelect = (key) => { ...@@ -128,6 +189,10 @@ const handleMenuSelect = (key) => {
if (key !== 'logout') { if (key !== 'logout') {
router.push(key) router.push(key)
} }
// 在小屏幕下,点击菜单项后自动折叠菜单
if (window.innerWidth <= 768) {
menuVisible.value = false
}
} }
const handleCommand = (command) => { const handleCommand = (command) => {
...@@ -138,88 +203,340 @@ const handleCommand = (command) => { ...@@ -138,88 +203,340 @@ const handleCommand = (command) => {
} }
} }
userInfo.value = authStore.userInfo const toggleMenu = () => {
menuVisible.value = !menuVisible.value
}
// 监听窗口大小变化,自动调整菜单显示状态
watch(
() => window.innerWidth,
(newWidth) => {
if (newWidth > 768) {
menuVisible.value = true
} else {
menuVisible.value = false
}
}
)
// 初始化时设置菜单状态
onMounted(() => {
if (window.innerWidth <= 768) {
menuVisible.value = false
}
userInfo.value = authStore.userInfo
})
</script> </script>
<style scoped> <style scoped>
.top-navbar { .top-navbar {
background-color: var(--bg-primary); background: linear-gradient(135deg, var(--bg-primary), var(--bg-secondary));
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-md);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: var(--z-index-sticky); z-index: var(--z-index-sticky);
transition: all var(--transition-normal);
height: 64px;
display: flex;
align-items: center;
} }
.nav-container { .nav-container {
display: flex; display: flex;
align-items: center; align-items: center;
height: 60px; justify-content: space-between;
padding: 0 20px; height: 100%;
gap: 30px; padding: 0 var(--spacing-5);
gap: var(--spacing-6);
max-width: 100%; max-width: 100%;
width: 100%;
position: relative;
} }
/* Logo样式 */
.nav-brand { .nav-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: var(--spacing-3);
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
transition: all var(--transition-normal);
cursor: pointer;
padding: var(--spacing-2) var(--spacing-3);
border-radius: var(--border-radius-lg);
}
.nav-brand:hover {
background-color: var(--bg-hover);
transform: translateY(-1px);
} }
.brand-icon { .brand-icon {
font-size: 24px; font-size: 28px;
transition: all var(--transition-normal);
animation: pulse 2s infinite;
} }
.brand-text { .brand-text {
font-size: 18px; font-size: 20px;
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--primary-color);
transition: all var(--transition-normal);
background: linear-gradient(135deg, var(--primary-color), var(--primary-color-light));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 汉堡菜单按钮 */
.menu-toggle {
display: none;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--border-radius-full);
cursor: pointer;
color: var(--text-primary); color: var(--text-primary);
transition: all var(--transition-normal);
flex-shrink: 0;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.menu-toggle:hover {
background-color: var(--bg-hover);
transform: scale(1.05);
box-shadow: var(--shadow-sm);
} }
/* 菜单容器 */
.nav-menu { .nav-menu {
flex: 1; flex: 1;
border: none !important; border: none !important;
transition: all var(--transition-normal);
overflow: hidden;
height: 100%;
display: flex;
align-items: center;
}
/* 菜单折叠状态 */
.nav-menu.menu-collapsed {
flex: 0;
overflow: hidden;
height: 0;
padding: 0;
opacity: 0;
} }
/* 菜单项样式 */
.nav-menu :deep(.el-menu-item), .nav-menu :deep(.el-menu-item),
.nav-menu :deep(.el-sub-menu__title) { .nav-menu :deep(.el-sub-menu__title) {
height: 60px; height: 48px;
line-height: 60px; line-height: 48px;
border: none !important; border: none !important;
transition: all var(--transition-normal);
padding: 0 var(--spacing-4) !important;
border-radius: var(--border-radius-lg);
margin: 0 var(--spacing-1);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
white-space: nowrap;
background-color: transparent;
color: var(--text-primary);
font-weight: var(--font-weight-medium);
} }
/* 菜单项hover效果 */
.nav-menu :deep(.el-menu-item:hover),
.nav-menu :deep(.el-sub-menu__title:hover) {
background-color: var(--bg-hover) !important;
color: var(--primary-color) !important;
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
/* 活动菜单项样式 */
.nav-menu :deep(.el-menu-item.is-active), .nav-menu :deep(.el-menu-item.is-active),
.nav-menu :deep(.el-sub-menu.is-active) { .nav-menu :deep(.el-sub-menu.is-active) {
border-bottom: 2px solid var(--color-primary) !important; background: linear-gradient(135deg, var(--primary-color), var(--primary-color-light)) !important;
color: var(--color-primary) !important; color: var(--text-inverse) !important;
border-bottom: none !important;
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
/* 活动菜单项文字颜色 */
.nav-menu :deep(.el-menu-item.is-active span),
.nav-menu :deep(.el-sub-menu.is-active span) {
color: var(--text-inverse) !important;
font-weight: var(--font-weight-semibold);
}
/* 下拉菜单样式 */
.nav-menu :deep(.el-sub-menu .el-menu) {
background-color: var(--bg-primary) !important;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
margin-top: var(--spacing-2) !important;
padding: var(--spacing-2) 0;
}
/* 下拉菜单项样式 */
.nav-menu :deep(.el-sub-menu .el-menu-item) {
height: 42px;
line-height: 42px;
margin: var(--spacing-1) var(--spacing-2);
padding: 0 var(--spacing-4) !important;
display: flex;
align-items: center;
justify-content: flex-start;
text-align: left;
white-space: nowrap;
border-radius: var(--border-radius-base);
transition: all var(--transition-normal);
}
.nav-menu :deep(.el-sub-menu .el-menu-item:hover) {
background-color: var(--bg-hover) !important;
color: var(--primary-color) !important;
transform: translateX(3px);
}
/* 图标样式 */
.nav-menu :deep(.el-menu-item .el-icon),
.nav-menu :deep(.el-sub-menu__title .el-icon) {
margin-right: var(--spacing-2);
width: 1em;
height: 1em;
transition: all var(--transition-normal);
} }
/* 用户菜单 */
.user-menu { .user-menu {
flex-shrink: 0; flex-shrink: 0;
transition: all var(--transition-normal);
} }
/* 下拉链接样式 */
.el-dropdown-link { .el-dropdown-link {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: var(--spacing-2);
cursor: pointer; cursor: pointer;
color: var(--text-primary); color: var(--text-primary);
padding: var(--spacing-2) var(--spacing-3);
border-radius: var(--border-radius-lg);
transition: all var(--transition-normal);
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
} }
/* 下拉链接hover效果 */
.el-dropdown-link:hover {
background-color: var(--bg-hover);
color: var(--primary-color);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* 用户名样式 */
.username { .username {
font-size: 14px; font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
max-width: 120px; max-width: 120px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
transition: all var(--transition-normal);
}
/* 响应式设计 */
@media (max-width: 1200px) {
.nav-container {
padding: 0 var(--spacing-4);
gap: var(--spacing-5);
}
.nav-menu :deep(.el-menu-item),
.nav-menu :deep(.el-sub-menu__title) {
padding: 0 var(--spacing-3) !important;
margin: 0 var(--spacing-1);
}
.nav-menu :deep(.el-menu-item span),
.nav-menu :deep(.el-sub-menu__title span) {
font-size: var(--font-size-sm);
}
.brand-text {
font-size: 18px;
}
}
@media (max-width: 992px) {
.nav-menu {
display: none;
}
.nav-menu.menu-collapsed {
display: flex;
flex: 1;
position: absolute;
top: 100%;
left: 0;
right: 0;
height: auto;
opacity: 1;
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
box-shadow: var(--shadow-lg);
padding: var(--spacing-3) 0;
z-index: var(--z-index-dropdown);
}
.menu-toggle {
display: flex;
}
.nav-menu.menu-collapsed :deep(.el-menu) {
display: flex;
flex-direction: column;
width: 100%;
}
.nav-menu.menu-collapsed :deep(.el-menu-item),
.nav-menu.menu-collapsed :deep(.el-sub-menu__title) {
height: 50px;
line-height: 50px;
margin: var(--spacing-1) var(--spacing-4);
border-radius: var(--border-radius-lg);
display: flex;
align-items: center;
justify-content: flex-start;
text-align: left;
white-space: nowrap;
padding: 0 var(--spacing-4) !important;
}
.username {
max-width: 100px;
}
.nav-brand {
padding: var(--spacing-1) var(--spacing-2);
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.nav-container { .nav-container {
padding: 0 10px; padding: 0 var(--spacing-3);
gap: 15px; gap: var(--spacing-4);
} }
.brand-text { .brand-text {
...@@ -229,5 +546,58 @@ userInfo.value = authStore.userInfo ...@@ -229,5 +546,58 @@ userInfo.value = authStore.userInfo
.username { .username {
display: none; display: none;
} }
.nav-brand {
gap: var(--spacing-2);
padding: var(--spacing-1) var(--spacing-2);
}
.brand-icon {
font-size: 24px;
}
.menu-toggle {
width: 40px;
height: 40px;
}
}
@media (max-width: 576px) {
.nav-container {
padding: 0 var(--spacing-2);
gap: var(--spacing-3);
}
.brand-text {
font-size: 14px;
}
.brand-icon {
font-size: 20px;
}
.menu-toggle {
width: 36px;
height: 36px;
}
.nav-menu.menu-collapsed :deep(.el-menu-item),
.nav-menu.menu-collapsed :deep(.el-sub-menu__title) {
margin: var(--spacing-1) var(--spacing-3);
padding: 0 var(--spacing-3) !important;
}
}
/* 动画效果 */
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
} }
</style> </style>
\ No newline at end of file
...@@ -20,21 +20,19 @@ const activeTab = ref('timeline') ...@@ -20,21 +20,19 @@ const activeTab = ref('timeline')
const timelinePanel = ref() const timelinePanel = ref()
const webBrowser = ref() const webBrowser = ref()
// 定义embed事件的详细信息类型
interface EmbedEventDetail {
url: string
type: string
title: string
htmlContent?: string
}
// 监听embed事件 // 监听embed事件
const handleEmbedEvent = (e: Event) => { const handleEmbedEvent = (e: Event) => {
const customEvent = e as CustomEvent const customEvent = e as CustomEvent<EmbedEventDetail>
const { url, type, title, htmlContent } = customEvent.detail const { url, type, title, htmlContent } = customEvent.detail
// 添加详细日志便于问题定位(遵循用户偏好:接口异常时强制日志输出)
console.log('[WorkArea] 接收embed事件完整信息:', {
url: url,
type: type,
title: title,
hasHtmlContent: !!htmlContent,
htmlSize: htmlContent?.length || 0,
timestamp: new Date().toISOString()
})
// 验证URL有效性 // 验证URL有效性
if (!url || typeof url !== 'string' || url.trim() === '') { if (!url || typeof url !== 'string' || url.trim() === '') {
console.error('[WorkArea] embed事件URL验证失败:', { console.error('[WorkArea] embed事件URL验证失败:', {
...@@ -48,7 +46,6 @@ const handleEmbedEvent = (e: Event) => { ...@@ -48,7 +46,6 @@ const handleEmbedEvent = (e: Event) => {
// 自动切换到浏览器标签页 // 自动切换到浏览器标签页
activeTab.value = 'browser' activeTab.value = 'browser'
console.log('[WorkArea] 切换到browser标签页')
// 调用WebpageBrowser的导航方法,传递完整信息 // 调用WebpageBrowser的导航方法,传递完整信息
if (webBrowser.value && typeof webBrowser.value.navigateToUrl === 'function') { if (webBrowser.value && typeof webBrowser.value.navigateToUrl === 'function') {
...@@ -57,7 +54,6 @@ const handleEmbedEvent = (e: Event) => { ...@@ -57,7 +54,6 @@ const handleEmbedEvent = (e: Event) => {
embedType: type, embedType: type,
embedTitle: title embedTitle: title
}) })
console.log('[WorkArea] 调用navigateToUrl成功')
} else { } else {
console.error('[WorkArea] webBrowser引用无效或navigateToUrl方法不存在', { console.error('[WorkArea] webBrowser引用无效或navigateToUrl方法不存在', {
hasWebBrowser: !!webBrowser.value, hasWebBrowser: !!webBrowser.value,
...@@ -68,19 +64,26 @@ const handleEmbedEvent = (e: Event) => { ...@@ -68,19 +64,26 @@ const handleEmbedEvent = (e: Event) => {
onMounted(() => { onMounted(() => {
// 监听embed事件 // 监听embed事件
window.addEventListener('embed-event', handleEmbedEvent) window.addEventListener('embed-event', handleEmbedEvent as EventListener)
}) })
onUnmounted(() => { onUnmounted(() => {
// 移除事件监听 // 移除事件监听
window.removeEventListener('embed-event', handleEmbedEvent) window.removeEventListener('embed-event', handleEmbedEvent as EventListener)
}) })
// 暴露方法供父组件调用 // 暴露方法供父组件调用
defineExpose({ defineExpose({
timelinePanel, timelinePanel,
webBrowser, webBrowser,
activeTab activeTab,
// 提供切换tab的方法
switchToTimeline: () => {
activeTab.value = 'timeline'
},
switchToBrowser: () => {
activeTab.value = 'browser'
}
}) })
</script> </script>
......
...@@ -217,10 +217,16 @@ const getToolCount = (agent) => { ...@@ -217,10 +217,16 @@ const getToolCount = (agent) => {
try { try {
// 如果tools是JSON字符串,需要解析 // 如果tools是JSON字符串,需要解析
if (typeof agent.tools === 'string') { if (typeof agent.tools === 'string') {
// 处理空字符串情况
if (agent.tools.trim() === '') return 0
const tools = JSON.parse(agent.tools) const tools = JSON.parse(agent.tools)
return Array.isArray(tools) ? tools.length : 0 return Array.isArray(tools) ? tools.length : (tools ? 1 : 0)
} else if (Array.isArray(agent.tools)) { } else if (Array.isArray(agent.tools)) {
return agent.tools.length return agent.tools.length
} else if (typeof agent.tools === 'object' && agent.tools !== null) {
// 如果是对象,返回1
return 1
} }
} catch (e) { } catch (e) {
console.error('解析工具配置失败:', e) console.error('解析工具配置失败:', e)
...@@ -236,6 +242,9 @@ const getToolNames = (agent) => { ...@@ -236,6 +242,9 @@ const getToolNames = (agent) => {
try { try {
// 如果tools是JSON字符串,需要解析 // 如果tools是JSON字符串,需要解析
if (typeof agent.tools === 'string') { if (typeof agent.tools === 'string') {
// 处理空字符串情况
if (agent.tools.trim() === '') return []
// 处理可能的双重转义情况 // 处理可能的双重转义情况
let toolsString = agent.tools; let toolsString = agent.tools;
// 如果字符串看起来像是被转义过的JSON字符串,则先解码 // 如果字符串看起来像是被转义过的JSON字符串,则先解码
...@@ -253,7 +262,7 @@ const getToolNames = (agent) => { ...@@ -253,7 +262,7 @@ const getToolNames = (agent) => {
return String(tool); return String(tool);
} }
}).filter(name => name.length > 0); }).filter(name => name.length > 0);
} else { } else if (tools !== null && tools !== undefined) {
return [String(tools)]; return [String(tools)];
} }
} else if (Array.isArray(agent.tools)) { } else if (Array.isArray(agent.tools)) {
...@@ -265,6 +274,9 @@ const getToolNames = (agent) => { ...@@ -265,6 +274,9 @@ const getToolNames = (agent) => {
return String(tool); return String(tool);
} }
}).filter(name => name.length > 0); }).filter(name => name.length > 0);
} else if (typeof agent.tools === 'object' && agent.tools !== null) {
// 如果是单个对象
return [agent.tools.name || ''];
} }
} catch (e) { } catch (e) {
console.error('解析工具配置失败:', e) console.error('解析工具配置失败:', e)
...@@ -419,16 +431,18 @@ const editAgent = async (agent) => { ...@@ -419,16 +431,18 @@ const editAgent = async (agent) => {
// 使用nextTick确保在DOM更新后设置表单数据 // 使用nextTick确保在DOM更新后设置表单数据
await nextTick() await nextTick()
// 深拷贝避免直接修改原对象
Object.assign(form, { ...agent })
// 处理工具配置 - 简化逻辑 // 处理工具配置 - 简化逻辑
// 先处理tools,避免将字符串直接赋值给form.tools
let toolsArray = [] let toolsArray = []
if (agent.tools) { if (agent.tools) {
try { try {
console.log('解析Agent工具配置:', agent.tools, typeof agent.tools) console.log('解析Agent工具配置:', agent.tools, typeof agent.tools)
// 如果tools是JSON字符串,需要解析为数组 // 如果tools是JSON字符串,需要解析为数组
if (typeof agent.tools === 'string') { if (typeof agent.tools === 'string') {
// 处理空字符串
if (agent.tools.trim() === '') {
toolsArray = []
} else {
// 处理可能的双重转义情况 // 处理可能的双重转义情况
let toolsString = agent.tools; let toolsString = agent.tools;
// 如果字符串看起来像是被转义过的JSON字符串,则先解码 // 如果字符串看起来像是被转义过的JSON字符串,则先解码
...@@ -444,8 +458,12 @@ const editAgent = async (agent) => { ...@@ -444,8 +458,12 @@ const editAgent = async (agent) => {
// 如果不是JSON格式,可能是单个工具名称,转换为数组 // 如果不是JSON格式,可能是单个工具名称,转换为数组
toolsArray = [toolsString] toolsArray = [toolsString]
} }
}
} else if (Array.isArray(agent.tools)) { } else if (Array.isArray(agent.tools)) {
toolsArray = [...agent.tools] toolsArray = [...agent.tools]
} else if (typeof agent.tools === 'object' && agent.tools !== null) {
// 如果是单个对象
toolsArray = [agent.tools]
} }
console.log('解析后的工具数组:', toolsArray) console.log('解析后的工具数组:', toolsArray)
} catch (e) { } catch (e) {
...@@ -581,6 +599,8 @@ const resetForm = () => { ...@@ -581,6 +599,8 @@ const resetForm = () => {
padding: var(--spacing-5); padding: var(--spacing-5);
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
min-height: 100%; min-height: 100%;
height: 100%;
overflow-y: auto;
} }
.management-page h2 { .management-page h2 {
......
...@@ -64,6 +64,7 @@ const stopResize = () => { ...@@ -64,6 +64,7 @@ const stopResize = () => {
document.removeEventListener('mouseup', stopResize) document.removeEventListener('mouseup', stopResize)
} }
// 组件卸载时清理事件监听器
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('mousemove', handleResize) document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize) document.removeEventListener('mouseup', stopResize)
......
...@@ -115,23 +115,27 @@ const getActiveAgentCount = () => { ...@@ -115,23 +115,27 @@ const getActiveAgentCount = () => {
return agents.value.filter(agent => agent.status === 'active').length return agents.value.filter(agent => agent.status === 'active').length
} }
onMounted(async () => { const loadData = async () => {
try { try {
// 获取用户的Agent列表 // 获取用户的Agent列表
const agentRes = await request.get('/agent') const agentRes = await request.get('/agent')
agents.value = agentRes.data.data || [] agents.value = Array.isArray(agentRes.data.data) ? agentRes.data.data : []
// 获取工具列表 // 获取工具列表
const toolRes = await request.get('/tools') const toolRes = await request.get('/tools')
tools.value = toolRes.data.data || [] tools.value = Array.isArray(toolRes.data.data) ? toolRes.data.data : []
// 获取文档列表 // 获取文档列表
const docRes = await request.get('/rag/documents') const docRes = await request.get('/rag/documents')
documents.value = docRes.data.data?.records || [] documents.value = Array.isArray(docRes.data.data?.records) ? docRes.data.data.records : []
} catch (error) { } catch (error: any) {
console.error('获取数据失败:', error) console.error('获取数据失败:', error)
ElMessage.error('获取数据失败: ' + (error.response?.data?.message || error.message)) ElMessage.error('获取数据失败: ' + (error.response?.data?.message || error.message || '网络错误'))
} }
}
onMounted(() => {
loadData()
}) })
</script> </script>
......
...@@ -46,20 +46,7 @@ import { useAuthStore } from '@/stores/auth' ...@@ -46,20 +46,7 @@ import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
// 表格数据 // 表格数据
const documents = ref([ const documents = ref<any[]>([])
{
id: 1,
name: '产品使用手册.pdf',
size: '2.4 MB',
uploadTime: '2024-01-15 14:30:22'
},
{
id: 2,
name: '技术规范文档.docx',
size: '1.8 MB',
uploadTime: '2024-01-10 09:15:45'
}
])
// 上传头信息 // 上传头信息
const uploadHeaders = ref({ const uploadHeaders = ref({
...@@ -67,25 +54,44 @@ const uploadHeaders = ref({ ...@@ -67,25 +54,44 @@ const uploadHeaders = ref({
}) })
// 处理上传成功 // 处理上传成功
const handleUploadSuccess = (response, uploadFile) => { const handleUploadSuccess = (response: any, uploadFile: any) => {
if (response.code === 200) {
ElMessage.success('文件上传成功') ElMessage.success('文件上传成功')
// 刷新文档列表 // 刷新文档列表
loadDocuments() loadDocuments()
} else {
ElMessage.error(response.message || '文件上传失败')
}
} }
// 加载文档列表 // 加载文档列表
const loadDocuments = () => { const loadDocuments = async () => {
// 这里应该调用API获取文档列表 try {
console.log('加载文档列表') const response = await request.get('/documents')
if (response.data.code === 200) {
documents.value = response.data.data || []
} else {
ElMessage.error(response.data.message || '获取文档列表失败')
}
} catch (error: any) {
console.error('获取文档列表失败:', error)
ElMessage.error('获取文档列表失败: ' + (error.message || '网络错误'))
}
} }
// 预览文档 // 预览文档
const handlePreview = (row) => { const handlePreview = (row: any) => {
ElMessage.info(`预览文档: ${row.name}`) try {
// 打开新窗口预览文档
window.open(`/api/documents/preview/${row.id}`, '_blank')
} catch (error: any) {
console.error('预览文档失败:', error)
ElMessage.error('预览文档失败: ' + (error.message || '未知错误'))
}
} }
// 删除文档 // 删除文档
const handleDelete = (row) => { const handleDelete = (row: any) => {
ElMessageBox.confirm( ElMessageBox.confirm(
`确定要删除文档 "${row.name}" 吗?`, `确定要删除文档 "${row.name}" 吗?`,
'确认删除', '确认删除',
...@@ -95,14 +101,23 @@ const handleDelete = (row) => { ...@@ -95,14 +101,23 @@ const handleDelete = (row) => {
type: 'warning', type: 'warning',
} }
) )
.then(() => { .then(async () => {
// 调用API删除文档 try {
const response = await request.delete(`/documents/${row.id}`)
if (response.data.code === 200) {
ElMessage.success('删除成功') ElMessage.success('删除成功')
// 从列表中移除 // 从列表中移除
const index = documents.value.findIndex(item => item.id === row.id) const index = documents.value.findIndex(item => item.id === row.id)
if (index > -1) { if (index > -1) {
documents.value.splice(index, 1) documents.value.splice(index, 1)
} }
} else {
ElMessage.error(response.data.message || '删除失败')
}
} catch (error: any) {
console.error('删除文档失败:', error)
ElMessage.error('删除失败: ' + (error.message || '网络错误'))
}
}) })
.catch(() => { .catch(() => {
ElMessage.info('已取消删除') ElMessage.info('已取消删除')
...@@ -120,6 +135,8 @@ onMounted(() => { ...@@ -120,6 +135,8 @@ onMounted(() => {
padding: var(--spacing-5); padding: var(--spacing-5);
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
min-height: 100%; min-height: 100%;
height: 100%;
overflow-y: auto;
} }
.document-management h1 { .document-management h1 {
......
...@@ -59,7 +59,7 @@ import DomSyncViewer from '@/components/DomSyncViewer.vue' ...@@ -59,7 +59,7 @@ import DomSyncViewer from '@/components/DomSyncViewer.vue'
// 定义DomSyncViewer组件实例的类型 // 定义DomSyncViewer组件实例的类型
interface DomSyncViewerInstance { interface DomSyncViewerInstance {
urlInput: Ref<string> urlInput: Ref<string>
navigateToUrl: () => void navigateToUrl: (url: string) => void
} }
// 响应式数据 // 响应式数据
...@@ -74,13 +74,13 @@ const domSyncViewerRef = ref<DomSyncViewerInstance | null>(null) ...@@ -74,13 +74,13 @@ const domSyncViewerRef = ref<DomSyncViewerInstance | null>(null)
const navigateToUrl = () => { const navigateToUrl = () => {
// 验证URL格式 // 验证URL格式
if (!targetUrl.value || targetUrl.value.trim() === '') { if (!targetUrl.value || targetUrl.value.trim() === '') {
alert('请输入有效的URL'); ElMessage.warning('请输入有效的URL')
return; return
} }
if (!targetUrl.value.toLowerCase().startsWith('http://') && !targetUrl.value.toLowerCase().startsWith('https://')) { if (!targetUrl.value.toLowerCase().startsWith('http://') && !targetUrl.value.toLowerCase().startsWith('https://')) {
alert('URL必须以http://或https://开头'); ElMessage.warning('URL必须以http://或https://开头')
return; return
} }
// 通过ref调用DomSyncViewer组件的navigateToUrl方法来导航到指定URL // 通过ref调用DomSyncViewer组件的navigateToUrl方法来导航到指定URL
...@@ -89,6 +89,7 @@ const navigateToUrl = () => { ...@@ -89,6 +89,7 @@ const navigateToUrl = () => {
domSyncViewerRef.value.navigateToUrl(targetUrl.value) domSyncViewerRef.value.navigateToUrl(targetUrl.value)
} else { } else {
console.error('DomSyncViewer组件引用未找到') console.error('DomSyncViewer组件引用未找到')
ElMessage.error('DomSyncViewer组件未正确加载')
} }
} }
......
...@@ -233,12 +233,13 @@ const fetchLlmConfigs = async () => { ...@@ -233,12 +233,13 @@ const fetchLlmConfigs = async () => {
}) })
if (response.data.code === 200) { if (response.data.code === 200) {
llmConfigs.value = response.data.data.records llmConfigs.value = response.data.data.records || []
pagination.total = response.data.data.total pagination.total = response.data.data.total || 0
} else { } else {
ElMessage.error(response.data.message || '获取配置列表失败') ElMessage.error(response.data.message || '获取配置列表失败')
} }
} catch (error) { } catch (error: any) {
console.error('获取LLM配置列表失败:', error)
// 检查是否是认证错误 // 检查是否是认证错误
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
// 只有在未登录状态下才跳转到登录页面 // 只有在未登录状态下才跳转到登录页面
...@@ -250,7 +251,7 @@ const fetchLlmConfigs = async () => { ...@@ -250,7 +251,7 @@ const fetchLlmConfigs = async () => {
router.push('/login') router.push('/login')
} }
} else { } else {
ElMessage.error('获取配置列表失败: ' + error.message) ElMessage.error('获取配置列表失败: ' + (error.message || '网络错误'))
} }
} finally { } finally {
loading.value = false loading.value = false
...@@ -409,7 +410,8 @@ const saveConfig = async () => { ...@@ -409,7 +410,8 @@ const saveConfig = async () => {
} else { } else {
ElMessage.error(response.data.message || (form.id ? '更新失败' : '创建失败')) ElMessage.error(response.data.message || (form.id ? '更新失败' : '创建失败'))
} }
} catch (error) { } catch (error: any) {
console.error('保存LLM配置失败:', error)
// 检查是否是认证错误 // 检查是否是认证错误
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
if (!authStore.token) { if (!authStore.token) {
...@@ -420,7 +422,7 @@ const saveConfig = async () => { ...@@ -420,7 +422,7 @@ const saveConfig = async () => {
router.push('/login') router.push('/login')
} }
} else { } else {
ElMessage.error((form.id ? '更新失败' : '创建失败') + ': ' + error.message) ElMessage.error((form.id ? '更新失败' : '创建失败') + ': ' + (error.message || '网络错误'))
} }
} }
}) })
...@@ -447,6 +449,9 @@ onMounted(() => { ...@@ -447,6 +449,9 @@ onMounted(() => {
<style scoped> <style scoped>
.llm-config-management { .llm-config-management {
padding: 20px; padding: 20px;
min-height: 100%;
height: 100%;
overflow-y: auto;
} }
.card-header { .card-header {
......
...@@ -78,10 +78,9 @@ const authStore = useAuthStore() ...@@ -78,10 +78,9 @@ const authStore = useAuthStore()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const loading = ref<boolean>(false) const loading = ref<boolean>(false)
const availableOAuth2Providers = ref<Array<{name: string, displayName: string}>>([{ const availableOAuth2Providers = ref<Array<{name: string, displayName: string}>>([
name: 'github', { name: 'github', displayName: 'GitHub 登录' }
displayName: 'GitHub 登录' ])
}])
interface LoginForm { interface LoginForm {
username: string username: string
...@@ -103,7 +102,7 @@ const rules = reactive<FormRules<LoginForm>>({ ...@@ -103,7 +102,7 @@ const rules = reactive<FormRules<LoginForm>>({
}) })
onMounted(() => { onMounted(() => {
// 检查是否有来自 OAuth2 回调的字件 // 检查是否有来自 OAuth2 回调的参数
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const token = params.get('token') const token = params.get('token')
const method = params.get('method') const method = params.get('method')
...@@ -122,15 +121,17 @@ onMounted(() => { ...@@ -122,15 +121,17 @@ onMounted(() => {
const handleLogin = async () => { const handleLogin = async () => {
if (!formRef.value) return if (!formRef.value) return
try {
await formRef.value.validate() await formRef.value.validate()
loading.value = true loading.value = true
try {
await authStore.login(form.username, form.password) await authStore.login(form.username, form.password)
ElMessage.success('登录成功') ElMessage.success('登录成功')
router.push('/agent/chat') router.push('/agent/chat')
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.message) console.error('登录失败:', error)
ElMessage.error(error.message || '登录失败,请检查用户名和密码')
} finally { } finally {
loading.value = false loading.value = false
} }
...@@ -138,13 +139,15 @@ const handleLogin = async () => { ...@@ -138,13 +139,15 @@ const handleLogin = async () => {
/** /**
* 处理 OAuth2 登录 * 处理 OAuth2 登录
* @param providerName OAuth2 提供商名称
*/ */
const handleOAuth2Login = (providerName: string) => { const handleOAuth2Login = (providerName: string) => {
try { try {
// 渐进到后端授权端点,后端会渐进重定向到指定的 OAuth2 提供者 // 重定向到后端授权端点,后端会重定向到指定的 OAuth2 提供者
window.location.href = `/api/v1/auth/oauth2/authorize?providerName=${providerName}` window.location.href = `/api/v1/auth/oauth2/authorize?providerName=${encodeURIComponent(providerName)}`
} catch (error: any) { } catch (error: any) {
ElMessage.error('执行 OAuth2 登录失败: ' + error.message) console.error('OAuth2 登录失败:', error)
ElMessage.error('执行 OAuth2 登录失败: ' + (error.message || '未知错误'))
} }
} }
</script> </script>
......
...@@ -71,61 +71,33 @@ import { ElMessage, ElMessageBox } from 'element-plus' ...@@ -71,61 +71,33 @@ import { ElMessage, ElMessageBox } from 'element-plus'
const activeTab = ref('dialogue') const activeTab = ref('dialogue')
// 对话记忆数据 // 对话记忆数据
const dialogueMemories = ref([ const dialogueMemories = ref<any[]>([])
{
id: 1,
agentName: '客服助手',
userName: '张三',
lastActive: '2024-01-15 14:30:22',
messageCount: 12
},
{
id: 2,
agentName: '技术支持',
userName: '李四',
lastActive: '2024-01-14 09:15:45',
messageCount: 8
}
])
// 知识记忆数据 // 知识记忆数据
const knowledgeMemories = ref([ const knowledgeMemories = ref<any[]>([])
{
id: 1,
title: '产品FAQ',
category: '产品',
createTime: '2024-01-10 10:30:00',
source: '人工录入'
},
{
id: 2,
title: '技术文档',
category: '技术',
createTime: '2024-01-08 15:45:22',
source: '文档上传'
}
])
// 对话框相关 // 对话框相关
const dialogVisible = ref(false) const dialogVisible = ref(false)
const selectedMemory = ref(null) const selectedMemory = ref(null)
// 处理查看详细信息 // 处理查看详细信息
const handleViewDetails = (row) => { const handleViewDetails = async (row: any) => {
selectedMemory.value = { try {
...row, const response = await request.get(`/memory/dialogue/${row.id}`)
messages: [ if (response.data.code === 200) {
{ sender: '用户', time: '2024-01-15 14:25:10', content: '你好,我想了解一下你们的产品' }, selectedMemory.value = response.data.data
{ sender: '客服助手', time: '2024-01-15 14:25:30', content: '您好!很高兴为您服务。我们的产品具有以下特点...' },
{ sender: '用户', time: '2024-01-15 14:26:15', content: '价格方面有什么优惠吗?' },
{ sender: '客服助手', time: '2024-01-15 14:26:40', content: '目前我们有一个限时优惠活动...' }
]
}
dialogVisible.value = true dialogVisible.value = true
} else {
ElMessage.error(response.data.message || '获取记忆详情失败')
}
} catch (error: any) {
console.error('获取记忆详情失败:', error)
ElMessage.error('获取记忆详情失败: ' + (error.message || '网络错误'))
}
} }
// 处理清空记忆 // 处理清空记忆
const handleClear = (row) => { const handleClear = (row: any) => {
ElMessageBox.confirm( ElMessageBox.confirm(
`确定要清空与"${row.agentName}"的对话记忆吗?此操作不可恢复。`, `确定要清空与"${row.agentName}"的对话记忆吗?此操作不可恢复。`,
'确认清空', '确认清空',
...@@ -135,8 +107,20 @@ const handleClear = (row) => { ...@@ -135,8 +107,20 @@ const handleClear = (row) => {
type: 'warning', type: 'warning',
} }
) )
.then(() => { .then(async () => {
try {
const response = await request.delete(`/memory/dialogue/${row.id}`)
if (response.data.code === 200) {
ElMessage.success('对话记忆已清空') ElMessage.success('对话记忆已清空')
// 重新加载数据
loadDialogueMemories()
} else {
ElMessage.error(response.data.message || '清空记忆失败')
}
} catch (error: any) {
console.error('清空记忆失败:', error)
ElMessage.error('清空记忆失败: ' + (error.message || '网络错误'))
}
}) })
.catch(() => { .catch(() => {
ElMessage.info('已取消操作') ElMessage.info('已取消操作')
...@@ -144,12 +128,13 @@ const handleClear = (row) => { ...@@ -144,12 +128,13 @@ const handleClear = (row) => {
} }
// 处理编辑知识记忆 // 处理编辑知识记忆
const handleEdit = (row) => { const handleEdit = (row: any) => {
ElMessage.info(`编辑记忆: ${row.title}`) ElMessage.info(`编辑记忆: ${row.title}`)
// TODO: 实现编辑功能
} }
// 处理删除知识记忆 // 处理删除知识记忆
const handleDelete = (row) => { const handleDelete = (row: any) => {
ElMessageBox.confirm( ElMessageBox.confirm(
`确定要删除知识记忆"${row.title}"吗?此操作不可恢复。`, `确定要删除知识记忆"${row.title}"吗?此操作不可恢复。`,
'确认删除', '确认删除',
...@@ -159,13 +144,23 @@ const handleDelete = (row) => { ...@@ -159,13 +144,23 @@ const handleDelete = (row) => {
type: 'warning', type: 'warning',
} }
) )
.then(() => { .then(async () => {
try {
const response = await request.delete(`/memory/knowledge/${row.id}`)
if (response.data.code === 200) {
// 从列表中移除 // 从列表中移除
const index = knowledgeMemories.value.findIndex(item => item.id === row.id) const index = knowledgeMemories.value.findIndex(item => item.id === row.id)
if (index > -1) { if (index > -1) {
knowledgeMemories.value.splice(index, 1) knowledgeMemories.value.splice(index, 1)
} }
ElMessage.success('删除成功') ElMessage.success('删除成功')
} else {
ElMessage.error(response.data.message || '删除失败')
}
} catch (error: any) {
console.error('删除知识记忆失败:', error)
ElMessage.error('删除失败: ' + (error.message || '网络错误'))
}
}) })
.catch(() => { .catch(() => {
ElMessage.info('已取消删除') ElMessage.info('已取消删除')
...@@ -187,15 +182,49 @@ const handleImport = () => { ...@@ -187,15 +182,49 @@ const handleImport = () => {
ElMessage.info('导入记忆') ElMessage.info('导入记忆')
} }
// 加载对话记忆数据
const loadDialogueMemories = async () => {
try {
const response = await request.get('/memory/dialogue')
if (response.data.code === 200) {
dialogueMemories.value = response.data.data || []
} else {
ElMessage.error(response.data.message || '获取对话记忆数据失败')
}
} catch (error: any) {
console.error('获取对话记忆数据失败:', error)
ElMessage.error('获取对话记忆数据失败: ' + (error.message || '网络错误'))
}
}
// 加载知识记忆数据
const loadKnowledgeMemories = async () => {
try {
const response = await request.get('/memory/knowledge')
if (response.data.code === 200) {
knowledgeMemories.value = response.data.data || []
} else {
ElMessage.error(response.data.message || '获取知识记忆数据失败')
}
} catch (error: any) {
console.error('获取知识记忆数据失败:', error)
ElMessage.error('获取知识记忆数据失败: ' + (error.message || '网络错误'))
}
}
// 页面加载时获取记忆数据 // 页面加载时获取记忆数据
onMounted(() => { onMounted(() => {
console.log('加载记忆数据') loadDialogueMemories()
loadKnowledgeMemories()
}) })
</script> </script>
<style scoped> <style scoped>
.memory-management { .memory-management {
padding: 20px; padding: 20px;
min-height: 100%;
height: 100%;
overflow-y: auto;
} }
.message-item { .message-item {
......
...@@ -64,6 +64,7 @@ const stopResize = () => { ...@@ -64,6 +64,7 @@ const stopResize = () => {
document.removeEventListener('mouseup', stopResize) document.removeEventListener('mouseup', stopResize)
} }
// 组件卸载时清理事件监听器
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('mousemove', handleResize) document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize) document.removeEventListener('mouseup', stopResize)
......
...@@ -221,14 +221,14 @@ const fetchProviders = async () => { ...@@ -221,14 +221,14 @@ const fetchProviders = async () => {
}) })
if (response.data.code === 200) { if (response.data.code === 200) {
providers.value = response.data.data.records providers.value = response.data.data.records || []
pagination.total = response.data.data.total pagination.total = response.data.data.total || 0
} else { } else {
ElMessage.error(response.data.message || '获取提供商列表失败') ElMessage.error(response.data.message || '获取提供商列表失败')
} }
} catch (error) { } catch (error: any) {
console.error('获取提供商列表失败:', error) console.error('获取提供商列表失败:', error)
ElMessage.error('获取提供商列表失败: ' + (error.response?.data?.message || error.message)) ElMessage.error('获取提供商列表失败: ' + (error.response?.data?.message || error.message || '网络错误'))
} finally { } finally {
loading.value = false loading.value = false
} }
...@@ -330,6 +330,8 @@ const handleChangeStatus = async (row) => { ...@@ -330,6 +330,8 @@ const handleChangeStatus = async (row) => {
// 保存提供商配置 // 保存提供商配置
const saveProvider = async () => { const saveProvider = async () => {
if (!formRef.value) return
formRef.value.validate(async (valid) => { formRef.value.validate(async (valid) => {
if (!valid) return if (!valid) return
...@@ -350,9 +352,9 @@ const saveProvider = async () => { ...@@ -350,9 +352,9 @@ const saveProvider = async () => {
} else { } else {
ElMessage.error(response.data.message || (form.id ? '更新失败' : '创建失败')) ElMessage.error(response.data.message || (form.id ? '更新失败' : '创建失败'))
} }
} catch (error) { } catch (error: any) {
console.error('保存失败:', error) console.error('保存OAuth2提供商配置失败:', error)
ElMessage.error((form.id ? '更新失败' : '创建失败') + ': ' + (error.response?.data?.message || error.message)) ElMessage.error((form.id ? '更新失败' : '创建失败') + ': ' + (error.response?.data?.message || error.message || '网络错误'))
} }
}) })
} }
...@@ -377,6 +379,9 @@ onMounted(() => { ...@@ -377,6 +379,9 @@ onMounted(() => {
<style scoped> <style scoped>
.oauth2-provider-management { .oauth2-provider-management {
padding: 20px; padding: 20px;
min-height: 100%;
height: 100%;
overflow-y: auto;
} }
.card-header { .card-header {
......
...@@ -94,21 +94,15 @@ const handleRegister = async () => { ...@@ -94,21 +94,15 @@ const handleRegister = async () => {
// 表单验证通过,开始注册流程 // 表单验证通过,开始注册流程
loading.value = true loading.value = true
try {
await authStore.register(form.username, form.password, form.email) await authStore.register(form.username, form.password, form.email)
ElMessage.success('注册成功,请登录') ElMessage.success('注册成功,请登录')
router.push('/login') router.push('/login')
} catch (error) { } catch (error: any) {
console.error('注册失败:', error)
ElMessage.error(error.message || '注册失败') ElMessage.error(error.message || '注册失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} catch (error) {
// 表单验证失败,Element Plus 会自动显示错误信息
// 这里可以添加额外的日志记录或其他处理
console.log('表单验证失败:', error)
// 不需要显示错误消息,因为 Element Plus 已经自动处理了
}
} }
</script> </script>
......
<template>
<div class="timer-history-page">
<h2>定时器执行历史</h2>
<!-- 筛选条件 -->
<el-card shadow="hover" class="filter-card">
<el-form :model="filterForm" label-position="top" inline>
<el-form-item label="定时器ID">
<el-input v-model="filterForm.timerId" placeholder="请输入定时器ID" style="width: 200px" />
</el-form-item>
<el-form-item label="执行结果">
<el-select v-model="filterForm.success" placeholder="请选择执行结果" style="width: 150px">
<el-option label="全部" value="" />
<el-option label="成功" :value="1" />
<el-option label="失败" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="执行时间">
<el-date-picker
v-model="filterForm.dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 400px"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchHistory">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 执行历史表格 -->
<el-card shadow="hover" class="history-table-card">
<el-table
:data="historyList"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="timerName" label="定时器名称" min-width="150" />
<el-table-column prop="executionTime" label="执行时间" width="200" :formatter="formatDateTime" />
<el-table-column prop="success" label="执行结果" width="100">
<template #default="{ row }">
<el-tag :type="row.success === 1 ? 'success' : 'danger'">
{{ row.success === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="executionDuration" label="执行时长" width="120">
<template #default="{ row }">
{{ row.executionDuration }} ms
</template>
</el-table-column>
<el-table-column prop="result" label="执行结果" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<div class="result-content">
{{ row.result ? (row.result.length > 100 ? row.result.substring(0, 100) + '...' : row.result) : '-' }}
</div>
</template>
</el-table-column>
<el-table-column prop="errorMessage" label="错误信息" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<div class="error-content" v-if="row.errorMessage">
{{ row.errorMessage.length > 100 ? row.errorMessage.substring(0, 100) + '...' : row.errorMessage }}
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="showDetail(row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 执行详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="执行详情"
width="800px"
destroy-on-close
>
<div v-if="selectedHistory" class="detail-content">
<el-descriptions :column="1" border>
<el-descriptions-item label="定时器名称">{{ selectedHistory.timerName }}</el-descriptions-item>
<el-descriptions-item label="执行时间">{{ formatDateTime(selectedHistory.executionTime) }}</el-descriptions-item>
<el-descriptions-item label="执行结果">
<el-tag :type="selectedHistory.success === 1 ? 'success' : 'danger'">
{{ selectedHistory.success === 1 ? '成功' : '失败' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="执行时长">{{ selectedHistory.executionDuration }} ms</el-descriptions-item>
<el-descriptions-item label="实际执行的提示词">
<el-input
v-model="selectedHistory.actualPrompt"
type="textarea"
:rows="6"
readonly
class="actual-prompt"
/>
</el-descriptions-item>
<el-descriptions-item label="执行结果详情">
<el-input
v-model="selectedHistory.result"
type="textarea"
:rows="8"
readonly
class="result-detail"
/>
</el-descriptions-item>
<el-descriptions-item v-if="selectedHistory.errorMessage" label="错误信息">
<el-input
v-model="selectedHistory.errorMessage"
type="textarea"
:rows="4"
readonly
class="error-message"
/>
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, reactive, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
const authStore = useAuthStore()
// 筛选条件
const filterForm = reactive({
timerId: '',
success: '',
dateRange: []
})
// 执行历史列表
const historyList = ref([])
// 加载状态
const loading = ref(false)
// 分页配置
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 详情对话框
const detailDialogVisible = ref(false)
const selectedHistory = ref(null)
// 格式化日期时间
const formatDateTime = (datetime: string) => {
if (!datetime) return '-'
try {
return new Date(datetime).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
} catch (e) {
console.error('日期格式化失败:', e)
return datetime
}
}
// 查询执行历史
const searchHistory = async () => {
try {
loading.value = true
const params: any = {
page: pagination.currentPage,
size: pagination.pageSize
}
// 添加筛选条件
if (filterForm.timerId) {
params.timerId = filterForm.timerId
}
if (filterForm.success !== '') {
params.success = filterForm.success
}
if (filterForm.dateRange && filterForm.dateRange.length === 2) {
params.startTime = filterForm.dateRange[0]
params.endTime = filterForm.dateRange[1]
}
const res = await authStore.get('/timer-history', params)
if (res && res.code === 200) {
// 处理分页数据
if (res.data && res.data.records) {
historyList.value = res.data.records || []
pagination.total = res.data.total || 0
} else {
historyList.value = res.data || []
pagination.total = res.data ? res.data.length : 0
}
} else {
ElMessage.error('获取执行历史失败: ' + (res?.message || '未知错误'))
}
} catch (error: any) {
console.error('获取执行历史失败:', error)
ElMessage.error('获取执行历史失败: ' + (error.response?.data?.message || error.message || '网络错误'))
} finally {
loading.value = false
}
}
// 重置筛选条件
const resetFilter = () => {
filterForm.timerId = ''
filterForm.success = ''
filterForm.dateRange = []
pagination.currentPage = 1
searchHistory()
}
// 查看详情
const showDetail = (history) => {
selectedHistory.value = { ...history }
detailDialogVisible.value = true
}
// 分页大小变化
const handleSizeChange = (size) => {
pagination.pageSize = size
searchHistory()
}
// 当前页码变化
const handleCurrentChange = (current) => {
pagination.currentPage = current
searchHistory()
}
// 初始化
onMounted(() => {
searchHistory()
})
</script>
<style scoped>
.timer-history-page {
padding: var(--spacing-5);
background-color: var(--bg-secondary);
min-height: 100%;
}
.timer-history-page h2 {
margin-bottom: var(--spacing-4);
color: var(--text-primary);
font-weight: var(--font-weight-bold);
}
.filter-card {
margin-bottom: var(--spacing-4);
}
.history-table-card {
margin-bottom: var(--spacing-4);
}
.pagination {
margin-top: var(--spacing-4);
display: flex;
justify-content: flex-end;
}
.result-content {
word-break: break-all;
}
.error-content {
word-break: break-all;
color: var(--color-danger);
}
.detail-content {
width: 100%;
}
.actual-prompt, .result-detail, .error-message {
font-family: var(--font-family-mono);
background-color: var(--bg-secondary);
}
.error-message {
color: var(--color-danger);
}
</style>
\ No newline at end of file
<template>
<div class="management-page">
<h2>定时器管理</h2>
<el-button type="primary" @click="dialogVisible = true" style="margin-bottom: 20px">
创建定时器
</el-button>
<el-table :data="timers" stripe style="width: 100%" v-loading="loading">
<el-table-column prop="name" label="名称" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="cronExpression" label="执行周期" />
<el-table-column prop="agentName" label="关联Agent" />
<el-table-column prop="enabled" label="状态">
<template #default="{ row }">
<el-tag :type="row.enabled === 1 ? 'success' : 'danger'">
{{ row.enabled === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastExecutionTime" label="上次执行时间" :formatter="formatDateTime" />
<el-table-column prop="nextExecutionTime" label="下次执行时间" :formatter="formatDateTime" />
<el-table-column label="操作">
<template #default="{ row }">
<el-button link type="primary" @click="editTimer(row)">编辑</el-button>
<el-button link type="danger" @click="deleteTimer(row)">删除</el-button>
<el-button link :type="row.enabled === 1 ? 'warning' : 'success'" @click="toggleTimerStatus(row)">
{{ row.enabled === 1 ? '禁用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑定时器' : '创建定时器'" width="800px" @close="resetForm">
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" />
</el-form-item>
<el-form-item label="执行周期" prop="cronExpression">
<el-input v-model="form.cronExpression" placeholder="请输入Cron表达式,如:0/5 * * * * ? 表示每5秒执行一次" />
<div style="margin-top: 10px; font-size: 12px; color: #8492a6;">
<span>常用Cron表达式:</span>
<el-tag size="small" @click="setCronExample('0/5 * * * * ?')">每5秒</el-tag>
<el-tag size="small" @click="setCronExample('0 0/1 * * * ?')">每分钟</el-tag>
<el-tag size="small" @click="setCronExample('0 0 0/1 * * ?')">每小时</el-tag>
<el-tag size="small" @click="setCronExample('0 0 12 * * ?')">每天中午12点</el-tag>
</div>
</el-form-item>
<el-form-item label="关联Agent" prop="agentId">
<el-select v-model="form.agentId" placeholder="请选择要调用的Agent">
<el-option
v-for="agent in agents"
:key="agent.id"
:label="agent.name"
:value="agent.id">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="提示词模板" prop="promptTemplate">
<el-input v-model="form.promptTemplate" type="textarea" :rows="6" placeholder="支持使用{{variable}}形式的动态参数" />
</el-form-item>
<el-form-item label="动态参数(JSON格式)" prop="paramsJson">
<el-input
v-model="form.paramsJson"
type="textarea"
:rows="4"
placeholder='如:{"name": "张三", "age": 18}'
@blur="validateParamsJson"
/>
</el-form-item>
<el-form-item label="状态" prop="enabled">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveTimer" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
const authStore = useAuthStore()
const timers = ref([])
const agents = ref([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const loading = ref(false)
const saving = ref(false)
const formRef = ref(null)
const form = reactive({
id: '',
name: '',
description: '',
cronExpression: '',
enabled: 0,
agentId: '',
agentName: '',
promptTemplate: '',
paramsJson: '{}'
})
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入定时器名称', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' }
],
cronExpression: [
{ required: true, message: '请输入Cron表达式', trigger: 'blur' }
],
agentId: [
{ required: true, message: '请选择关联Agent', trigger: 'change' }
]
}
// 格式化日期时间
const formatDateTime = (row, column, cellValue) => {
if (!cellValue) return '-'
try {
return new Date(cellValue).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
} catch (e) {
console.error('日期格式化失败:', e)
return cellValue
}
}
// 设置Cron表达式示例
const setCronExample = (cron: string) => {
form.cronExpression = cron
}
onMounted(async () => {
loadTimers()
loadAgents()
})
// 加载定时器列表
const loadTimers = async () => {
try {
loading.value = true
const res = await authStore.get('/timer')
// 确保timers始终是数组
timers.value = Array.isArray(res.data) ? res.data : (res.data?.data || [])
} catch (error) {
console.error('获取定时器列表失败:', error)
ElMessage.error('获取定时器列表失败: ' + (error.response?.data?.message || error.message || '未知错误'))
// 发生错误时确保timers是数组
timers.value = []
} finally {
loading.value = false
}
}
// 加载Agent列表
const loadAgents = async () => {
try {
const res = await authStore.get('/agent')
// 确保agents始终是数组
agents.value = Array.isArray(res.data) ? res.data : (res.data?.data || [])
} catch (error) {
console.error('获取Agent列表失败:', error)
ElMessage.error('获取Agent列表失败: ' + (error.response?.data?.message || error.message || '未知错误'))
// 发生错误时确保agents是数组
agents.value = []
}
}
// 编辑定时器
const editTimer = (timer) => {
isEdit.value = true
dialogVisible.value = true
// 深拷贝避免直接修改原对象
const timerCopy = JSON.parse(JSON.stringify(timer))
// 确保参数是有效的JSON字符串
if (timerCopy.paramsJson && typeof timerCopy.paramsJson === 'string') {
try {
// 如果已经是JSON字符串,保持原样
JSON.parse(timerCopy.paramsJson)
} catch (e) {
// 如果不是有效JSON,设置为默认空对象
timerCopy.paramsJson = '{}'
}
} else {
timerCopy.paramsJson = '{}'
}
Object.assign(form, timerCopy)
}
// 验证参数JSON格式
const validateParamsJson = () => {
if (form.paramsJson) {
try {
JSON.parse(form.paramsJson)
} catch (e) {
ElMessage.error('参数JSON格式不正确')
}
}
}
// 保存定时器
const saveTimer = async () => {
// 表单验证
try {
await formRef.value.validate()
} catch (error) {
ElMessage.warning('请填写必填项')
return
}
try {
saving.value = true
// 准备提交的数据
const submitData = { ...form }
// 验证参数JSON格式
if (submitData.paramsJson) {
try {
JSON.parse(submitData.paramsJson)
} catch (e) {
ElMessage.error('参数JSON格式不正确')
return
}
}
// 确保enabled字段是数字类型
submitData.enabled = submitData.enabled ? 1 : 0
// 获取Agent名称
const agent = agents.value.find(a => a.id === submitData.agentId)
if (agent) {
submitData.agentName = agent.name
} else if (!submitData.agentName && submitData.agentId) {
// 如果找不到agent但有agentId,则设置一个默认名称
submitData.agentName = '未知Agent'
}
// 确保所有必需字段都有值
if (!submitData.agentName) {
ElMessage.error('请选择关联Agent')
return
}
if (isEdit.value) {
// 编辑模式
await authStore.put(`/timer/${submitData.id}`, submitData)
ElMessage.success('更新成功')
} else {
// 创建模式
await authStore.post('/timer', submitData)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadTimers()
} catch (error: any) {
console.error('保存定时器失败:', error)
let errorMessage = isEdit.value ? '更新失败' : '创建失败'
if (error.response && error.response.data && error.response.data.message) {
errorMessage += `: ${error.response.data.message}`
} else if (error.response && error.response.data) {
errorMessage += `: ${JSON.stringify(error.response.data)}`
}
ElMessage.error(errorMessage)
} finally {
saving.value = false
}
}
// 删除定时器
const deleteTimer = (timer) => {
ElMessageBox.confirm(`确认删除定时器 "${timer.name}"吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await authStore.del(`/timer/${timer.id}`)
ElMessage.success('删除成功')
loadTimers()
} catch (error) {
console.error('删除定时器失败:', error)
let errorMessage = '删除失败'
if (error.response && error.response.data && error.response.data.message) {
errorMessage += `: ${error.response.data.message}`
}
ElMessage.error(errorMessage)
}
}).catch(() => {
// 用户取消删除
ElMessage.info('已取消删除')
})
}
// 切换定时器状态
const toggleTimerStatus = async (timer) => {
try {
const newStatus = timer.enabled === 1 ? 0 : 1
const action = newStatus === 1 ? '启用' : '禁用'
await authStore.post(`/timer/${timer.id}/${newStatus === 1 ? 'enable' : 'disable'}`)
ElMessage.success(`${action}成功`)
loadTimers()
} catch (error) {
console.error(`切换定时器状态失败:`, error)
ElMessage.error('操作失败: ' + (error.response?.data?.message || error.message || '未知错误'))
}
}
// 重置表单
const resetForm = () => {
formRef.value?.resetFields()
isEdit.value = false
// 重置为默认值
Object.assign(form, {
id: '',
name: '',
description: '',
cronExpression: '',
enabled: 0,
agentId: '',
agentName: '',
promptTemplate: '',
paramsJson: '{}'
})
}
</script>
<style scoped>
.management-page {
padding: var(--spacing-5);
background-color: var(--bg-secondary);
min-height: 100%;
height: 100%;
overflow-y: auto;
}
.management-page h2 {
margin-bottom: var(--spacing-4);
color: var(--text-primary);
font-weight: var(--font-weight-bold);
}
.el-card {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
transition: all var(--transition-normal);
}
.el-table {
border-radius: var(--border-radius-md);
overflow: hidden;
}
.el-table :deep(.el-table__header th) {
background-color: var(--bg-secondary);
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
}
.el-table :deep(.el-table__row:hover) {
background-color: var(--bg-hover);
}
.el-dialog {
border-radius: var(--border-radius-lg);
overflow: hidden;
}
.el-dialog__header {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-4);
}
.el-dialog__body {
padding: var(--spacing-4);
}
.el-dialog__footer {
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: var(--spacing-4);
}
.el-form-item {
margin-bottom: var(--spacing-4);
}
.el-input, .el-select, .el-textarea {
border-radius: var(--border-radius-md);
}
.el-input :deep(.el-input__inner), .el-textarea :deep(.el-textarea__inner) {
border: 1px solid var(--border-color);
transition: border-color var(--transition-normal);
}
.el-input :deep(.el-input__inner:focus), .el-textarea :deep(.el-textarea__inner:focus) {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
</style>
...@@ -10,8 +10,9 @@ ...@@ -10,8 +10,9 @@
<el-table :data="tools" style="width: 100%"> <el-table :data="tools" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="工具名称" /> <el-table-column prop="name" label="工具名称" />
<el-table-column prop="displayName" label="显示名称" />
<el-table-column prop="category" label="分类" width="120" /> <el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="version" label="版本" width="100" /> <el-table-column prop="timeout" label="超时(ms)" width="100" />
<el-table-column prop="status" label="状态" width="100"> <el-table-column prop="status" label="状态" width="100">
<template #default="scope"> <template #default="scope">
<el-tag :type="scope.row.status === 'active' ? 'success' : 'info'"> <el-tag :type="scope.row.status === 'active' ? 'success' : 'info'">
...@@ -20,7 +21,7 @@ ...@@ -20,7 +21,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="description" label="描述" /> <el-table-column prop="description" label="描述" />
<el-table-column label="操作" width="250"> <el-table-column label="操作" width="320">
<template #default="scope"> <template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button> <el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="small" @click="handleTest(scope.row)">测试</el-button> <el-button size="small" @click="handleTest(scope.row)">测试</el-button>
...@@ -30,6 +31,7 @@ ...@@ -30,6 +31,7 @@
@click="handleChangeStatus(scope.row)"> @click="handleChangeStatus(scope.row)">
{{ scope.row.status === 'active' ? '禁用' : '启用' }} {{ scope.row.status === 'active' ? '禁用' : '启用' }}
</el-button> </el-button>
<el-button size="small" type="primary" @click="handleConfig(scope.row)">参数配置</el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button> <el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
...@@ -39,20 +41,26 @@ ...@@ -39,20 +41,26 @@
<!-- 工具编辑对话框 --> <!-- 工具编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="50%"> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="50%">
<el-form :model="currentTool" label-width="100px"> <el-form :model="currentTool" label-width="100px">
<el-form-item label="工具名称"> <el-form-item label="工具名称" required>
<el-input v-model="currentTool.name" /> <el-input v-model="currentTool.name" placeholder="请输入工具名称" />
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="currentTool.displayName" placeholder="请输入显示名称" />
</el-form-item> </el-form-item>
<el-form-item label="分类"> <el-form-item label="分类">
<el-select v-model="currentTool.category" placeholder="请选择分类"> <el-select v-model="currentTool.category" placeholder="请选择分类">
<el-option label="数据处理" value="data" /> <el-option label="API" value="API" />
<el-option label="文件操作" value="file" /> <el-option label="FUNCTION" value="FUNCTION" />
<el-option label="网络请求" value="network" /> </el-select>
<el-option label="系统工具" value="system" /> </el-form-item>
<el-option label="AI辅助" value="ai" /> <el-form-item label="状态">
<el-select v-model="currentTool.status" placeholder="请选择状态">
<el-option label="启用" value="active" />
<el-option label="禁用" value="inactive" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="版本"> <el-form-item label="版本">
<el-input v-model="currentTool.version" /> <el-input v-model="currentTool.version" placeholder="请输入版本号" />
</el-form-item> </el-form-item>
<el-form-item label="描述"> <el-form-item label="描述">
<el-input v-model="currentTool.description" type="textarea" /> <el-input v-model="currentTool.description" type="textarea" />
...@@ -68,56 +76,92 @@ ...@@ -68,56 +76,92 @@
</span> </span>
</template> </template>
</el-dialog> </el-dialog>
<!-- 参数配置对话框 -->
<el-dialog v-model="configDialogVisible" :title="'工具参数配置 - ' + selectedTool.name" width="70%">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="10" animated />
</div>
<el-form v-else :model="toolConfig" label-width="150px" class="config-form">
<el-form-item
v-for="config in toolConfig"
:key="config.paramName"
:label="config.description || config.paramName"
:required="config.required">
<template #default>
<el-input
v-if="config.type === 'string'"
v-model="config.paramValue"
placeholder="请输入{{ config.description || config.paramName }}"
/>
<el-input-number
v-else-if="config.type === 'integer'"
v-model="config.paramValue"
:min="0"
placeholder="请输入{{ config.description || config.paramName }}"
/>
<el-switch
v-else-if="config.type === 'boolean'"
v-model="config.paramValue"
:active-value="true"
:inactive-value="false"
/>
<el-input
v-else
v-model="config.paramValue"
placeholder="请输入{{ config.description || config.paramName }}"
/>
</template>
<template #suffix>
<span class="default-value" v-if="config.defaultValue">
默认值: {{ config.defaultValue }}
</span>
</template>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="configDialogVisible = false">取消</el-button>
<el-button @click="handleResetConfig">重置</el-button>
<el-button type="primary" @click="handleSaveConfig">保存配置</el-button>
</span>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import { useAuthStore } from '@/stores/auth'
// 工具数据 // 工具数据
const tools = ref([ const tools = ref([])
{ const authStore = useAuthStore()
id: 1,
name: '计算器',
category: 'system',
version: '1.0.0',
status: 'active',
description: '基础数学计算工具'
},
{
id: 2,
name: '文件处理器',
category: 'file',
version: '1.2.1',
status: 'active',
description: '文件读写和处理工具'
},
{
id: 3,
name: '天气查询',
category: 'network',
version: '1.1.0',
status: 'inactive',
description: '查询实时天气信息'
}
])
// 对话框相关 // 对话框相关
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogTitle = ref('') const dialogTitle = ref('')
const currentTool = ref({}) const currentTool = ref({})
// 参数配置对话框相关
const configDialogVisible = ref(false)
const selectedTool = ref({})
const toolConfig = ref([])
const loading = ref(false)
// 处理添加工具 // 处理添加工具
const handleAddTool = () => { const handleAddTool = () => {
dialogTitle.value = '添加工具' dialogTitle.value = '添加工具'
currentTool.value = { currentTool.value = {
id: Date.now(),
name: '', name: '',
category: '', displayName: '',
category: 'API',
status: 'active',
version: '1.0.0', version: '1.0.0',
status: 'inactive',
description: '', description: '',
timeout: 5000,
config: '' config: ''
} }
dialogVisible.value = true dialogVisible.value = true
...@@ -126,7 +170,13 @@ const handleAddTool = () => { ...@@ -126,7 +170,13 @@ const handleAddTool = () => {
// 处理编辑工具 // 处理编辑工具
const handleEdit = (row) => { const handleEdit = (row) => {
dialogTitle.value = '编辑工具' dialogTitle.value = '编辑工具'
currentTool.value = { ...row } // 深拷贝避免直接修改原对象
const toolCopy = JSON.parse(JSON.stringify(row))
currentTool.value = {
...toolCopy,
displayName: toolCopy.displayName || toolCopy.name, // 确保displayName存在
version: toolCopy.version || '1.0.0' // 确保version存在
}
dialogVisible.value = true dialogVisible.value = true
} }
...@@ -136,11 +186,28 @@ const handleTest = (row) => { ...@@ -136,11 +186,28 @@ const handleTest = (row) => {
} }
// 处理更改状态 // 处理更改状态
const handleChangeStatus = (row) => { const handleChangeStatus = async (row) => {
try {
// 构造更新数据,切换状态
const updatedTool = {
...row,
status: row.status === 'active' ? 'inactive' : 'active'
}
const response = await authStore.put(`/tools/${row.id}`, updatedTool)
if (response.data.code === 200) {
// 更新成功后,更新本地数据
const index = tools.value.findIndex(item => item.id === row.id) const index = tools.value.findIndex(item => item.id === row.id)
if (index > -1) { if (index > -1) {
tools.value[index].status = tools.value[index].status === 'active' ? 'inactive' : 'active' tools.value[index].status = updatedTool.status
ElMessage.success(`${tools.value[index].status === 'active' ? '启用' : '禁用'}成功`) }
ElMessage.success(`${updatedTool.status === 'active' ? '启用' : '禁用'}成功`)
} else {
ElMessage.error(response.data.message || '更新工具状态失败')
}
} catch (error) {
console.error('更新工具状态失败:', error)
ElMessage.error('更新工具状态失败: ' + (error.response?.data?.message || error.message || '未知错误'))
} }
} }
...@@ -155,13 +222,19 @@ const handleDelete = (row) => { ...@@ -155,13 +222,19 @@ const handleDelete = (row) => {
type: 'warning', type: 'warning',
} }
) )
.then(() => { .then(async () => {
// 从列表中移除 try {
const index = tools.value.findIndex(item => item.id === row.id) const response = await authStore.del(`/tools/${row.id}`)
if (index > -1) { if (response.data.code === 200) {
tools.value.splice(index, 1)
}
ElMessage.success('删除成功') ElMessage.success('删除成功')
loadTools() // 重新加载工具列表
} else {
ElMessage.error(response.data.message || '删除工具失败')
}
} catch (error) {
console.error('删除工具失败:', error)
ElMessage.error('删除工具失败: ' + (error.response?.data?.message || error.message || '未知错误'))
}
}) })
.catch(() => { .catch(() => {
ElMessage.info('已取消删除') ElMessage.info('已取消删除')
...@@ -169,36 +242,184 @@ const handleDelete = (row) => { ...@@ -169,36 +242,184 @@ const handleDelete = (row) => {
} }
// 处理保存 // 处理保存
const handleSave = () => { const handleSave = async () => {
if (currentTool.value.name.trim() === '') { if (currentTool.value.name.trim() === '') {
ElMessage.warning('请输入工具名称') ElMessage.warning('请输入工具名称')
return return
} }
if (currentTool.value.id > 0) { try {
if (currentTool.value.id) {
// 编辑现有工具 // 编辑现有工具
const index = tools.value.findIndex(item => item.id === currentTool.value.id) const response = await authStore.put(`/tools/${currentTool.value.id}`, currentTool.value)
if (index > -1) { if (response.data.code === 200) {
tools.value[index] = { ...currentTool.value } ElMessage.success('更新工具成功')
dialogVisible.value = false
loadTools() // 重新加载工具列表
} else {
ElMessage.error(response.data.message || '更新工具失败')
} }
} else { } else {
// 添加新工具 // 添加新工具
tools.value.push({ ...currentTool.value }) // 构造符合后端要求的工具对象
const toolData = {
name: currentTool.value.name,
displayName: currentTool.value.displayName || currentTool.value.name,
category: currentTool.value.category,
status: currentTool.value.status,
description: currentTool.value.description,
timeout: currentTool.value.timeout || 5000
} }
const response = await authStore.post('/tools', toolData)
if (response.data.code === 200) {
ElMessage.success('创建工具成功')
dialogVisible.value = false dialogVisible.value = false
ElMessage.success('保存成功') loadTools() // 重新加载工具列表
} else {
ElMessage.error(response.data.message || '创建工具失败')
}
}
} catch (error) {
console.error('保存工具失败:', error)
ElMessage.error('保存工具失败: ' + (error.response?.data?.message || error.message || '未知错误'))
}
} }
// 处理刷新 // 处理刷新
const handleRefresh = () => { const handleRefresh = () => {
ElMessage.info('刷新工具列表') loadTools()
ElMessage.success('刷新工具列表成功')
}
// 处理参数配置
const handleConfig = (row) => {
selectedTool.value = { ...row }
configDialogVisible.value = true
fetchToolConfig(row.name)
}
// 获取工具配置
const fetchToolConfig = async (toolName: string) => {
try {
loading.value = true
// 根据工具名称获取参数配置
const response = await request.get(`/tool-configs/${toolName}`)
// 检查响应数据
console.log('Tool config response:', response.data)
// 如果返回的是空对象,则从所有工具配置中筛选
if (Object.keys(response.data).length === 0) {
// 获取所有工具配置,然后筛选出当前工具的配置
const allConfigsResponse = await request.get('/tool-configs')
const currentToolConfigs = allConfigsResponse.data.filter((item: any) => item.toolName === toolName)
// 转换为数组格式以适配前端显示
const configArray = []
currentToolConfigs.forEach((config: any) => {
configArray.push({
paramName: config.paramName,
paramValue: config.paramValue,
defaultValue: config.defaultValue,
description: config.description,
type: config.type,
required: config.required,
groupName: config.groupName
})
})
toolConfig.value = configArray
} else {
// 将返回的对象转换为数组格式以适配前端显示
const configArray = []
for (const [paramName, paramValue] of Object.entries(response.data)) {
configArray.push({
paramName: paramName,
paramValue: paramValue,
defaultValue: '', // 默认值需要从其他地方获取
description: '', // 描述需要从其他地方获取
type: 'string', // 类型需要从其他地方获取
required: false, // 是否必填需要从其他地方获取
groupName: 'default' // 分组需要从其他地方获取
})
}
toolConfig.value = configArray
}
loading.value = false
} catch (error: any) {
console.error('Failed to fetch tool config:', error)
loading.value = false
ElMessage.error('获取工具配置失败: ' + (error.message || '未知错误'))
}
}
// 保存工具配置
const handleSaveConfig = async () => {
try {
loading.value = true
for (const config of toolConfig.value) {
await request.post('/tool-configs', {
toolName: selectedTool.value.name,
paramName: config.paramName,
paramValue: config.paramValue,
description: config.description,
defaultValue: config.defaultValue,
type: config.type,
required: config.required,
groupName: config.groupName
})
}
loading.value = false
configDialogVisible.value = false
ElMessage.success('保存工具配置成功')
} catch (error) {
console.error('Failed to save tool config:', error)
loading.value = false
ElMessage.error('保存工具配置失败')
}
}
// 重置工具配置
const handleResetConfig = () => {
// 重置为默认值
for (const config of toolConfig.value) {
if (config.defaultValue) {
config.paramValue = config.defaultValue
}
}
ElMessage.info('已重置工具配置')
} }
// 页面加载时获取工具数据 // 页面加载时获取工具数据
onMounted(() => { onMounted(() => {
console.log('加载工具数据') console.log('工具管理页面已挂载,开始加载工具列表')
loadTools()
}) })
// 加载工具数据
const loadTools = async () => {
try {
const response = await authStore.get('/tools')
console.log('获取工具列表响应:', response)
if (response.data && response.data.code === 200) {
tools.value = response.data.data.map(tool => ({
...tool,
version: tool.version || '1.0.0' // 如果没有版本号,使用默认值
}))
console.log('工具列表数据:', tools.value)
} else {
ElMessage.error(response.data?.message || '获取工具列表失败')
}
} catch (error) {
console.error('获取工具列表失败:', error)
ElMessage.error('获取工具列表失败: ' + (error.response?.data?.message || error.message || '未知错误'))
}
}
</script> </script>
<style scoped> <style scoped>
...@@ -206,6 +427,8 @@ onMounted(() => { ...@@ -206,6 +427,8 @@ onMounted(() => {
padding: var(--spacing-5); padding: var(--spacing-5);
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
min-height: 100%; min-height: 100%;
height: 100%;
overflow-y: auto;
} }
.tool-management h1 { .tool-management h1 {
...@@ -287,6 +510,23 @@ onMounted(() => { ...@@ -287,6 +510,23 @@ onMounted(() => {
border-radius: var(--border-radius-full); border-radius: var(--border-radius-full);
} }
/* 加载容器样式 */
.loading-container {
margin: 20px 0;
}
/* 配置表单样式 */
.config-form {
margin-top: 20px;
}
/* 默认值样式 */
.default-value {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-left: var(--spacing-2);
}
/* 响应式设计 */ /* 响应式设计 */
@media (max-width: 768px) { @media (max-width: 768px) {
.tool-management { .tool-management {
......
...@@ -66,6 +66,13 @@ const routes: RouteRecordRaw[] = [ ...@@ -66,6 +66,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/ToolManagement.vue'), component: () => import('@/pages/ToolManagement.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/timer',
name: 'TimerManagement',
component: () => import('@/pages/TimerManagement.vue'),
meta: { requiresAuth: true }
},
{ {
path: '/dom-sync', path: '/dom-sync',
name: 'DomSync', name: 'DomSync',
......
// 二进制消息协议处理器
import { addLog } from '@/utils/logUtils';
/**
* WebSocket二进制消息协议处理类
*
* 协议格式:
* ┌────────┬─────────┬─────────┬──────────────┬──────────────┐
* │ 头字节 │ 消息ID │ 总分片数 │ 当前分片索引 │ 数据 │
* │(1B) │ (4B) │ (2B) │ (2B) │ (可变) │
* └────────┴─────────┴─────────┴──────────────┴──────────────┘
*
* 头字节定义:
* bit 7-5: 消息类型 (000=data, 001=ack, 010=error)
* bit 4-2: 编码方式 (000=raw, 001=gzip, 010=brotli)
* bit 1-0: 保留位
*/
export class BinaryMessageHandler {
// ========== 消息类型常量 ==========
static readonly TYPE_DATA = 0x00; // 数据帧
static readonly TYPE_ACK = 0x01; // 确认帧
static readonly TYPE_ERROR = 0x02; // 错误帧
// ========== 编码类型常量 ==========
static readonly ENCODING_RAW = 0x00; // 无编码
static readonly ENCODING_GZIP = 0x01; // GZIP压缩
static readonly ENCODING_BROTLI = 0x02; // Brotli压缩
// ========== 协议字段大小 ==========
static readonly HEADER_SIZE = 12; // 协议头大小(字节)
/**
* 解析二进制消息头
*
* @param buffer 包含协议头的ArrayBuffer(至少12字节)
* @returns 解码后的消息头对象
*/
static decodeHeader(buffer: ArrayBuffer): {
messageType: number;
encoding: number;
messageId: number;
totalFragments: number;
currentFragment: number;
} {
if (buffer.byteLength < this.HEADER_SIZE) {
throw new Error(
`消息头长度不足,期望至少${this.HEADER_SIZE}字节,实际${buffer.byteLength}字节`
);
}
const view = new DataView(buffer, 0, this.HEADER_SIZE);
// 解析第1字节
const headerByte = view.getUint8(0);
const messageType = (headerByte >> 5) & 0x07;
const encoding = (headerByte >> 2) & 0x07;
// 解析消息ID (4字节,大端序)
const messageId = view.getUint32(1);
// 解析总分片数 (2字节,大端序)
const totalFragments = view.getUint16(5);
// 解析当前分片索引 (2字节,大端序)
const currentFragment = view.getUint16(7);
// 验证解析结果
this.validateMessageType(messageType);
this.validateEncoding(encoding);
if (totalFragments === 0 || totalFragments > 65535) {
throw new Error(`总分片数无效: ${totalFragments}`);
}
if (currentFragment >= totalFragments) {
throw new Error(`分片索引越界: ${currentFragment} >= ${totalFragments}`);
}
return {
messageType,
encoding,
messageId,
totalFragments,
currentFragment
};
}
/**
* 从完整消息中提取数据部分(跳过12字节的协议头)
*
* @param buffer 完整的消息ArrayBuffer
* @returns 数据部分的Uint8Array
*/
static extractData(buffer: ArrayBuffer): Uint8Array {
if (buffer.byteLength < this.HEADER_SIZE) {
throw new Error(`消息长度不足,期望至少${this.HEADER_SIZE}字节`);
}
return new Uint8Array(buffer, this.HEADER_SIZE);
}
/**
* 验证消息类型
*/
private static validateMessageType(type: number) {
if (type !== this.TYPE_DATA && type !== this.TYPE_ACK && type !== this.TYPE_ERROR) {
throw new Error(`无效的消息类型: ${type}`);
}
}
/**
* 验证编码方式
*/
private static validateEncoding(encoding: number) {
if (encoding !== this.ENCODING_RAW &&
encoding !== this.ENCODING_GZIP &&
encoding !== this.ENCODING_BROTLI) {
throw new Error(`无效的编码方式: ${encoding}`);
}
}
/**
* 将Uint8Array转换为字符串(用于HTML数据)
*/
static uint8ArrayToString(data: Uint8Array): string {
const decoder = new TextDecoder('utf-8');
return decoder.decode(data);
}
/**
* 获取编码方式的名称(用于日志记录)
*/
static getEncodingName(encoding: number): string {
switch (encoding) {
case this.ENCODING_RAW:
return 'RAW(无编码)';
case this.ENCODING_GZIP:
return 'GZIP(压缩)';
case this.ENCODING_BROTLI:
return 'BROTLI(压缩)';
default:
return `未知(${encoding})`;
}
}
/**
* 获取消息类型的名称(用于日志记录)
*/
static getMessageTypeName(type: number): string {
switch (type) {
case this.TYPE_DATA:
return 'DATA(数据帧)';
case this.TYPE_ACK:
return 'ACK(确认帧)';
case this.TYPE_ERROR:
return 'ERROR(错误帧)';
default:
return `未知(${type})`;
}
}
}
/**
* 二进制消息分片缓存管理
* 支持重试机制和失败恢复
*/
export class BinaryFragmentBuffer {
private fragments: Map<number, Map<number, Uint8Array>> = new Map(); // messageId -> (fragmentIndex -> data)
private messageInfo: Map<number, { totalFragments: number; encoding: number; receiveTime: number; retryCount: number }> = new Map();
private readonly TIMEOUT = 60000; // 60秒超时
private readonly MAX_RETRIES = 3; // 最大重试次数
/**
* 添加分片数据
* 支持重复接收相同分片(自动去重)
*
* @param messageId 消息ID
* @param totalFragments 总分片数
* @param currentFragment 当前分片索引
* @param encoding 编码方式
* @param data 分片数据
* @returns 如果所有分片都已接收,返回完整数据;否则返回null
*/
addFragment(
messageId: number,
totalFragments: number,
currentFragment: number,
encoding: number,
data: Uint8Array
): { data: Uint8Array; encoding: number } | null {
// 初始化消息缓存
if (!this.fragments.has(messageId)) {
this.fragments.set(messageId, new Map());
this.messageInfo.set(messageId, {
totalFragments,
encoding,
receiveTime: Date.now(),
retryCount: 0
});
addLog(
`🔄 开始接收二进制消息,ID=${messageId}, 总分片=${totalFragments}, 编码=${BinaryMessageHandler.getEncodingName(encoding)}`,
'info'
);
}
const fragmentMap = this.fragments.get(messageId)!;
// 检查是否已经接收过该分片(支持幂等性,允许重复接收)
if (fragmentMap.has(currentFragment)) {
const existingData = fragmentMap.get(currentFragment);
// 验证数据一致性
if (existingData && existingData.byteLength === data.byteLength) {
const isIdentical = this.isArrayEqual(existingData, data);
if (isIdentical) {
addLog(
`⚠️ 分片${currentFragment + 1}/${totalFragments}已接收(内容一致),跳过重复`,
'warn'
);
return null;
} else {
addLog(
`❌ 分片${currentFragment + 1}/${totalFragments}数据不一致,覆盖旧数据`,
'error'
);
// 数据不一致,覆盖
fragmentMap.set(currentFragment, data);
return null;
}
}
return null;
}
// 添加分片
fragmentMap.set(currentFragment, data);
addLog(
`📥 接收二进制分片 ${currentFragment + 1}/${totalFragments} (大小: ${data.byteLength}字节)`,
'info'
);
// 检查是否已接收所有分片
if (fragmentMap.size !== totalFragments) {
return null;
}
// 所有分片已接收,合并数据
addLog(`✅ 所有分片接收完成,开始合并数据...`, 'info');
// 按顺序合并分片
const totalSize = Array.from(fragmentMap.values()).reduce((sum, chunk) => sum + chunk.byteLength, 0);
const merged = new Uint8Array(totalSize);
let offset = 0;
for (let i = 0; i < totalFragments; i++) {
const fragment = fragmentMap.get(i);
if (!fragment) {
addLog(`❌ 分片${i}丢失,无法完成合并`, 'error');
this.cleanup(messageId);
throw new Error(`分片${i}丢失`);
}
merged.set(fragment, offset);
offset += fragment.byteLength;
}
addLog(`✓ 数据合并完成,总大小: ${merged.byteLength}字节`, 'info');
// 清理缓存
this.cleanup(messageId);
return {
data: merged,
encoding: this.messageInfo.get(messageId)?.encoding || BinaryMessageHandler.ENCODING_RAW
};
}
/**
* 清理特定消息的缓存
*/
private cleanup(messageId: number) {
this.fragments.delete(messageId);
this.messageInfo.delete(messageId);
}
/**
* 清理过期的消息缓存(超过30秒未完成)
*/
cleanupExpired() {
const now = Date.now();
const expiredMessages: number[] = [];
for (const [messageId, info] of this.messageInfo.entries()) {
if (now - info.receiveTime > this.TIMEOUT) {
expiredMessages.push(messageId);
const fragmentMap = this.fragments.get(messageId);
if (fragmentMap) {
const progress = fragmentMap.size;
const total = info.totalFragments;
const percentage = ((progress / total) * 100).toFixed(1);
addLog(
`⏱️ 消息${messageId}接收超时,进度${progress}/${total}(${percentage}%),已清空缓存`,
'warn'
);
}
}
}
expiredMessages.forEach(id => this.cleanup(id));
}
/**
* 获取当前缓存状态(用于调试)
*/
getStatus(): string {
const messageCount = this.fragments.size;
const totalFragments = Array.from(this.fragments.values()).reduce((sum, map) => sum + map.size, 0);
return `缓存消息数: ${messageCount}, 缓存分片数: ${totalFragments}`;
}
/**
* 高效比较两个Uint8Array数组是否相等
* @param a 第一个数组
* @param b 第二个数组
* @returns 如果相等返回true,否则返回false
*/
private isArrayEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
}
/**
* 二进制消息处理函数
* 在WebSocket消息处理中调用
*
* 使用示例:
* ```typescript
* const fragmentBuffer = new BinaryFragmentBuffer();
*
* ws.onmessage = (event) => {
* if (event.data instanceof ArrayBuffer) {
* const result = BinaryMessageHandler.handleBinaryMessage(
* event.data,
* fragmentBuffer,
* (data, url) => renderDOM(data, url) // 完成回调
* );
* }
* };
* ```
*/
export function handleBinaryMessage(
buffer: ArrayBuffer,
fragmentBuffer: BinaryFragmentBuffer,
onComplete?: (data: any, encoding: number) => void
): boolean {
// 记录接收到的消息基本信息
addLog(`📥 开始处理二进制消息,大小: ${buffer.byteLength} 字节`, 'debug');
try {
addLog(`📥 接收到WebSocket消息,大小: ${buffer.byteLength}字节`, 'debug');
// 验证缓冲区大小
if (buffer.byteLength < BinaryMessageHandler.HEADER_SIZE) {
addLog(
`❌ 接收到的消息过短 (${buffer.byteLength}字节,需要至少${BinaryMessageHandler.HEADER_SIZE}字节)`,
'error'
);
return false;
}
// 解析协议头
const header = BinaryMessageHandler.decodeHeader(buffer);
addLog(
`📨 接收二进制消息: 类型=${BinaryMessageHandler.getMessageTypeName(header.messageType)}, ` +
`ID=${header.messageId}, 分片=${header.currentFragment + 1}/${header.totalFragments}, ` +
`总大小=${buffer.byteLength}字节`,
'debug'
);
// 处理不同的消息类型
switch (header.messageType) {
case BinaryMessageHandler.TYPE_DATA:
// 数据帧:提取数据并处理
const data = BinaryMessageHandler.extractData(buffer);
const result = fragmentBuffer.addFragment(
header.messageId,
header.totalFragments,
header.currentFragment,
header.encoding,
data
);
if (result) {
// 所有分片已接收,处理完整数据
addLog(
`✓ 消息${header.messageId}已完整接收,开始处理数据...`,
'info'
);
// 处理编码方式
let decodedData: any;
try {
if (header.encoding === BinaryMessageHandler.ENCODING_RAW) {
// 原始数据,直接转换为字符串
const htmlString = BinaryMessageHandler.uint8ArrayToString(result.data);
decodedData = htmlString; // 直接传递HTML字符串
addLog(`✓ 原始数据处理完成,数据大小: ${htmlString.length} 字符`, 'info');
} else if (header.encoding === BinaryMessageHandler.ENCODING_GZIP) {
addLog('⚠️ GZIP压缩数据,需要额外的解压缩库支持(暂未实现)', 'warn');
return false;
} else if (header.encoding === BinaryMessageHandler.ENCODING_BROTLI) {
addLog('⚠️ Brotli压缩数据,需要额外的解压缩库支持(暂未实现)', 'warn');
return false;
}
if (onComplete) {
addLog(`📤 调用onComplete回调,数据类型: ${typeof decodedData}`, 'debug');
onComplete(decodedData, header.encoding);
}
return true;
} catch (error) {
addLog(`❌ 处理数据失败: ${(error as Error).message}`, 'error');
return false;
}
}
return true;
case BinaryMessageHandler.TYPE_ACK:
// 确认帧:客户端无需处理(用于服务器接收确认)
addLog(`ℹ️ 接收到确认帧 (ACK),消息ID=${header.messageId}`, 'debug');
return true;
case BinaryMessageHandler.TYPE_ERROR:
// 错误帧:提取错误信息并记录详细日志
try {
const errorData = BinaryMessageHandler.extractData(buffer);
const errorMessage = BinaryMessageHandler.uint8ArrayToString(errorData);
addLog(`❌ 服务器错误 (消息ID=${header.messageId}): ${errorMessage}`, 'error');
addLog(`📝 错误详情: 缓冲区大小=${buffer.byteLength}字节`, 'debug');
// 如果是第三方页面错误,给出友好提示
if (errorMessage.includes('sso.hisense.com') && errorMessage.includes('setRequestHeader')) {
addLog('💡 提示:这是海信SSO系统页面的JavaScript错误,不影响系统核心功能', 'info');
}
} catch (parseError) {
const parseError_ = parseError as Error;
addLog(
`❌ 服务器错误,且错误信息解析失败: ${parseError_.message}`,
'error'
);
addLog(`🔍 解析失败堆栈: ${parseError_.stack || 'N/A'}`, 'debug');
}
return false;
default:
addLog(`⚠️ 未知的消息类型: ${header.messageType}`, 'warn');
return false;
}
} catch (error) {
const error_ = error as Error;
const errorMsg = error_.message;
const errorStack = error_.stack || 'N/A';
addLog(`❌ 处理二进制消息失败: ${errorMsg}`, 'error');
addLog(`📋 错误详情: ${errorMsg}`, 'debug');
addLog(`📍 错误堆栈: ${errorStack}`, 'debug');
// 记录接收到的原始数据大小用于调试
addLog(
`📊 接收缓冲区大小: ${buffer.byteLength}字节 (最小需要: ${BinaryMessageHandler.HEADER_SIZE}字节)`,
'debug'
);
// 如果是头部解析错误,可能是网络问题,记录更多调试信息
if (buffer.byteLength < BinaryMessageHandler.HEADER_SIZE) {
addLog(
`🔧 缓冲区过小,这可能表示网络传输问题或消息被截断`,
'warn'
);
}
return false;
}
}
// DOM同步服务
import { unescapeHtmlContent } from '@/utils/stringUtils';
import { addLog } from '@/utils/logUtils';
export class DomSyncService {
private chunkBuffer: Map<number, string> = new Map();
private totalChunks: number = 0;
private receivedChunks: number = 0;
private chunkTimeoutId: number | null = null;
// 已参数化:handleChunkData() 方法(不再需要,改为使用二进制协议)
// 此方法外氺了Base64解码步骤,现在二进制协议直接传输UTF-8数据
// 处理DOM同步数据
handleDomSyncData(data: any, iframeDoc: Document, onIframeEventListenersChanged: () => void) {
try {
addLog(`📥 DOM同步数据处理开始,数据类型: ${typeof data}`, 'debug');
// 检查数据是否有效
if (!data) {
addLog('接收到空数据', 'error');
return;
}
// 记录数据大小
if (typeof data === 'string') {
addLog(`接收到DOM数据,长度: ${data.length} 字符`, 'debug');
}
// 直接渲染DOM内容,不再使用type字段区分
this.renderFullDomInIframe({ dom: data }, iframeDoc, onIframeEventListenersChanged);
addLog('已初始化页面DOM', 'init');
} catch (e) {
addLog('处理DOM同步数据失败:' + (e as Error).message, 'error');
}
}
// 绑定事件监听器的辅助方法
private bindEventListeners(onIframeEventListenersChanged: () => void) {
setTimeout(() => {
try {
onIframeEventListenersChanged();
addLog('事件监听器重新绑定成功', 'info');
} catch (rebindError) {
addLog('事件监听器重新绑定失败: ' + (rebindError as Error).message, 'error');
}
}, 100);
}
// 在iframe中渲染完整DOM
private renderFullDomInIframe(data: any, doc: Document, onIframeEventListenersChanged: () => void) {
try {
// 检查必要数据
if (!data.dom) {
addLog('缺少DOM数据', 'error');
return;
}
const domContent = data.dom;
// 注意:后端已经不再进行手动转义。fastjson2会自动处理JSON中的特殊字符
// 因此,domContent已经是有效的HTML内容了
let actualHtmlContent = domContent;
// 验证HTML内容的基本有效性
if (!actualHtmlContent || actualHtmlContent.trim().length < 10) {
addLog('DOM内容无效或过短(' + (actualHtmlContent ? actualHtmlContent.length : 0) + '字节)', 'error');
return;
}
addLog('开始在iframe中渲染DOM,内容长度: ' + actualHtmlContent.length + ' 字节', 'info');
// 使用最简单有效的方法:直接设置innerHTML
try {
// 清空现有内容
doc.documentElement.innerHTML = '';
// 使用 DOMParser 解析HTML,更安全且不会导致脚本中断
const parser = new DOMParser();
const parsedDoc = parser.parseFromString(actualHtmlContent, 'text/html');
if (parsedDoc && parsedDoc.documentElement) {
// 导入新的documentElement
const newHtmlElement = doc.importNode(parsedDoc.documentElement, true);
doc.replaceChild(newHtmlElement, doc.documentElement);
addLog('文档内容替换成功', 'info');
// 重新绑定事件监听器
this.bindEventListeners(onIframeEventListenersChanged);
return;
}
} catch (error) {
addLog('DOMParser方案失败: ' + (error as Error).message + ',尝试备用方案...', 'warn');
}
// 备用方案:使用 document.open/write
try {
addLog('尝试使用document.open/write方案...', 'info');
// 禁用动画和过渡,减少视觉闪烁
doc.documentElement.style.animation = 'none';
doc.documentElement.style.transition = 'none';
doc.open();
doc.write(actualHtmlContent);
doc.close();
addLog('document.write方案成功', 'info');
// 重新绑定事件监听器
this.bindEventListeners(onIframeEventListenersChanged);
return;
} catch (writeError) {
addLog('document.write方案失败: ' + (writeError as Error).message, 'error');
}
// 最后的尝试:直接设置innerHTML到body
try {
addLog('尝试最后的方案:直接使用innerHTML...', 'warn');
if (doc.body) {
doc.body.innerHTML = actualHtmlContent;
this.bindEventListeners(onIframeEventListenersChanged);
}
} catch (lastError) {
addLog('所有渲染方案都失败了,请检查HTML内容的有效性: ' + (lastError as Error).message, 'error');
}
} catch (e) {
addLog('在iframe中渲染完整DOM失败:' + (e as Error).message, 'error');
addLog('错误堆栈:' + (e as any).stack, 'error');
addLog('DOM数据预览(前200字符):' + (data.dom ? data.dom.substring(0, 200) + '...' : '无'), 'error');
}
}
// 在iframe中更新增量 DOM(已移除,使用完整DOM更新替代)
private updateIncrementalDomInIframe(data: any, doc: Document) {
// 不再使用增量更新,改为完整DOM更新以简化实现
addLog('增量DOM更新已被移除,使用完整DOM更新替代', 'warn');
}
}
\ No newline at end of file
// iframe服务
import { addLog } from '@/utils/logUtils';
import { debounce, throttle } from '@/utils/functionUtils';
import { getElementSelector } from '@/utils/domUtils';
export class IframeService {
private domViewRef: HTMLIFrameElement | null = null;
private eventListeners: {
[key: string]: EventListenerOrEventListenerObject;
} = {};
private mutationObserver: MutationObserver | null = null;
constructor(domViewRef: HTMLIFrameElement | null) {
this.domViewRef = domViewRef;
}
// 为iframe内容添加事件监听器
addEventListeners(
onClick: (selector: string) => void,
onDoubleClick: (selector: string) => void,
onInput: (selector: string, value: string) => void,
onSubmit: (selector: string) => void,
onScroll: (scrollY: number) => void,
onSelect: (selector: string, value: string) => void,
onKeyDown: (key: string) => void,
onKeyUp: (key: string) => void,
onKeyPress: (key: string) => void,
onHover: (selector: string) => void,
onFocus: (selector: string) => void,
onBlur: (selector: string) => void
) {
if (!this.domViewRef || !this.domViewRef.contentDocument) {
addLog('无法访问iframe内容文档', 'error');
return;
}
const iframeDoc = this.domViewRef.contentDocument;
try {
// 先移除已存在的监听器,避免重复绑定
this.removeEventListeners();
// 定义事件处理器配置
const eventHandlers = [
{
type: 'click',
handler: (event: Event) => {
const target = event.target as HTMLElement;
if (target) {
const selector = getElementSelector(target);
onClick(selector);
}
},
options: { passive: true }
},
{
type: 'dblclick',
handler: (event: Event) => {
const target = event.target as HTMLElement;
if (target) {
const selector = getElementSelector(target);
onDoubleClick(selector);
}
},
options: { passive: true }
},
{
type: 'input',
handler: debounce((event: Event) => {
const target = event.target as HTMLInputElement;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
const selector = getElementSelector(target);
onInput(selector, target.value);
}
}, 500),
options: { passive: true }
},
{
type: 'submit',
handler: (event: Event) => {
const target = event.target as HTMLFormElement;
if (target) {
event.preventDefault(); // 阻止默认提交行为
const selector = getElementSelector(target);
onSubmit(selector);
}
},
options: {}
},
{
type: 'scroll',
handler: throttle((event: Event) => {
const target = event.target as HTMLElement;
if (target) {
const scrollY = target.scrollTop || 0;
onScroll(scrollY);
}
}, 200),
options: { passive: true }
},
{
type: 'keydown',
handler: (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (target) {
onKeyDown(event.key);
}
},
options: { passive: true }
},
{
type: 'keyup',
handler: (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (target) {
onKeyUp(event.key);
}
},
options: { passive: true }
},
{
type: 'keypress',
handler: (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (target) {
onKeyPress(event.key);
}
},
options: { passive: true }
},
{
type: 'mouseover',
handler: (event: Event) => {
const target = event.target as HTMLElement;
if (target) {
const selector = getElementSelector(target);
onHover(selector);
}
},
options: { passive: true }
},
{
type: 'change',
handler: (event: Event) => {
const target = event.target as HTMLSelectElement;
if (target && target.tagName === 'SELECT') {
const selector = getElementSelector(target);
const selectedValue = target.value;
onSelect(selector, selectedValue);
}
},
options: { passive: true }
},
{
type: 'focus',
handler: (event: Event) => {
const target = event.target as HTMLElement;
if (target) {
const selector = getElementSelector(target);
onFocus(selector);
}
},
options: true // 使用捕获阶段
},
{
type: 'blur',
handler: (event: Event) => {
const target = event.target as HTMLElement;
if (target) {
const selector = getElementSelector(target);
onBlur(selector);
}
},
options: true // 使用捕获阶段
}
];
// 注册所有事件监听器
eventHandlers.forEach(({ type, handler, options }) => {
this.eventListeners[type] = handler;
iframeDoc.addEventListener(type, handler, options);
});
// 添加MutationObserver监控DOM变化
// 确保body元素存在后再创建观察器
if (iframeDoc.body) {
this.mutationObserver = new MutationObserver(() => {
// 当DOM发生变化时,重新绑定事件监听器以确保新元素能响应事件
});
this.mutationObserver.observe(iframeDoc.body, {
childList: true,
subtree: true
});
} else {
addLog('iframe body元素尚未准备就绪,跳过MutationObserver设置', 'warn');
}
addLog('已为iframe添加事件监听器', 'info');
} catch (e) {
addLog('为iframe添加事件监听器失败: ' + (e as Error).message, 'error');
}
}
// 移除iframe事件监听器
removeEventListeners() {
if (!this.domViewRef || !this.domViewRef.contentDocument) {
return;
}
const iframeDoc = this.domViewRef.contentDocument;
try {
// 定义需要移除的事件类型
const eventTypes = [
{ type: 'click', useCapture: false },
{ type: 'dblclick', useCapture: false },
{ type: 'input', useCapture: false },
{ type: 'submit', useCapture: false },
{ type: 'scroll', useCapture: false },
{ type: 'keydown', useCapture: false },
{ type: 'keyup', useCapture: false },
{ type: 'keypress', useCapture: false },
{ type: 'mouseover', useCapture: false },
{ type: 'change', useCapture: false },
{ type: 'focus', useCapture: true },
{ type: 'blur', useCapture: true }
];
// 移除所有事件监听器
eventTypes.forEach(({ type, useCapture }) => {
if (this.eventListeners[type]) {
iframeDoc.removeEventListener(type, this.eventListeners[type], useCapture);
}
});
// 清空事件监听器对象
this.eventListeners = {};
// 断开MutationObserver
if (this.mutationObserver) {
this.mutationObserver.disconnect();
this.mutationObserver = null;
}
addLog('已移除iframe事件监听器', 'info');
} catch (e) {
addLog('移除iframe事件监听器失败: ' + (e as Error).message, 'error');
}
}
// 监听iframe内部的导航事件
monitorNavigation() {
if (!this.domViewRef || !this.domViewRef.contentWindow) {
addLog('无法访问iframe内容窗口', 'error');
return;
}
try {
// 监听iframe内部的beforeunload事件
const beforeUnloadHandler = () => {
addLog('iframe即将导航到新页面', 'info');
// 在页面卸载前移除事件监听器
this.removeEventListeners();
};
this.domViewRef.contentWindow.addEventListener('beforeunload', beforeUnloadHandler as EventListener);
// 监听iframe内部的popstate事件(浏览器前进后退)
const popStateHandler = () => {
addLog('iframe历史状态改变', 'info');
};
this.domViewRef.contentWindow.addEventListener('popstate', popStateHandler as EventListener);
// 监听hashchange事件
const hashChangeHandler = () => {
addLog('iframe哈希值改变', 'info');
};
this.domViewRef.contentWindow.addEventListener('hashchange', hashChangeHandler as EventListener);
addLog('iframe导航事件监听器设置完成', 'info');
} catch (e) {
addLog('设置iframe导航监听失败: ' + (e as Error).message, 'error');
}
}
}
\ No newline at end of file
// WebSocket服务
import { addLog } from '@/utils/logUtils';
import { BinaryFragmentBuffer, handleBinaryMessage } from './binaryMessageHandler';
import { TokenUtils } from '@/utils/tokenUtils';
interface WebSocketServiceOptions {
onMessage?: (data: any) => void;
onOpen?: () => void;
onClose?: (event: CloseEvent) => void;
onError?: (error: any) => void;
}
export class WebSocketService {
private ws: WebSocket | null = null;
private url: string = '';
private reconnectAttempts: number = 0;
private maxReconnectAttempts: number = 5;
private reconnectDelay: number = 3000;
private connectionTimeout: number | null = null;
private cleanupInterval: number | null = null;
private options: WebSocketServiceOptions;
private binaryFragmentBuffer: BinaryFragmentBuffer = new BinaryFragmentBuffer();
constructor(options: WebSocketServiceOptions = {}) {
this.options = options;
}
connect(url: string) {
// 避免重复连接
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
addLog('WebSocket连接已存在且处于打开状态', 'info');
return;
}
// 如果已有连接但处于其他状态,先关闭它
if (this.ws) {
try {
addLog(`WebSocket当前状态: ${this.ws.readyState}`, 'info');
this.ws.close();
} catch (e) {
addLog('关闭旧WebSocket连接时出错: ' + (e as Error).message, 'error');
}
this.ws = null;
}
this.url = url;
addLog('正在连接WebSocket: ' + this.url, 'info');
try {
const ws = new WebSocket(this.url);
// 设置二进制数据类型为ArrayBuffer而不是Blob
ws.binaryType = 'arraybuffer';
this.ws = ws;
// 设置连接超时
this.connectionTimeout = window.setTimeout(() => {
if (ws.readyState === WebSocket.CONNECTING) {
addLog('WebSocket连接超时(30秒未建立连接)。可能的原因:', 'error');
addLog('1. 后端服务未启动或无法访问 (ws://localhost:8080/ws/dom-sync)', 'warn');
addLog('2. 防火墙或网络配置阻止WebSocket连接', 'warn');
addLog('3. 反向代理或负载均衡器未正确配置WebSocket支持', 'warn');
addLog('4. 握手拦截器验证失败(如JWT Token验证失败)', 'warn');
try {
ws.close();
} catch (closeError) {
addLog('关闭WebSocket连接时出错: ' + (closeError as Error).message, 'error');
}
// 尝试重连
this.attemptReconnect();
}
}, 30000); // 增加到30秒超时,给连接更多时间
// 连接打开事件
ws.onopen = () => {
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
this.connectionTimeout = null;
}
this.reconnectAttempts = 0;
addLog('WebSocket连接已建立,使用二进制协议', 'info');
// 启动定期清理过期消息缓存
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.cleanupInterval = window.setInterval(() => {
this.binaryFragmentBuffer.cleanupExpired();
}, 30000); // 每30秒检查一次
if (this.options.onOpen) {
this.options.onOpen();
}
};
// 接收消息事件
ws.onmessage = (event) => {
try {
addLog(`📥 WebSocket onmessage事件触发,数据类型: ${event.data.constructor.name}`, 'debug');
// 只处理二进制消息
if (event.data instanceof ArrayBuffer) {
// 二进制消息处理
addLog('接收到二进制消息,大小: ' + (event.data as ArrayBuffer).byteLength + ' 字节', 'debug');
const success = handleBinaryMessage(
event.data as ArrayBuffer,
this.binaryFragmentBuffer,
(decodedData: any, encoding: number) => {
addLog(`📤 二进制消息处理完成,调用onMessage回调,数据类型: ${typeof decodedData}`, 'debug');
// 消息完整接收并处理成功
if (this.options.onMessage) {
this.options.onMessage(decodedData);
}
}
);
if (!success) {
addLog('二进制消息处理失败', 'warn');
}
} else {
addLog(`⚠️ 接收到非二进制消息,类型: ${typeof event.data}`, 'warn');
}
} catch (e) {
addLog('处理WebSocket消息时发生异常:' + (e as Error).message, 'error');
addLog('错误堆栈:' + (e as Error).stack, 'debug');
}
};
// 连接关闭事件
ws.onclose = (event) => {
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
this.connectionTimeout = null;
}
addLog(`WebSocket连接已关闭,代码: ${event.code}, 原因: ${event.reason}`, 'info');
// 检查关闭原因
if (event.code === 1006) {
addLog('WebSocket连接异常关闭(代码1006),可能原因:', 'warn');
addLog('1. 网络中断或连接超时', 'info');
addLog('2. 服务器主动断开连接', 'info');
addLog('3. 防火墙、代理或中间件中断连接', 'info');
// 添加Token状态检查
this.checkTokenStatus();
} else if (event.code !== 1000) {
addLog(`WebSocket异常关闭(代码${event.code}),需要检查服务器日志`, 'warn');
}
// 只有在非正常关闭的情况下才尝试重连
if (event.code !== 1000) { // 1000表示正常关闭
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
addLog(`WebSocket连接已断开,正在尝试第${this.reconnectAttempts}次重连...`, 'error');
// 指数退避重连策略
this.scheduleReconnect();
} else {
addLog('WebSocket连接已断开,已达到最大重连次数,停止重连', 'error');
// 重置重连次数,以便用户手动重新连接
this.reconnectAttempts = 0;
}
} else {
addLog('WebSocket连接正常关闭,无需重连', 'info');
}
if (this.options.onClose) {
this.options.onClose(event);
}
};
// 连接错误事件
ws.onerror = (error) => {
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
this.connectionTimeout = null;
}
// 检查当前WebSocket状态来判断错误类型
const readyState = ws.readyState;
let errorDescription = '';
if (readyState === WebSocket.CLOSED) {
errorDescription = '握手失败:连接已关闭。通常是服务器拒绝连接(可能是认证失败、Token过期或无效)';
// 添加Token状态检查
this.checkTokenStatus();
} else if (readyState === WebSocket.CLOSING) {
errorDescription = '连接正在关闭中';
} else if (readyState === WebSocket.CONNECTING) {
errorDescription = '连接超时或连接被中断';
} else {
errorDescription = '未知错误';
}
addLog('WebSocket错误 [状态码: ' + readyState + ']:' + errorDescription, 'error');
addLog('错误详情:' + (error as any).message, 'error');
addLog('URL: ' + this.url, 'error');
// 检查网络连接状态
if (!navigator.onLine) {
addLog('网络诊断:网络连接不可用,请检查您的网络连接', 'error');
} else {
addLog('网络诊断:网络连接正常,问题可能在服务器端', 'warn');
}
// 如果连接失败,尝试重新连接
this.attemptReconnect();
if (this.options.onError) {
this.options.onError(error);
}
};
} catch (e) {
addLog('创建WebSocket连接失败: ' + (e as Error).message, 'error');
addLog('详细信息: ' + (e as Error).stack, 'debug');
addLog('诊断帮助:', 'info');
addLog('1. 检查URL格式是否正确: ' + this.url, 'info');
addLog('2. WebSocket创建失败可能指示浏览器不支持WebSocket或安全需求不满足', 'warn');
addLog('3. 检查HTTPS页面是否使用了WSS协议', 'warn');
// 如果创建连接失败,也尝试重连
this.attemptReconnect();
}
}
send(message: string) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(message);
addLog('已发送指令:' + message, 'info');
} catch (e) {
addLog('发送指令失败:' + (e as Error).message, 'error');
}
} else {
addLog('WebSocket连接已断开,无法发送指令', 'error');
// 尝试重新连接
this.attemptReconnect();
}
}
close() {
if (this.ws) {
try {
this.ws.close();
addLog('WebSocket连接已关闭', 'info');
} catch (e) {
addLog('关闭WebSocket连接时出错: ' + (e as Error).message, 'error');
}
this.ws = null;
}
// 清理定时器
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
}
// 检查Token状态
private checkTokenStatus() {
const token = localStorage.getItem('token');
const tokenValidation = TokenUtils.validateToken(token);
if (!token) {
addLog('错误:未找到认证Token,请重新登录', 'error');
} else if (tokenValidation.isExpired) {
addLog('错误:Token已过期,请重新登录', 'error');
} else {
addLog(`Token有效,将在 ${tokenValidation.minutesLeft?.toFixed(1)} 分钟后过期`, 'info');
}
}
// 尝试重连
private attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000);
addLog(`WebSocket连接失败,${delay}ms后进行第${this.reconnectAttempts}次重连...`, 'warn');
this.scheduleReconnect(delay);
} else {
addLog('WebSocket连接失败,已达到最大重连次数,停止重连', 'error');
if (this.url.includes('ws://localhost:8080/ws/dom-sync')) {
addLog('诊断建议:', 'info');
addLog('1. 检查后端服务是否正常运行(默认: ws://localhost:8080/ws/dom-sync)', 'info');
addLog('2. 检查JWT Token是否过期(Token有效期: 2小时)', 'info');
addLog('3. 检查浏览器控制台是否有详细的错误日志', 'info');
addLog('4. 检查后端是否输出认证拒绝的原因日志', 'info');
addLog('5. 可尝试手动刷新页面重新连接', 'info');
}
// 重置重连次数,以便用户手动重新连接
this.reconnectAttempts = 0;
}
}
// 安排重连
private scheduleReconnect(delay?: number) {
const reconnectDelay = delay ?? Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000);
setTimeout(() => this.connect(this.url), reconnectDelay);
}
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
}
\ No newline at end of file
import { AxiosResponse, AxiosRequestConfig } from 'axios' import { AxiosResponse, AxiosRequestConfig } from 'axios'
// 定义用户信息接口
interface UserInfo { interface UserInfo {
id?: string id?: string
username?: string username?: string
...@@ -7,18 +8,34 @@ interface UserInfo { ...@@ -7,18 +8,34 @@ interface UserInfo {
[key: string]: any [key: string]: any
} }
// 定义认证响应数据接口
interface AuthResponseData {
token: string
[key: string]: any
}
// 定义API响应接口
interface ApiResponse<T = any> {
code: number
message: string
data: T
}
interface AuthStore { interface AuthStore {
token: { value: string | null } token: { value: string | null }
userInfo: { value: UserInfo } userInfo: { value: UserInfo }
register: (username: string, password: string, email: string) => Promise<any> register: (username: string, password: string, email: string) => Promise<ApiResponse<AuthResponseData>>
login: (username: string, password: string) => Promise<any> login: (username: string, password: string) => Promise<ApiResponse<AuthResponseData>>
loginWithOAuth2: (providerName: string) => Promise<void>
handleOAuth2Callback: () => Promise<ApiResponse<AuthResponseData>>
loginWithOAuth2Code: (authCode: string, providerName: string) => Promise<ApiResponse<AuthResponseData>>
logout: () => void logout: () => void
setUserInfo: (info: UserInfo) => void setUserInfo: (info: UserInfo) => void
api: any api: any
get: (url: string, config?: AxiosRequestConfig) => Promise<AxiosResponse> get: <T = any>(url: string, config?: AxiosRequestConfig) => Promise<AxiosResponse<T>>
post: (url: string, data?: any, config?: AxiosRequestConfig) => Promise<AxiosResponse> post: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) => Promise<AxiosResponse<T>>
put: (url: string, data?: any, config?: AxiosRequestConfig) => Promise<AxiosResponse> put: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) => Promise<AxiosResponse<T>>
del: (url: string, config?: AxiosRequestConfig) => Promise<AxiosResponse> del: <T = any>(url: string, config?: AxiosRequestConfig) => Promise<AxiosResponse<T>>
} }
export declare function useAuthStore(): AuthStore export declare function useAuthStore(): AuthStore
\ No newline at end of file
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, Ref } from 'vue' import { ref } from 'vue'
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios' import axios, {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig,
AxiosError
} from 'axios'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
// 定义用户信息接口 // 定义用户信息接口
...@@ -11,10 +17,44 @@ interface UserInfo { ...@@ -11,10 +17,44 @@ interface UserInfo {
[key: string]: any [key: string]: any
} }
// 定义认证响应数据接口
interface AuthResponseData {
token: string
[key: string]: any
}
// 定义API响应接口
interface ApiResponse<T = any> {
code: number
message: string
data: T
}
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token') || null) // 安全地从localStorage获取token
const userInfo = ref<UserInfo>(JSON.parse(localStorage.getItem('userInfo') || '{}')) const getTokenFromStorage = (): string | null => {
try {
return localStorage.getItem('token')
} catch (error) {
console.warn('无法从localStorage获取token:', error)
return null
}
}
// 安全地从localStorage获取用户信息
const getUserInfoFromStorage = (): UserInfo => {
try {
const userInfoStr = localStorage.getItem('userInfo')
return userInfoStr ? JSON.parse(userInfoStr) : {}
} catch (error) {
console.warn('无法从localStorage获取用户信息:', error)
return {}
}
}
const token = ref<string | null>(getTokenFromStorage())
const userInfo = ref<UserInfo>(getUserInfoFromStorage())
const api: AxiosInstance = axios.create({ const api: AxiosInstance = axios.create({
baseURL: '/api/v1' baseURL: '/api/v1'
...@@ -27,14 +67,22 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -27,14 +67,22 @@ export const useAuthStore = defineStore('auth', () => {
} }
return config return config
}, },
(error: any) => Promise.reject(error) (error: unknown) => {
console.error('请求拦截器错误:', error)
return Promise.reject(error)
}
) )
// 添加响应拦截器处理401错误 // 添加响应拦截器处理401错误
api.interceptors.response.use( api.interceptors.response.use(
(response: AxiosResponse) => response, (response: AxiosResponse) => response,
(error: any) => { (error: unknown) => {
if (error.response && error.response.status === 401) { console.error('响应拦截器错误:', error)
if (axios.isAxiosError(error) && error.response && error.response.status === 401) {
// 记录详细的错误日志
console.warn('认证失效,正在清除认证信息并跳转到登录页')
// 清除认证信息 // 清除认证信息
token.value = null token.value = null
userInfo.value = {} userInfo.value = {}
...@@ -52,48 +100,94 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -52,48 +100,94 @@ export const useAuthStore = defineStore('auth', () => {
} }
) )
async function register(username: string, password: string, email: string): Promise<any> { /**
* 用户注册
* @param username 用户名
* @param password 密码
* @param email 邮箱
* @returns 注册响应数据
*/
async function register(username: string, password: string, email: string): Promise<ApiResponse<AuthResponseData>> {
try { try {
const response: AxiosResponse = await api.post('/auth/register', { username, password, email }) const response: AxiosResponse<ApiResponse<AuthResponseData>> = await api.post('/auth/register', { username, password, email })
return response.data return response.data
} catch (error: any) { } catch (error: unknown) {
throw new Error(error.response?.data?.message || '注册失败') console.error('注册失败:', error)
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<ApiResponse<null>>
const errorMessage = axiosError.response?.data?.message || '注册失败'
console.error('注册错误详情:', errorMessage)
throw new Error(errorMessage)
}
console.error('注册未知错误:', error)
throw new Error('注册失败')
} }
} }
async function login(username: string, password: string): Promise<any> { /**
* 用户登录
* @param username 用户名
* @param password 密码
* @returns 登录响应数据
*/
async function login(username: string, password: string): Promise<ApiResponse<AuthResponseData>> {
try { try {
const response: AxiosResponse = await api.post('/auth/login', { username, password }) const response: AxiosResponse<ApiResponse<AuthResponseData>> = await api.post('/auth/login', { username, password })
const { token: newToken } = response.data.data const { token: newToken } = response.data.data
token.value = newToken token.value = newToken
localStorage.setItem('token', newToken) localStorage.setItem('token', newToken)
return response.data return response.data
} catch (error: any) { } catch (error: unknown) {
throw new Error(error.response?.data?.message || '登录失败') console.error('登录失败:', error)
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<ApiResponse<null>>
const errorMessage = axiosError.response?.data?.message || '登录失败'
console.error('登录错误详情:', errorMessage)
throw new Error(errorMessage)
}
console.error('登录未知错误:', error)
throw new Error('登录失败')
} }
} }
/** /**
* OAuth2 授权流程 * OAuth2 授权流程
* 重定向用户到授权服务器 * 重定向用户到授权服务器
* @param providerName OAuth2提供商名称
*/ */
async function loginWithOAuth2(providerName: string): Promise<void> { async function loginWithOAuth2(providerName: string): Promise<void> {
try { try {
// 调用后端端点得到授权 URL // 获取OAuth2授权URL
const response: AxiosResponse = await api.get(`/auth/oauth2/authorize?providerName=${providerName}`) const response: AxiosResponse<ApiResponse<{ authorizationUrl: string }>> = await api.get(`/auth/oauth2/authorize?providerName=${providerName}`)
// 后端分路归结会停止进一步的处理,此处是正常的
// 实际上此调用为无效的。前端应该直接渐进到授权端点 // 重定向到授权URL
} catch (error: any) { if (response.data.data?.authorizationUrl) {
throw new Error(error.response?.data?.message || 'OAuth2 授权失败') window.location.href = response.data.data.authorizationUrl
} else {
throw new Error('无法获取OAuth2授权URL')
}
} catch (error: unknown) {
console.error('OAuth2授权失败:', error)
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<ApiResponse<null>>
const errorMessage = axiosError.response?.data?.message || 'OAuth2授权失败'
console.error('OAuth2授权错误详情:', errorMessage)
throw new Error(errorMessage)
}
console.error('OAuth2授权未知错误:', error)
throw new Error('OAuth2授权失败')
} }
} }
/** /**
* OAuth2 回调函数 * OAuth2 回调函数
* 处理来自授权服务器的回调 * 处理来自授权服务器的回调
* @returns 处理结果Promise
*/ */
function handleOAuth2Callback(): Promise<any> { async function handleOAuth2Callback(): Promise<ApiResponse<AuthResponseData>> {
return new Promise((resolve, reject) => {
// 从 URL 参数中提取授权码 // 从 URL 参数中提取授权码
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const authCode = params.get('code') const authCode = params.get('code')
...@@ -101,28 +195,29 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -101,28 +195,29 @@ export const useAuthStore = defineStore('auth', () => {
const error = params.get('error') const error = params.get('error')
if (error) { if (error) {
reject(new Error(`OAuth2 错误: ${error}`)) const errorMsg = `OAuth2 错误: ${error}`
return console.error(errorMsg)
throw new Error(errorMsg)
} }
if (!authCode || !providerName) { if (!authCode || !providerName) {
reject(new Error('不完整的 OAuth2 回调参数')) const errorMsg = '不完整的 OAuth2 回调参数'
return console.error(errorMsg)
throw new Error(errorMsg)
} }
// 使用授权码进行令牌交换 // 使用授权码进行令牌交换
loginWithOAuth2Code(authCode, providerName) return await loginWithOAuth2Code(authCode, providerName)
.then(resolve)
.catch(reject)
})
} }
/** /**
* 使用授权码不什止迟不何敷不洘华丞身式末身 * 使用授权码进行令牌交换
* @param authCode 授权码
* @param providerName 提供商名称
*/ */
async function loginWithOAuth2Code(authCode: string, providerName: string): Promise<any> { async function loginWithOAuth2Code(authCode: string, providerName: string): Promise<ApiResponse<AuthResponseData>> {
try { try {
const response: AxiosResponse = await api.post('/auth/oauth2/token', { const response: AxiosResponse<ApiResponse<AuthResponseData>> = await api.post('/auth/oauth2/token', {
authorizationCode: authCode, authorizationCode: authCode,
providerName: providerName providerName: providerName
}) })
...@@ -132,28 +227,47 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -132,28 +227,47 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.setItem('token', newToken) localStorage.setItem('token', newToken)
return response.data return response.data
} catch (error: any) { } catch (error: unknown) {
throw new Error(error.response?.data?.message || 'OAuth2 认证失败') if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<ApiResponse<null>>
throw new Error(axiosError.response?.data?.message || 'OAuth2 认证失败')
}
throw new Error('OAuth2 认证失败')
} }
} }
/**
* 用户登出
*/
function logout(): void { function logout(): void {
token.value = null token.value = null
userInfo.value = {} userInfo.value = {}
try {
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('userInfo') localStorage.removeItem('userInfo')
} catch (error) {
console.warn('清除localStorage失败:', error)
}
} }
/**
* 设置用户信息
* @param info 用户信息
*/
function setUserInfo(info: UserInfo): void { function setUserInfo(info: UserInfo): void {
userInfo.value = info userInfo.value = info
try {
localStorage.setItem('userInfo', JSON.stringify(info)) localStorage.setItem('userInfo', JSON.stringify(info))
} catch (error) {
console.warn('保存用户信息到localStorage失败:', error)
}
} }
// 添加便捷方法 // 添加便捷方法
const get = (url: string, config: AxiosRequestConfig = {}): Promise<AxiosResponse> => api({ method: 'get', url, ...config }) const get = <T = any>(url: string, config: AxiosRequestConfig = {}): Promise<AxiosResponse<T>> => api({ method: 'get', url, ...config })
const post = (url: string, data?: any, config: AxiosRequestConfig = {}): Promise<AxiosResponse> => api({ method: 'post', url, data, ...config }) const post = <T = any>(url: string, data?: any, config: AxiosRequestConfig = {}): Promise<AxiosResponse<T>> => api({ method: 'post', url, data, ...config })
const put = (url: string, data?: any, config: AxiosRequestConfig = {}): Promise<AxiosResponse> => api({ method: 'put', url, data, ...config }) const put = <T = any>(url: string, data?: any, config: AxiosRequestConfig = {}): Promise<AxiosResponse<T>> => api({ method: 'put', url, data, ...config })
const del = (url: string, config: AxiosRequestConfig = {}): Promise<AxiosResponse> => api({ method: 'delete', url, ...config }) const del = <T = any>(url: string, config: AxiosRequestConfig = {}): Promise<AxiosResponse<T>> => api({ method: 'delete', url, ...config })
return { return {
token, token,
......
...@@ -75,24 +75,268 @@ input:focus, textarea:focus, select:focus { ...@@ -75,24 +75,268 @@ input:focus, textarea:focus, select:focus {
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
overflow: hidden; overflow: hidden;
transition: all var(--transition-normal);
}
.card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
} }
.card-header { .card-header {
padding: var(--spacing-4); padding: var(--spacing-4);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
} }
.card-body { .card-body {
padding: var(--spacing-4); padding: var(--spacing-4);
} }
.card-footer {
padding: var(--spacing-4);
border-top: 1px solid var(--border-color);
background-color: var(--bg-secondary);
display: flex;
justify-content: flex-end;
gap: var(--spacing-3);
}
/* 按钮样式 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-2) var(--spacing-4);
border: 1px solid transparent;
border-radius: var(--border-radius-base);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--transition-normal);
gap: var(--spacing-2);
}
.btn-primary {
background-color: var(--primary-color);
color: var(--text-inverse);
}
.btn-primary:hover {
background-color: var(--primary-color-dark);
box-shadow: var(--shadow-md);
}
.btn-secondary {
background-color: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-color);
}
.btn-secondary:hover {
background-color: var(--bg-tertiary);
border-color: var(--gray-400);
}
.btn-danger {
background-color: var(--error-color);
color: var(--text-inverse);
}
.btn-danger:hover {
background-color: #e01e37;
box-shadow: var(--shadow-md);
}
.btn-success {
background-color: var(--success-color);
color: var(--text-inverse);
}
.btn-success:hover {
background-color: #389e0d;
box-shadow: var(--shadow-md);
}
.btn-warning {
background-color: var(--warning-color);
color: var(--text-primary);
}
.btn-warning:hover {
background-color: #d48806;
box-shadow: var(--shadow-md);
}
.btn-sm {
padding: var(--spacing-1) var(--spacing-3);
font-size: var(--font-size-sm);
}
.btn-lg {
padding: var(--spacing-3) var(--spacing-6);
font-size: var(--font-size-lg);
}
/* 表格样式 */
.table {
width: 100%;
border-collapse: collapse;
background-color: var(--bg-primary);
border-radius: var(--border-radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.table th,
.table td {
padding: var(--spacing-3);
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.table th {
background-color: var(--bg-secondary);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.table tr:last-child td {
border-bottom: none;
}
.table tr:hover {
background-color: var(--bg-hover);
}
.table-striped tr:nth-child(even) {
background-color: var(--bg-secondary);
}
.table-bordered {
border: 1px solid var(--border-color);
}
.table-bordered th,
.table-bordered td {
border: 1px solid var(--border-color);
}
/* Element Plus 组件样式覆盖 */
/* 统一按钮样式 */
:root {
--el-button-font-weight: var(--font-weight-medium);
--el-button-border-radius: var(--border-radius-base);
--el-button-text-color: var(--text-primary);
--el-button-bg-color: var(--bg-primary);
--el-button-border-color: var(--border-color);
--el-button-hover-text-color: var(--primary-color);
--el-button-hover-bg-color: var(--bg-hover);
--el-button-hover-border-color: var(--primary-color);
--el-button-active-text-color: var(--primary-color-dark);
--el-button-active-bg-color: var(--bg-active);
--el-button-active-border-color: var(--primary-color-dark);
--el-button-primary-text-color: var(--text-inverse);
--el-button-primary-bg-color: var(--primary-color);
--el-button-primary-border-color: var(--primary-color);
--el-button-primary-hover-text-color: var(--text-inverse);
--el-button-primary-hover-bg-color: var(--primary-color-dark);
--el-button-primary-hover-border-color: var(--primary-color-dark);
--el-button-primary-active-text-color: var(--text-inverse);
--el-button-primary-active-bg-color: var(--primary-color-dark);
--el-button-primary-active-border-color: var(--primary-color-dark);
}
/* 统一输入框样式 */
:root {
--el-input-border-radius: var(--border-radius-base);
--el-input-border-color: var(--border-color);
--el-input-focus-border-color: var(--primary-color);
--el-input-focus-box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
--el-input-text-color: var(--text-primary);
--el-input-placeholder-color: var(--text-disabled);
}
/* 统一表格样式 */
:root {
--el-table-border-color: var(--border-color);
--el-table-header-bg-color: var(--bg-secondary);
--el-table-header-color: var(--text-primary);
--el-table-row-hover-bg-color: var(--bg-hover);
--el-table-stripe-bg-color: var(--bg-secondary);
}
/* 统一卡片样式 */
:root {
--el-card-border-radius: var(--border-radius-lg);
--el-card-border-color: var(--border-color);
--el-card-shadow: var(--shadow-sm);
--el-card-header-bg-color: var(--bg-secondary);
}
/* 统一对话框样式 */
:root {
--el-dialog-border-radius: var(--border-radius-lg);
--el-dialog-header-bg-color: var(--bg-primary);
--el-dialog-title-color: var(--text-primary);
}
/* 统一标签样式 */
:root {
--el-tag-border-radius: var(--border-radius-full);
}
/* 统一开关样式 */
:root {
--el-switch-on-color: var(--primary-color);
--el-switch-off-color: var(--gray-300);
}
/* 统一滑块样式 */
:root {
--el-slider-track-fill-color: var(--primary-color);
--el-slider-button-bg-color: var(--primary-color);
}
/* 统一选择器样式 */
:root {
--el-select-border-radius: var(--border-radius-base);
--el-select-border-color: var(--border-color);
--el-select-focus-border-color: var(--primary-color);
}
/* 统一分页样式 */
:root {
--el-pagination-button-size: 32px;
--el-pagination-border-radius: var(--border-radius-base);
--el-pagination-button-bg-color: var(--bg-primary);
--el-pagination-button-border-color: var(--border-color);
--el-pagination-button-hover-bg-color: var(--bg-hover);
--el-pagination-button-hover-color: var(--primary-color);
--el-pagination-button-hover-border-color: var(--primary-color);
--el-pagination-button-active-bg-color: var(--primary-color);
--el-pagination-button-active-color: var(--text-inverse);
--el-pagination-button-active-border-color: var(--primary-color);
}
/* 排版规范 */
/* 标题样式 */ /* 标题样式 */
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight); line-height: var(--line-height-tight);
color: var(--text-primary); color: var(--text-primary);
margin-bottom: var(--spacing-3); margin: 0 0 var(--spacing-3) 0;
transition: all var(--transition-normal);
} }
h1 { h1 {
...@@ -101,6 +345,9 @@ h1 { ...@@ -101,6 +345,9 @@ h1 {
h2 { h2 {
font-size: var(--font-size-3xl); font-size: var(--font-size-3xl);
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--spacing-2);
margin-bottom: var(--spacing-4);
} }
h3 { h3 {
...@@ -113,27 +360,67 @@ h4 { ...@@ -113,27 +360,67 @@ h4 {
h5 { h5 {
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
} }
h6 { h6 {
font-size: var(--font-size-base); font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
} }
/* 段落样式 */ /* 段落样式 */
p { p {
margin-bottom: var(--spacing-4); margin: 0 0 var(--spacing-4) 0;
color: var(--text-secondary); color: var(--text-secondary);
line-height: var(--line-height-relaxed);
transition: all var(--transition-normal);
} }
/* 列表样式 */ /* 列表样式 */
ul, ol { ul, ol {
margin-bottom: var(--spacing-4); margin: 0 0 var(--spacing-4) 0;
padding-left: var(--spacing-6); padding-left: var(--spacing-6);
line-height: var(--line-height-relaxed);
} }
li { li {
margin-bottom: var(--spacing-2); margin: 0 0 var(--spacing-2) 0;
color: var(--text-secondary); color: var(--text-secondary);
transition: all var(--transition-normal);
}
ul ul, ul ol, ol ul, ol ol {
margin-bottom: var(--spacing-0);
margin-top: var(--spacing-2);
padding-left: var(--spacing-5);
}
/* 链接样式 */
a {
color: var(--primary-color);
text-decoration: none;
transition: all var(--transition-normal);
position: relative;
}
a:hover {
color: var(--primary-color-dark);
text-decoration: none;
}
a::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 1px;
background-color: var(--primary-color);
transition: width var(--transition-normal);
}
a:hover::after {
width: 100%;
} }
/* 代码块样式 */ /* 代码块样式 */
...@@ -144,6 +431,7 @@ code { ...@@ -144,6 +431,7 @@ code {
color: var(--accent-color); color: var(--accent-color);
padding: var(--spacing-1) var(--spacing-2); padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
transition: all var(--transition-normal);
} }
pre { pre {
...@@ -153,12 +441,195 @@ pre { ...@@ -153,12 +441,195 @@ pre {
padding: var(--spacing-4); padding: var(--spacing-4);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
overflow-x: auto; overflow-x: auto;
margin-bottom: var(--spacing-4); margin: 0 0 var(--spacing-4) 0;
border: 1px solid var(--border-color);
transition: all var(--transition-normal);
} }
pre code { pre code {
background: none; background: none;
padding: 0; padding: 0;
border: none;
font-size: var(--font-size-sm);
}
/* 标签样式 */
.tag {
display: inline-block;
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--border-radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all var(--transition-normal);
}
.tag-primary {
background-color: rgba(102, 126, 234, 0.1);
color: var(--primary-color);
}
.tag-secondary {
background-color: rgba(75, 85, 99, 0.1);
color: var(--text-secondary);
}
.tag-success {
background-color: rgba(82, 196, 26, 0.1);
color: var(--success-color);
}
.tag-warning {
background-color: rgba(250, 173, 20, 0.1);
color: var(--warning-color);
}
.tag-danger {
background-color: rgba(245, 34, 45, 0.1);
color: var(--error-color);
}
/* 文本样式工具类 */
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-tertiary {
color: var(--text-tertiary);
}
.text-muted {
color: var(--text-disabled);
}
.text-inverse {
color: var(--text-inverse);
}
.text-success {
color: var(--success-color);
}
.text-warning {
color: var(--warning-color);
}
.text-danger {
color: var(--error-color);
}
.text-info {
color: var(--info-color);
}
/* 文本对齐工具类 */
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-justify {
text-align: justify;
}
/* 文本转换工具类 */
.text-uppercase {
text-transform: uppercase;
}
.text-lowercase {
text-transform: lowercase;
}
.text-capitalize {
text-transform: capitalize;
}
/* 文本粗细工具类 */
.font-light {
font-weight: var(--font-weight-light);
}
.font-normal {
font-weight: var(--font-weight-normal);
}
.font-medium {
font-weight: var(--font-weight-medium);
}
.font-semibold {
font-weight: var(--font-weight-semibold);
}
.font-bold {
font-weight: var(--font-weight-bold);
}
/* 行高工具类 */
.leading-none {
line-height: var(--line-height-none);
}
.leading-tight {
line-height: var(--line-height-tight);
}
.leading-snug {
line-height: var(--line-height-snug);
}
.leading-normal {
line-height: var(--line-height-normal);
}
.leading-relaxed {
line-height: var(--line-height-relaxed);
}
.leading-loose {
line-height: var(--line-height-loose);
}
/* 段落间距工具类 */
.mb-0 {
margin-bottom: var(--spacing-0);
}
.mb-1 {
margin-bottom: var(--spacing-1);
}
.mb-2 {
margin-bottom: var(--spacing-2);
}
.mb-3 {
margin-bottom: var(--spacing-3);
}
.mb-4 {
margin-bottom: var(--spacing-4);
}
.mb-5 {
margin-bottom: var(--spacing-5);
}
.mb-6 {
margin-bottom: var(--spacing-6);
} }
/* 表格样式 */ /* 表格样式 */
...@@ -184,6 +655,49 @@ tr:hover { ...@@ -184,6 +655,49 @@ tr:hover {
background-color: var(--bg-hover); background-color: var(--bg-hover);
} }
/* 统一页面布局样式 */
.page-container {
width: 100%;
min-height: 100%;
padding: var(--spacing-5);
background-color: var(--bg-secondary);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-5);
padding-bottom: var(--spacing-3);
border-bottom: 1px solid var(--border-color);
}
.page-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin: 0;
}
.page-actions {
display: flex;
gap: var(--spacing-3);
}
/* 管理页面样式 */
.management-page {
padding: var(--spacing-5);
background-color: var(--bg-secondary);
min-height: 100%;
}
.management-page h2 {
margin-bottom: var(--spacing-4);
color: var(--text-primary);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xl);
}
/* 工具类 */ /* 工具类 */
.text-center { .text-center {
text-align: center; text-align: center;
...@@ -282,6 +796,36 @@ tr:hover { ...@@ -282,6 +796,36 @@ tr:hover {
margin-right: var(--spacing-4); margin-right: var(--spacing-4);
} }
/* 响应式工具类 */
@media (max-width: 768px) {
.page-container,
.management-page {
padding: var(--spacing-3);
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-3);
}
.page-actions {
flex-wrap: wrap;
width: 100%;
}
}
@media (max-width: 576px) {
.page-container,
.management-page {
padding: var(--spacing-2);
}
.page-title {
font-size: var(--font-size-xl);
}
}
/* 表单控件样式 */ /* 表单控件样式 */
.form-control { .form-control {
width: 100%; width: 100%;
...@@ -396,30 +940,250 @@ tr:hover { ...@@ -396,30 +940,250 @@ tr:hover {
} }
/* 响应式工具类 */ /* 响应式工具类 */
/* 显示/隐藏工具类 */
.hidden-sm { display: none; }
.hidden-md { display: block; }
.hidden-lg { display: block; }
.hidden-xl { display: block; }
@media (max-width: 1200px) {
.hidden-xl { display: none; }
.display-xl { display: block; }
}
@media (max-width: 992px) {
.hidden-lg { display: none; }
.display-lg { display: block; }
}
@media (max-width: 768px) { @media (max-width: 768px) {
html { html {
font-size: 14px; font-size: 14px;
} }
.hidden-md { display: none; }
.display-md { display: block; }
.hidden-sm { .hidden-sm {
display: none; display: block;
} }
.display-sm { display: none; }
.flex-col-sm { .flex-col-sm {
flex-direction: column; flex-direction: column;
} }
/* 响应式间距 */
.p-0-sm { padding: var(--spacing-0); }
.p-2-sm { padding: var(--spacing-2); }
.p-3-sm { padding: var(--spacing-3); }
.p-4-sm { padding: var(--spacing-4); }
.m-0-sm { margin: var(--spacing-0); }
.m-2-sm { margin: var(--spacing-2); }
.m-3-sm { margin: var(--spacing-3); }
.m-4-sm { margin: var(--spacing-4); }
.mb-0-sm { margin-bottom: var(--spacing-0); }
.mb-2-sm { margin-bottom: var(--spacing-2); }
.mb-3-sm { margin-bottom: var(--spacing-3); }
.mb-4-sm { margin-bottom: var(--spacing-4); }
/* 响应式字体大小 */
.text-xs-sm { font-size: var(--font-size-xs); }
.text-sm-sm { font-size: var(--font-size-sm); }
.text-base-sm { font-size: var(--font-size-base); }
.text-lg-sm { font-size: var(--font-size-lg); }
/* 响应式卡片 */
.card {
margin-bottom: var(--spacing-3);
}
.card-header,
.card-body,
.card-footer {
padding: var(--spacing-3);
}
/* 响应式表格 */
.table {
display: block;
overflow-x: auto;
}
} }
@media (max-width: 576px) { @media (max-width: 576px) {
.hidden-xs { html {
display: none; font-size: 13px;
} }
.p-4-xs { .hidden-sm { display: none; }
padding: var(--spacing-3); .display-sm { display: block; }
/* 响应式间距 */
.p-0-xs { padding: var(--spacing-0); }
.p-1-xs { padding: var(--spacing-1); }
.p-2-xs { padding: var(--spacing-2); }
.p-3-xs { padding: var(--spacing-3); }
.m-0-xs { margin: var(--spacing-0); }
.m-1-xs { margin: var(--spacing-1); }
.m-2-xs { margin: var(--spacing-2); }
.m-3-xs { margin: var(--spacing-3); }
.mb-0-xs { margin-bottom: var(--spacing-0); }
.mb-1-xs { margin-bottom: var(--spacing-1); }
.mb-2-xs { margin-bottom: var(--spacing-2); }
.mb-3-xs { margin-bottom: var(--spacing-3); }
/* 响应式字体大小 */
.text-xs-xs { font-size: var(--font-size-xs); }
.text-sm-xs { font-size: var(--font-size-sm); }
.text-base-xs { font-size: var(--font-size-base); }
.text-lg-xs { font-size: var(--font-size-lg); }
/* 响应式卡片 */
.card {
margin-bottom: var(--spacing-2);
border-radius: var(--border-radius-md);
}
.card-header,
.card-body,
.card-footer {
padding: var(--spacing-2);
}
/* 响应式按钮 */
.btn {
padding: var(--spacing-1) var(--spacing-3);
font-size: var(--font-size-sm);
}
.btn-lg {
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-base);
}
.btn-sm {
padding: var(--spacing-1) var(--spacing-2);
font-size: var(--font-size-xs);
}
}
/* 响应式布局容器 */
.container {
width: 100%;
padding-right: var(--spacing-4);
padding-left: var(--spacing-4);
margin-right: auto;
margin-left: auto;
}
@media (min-width: 576px) {
.container {
max-width: 540px;
}
}
@media (min-width: 768px) {
.container {
max-width: 720px;
}
}
@media (min-width: 992px) {
.container {
max-width: 960px;
}
}
@media (min-width: 1200px) {
.container {
max-width: 1140px;
}
}
@media (min-width: 1400px) {
.container {
max-width: 1320px;
}
}
/* 响应式网格 */
.row {
display: flex;
flex-wrap: wrap;
margin-right: calc(var(--spacing-2) * -1);
margin-left: calc(var(--spacing-2) * -1);
}
.col {
flex: 1 0 0%;
padding-right: var(--spacing-2);
padding-left: var(--spacing-2);
}
.col-sm {
flex: 1 0 0%;
padding-right: var(--spacing-2);
padding-left: var(--spacing-2);
}
@media (min-width: 576px) {
.col-sm {
flex: 0 0 auto;
width: 50%;
}
}
.col-md {
flex: 1 0 0%;
padding-right: var(--spacing-2);
padding-left: var(--spacing-2);
}
@media (min-width: 768px) {
.col-md {
flex: 0 0 auto;
width: 33.33333333%;
}
}
.col-lg {
flex: 1 0 0%;
padding-right: var(--spacing-2);
padding-left: var(--spacing-2);
}
@media (min-width: 992px) {
.col-lg {
flex: 0 0 auto;
width: 25%;
}
}
/* 响应式聊天页面 */
@media (max-width: 768px) {
.chat-page {
flex-direction: column;
}
.left-panel {
width: 100% !important;
height: 60% !important;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.divider {
width: 100%;
height: 4px;
cursor: row-resize;
} }
.m-4-xs { .right-panel {
margin: var(--spacing-3); width: 100% !important;
height: 40% !important;
} }
} }
\ No newline at end of file
// DOM工具函数
/**
* DOM工具函数模块
* 提供DOM操作相关的实用函数
*/
/**
* 获取元素的选择器(根据后端协议重构)
* @param element - 要获取选择器的DOM元素
* @returns 元素的CSS选择器字符串
*/
export const getElementSelector = (element: Element): string => {
if (!element || element === document.documentElement || element === document.body) {
return 'body';
}
// 优先使用ID选择器
if (element.id) {
try {
return '#' + CSS.escape(element.id);
} catch (e) {
// 如果CSS.escape失败,使用简单的ID选择器
return '#' + element.id;
}
}
// 使用类名选择器
if (element.className && typeof element.className === 'string') {
const classes = element.className.split(' ').filter(cls => cls.length > 0);
if (classes.length > 0) {
try {
return '.' + classes.map(cls => CSS.escape(cls)).join('.');
} catch (e) {
// 如果CSS.escape失败,使用简单的类名选择器
return '.' + classes.join('.');
}
}
}
// 使用标签名选择器,并结合nth-child定位
let selector = element.tagName.toLowerCase();
const parent = element.parentElement;
if (parent) {
// 获取同级同标签元素的数量
const siblings = Array.from(parent.children).filter(child =>
child.tagName === element.tagName);
if (siblings.length > 1) {
// 计算当前元素在同级元素中的位置(1-based)
const index = Array.from(siblings).indexOf(element) + 1;
selector += `:nth-child(${index})`;
}
}
// 如果有父元素,递归构建更精确的选择器
if (parent && parent !== document.body) {
const parentSelector = getElementSelector(parent);
if (parentSelector) {
selector = `${parentSelector} > ${selector}`;
}
}
return selector;
};
/**
* 检查脚本是否安全执行
* @param scriptText - 要检查的脚本文本
* @returns 如果脚本安全则返回true,否则返回false
*/
export const isScriptSafe = (scriptText: string): boolean => {
// 简单的安全检查,防止明显的恶意脚本执行
const unsafePatterns = [
/document\.cookie/i,
/localStorage/i,
/sessionStorage/i,
/indexedDB/i,
/navigator\.sendBeacon/i,
/XMLHttpRequest/i,
/fetch\s*\(/i,
/eval\s*\(/i,
/Function\s*\(/i,
/setTimeout\s*\([^,]+,[^,]+\)/i,
/setInterval\s*\([^,]+,[^,]+\)/i,
/location\s*=\s*/i,
/window\.open/i,
/document\.write/i
];
// 检查是否存在不安全模式
return !unsafePatterns.some(pattern => pattern.test(scriptText));
};
/**
* 验证URL格式是否有效
* @param urlString - 要验证的URL字符串
* @returns 如果URL有效则返回true,否则返回false
*/
export const isValidUrl = (urlString: string): boolean => {
if (!urlString || urlString.trim() === '') {
return false;
}
let url = urlString.trim();
// 检查是否以http://或https://开头
if (!url.toLowerCase().startsWith('http://') && !url.toLowerCase().startsWith('https://')) {
// 如果没有协议前缀,自动添加https://
url = 'https://' + url;
}
// 确保URL末尾没有多余的斜杠(除了域名根路径)
if (url.endsWith('/') && url.length > 8) { // 8是'http://a'的长度
url = url.slice(0, -1);
}
// 使用URL构造函数验证URL格式
try {
new URL(url);
return true;
} catch (e) {
return false;
}
};
\ No newline at end of file
...@@ -3,7 +3,8 @@ import { ElMessage } from 'element-plus' ...@@ -3,7 +3,8 @@ import { ElMessage } from 'element-plus'
/** /**
* 全局错误处理器 * 全局错误处理器
* @param {Error} error - 捕获到的错误对象 * @param error - 捕获到的错误对象
* @returns 表示错误是否已被处理
*/ */
export function handleGlobalError(error: any): boolean { export function handleGlobalError(error: any): boolean {
// 检查是否为HTTP错误响应 // 检查是否为HTTP错误响应
...@@ -46,8 +47,9 @@ export function handleGlobalError(error: any): boolean { ...@@ -46,8 +47,9 @@ export function handleGlobalError(error: any): boolean {
/** /**
* 包装异步操作,自动处理错误 * 包装异步操作,自动处理错误
* @param {Function} asyncFn - 异步函数 * @param asyncFn - 异步函数
* @param {String} errorMessage - 自定义错误消息前缀 * @param errorMessage - 自定义错误消息前缀
* @returns 异步函数的结果
*/ */
export async function withErrorHandling<T>(asyncFn: () => Promise<T>, errorMessage: string = ''): Promise<T> { export async function withErrorHandling<T>(asyncFn: () => Promise<T>, errorMessage: string = ''): Promise<T> {
try { try {
......
/**
/**
* 函数工具函数模块
* 提供通用的函数处理实用函数
*/
* @param obj - 要格式化的对象
* @returns 格式化后的字符串
*/
export const formatJson = (obj: any): string => {
if (obj === null || obj === undefined) {
return '无数据';
}
if (typeof obj === 'string') {
try {
// 尝试解析字符串为JSON对象
const parsed = JSON.parse(obj);
return JSON.stringify(parsed, null, 2);
} catch (e) {
// 如果不是有效的JSON字符串,直接返回原字符串
return obj;
}
} else if (typeof obj === 'object') {
// 如果是对象,直接格式化
return JSON.stringify(obj, null, 2);
} else {
// 其他情况转换为字符串
return String(obj);
}
};
/**
* 格式化工具数据
* @param data - 要格式化的数据
* @returns 格式化后的字符串
*/
export const formatToolData = (data: any): string => {
try {
return formatJson(data);
} catch (error) {
console.error('格式化工具数据出错:', error);
return '数据格式错误';
}
};
\ No newline at end of file
// 工具函数入口文件
export * from './domUtils';
export * from './stringUtils';
export * from './functionUtils';
export * from './logUtils';
\ No newline at end of file
/**
* 日志工具函数模块
* 提供日志记录和管理功能
*/
import { nextTick } from 'vue';
/**
* 添加日志
* @param message - 日志消息
* @param type - 日志类型 (info, warn, error等)
* @param logAreaId - 日志显示区域的ID
*/
export const addLog = (message: string, type: string, logAreaId: string = 'log-area'): void => {
try {
const logArea = document.getElementById(logAreaId);
if (logArea) {
const logItem = document.createElement('div');
logItem.className = `log-${type}`;
// 对特定错误消息进行友好化处理
let displayMessage = message;
if (message.includes('sso.hisense.com') && message.includes('setRequestHeader')) {
displayMessage = `${message} (这是海信SSO系统的前端错误,不影响系统核心功能)`;
}
logItem.textContent = `[${new Date().toLocaleTimeString()}] ${displayMessage}`;
// 添加数据属性以便于调试
logItem.setAttribute('data-log-type', type);
logItem.setAttribute('data-timestamp', new Date().toISOString());
logArea.appendChild(logItem);
// 限制日志数量,避免内存泄漏
while (logArea.children.length > 100) {
logArea.removeChild(logArea.firstChild!);
}
// 使用nextTick确保DOM更新后再滚动
nextTick(() => {
// 检查用户是否在查看历史日志
const isScrolledToBottom = logArea.scrollHeight - logArea.clientHeight <= logArea.scrollTop + 10;
if (isScrolledToBottom) {
// 自动滚动到最新日志
logArea.scrollTop = logArea.scrollHeight;
}
});
// 同时输出到浏览器控制台,方便调试
console.log(`[${type.toUpperCase()}][${new Date().toLocaleTimeString()}] ${message}`);
} else {
// 如果无法找到日志区域,使用console作为后备
console.log(`[${type.toUpperCase()}][${new Date().toLocaleTimeString()}] ${message}`);
}
} catch (e) {
// 即使日志记录失败,也不要影响主流程
console.error('日志记录失败:', e);
}
};
/**
* 清空日志
* @param logAreaId - 日志显示区域的ID
*/
export const clearLogs = (logAreaId: string = 'log-area'): void => {
const logArea = document.getElementById(logAreaId);
if (logArea) {
logArea.innerHTML = '';
}
};
\ No newline at end of file
...@@ -2,13 +2,17 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosR ...@@ -2,13 +2,17 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosR
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
// 创建一个 axios 实例 /**
* 创建一个 axios 实例
*/
const request: AxiosInstance = axios.create({ const request: AxiosInstance = axios.create({
baseURL: '/api/v1', // 设置基础URL baseURL: '/api/v1', // 设置基础URL
timeout: 120000, // 设置超时时间为2分钟,解决Dashboard页面请求超时问题 timeout: 120000, // 设置超时时间为2分钟,解决Dashboard页面请求超时问题
}) })
// 请求拦截器 /**
* 请求拦截器
*/
request.interceptors.request.use( request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
// 从 localStorage 获取 token // 从 localStorage 获取 token
...@@ -23,7 +27,9 @@ request.interceptors.request.use( ...@@ -23,7 +27,9 @@ request.interceptors.request.use(
} }
) )
// 响应拦截器 /**
* 响应拦截器
*/
request.interceptors.response.use( request.interceptors.response.use(
(response: AxiosResponse) => { (response: AxiosResponse) => {
// 对响应数据做点什么 // 对响应数据做点什么
......
/**
* 字符串工具函数模块
* 提供字符串处理相关的实用函数
*/
/**
* 【关键工具函数】处理后端转义的HTML内容
* 后端使用escapeHtmlContent()转义HTML,但这不是标准JSON转义
* 需要特殊处理来还原原始HTML
* @param content - 需要反转义的HTML内容
* @returns 反转义后的HTML内容
*/
export const unescapeHtmlContent = (content: string): string => {
if (!content) return content;
try {
// 检查是否包含转义标记
if (!content.includes('\\')) {
// 没有转义标记,直接返回
return content;
}
console.log('检测到HTML内容可能被转义,开始还原...');
let unescaped = content;
// 还原基本的转义序列
// 注意顺序很重要:先处理\\,再处理其他的
unescaped = unescaped.replace(/\\\\/g, '\\'); // \\: \
unescaped = unescaped.replace(/\\\"/g, '"'); // \": "
unescaped = unescaped.replace(/\\'/g, "'"); // \': '
unescaped = unescaped.replace(/\\n/g, '\n'); // \n: 换行符
unescaped = unescaped.replace(/\\r/g, '\r'); // \r: 回车符
unescaped = unescaped.replace(/\\t/g, '\t'); // \t: 制表符
unescaped = unescaped.replace(/\\b/g, '\b'); // \b: 退格符
unescaped = unescaped.replace(/\\f/g, '\f'); // \f: 换页符
// 处理Unicode转义序列 \\uXXXX
unescaped = unescaped.replace(/\\u([0-9a-fA-F]{4})/g, (match, code) => {
return String.fromCharCode(parseInt(code, 16));
});
console.log('HTML内容还原完成,长度: ' + unescaped.length + ' 字节');
return unescaped;
} catch (e) {
console.warn('HTML内容还原失败: ' + (e as Error).message + ',使用原始内容');
return content;
}
};
\ No newline at end of file
/**
* Token工具类
* 提供JWT Token处理相关的实用函数
*/
export class TokenUtils {
/**
* 检查Token是否有效
* @param token JWT Token
* @returns 包含有效性、过期状态、过期时间和剩余分钟数的对象
*/
static validateToken(token: string | null): {isValid: boolean, isExpired: boolean, expiresAt: Date | null, minutesLeft: number | null} {
if (!token) {
return {isValid: false, isExpired: true, expiresAt: null, minutesLeft: null};
}
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const expTime = payload.exp * 1000;
const expiresAt = new Date(expTime);
const now = Date.now();
const isExpired = expTime < now;
const minutesLeft = isExpired ? 0 : (expTime - now) / 1000 / 60;
return {
isValid: !isExpired,
isExpired,
expiresAt,
minutesLeft
};
} catch (e) {
return {isValid: false, isExpired: true, expiresAt: null, minutesLeft: null};
}
}
/**
* 检查Token是否即将过期(默认30分钟内)
* @param token JWT Token
* @param thresholdMinutes 阈值分钟数
* @returns 如果Token即将过期则返回true,否则返回false
*/
static isTokenExpiringSoon(token: string | null, thresholdMinutes: number = 30): boolean {
const validation = this.validateToken(token);
if (!validation.isValid || validation.minutesLeft === null) {
return true; // 如果Token无效或已过期,视为即将过期
}
return validation.minutesLeft < thresholdMinutes;
}
/**
* 获取Token中的用户ID
* @param token JWT Token
* @returns 用户ID或null
*/
static getUserIdFromToken(token: string | null): string | null {
if (!token) {
return null;
}
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.userId || null;
} catch (e) {
return null;
}
}
}
\ No newline at end of file
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true,
/* Linting */ /* Linting */
"strict": true, "strict": true,
...@@ -27,10 +28,17 @@ ...@@ -27,10 +28,17 @@
/* Vue specific */ /* Vue specific */
"jsx": "preserve", "jsx": "preserve",
"jsxImportSource": "vue",
/* Type checking */ /* Type checking */
"types": ["element-plus/global"] "types": ["element-plus/global"]
}, },
"vueCompilerOptions": {
"target": 3,
"strictTemplates": true,
"skipTemplateCodegen": true,
"experimentalRuntimeMode": "runtime-dom"
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }
\ No newline at end of file
...@@ -12,6 +12,14 @@ export default defineConfig({ ...@@ -12,6 +12,14 @@ export default defineConfig({
server: { server: {
port: 5174, port: 5174,
strictPort: true, // 确保总是使用指定端口 strictPort: true, // 确保总是使用指定端口
fs: {
// 允许访问文件系统,解决Windows平台上的动态导入问题
allow: [
'..'
],
// 禁用严格的文件系统限制
strict: false
},
// 添加headers配置以允许iframe加载 // 添加headers配置以允许iframe加载
headers: { headers: {
'X-Frame-Options': 'SAMEORIGIN' 'X-Frame-Options': 'SAMEORIGIN'
......
@echo off @echo off
chcp 65001 >nul
REM HiAgent 后端Debug启动脚本 REM HiAgent 后端Debug启动脚本
REM 此脚本用于启动Spring Boot应用进行远程调试 REM 此脚本用于启动Spring Boot应用进行远程调试
...@@ -29,10 +30,10 @@ REM 进入后端目录 ...@@ -29,10 +30,10 @@ REM 进入后端目录
cd /d "%~dp0backend" cd /d "%~dp0backend"
echo [INFO] 清理旧的构建文件... echo [INFO] 清理旧的构建文件...
call mvn clean -q call mvn clean -q -Dfile.encoding=UTF-8
echo [INFO] 编译项目... echo [INFO] 编译项目...
call mvn compile -q call mvn compile -q -Dfile.encoding=UTF-8
if errorlevel 1 ( if errorlevel 1 (
echo [ERROR] 编译失败,请检查代码 echo [ERROR] 编译失败,请检查代码
pause pause
...@@ -45,9 +46,19 @@ echo [INFO] Swagger UI: http://localhost:8080/swagger-ui.html ...@@ -45,9 +46,19 @@ echo [INFO] Swagger UI: http://localhost:8080/swagger-ui.html
echo [INFO] 调试端口: 5005 (JDWP) echo [INFO] 调试端口: 5005 (JDWP)
echo. echo.
REM 启动应用,开启JDWP调试端口5005 REM 确保日志目录存在
set MAVEN_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 -Dfile.encoding=UTF-8 echo [INFO] 检查日志目录...
if not exist "logs" mkdir logs
call mvn spring-boot:run -Dspring-boot.run.arguments="--spring.jpa.hibernate.ddl-auto=create-drop" REM 启动应用,开启JDWP调试端口5005,并设置调试相关JVM参数
set MAVEN_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./logs/heapdump.hprof
echo [INFO] 设置Spring Boot调试参数...
echo [INFO] 启用详细日志记录
echo [INFO] 激活开发环境配置
echo [INFO] 开启WebSocket详细日志
echo [INFO] 开启安全框架详细日志
set SPRING_PROFILES_ACTIVE=dev
call mvn spring-boot:run -Dspring-boot.run.arguments="--spring.jpa.hibernate.ddl-auto=create-drop --logging.level.root=INFO --logging.level.pangea.hiagent=TRACE --logging.level.org.springframework.web=INFO --logging.level.org.springframework.security=INFO --logging.level.org.springframework.web.socket=INFO"
pause pause
\ No newline at end of file
@echo off
echo Setting environment variables for HiAgent...
REM 设置DeepSeek API密钥(请替换为你的实际API密钥)
set DEEPSEEK_API_KEY=your-deepseek-api-key-here
REM 设置JWT密钥
set JWT_SECRET=hiagent-secret-key-for-production-change-this
REM 设置其他可选的API密钥
REM set OPENAI_API_KEY=your-openai-api-key-here
echo Environment variables set successfully!
echo Starting HiAgent application...
REM 启动后端应用
cd backend
call mvn spring-boot:run
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