Commit 94404fa5 authored by ligaowei's avatar ligaowei

feat: 重构时间轴功能并增强工具事件处理

重构时间轴功能,引入SSE服务管理事件流,优化事件处理性能。新增事件详情面板和过滤功能,增强工具事件监控和错误处理。改进内容展开管理,使用WeakMap优化性能。移除冗余的TimelineService,整合到SSE服务中。添加事件持久化存储和导出功能。

主要变更包括:
1. 新增BaseTool基类统一工具事件处理
2. 实现事件详情面板和虚拟滚动
3. 添加时间轴事件过滤和搜索功能
4. 优化内容展开管理使用WeakMap
5. 重构SSE服务为独立模块
6. 添加事件持久化存储和导出功能
7. 改进错误处理和性能监控
parent 8c6ba975
...@@ -134,21 +134,20 @@ public class SecurityConfig { ...@@ -134,21 +134,20 @@ public class SecurityConfig {
try { try {
// 对于SSE端点的特殊处理 // 对于SSE端点的特殊处理
boolean isStreamEndpoint = request.getRequestURI().contains("/api/v1/agent/chat-stream"); boolean isStreamEndpoint = request.getRequestURI().contains("/api/v1/agent/chat-stream");
boolean isTimelineEndpoint = request.getRequestURI().contains("/api/v1/agent/timeline-events");
if (isStreamEndpoint) {
if (isStreamEndpoint || isTimelineEndpoint) { // 对于SSE端点,发送SSE格式的错误事件
// 对于SSE端点,发送SSE格式的错误事件 response.setContentType("text/event-stream;charset=UTF-8");
response.setContentType("text/event-stream;charset=UTF-8"); response.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8"); response.getWriter().write("event: error\ndata: {\"error\": \"未授权访问\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n");
response.getWriter().write("event: error\ndata: {\"error\": \"未授权访问\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n"); response.getWriter().flush();
response.getWriter().flush(); // 确保响应被正确提交
// 确保响应被正确提交 if (!response.isCommitted()) {
if (!response.isCommitted()) { response.flushBuffer();
response.flushBuffer();
}
return;
} }
return;
}
response.setStatus(401); response.setStatus(401);
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
...@@ -174,21 +173,20 @@ public class SecurityConfig { ...@@ -174,21 +173,20 @@ public class SecurityConfig {
try { try {
// 对于SSE端点的特殊处理 // 对于SSE端点的特殊处理
boolean isStreamEndpoint = request.getRequestURI().contains("/api/v1/agent/chat-stream"); boolean isStreamEndpoint = request.getRequestURI().contains("/api/v1/agent/chat-stream");
boolean isTimelineEndpoint = request.getRequestURI().contains("/api/v1/agent/timeline-events");
if (isStreamEndpoint) {
if (isStreamEndpoint || isTimelineEndpoint) { // 对于SSE端点,发送SSE格式的错误事件
// 对于SSE端点,发送SSE格式的错误事件 response.setContentType("text/event-stream;charset=UTF-8");
response.setContentType("text/event-stream;charset=UTF-8"); response.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8"); response.getWriter().write("event: error\ndata: {\"error\": \"访问被拒绝\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n");
response.getWriter().write("event: error\ndata: {\"error\": \"访问被拒绝\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n"); response.getWriter().flush();
response.getWriter().flush(); // 确保响应被正确提交
// 确保响应被正确提交 if (!response.isCommitted()) {
if (!response.isCommitted()) { response.flushBuffer();
response.flushBuffer();
}
return;
} }
return;
}
response.setStatus(403); response.setStatus(403);
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
......
package pangea.hiagent.tool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import pangea.hiagent.workpanel.event.SseEventBroadcaster;
import pangea.hiagent.web.dto.WorkPanelEvent;
import java.util.HashMap;
import java.util.Map;
/**
* 所有工具类的基础抽象类
* 提供工具执行监控和SSE事件发送功能
*/
@Slf4j
public abstract class BaseTool {
@Autowired
private SseEventBroadcaster sseEventBroadcaster;
/**
* 工具执行包装方法
* 监控工具方法的完整执行生命周期
* @param methodName 被调用的方法名称
* @param params 方法参数映射
* @param action 实际执行的工具逻辑
* @param <T> 返回类型
* @return 工具执行结果
*/
protected <T> T execute(String methodName, Map<String, Object> params, ToolAction<T> action) {
String toolName = this.getClass().getSimpleName();
long startTime = System.currentTimeMillis();
// 1. 发送工具开始执行事件
sendToolEvent(toolName, methodName, params, null, "执行中", startTime, null);
T result = null;
String status = "成功";
Exception exception = null;
try {
// 2. 执行实际的工具逻辑
result = action.run();
} catch (Exception e) {
status = "失败";
exception = e;
throw new RuntimeException("工具执行失败: " + e.getMessage(), e);
} finally {
// 记录结束时间和耗时
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
// 3. 发送工具执行完成事件
sendToolEvent(toolName, methodName, params, result, status, startTime, duration, exception);
}
return result;
}
/**
* 简化版execute方法,无需手动构建参数映射
* @param methodName 被调用的方法名称
* @param action 实际执行的工具逻辑
* @param <T> 返回类型
* @return 工具执行结果
*/
protected <T> T execute(String methodName, ToolAction<T> action) {
return execute(methodName, new HashMap<>(), action);
}
/**
* 发送工具事件给前端
* @param toolName 工具名称
* @param methodName 方法名称
* @param params 参数信息
* @param result 执行结果
* @param status 执行状态(执行中/成功/失败)
* @param startTime 开始时间戳
* @param duration 执行耗时(毫秒)
* @param exception 异常信息(可选)
*/
private void sendToolEvent(String toolName, String methodName,
Map<String, Object> params, Object result, String status,
Long startTime, Long duration, Exception... exception) {
try {
Map<String, Object> eventData = new HashMap<>();
eventData.put("toolName", toolName);
eventData.put("methodName", methodName);
eventData.put("params", params);
eventData.put("result", result);
eventData.put("status", status);
eventData.put("startTime", startTime);
eventData.put("duration", duration);
if (exception != null && exception.length > 0 && exception[0] != null) {
eventData.put("error", exception[0].getMessage());
eventData.put("errorType", exception[0].getClass().getSimpleName());
}
WorkPanelEvent event = WorkPanelEvent.builder()
.type("tool_call")
.title(toolName + "." + methodName)
.timestamp(System.currentTimeMillis())
.metadata(eventData)
.build();
sseEventBroadcaster.broadcastWorkPanelEvent(event);
log.debug("已发送工具事件: {}#{}, 状态: {}", toolName, methodName, status);
} catch (Exception e) {
log.error("发送工具事件失败: {}", e.getMessage(), e);
}
}
/**
* 工具动作函数式接口
* 用于封装实际要执行的工具逻辑
* @param <T> 返回类型
*/
@FunctionalInterface
protected interface ToolAction<T> {
T run() throws Exception;
}
}
\ No newline at end of file
...@@ -2,6 +2,9 @@ package pangea.hiagent.tool.impl; ...@@ -2,6 +2,9 @@ package pangea.hiagent.tool.impl;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import pangea.hiagent.tool.BaseTool;
import java.util.HashMap;
import java.util.Map;
/** /**
* 计算器工具类 * 计算器工具类
...@@ -9,38 +12,62 @@ import org.springframework.ai.tool.annotation.Tool; ...@@ -9,38 +12,62 @@ import org.springframework.ai.tool.annotation.Tool;
*/ */
@Slf4j @Slf4j
@Component @Component
public class CalculatorTools { public class CalculatorTools extends BaseTool {
@Tool(description = "执行两个数字的加法运算") @Tool(description = "执行两个数字的加法运算")
public double add(double a, double b) { public double add(double a, double b) {
double result = a + b; Map<String, Object> params = new HashMap<>();
log.debug("执行加法运算: {} + {} = {}", a, b, result); params.put("a", a);
return result; params.put("b", b);
return execute("add", params, () -> {
double result = a + b;
log.debug("执行加法运算: {} + {} = {}", a, b, result);
return result;
});
} }
@Tool(description = "执行两个数字的减法运算") @Tool(description = "执行两个数字的减法运算")
public double subtract(double a, double b) { public double subtract(double a, double b) {
double result = a - b; Map<String, Object> params = new HashMap<>();
log.debug("执行减法运算: {} - {} = {}", a, b, result); params.put("a", a);
return result; params.put("b", b);
return execute("subtract", params, () -> {
double result = a - b;
log.debug("执行减法运算: {} - {} = {}", a, b, result);
return result;
});
} }
@Tool(description = "执行两个数字的乘法运算") @Tool(description = "执行两个数字的乘法运算")
public double multiply(double a, double b) { public double multiply(double a, double b) {
double result = a * b; Map<String, Object> params = new HashMap<>();
log.debug("执行乘法运算: {} * {} = {}", a, b, result); params.put("a", a);
return result; params.put("b", b);
return execute("multiply", params, () -> {
double result = a * b;
log.debug("执行乘法运算: {} * {} = {}", a, b, result);
return result;
});
} }
@Tool(description = "执行两个数字的除法运算") @Tool(description = "执行两个数字的除法运算")
public String divide(double a, double b) { public String divide(double a, double b) {
log.debug("执行除法运算: {} / {}", a, b); Map<String, Object> params = new HashMap<>();
if (b == 0) { params.put("a", a);
log.warn("除法运算错误:除数不能为零"); params.put("b", b);
return "错误:除数不能为零";
} return execute("divide", params, () -> {
double result = a / b; log.debug("执行除法运算: {} / {}", a, b);
log.debug("除法运算结果: {}", result); if (b == 0) {
return String.valueOf(result); log.warn("除法运算错误:除数不能为零");
return "错误:除数不能为零";
}
double result = a / b;
log.debug("除法运算结果: {}", result);
return String.valueOf(result);
});
} }
} }
\ No newline at end of file
...@@ -3,7 +3,7 @@ package pangea.hiagent.tool.impl; ...@@ -3,7 +3,7 @@ package pangea.hiagent.tool.impl;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import pangea.hiagent.tool.BaseTool;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalDate; import java.time.LocalDate;
...@@ -16,7 +16,7 @@ import java.time.format.DateTimeFormatter; ...@@ -16,7 +16,7 @@ import java.time.format.DateTimeFormatter;
*/ */
@Slf4j @Slf4j
@Component @Component
public class DateTimeTools { public class DateTimeTools extends BaseTool {
private String dateTimeFormat = "yyyy-MM-dd HH:mm:ss"; private String dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
...@@ -26,61 +26,69 @@ public class DateTimeTools { ...@@ -26,61 +26,69 @@ public class DateTimeTools {
@Tool(description = "获取当前日期和时间,返回格式为 'yyyy-MM-dd HH:mm:ss'") @Tool(description = "获取当前日期和时间,返回格式为 'yyyy-MM-dd HH:mm:ss'")
public String getCurrentDateTime() { public String getCurrentDateTime() {
try { return execute("getCurrentDateTime", () -> {
if (dateTimeFormat == null || dateTimeFormat.trim().isEmpty()) { try {
dateTimeFormat = "yyyy-MM-dd HH:mm:ss"; if (dateTimeFormat == null || dateTimeFormat.trim().isEmpty()) {
dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
}
String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateTimeFormat));
log.info("【时间工具】获取当前日期时间: {}", dateTime);
return dateTime;
} catch (Exception e) {
log.error("获取当前日期时间时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
} }
String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateTimeFormat)); });
log.info("【时间工具】获取当前日期时间: {}", dateTime);
return dateTime;
} catch (Exception e) {
log.error("获取当前日期时间时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
} }
@Tool(description = "获取当前日期,返回格式为 'yyyy-MM-dd'") @Tool(description = "获取当前日期,返回格式为 'yyyy-MM-dd'")
public String getCurrentDate() { public String getCurrentDate() {
try { return execute("getCurrentDate", () -> {
if (dateFormat == null || dateFormat.trim().isEmpty()) { try {
dateFormat = "yyyy-MM-dd"; if (dateFormat == null || dateFormat.trim().isEmpty()) {
dateFormat = "yyyy-MM-dd";
}
String date = LocalDate.now().format(DateTimeFormatter.ofPattern(dateFormat));
log.info("【时间工具】获取当前日期: {}", date);
return date;
} catch (Exception e) {
log.error("获取当前日期时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
} }
String date = LocalDate.now().format(DateTimeFormatter.ofPattern(dateFormat)); });
log.info("【时间工具】获取当前日期: {}", date);
return date;
} catch (Exception e) {
log.error("获取当前日期时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
} }
@Tool(description = "获取当前时间,返回格式为 'HH:mm:ss'") @Tool(description = "获取当前时间,返回格式为 'HH:mm:ss'")
public String getCurrentTime() { public String getCurrentTime() {
try { return execute("getCurrentTime", () -> {
if (timeFormat == null || timeFormat.trim().isEmpty()) { try {
timeFormat = "HH:mm:ss"; if (timeFormat == null || timeFormat.trim().isEmpty()) {
timeFormat = "HH:mm:ss";
}
String time = LocalTime.now().format(DateTimeFormatter.ofPattern(timeFormat));
log.info("【时间工具】获取当前时间: {}", time);
return time;
} catch (Exception e) {
log.error("获取当前时间时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
} }
String time = LocalTime.now().format(DateTimeFormatter.ofPattern(timeFormat)); });
log.info("【时间工具】获取当前时间: {}", time);
return time;
} catch (Exception e) {
log.error("获取当前时间时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
}
} }
@Tool(description = "获取当前时间戳(毫秒),返回自1970年1月1日00:00:00 UTC以来的毫秒数") @Tool(description = "获取当前时间戳(毫秒),返回自1970年1月1日00:00:00 UTC以来的毫秒数")
public String getCurrentTimeMillis() { public String getCurrentTimeMillis() {
try { return execute("getCurrentTimeMillis", () -> {
long timestamp = System.currentTimeMillis(); try {
log.info("【时间工具】获取当前时间戳: {}", timestamp); long timestamp = System.currentTimeMillis();
return String.valueOf(timestamp); log.info("【时间工具】获取当前时间戳: {}", timestamp);
} catch (Exception e) { return String.valueOf(timestamp);
log.error("获取当前时间戳时发生错误: {}", e.getMessage(), e); } catch (Exception e) {
return String.valueOf(System.currentTimeMillis()); log.error("获取当前时间戳时发生错误: {}", e.getMessage(), e);
} return String.valueOf(System.currentTimeMillis());
}
});
} }
} }
...@@ -14,7 +14,10 @@ import java.time.LocalDateTime; ...@@ -14,7 +14,10 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.HashMap;
import java.util.Map;
import pangea.hiagent.tool.BaseTool;
import pangea.hiagent.web.service.ToolConfigService; import pangea.hiagent.web.service.ToolConfigService;
import pangea.hiagent.workpanel.playwright.PlaywrightManager; import pangea.hiagent.workpanel.playwright.PlaywrightManager;
...@@ -25,7 +28,7 @@ import pangea.hiagent.workpanel.playwright.PlaywrightManager; ...@@ -25,7 +28,7 @@ import pangea.hiagent.workpanel.playwright.PlaywrightManager;
*/ */
@Slf4j @Slf4j
@Component @Component
public class HisenseLbpmApprovalTool { public class HisenseLbpmApprovalTool extends BaseTool {
// SSO登录页面URL // SSO登录页面URL
private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/"; private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/";
...@@ -62,106 +65,108 @@ public class HisenseLbpmApprovalTool { ...@@ -62,106 +65,108 @@ public class HisenseLbpmApprovalTool {
*/ */
@Tool(description = "处理海信请假审批、自驾车审批、调休审批,需要先使用HisenseSsoLoginTool登录,提供用户ID以区分会话") @Tool(description = "处理海信请假审批、自驾车审批、调休审批,需要先使用HisenseSsoLoginTool登录,提供用户ID以区分会话")
public String processHisenseLeaveApproval(String approvalUrl, String approvalOpinion) { public String processHisenseLeaveApproval(String approvalUrl, String approvalOpinion) {
Map<String, Object> params = new HashMap<>();
String ssoUsername = getSsoUsername(); params.put("approvalUrl", approvalUrl);
log.info("开始为用户 {} 处理海信请假审批,URL: {}", ssoUsername, approvalUrl); params.put("approvalOpinion", approvalOpinion);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalUrl == null || approvalUrl.isEmpty()) {
String errorMsg = "审批URL不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalOpinion == null || approvalOpinion.isEmpty()) {
String errorMsg = "审批意见不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try { return execute("processHisenseLeaveApproval", params, () -> {
// 获取用户专用的浏览器上下文 String ssoUsername = getSsoUsername();
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername); log.info("开始为用户 {} 处理海信请假审批,URL: {}", ssoUsername, approvalUrl);
// 创建新页面
page = userContext.newPage();
// 访问审批页面
log.info("正在访问审批页面: {}", approvalUrl);
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面 // 参数校验
String currentUrl = page.url(); if (ssoUsername == null || ssoUsername.isEmpty()) {
log.info("当前页面URL: {}", currentUrl); String errorMsg = "用户ID不能为空";
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg); log.error(errorMsg);
return errorMsg; return errorMsg;
} }
if (approvalUrl == null || approvalUrl.isEmpty()) {
String errorMsg = "审批URL不能为空";
// 等待页面完全加载完成 log.error(errorMsg);
page.waitForLoadState(LoadState.NETWORKIDLE); return errorMsg;
// 等待关键元素加载完成,确保页面完全就绪
try {
page.waitForSelector("input[type='radio'][name*='oprGroup'], input[type='radio'][name*='fdNotifyLevel']",
new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(5000));
} catch (com.microsoft.playwright.TimeoutError e) {
log.warn("关键审批元素未在预期时间内加载完成,继续执行审批操作");
} }
// 执行审批操作 if (approvalOpinion == null || approvalOpinion.isEmpty()) {
performApprovalOperation(page, approvalOpinion); String errorMsg = "审批意见不能为空";
log.error(errorMsg);
// 截图并保存 return errorMsg;
takeScreenshotAndSave(page, "lbpm_approval_success_" + ssoUsername); }
long endTime = System.currentTimeMillis();
log.info("请假审批处理完成,耗时: {} ms", endTime - startTime);
return "请假审批处理成功"; Page page = null;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "请假审批处理失败: " + e.getMessage();
log.error("请假审批处理失败,耗时: {} ms", endTime - startTime, e);
// 如果页面对象存在,截图保存错误页面 try {
if (page != null) { // 获取用户专用的浏览器上下文
try { BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
takeScreenshotAndSave(page, "lbpm_approval_fail_" + ssoUsername);
} catch (Exception screenshotException) { // 创建新页面
log.warn("截图保存失败: {}", screenshotException.getMessage()); page = userContext.newPage();
// 访问审批页面
log.info("正在访问审批页面: {}", approvalUrl);
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg);
return errorMsg;
} }
}
return errorMsg;
} finally {
// 不立即关闭页面,让服务器完成审批处理 // 等待页面完全加载完成
// 保留页面、上下文和浏览器实例供后续操作使用 page.waitForLoadState(LoadState.NETWORKIDLE);
// 仅在发生异常时才关闭页面
if (page != null) { // 等待关键元素加载完成,确保页面完全就绪
try { try {
// 可以选择保留页面不关闭,或者等待一段时间后关闭 page.waitForSelector("input[type='radio'][name*='oprGroup'], input[type='radio'][name*='fdNotifyLevel']",
// 目前保持原逻辑,但实际使用中可能需要根据业务需求调整 new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(5000));
log.debug("保留页面实例以等待服务器完成审批处理"); } catch (com.microsoft.playwright.TimeoutError e) {
} catch (Exception e) { log.warn("关键审批元素未在预期时间内加载完成,继续执行审批操作");
log.warn("处理页面实例时发生异常: {}", e.getMessage()); }
// 执行审批操作
performApprovalOperation(page, approvalOpinion);
// 截图并保存
takeScreenshotAndSave(page, "lbpm_approval_success_" + ssoUsername);
log.info("请假审批处理完成");
return "请假审批处理成功";
} catch (Exception e) {
String errorMsg = "请假审批处理失败: " + e.getMessage();
log.error("请假审批处理失败", e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
try {
takeScreenshotAndSave(page, "lbpm_approval_fail_" + ssoUsername);
} catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
}
}
return errorMsg;
} finally {
// 不立即关闭页面,让服务器完成审批处理
// 保留页面、上下文和浏览器实例供后续操作使用
// 仅在发生异常时才关闭页面
if (page != null) {
try {
// 可以选择保留页面不关闭,或者等待一段时间后关闭
// 目前保持原逻辑,但实际使用中可能需要根据业务需求调整
log.debug("保留页面实例以等待服务器完成审批处理");
} catch (Exception e) {
log.warn("处理页面实例时发生异常: {}", e.getMessage());
}
} }
} }
} });
} }
/** /**
...@@ -172,73 +177,74 @@ public class HisenseLbpmApprovalTool { ...@@ -172,73 +177,74 @@ public class HisenseLbpmApprovalTool {
*/ */
@Tool(description = "获取海信LBPM业务系统的网页内容,需要先使用HisenseSsoLoginTool登录") @Tool(description = "获取海信LBPM业务系统的网页内容,需要先使用HisenseSsoLoginTool登录")
public String getHisenseLbpmBusinessSystemContent(String businessSystemUrl) { public String getHisenseLbpmBusinessSystemContent(String businessSystemUrl) {
String ssoUsername = getSsoUsername(); Map<String, Object> params = new HashMap<>();
log.info("开始为用户 {} 获取海信业务系统内容,URL: {}", ssoUsername, businessSystemUrl); params.put("businessSystemUrl", businessSystemUrl);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg);
return errorMsg;
}
if (businessSystemUrl == null || businessSystemUrl.isEmpty()) {
String errorMsg = "业务系统URL不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try { return execute("getHisenseLbpmBusinessSystemContent", params, () -> {
// 获取用户专用的浏览器上下文 String ssoUsername = getSsoUsername();
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername); log.info("开始为用户 {} 获取海信业务系统内容,URL: {}", ssoUsername, businessSystemUrl);
// 创建新页面
page = userContext.newPage();
// 访问业务系统页面
log.info("正在访问业务系统页面: {}", businessSystemUrl);
page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) { // 参数校验
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录"; if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg); log.error(errorMsg);
return errorMsg; return errorMsg;
} }
// 提取页面内容 if (businessSystemUrl == null || businessSystemUrl.isEmpty()) {
String content = page.locator("body").innerText(); String errorMsg = "业务系统URL不能为空";
long endTime = System.currentTimeMillis(); log.error(errorMsg);
log.info("成功获取业务系统页面内容,耗时: {} ms", endTime - startTime); return errorMsg;
// 检查是否包含错误信息
if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) {
log.warn("检测到页面中可能存在JavaScript错误,但这不会影响主要功能");
} }
Page page = null;
return content; try {
} catch (Exception e) { // 获取用户专用的浏览器上下文
long endTime = System.currentTimeMillis(); BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
log.error("获取海信业务系统内容失败,耗时: {} ms", endTime - startTime, e); // 创建新页面
return errorMsg; page = userContext.newPage();
} finally {
// 释放页面资源,但保留浏览器上下文供后续使用 // 访问业务系统页面
if (page != null) { log.info("正在访问业务系统页面: {}", businessSystemUrl);
try { page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
page.close();
} catch (Exception e) { // 检查是否重定向到了SSO登录页面
log.warn("关闭页面时发生异常: {}", e.getMessage()); String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg);
return errorMsg;
}
// 提取页面内容
String content = page.locator("body").innerText();
log.info("成功获取业务系统页面内容");
// 检查是否包含错误信息
if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) {
log.warn("检测到页面中可能存在JavaScript错误,但这不会影响主要功能");
}
return content;
} catch (Exception e) {
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
log.error("获取海信业务系统内容失败", e);
return errorMsg;
} finally {
// 释放页面资源,但保留浏览器上下文供后续使用
if (page != null) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
} }
} }
} });
} }
/** /**
...@@ -370,118 +376,116 @@ public class HisenseLbpmApprovalTool { ...@@ -370,118 +376,116 @@ public class HisenseLbpmApprovalTool {
*/ */
@Tool(description = "自动查找所有待审批的请假流程的网址,需要先使用HisenseSsoLoginTool登录") @Tool(description = "自动查找所有待审批的请假流程的网址,需要先使用HisenseSsoLoginTool登录")
public List<String> processAllPendingLeaveApprovals() { public List<String> processAllPendingLeaveApprovals() {
String ssoUsername = getSsoUsername(); Map<String, Object> params = new HashMap<>();
log.info("开始为用户 {} 处理所有待审批的请假流程", ssoUsername);
long startTime = System.currentTimeMillis();
int processedCount = 0;
// 参数校验 return execute("processAllPendingLeaveApprovals", params, () -> {
if (ssoUsername == null || ssoUsername.isEmpty()) { String ssoUsername = getSsoUsername();
String errorMsg = "用户ID不能为空"; log.info("开始为用户 {} 处理所有待审批的请假流程", ssoUsername);
log.error(errorMsg);
return List.of(errorMsg);
}
Page page = null;
try {
// 获取用户专用的浏览器上下文
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
// 创建新页面 // 参数校验
page = userContext.newPage(); if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
// 访问待审批列表页面
String approvalListUrl = "https://lbpm.hisense.com/km/review/?categoryId=1843775ea85be87f9756e2540e5b20b0&nodeType=CATEGORY#j_path=%2FlistAll&mydoc=all&cri.q=docStatus%3A20%3BfdTemplate%3A1843775ea85be87f9756e2540e5b20b0";
log.info("正在访问待审批列表页面: {}", approvalListUrl);
page.navigate(approvalListUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg); log.error(errorMsg);
return List.of(errorMsg); return List.of(errorMsg);
} }
// 等待页面完全加载完成 Page page = null;
page.waitForLoadState(LoadState.NETWORKIDLE);
// 等待待审批项目加载完成
try { try {
page.waitForSelector("span.com_subject", // 获取用户专用的浏览器上下文
new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(10000)); BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
} catch (com.microsoft.playwright.TimeoutError e) {
log.info("在预期时间内未找到待审批项目,可能没有待审批的项目"); // 创建新页面
return List.of("没有找到待审批的项目"); page = userContext.newPage();
}
// 访问待审批列表页面
ArrayList<String> urls= new ArrayList<>(); String approvalListUrl = "https://lbpm.hisense.com/km/review/?categoryId=1843775ea85be87f9756e2540e5b20b0&nodeType=CATEGORY#j_path=%2FlistAll&mydoc=all&cri.q=docStatus%3A20%3BfdTemplate%3A1843775ea85be87f9756e2540e5b20b0";
log.info("正在访问待审批列表页面: {}", approvalListUrl);
// 查找所有待审批项目行,这些行包含kmss_fdid属性 page.navigate(approvalListUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
Locator approvalRows = page.locator("tr[kmss_fdid]");
int itemCount = approvalRows.count(); // 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
if (itemCount == 0) { log.info("当前页面URL: {}", currentUrl);
log.info("没有更多待审批项目,处理完成");
return List.of("没有更多待审批项目,处理完成"); if (currentUrl.startsWith(SSO_LOGIN_URL)) {
} String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg);
log.info("找到 {} 个待审批项目", itemCount); return List.of(errorMsg);
// 遍历所有待审批项目行,提取kmss_fdid属性值并构建审批URL
for (int i = 0; i < itemCount; i++) {
Locator currentApprovalRow = approvalRows.nth(i);
// 获取kmss_fdid属性值
String kmssFdid = currentApprovalRow.getAttribute("kmss_fdid");
if (kmssFdid != null && !kmssFdid.isEmpty()) {
// 构建完整的审批URL
String approvalUrl = "https://lbpm.hisense.com/km/review/km_review_main/kmReviewMain.do?method=view&fdId=" + kmssFdid;
// 获取审批项目文本(从span.com_subject中获取)
Locator approvalSubject = currentApprovalRow.locator("span.com_subject");
String approvalText = approvalSubject.count() > 0 ? approvalSubject.textContent() : "未知审批项目";
log.info("获取到待审批项目: {},链接: {}", approvalText, approvalUrl);
urls.add(approvalUrl);
}
}
long endTime = System.currentTimeMillis();
String resultMessage = String.format("待审批处理完成,共 %d 个项目,耗时: %d ms", processedCount, endTime - startTime);
log.info(resultMessage);
return urls;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "处理待审批项目失败: " + e.getMessage();
log.error("处理待审批项目失败,耗时: {} ms", endTime - startTime, e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
try {
takeScreenshotAndSave(page, "lbpm_pending_approval_fail_" + ssoUsername);
} catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
} }
}
// 等待页面完全加载完成
return List.of(errorMsg); page.waitForLoadState(LoadState.NETWORKIDLE);
} finally {
// 不立即关闭页面,让服务器完成审批处理 // 等待待审批项目加载完成
// 保留页面、上下文和浏览器实例供后续操作使用
// 仅在发生异常时才关闭页面
if (page != null) {
try { try {
log.debug("保留页面实例以等待服务器完成审批处理"); page.waitForSelector("span.com_subject",
} catch (Exception e) { new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(10000));
log.warn("处理页面实例时发生异常: {}", e.getMessage()); } catch (com.microsoft.playwright.TimeoutError e) {
log.info("在预期时间内未找到待审批项目,可能没有待审批的项目");
return List.of("没有找到待审批的项目");
}
ArrayList<String> urls= new ArrayList<>();
// 查找所有待审批项目行,这些行包含kmss_fdid属性
Locator approvalRows = page.locator("tr[kmss_fdid]");
int itemCount = approvalRows.count();
if (itemCount == 0) {
log.info("没有更多待审批项目,处理完成");
return List.of("没有更多待审批项目,处理完成");
}
log.info("找到 {} 个待审批项目", itemCount);
// 遍历所有待审批项目行,提取kmss_fdid属性值并构建审批URL
for (int i = 0; i < itemCount; i++) {
Locator currentApprovalRow = approvalRows.nth(i);
// 获取kmss_fdid属性值
String kmssFdid = currentApprovalRow.getAttribute("kmss_fdid");
if (kmssFdid != null && !kmssFdid.isEmpty()) {
// 构建完整的审批URL
String approvalUrl = "https://lbpm.hisense.com/km/review/km_review_main/kmReviewMain.do?method=view&fdId=" + kmssFdid;
// 获取审批项目文本(从span.com_subject中获取)
Locator approvalSubject = currentApprovalRow.locator("span.com_subject");
String approvalText = approvalSubject.count() > 0 ? approvalSubject.textContent() : "未知审批项目";
log.info("获取到待审批项目: {},链接: {}", approvalText, approvalUrl);
urls.add(approvalUrl);
}
}
log.info("待审批处理完成");
return urls;
} catch (Exception e) {
String errorMsg = "处理待审批项目失败: " + e.getMessage();
log.error("处理待审批项目失败", e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
try {
takeScreenshotAndSave(page, "lbpm_pending_approval_fail_" + ssoUsername);
} catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
}
}
return List.of(errorMsg);
} finally {
// 不立即关闭页面,让服务器完成审批处理
// 保留页面、上下文和浏览器实例供后续操作使用
// 仅在发生异常时才关闭页面
if (page != null) {
try {
log.debug("保留页面实例以等待服务器完成审批处理");
} catch (Exception e) {
log.warn("处理页面实例时发生异常: {}", e.getMessage());
}
} }
} }
} });
} }
} }
...@@ -14,7 +14,10 @@ import java.time.LocalDateTime; ...@@ -14,7 +14,10 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.HashMap;
import java.util.Map;
import pangea.hiagent.tool.BaseTool;
import pangea.hiagent.web.service.ToolConfigService; import pangea.hiagent.web.service.ToolConfigService;
import pangea.hiagent.workpanel.playwright.PlaywrightManager; import pangea.hiagent.workpanel.playwright.PlaywrightManager;
...@@ -25,7 +28,7 @@ import pangea.hiagent.workpanel.playwright.PlaywrightManager; ...@@ -25,7 +28,7 @@ import pangea.hiagent.workpanel.playwright.PlaywrightManager;
*/ */
@Slf4j @Slf4j
@Component @Component
public class HisensePerformanceApprovalTool { public class HisensePerformanceApprovalTool extends BaseTool {
// SSO登录页面URL // SSO登录页面URL
private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/"; private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/";
...@@ -60,163 +63,163 @@ public class HisensePerformanceApprovalTool { ...@@ -60,163 +63,163 @@ public class HisensePerformanceApprovalTool {
*/ */
@Tool(description = "自动查找所有待审批的绩效流程的网址,需要先使用HisenseSsoLoginTool登录") @Tool(description = "自动查找所有待审批的绩效流程的网址,需要先使用HisenseSsoLoginTool登录")
public List<String> checkHisensePerformancePendingTasks() { public List<String> checkHisensePerformancePendingTasks() {
String ssoUsername = getSsoUsername(); Map<String, Object> params = new HashMap<>();
log.info("开始为用户 {} 查找所有待审批的绩效流程", ssoUsername);
long startTime = System.currentTimeMillis(); return execute("checkHisensePerformancePendingTasks", params, () -> {
String ssoUsername = getSsoUsername();
// 参数校验 log.info("开始为用户 {} 查找所有待审批的绩效流程", ssoUsername);
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg);
return List.of(errorMsg);
}
Page page = null;
try {
// 获取用户专用的浏览器上下文
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
// 创建新页面
page = userContext.newPage();
// 访问待审批列表页面
log.info("正在访问绩效系统待审批页面: {}", PERFORMANCE_PENDING_URL);
page.navigate(PERFORMANCE_PENDING_URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) { // 参数校验
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录"; if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg); log.error(errorMsg);
return List.of(errorMsg); return List.of(errorMsg);
} }
// 等待页面完全加载完成 Page page = null;
page.waitForLoadState(LoadState.NETWORKIDLE);
// 等待按钮加载完成
try { try {
page.waitForSelector("button[data-v-991781fe] span", // 获取用户专用的浏览器上下文
new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(10000)); BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
} catch (com.microsoft.playwright.TimeoutError e) {
log.info("在预期时间内未找到待审批项目,可能没有待审批的流程"); // 创建新页面
return List.of("没有找到待审批的流程"); page = userContext.newPage();
}
// 访问待审批列表页面
ArrayList<String> urls = new ArrayList<>(); log.info("正在访问绩效系统待审批页面: {}", PERFORMANCE_PENDING_URL);
page.navigate(PERFORMANCE_PENDING_URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 查找所有"审核"按钮(第一种)
Locator auditButtons = page.locator("button[data-v-991781fe][type='button']:has(span:text('审核'))"); // 检查是否重定向到了SSO登录页面
int auditButtonCount = auditButtons.count(); String currentUrl = page.url();
log.info("找到 {} 个'审核'按钮", auditButtonCount); log.info("当前页面URL: {}", currentUrl);
// 查找所有"复评"按钮(第二种) if (currentUrl.startsWith(SSO_LOGIN_URL)) {
Locator reviewButtons = page.locator("button[data-v-991781fe][type='button']:has(span:text('复评'))"); String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
int reviewButtonCount = reviewButtons.count(); log.error(errorMsg);
log.info("找到 {} 个'复评'按钮", reviewButtonCount); return List.of(errorMsg);
}
// 处理"审核"按钮,获取跳转地址
for (int i = 0; i < auditButtonCount; i++) { // 等待页面完全加载完成
page.waitForLoadState(LoadState.NETWORKIDLE);
// 等待按钮加载完成
try { try {
Locator auditButton = auditButtons.nth(i); page.waitForSelector("button[data-v-991781fe] span",
new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(10000));
// 尝试获取按钮的href属性或从父元素获取 } catch (com.microsoft.playwright.TimeoutError e) {
String href = auditButton.getAttribute("href"); log.info("在预期时间内未找到待审批项目,可能没有待审批的流程");
if (href != null && !href.isEmpty()) { return List.of("没有找到待审批的流程");
log.info("获取到审核流程URL: {}", href); }
urls.add(href);
} else { ArrayList<String> urls = new ArrayList<>();
// 如果按钮没有href属性,点击按钮后获取新页面URL
log.debug("审核按钮无href属性,将通过点击获取URL"); // 查找所有"审核"按钮(第一种)
Locator auditButtons = page.locator("button[data-v-991781fe][type='button']:has(span:text('审核'))");
// 监听新的页面打开事件 int auditButtonCount = auditButtons.count();
Page newPage = page.context().waitForPage(() -> { log.info("找到 {} 个'审核'按钮", auditButtonCount);
auditButton.click();
}); // 查找所有"复评"按钮(第二种)
Locator reviewButtons = page.locator("button[data-v-991781fe][type='button']:has(span:text('复评'))");
String newPageUrl = newPage.url(); int reviewButtonCount = reviewButtons.count();
log.info("获取到审核流程URL (通过点击): {}", newPageUrl); log.info("找到 {} 个'复评'按钮", reviewButtonCount);
urls.add(newPageUrl);
// 处理"审核"按钮,获取跳转地址
for (int i = 0; i < auditButtonCount; i++) {
try {
Locator auditButton = auditButtons.nth(i);
// 关闭新页面,返回原页面 // 尝试获取按钮的href属性或从父元素获取
newPage.close(); String href = auditButton.getAttribute("href");
page.bringToFront(); if (href != null && !href.isEmpty()) {
log.info("获取到审核流程URL: {}", href);
urls.add(href);
} else {
// 如果按钮没有href属性,点击按钮后获取新页面URL
log.debug("审核按钮无href属性,将通过点击获取URL");
// 监听新的页面打开事件
Page newPage = page.context().waitForPage(() -> {
auditButton.click();
});
String newPageUrl = newPage.url();
log.info("获取到审核流程URL (通过点击): {}", newPageUrl);
urls.add(newPageUrl);
// 关闭新页面,返回原页面
newPage.close();
page.bringToFront();
}
} catch (Exception e) {
log.warn("处理审核按钮时发生异常: {}", e.getMessage());
} }
} catch (Exception e) {
log.warn("处理审核按钮时发生异常: {}", e.getMessage());
} }
}
// 处理"复评"按钮,获取跳转地址
// 处理"复评"按钮,获取跳转地址 for (int i = 0; i < reviewButtonCount; i++) {
for (int i = 0; i < reviewButtonCount; i++) { try {
try { Locator reviewButton = reviewButtons.nth(i);
Locator reviewButton = reviewButtons.nth(i);
// 尝试获取按钮的href属性或从父元素获取
String href = reviewButton.getAttribute("href");
if (href != null && !href.isEmpty()) {
log.info("获取到复评流程URL: {}", href);
urls.add(href);
} else {
// 如果按钮没有href属性,点击按钮后获取新页面URL
log.debug("复评按钮无href属性,将通过点击获取URL");
// 监听新的页面打开事件
Page newPage = page.context().waitForPage(() -> {
reviewButton.click();
});
String newPageUrl = newPage.url();
log.info("获取到复评流程URL (通过点击): {}", newPageUrl);
urls.add(newPageUrl);
// 关闭新页面,返回原页面 // 尝试获取按钮的href属性或从父元素获取
newPage.close(); String href = reviewButton.getAttribute("href");
page.bringToFront(); if (href != null && !href.isEmpty()) {
log.info("获取到复评流程URL: {}", href);
urls.add(href);
} else {
// 如果按钮没有href属性,点击按钮后获取新页面URL
log.debug("复评按钮无href属性,将通过点击获取URL");
// 监听新的页面打开事件
Page newPage = page.context().waitForPage(() -> {
reviewButton.click();
});
String newPageUrl = newPage.url();
log.info("获取到复评流程URL (通过点击): {}", newPageUrl);
urls.add(newPageUrl);
// 关闭新页面,返回原页面
newPage.close();
page.bringToFront();
}
} catch (Exception e) {
log.warn("处理复评按钮时发生异常: {}", e.getMessage());
} }
} catch (Exception e) {
log.warn("处理复评按钮时发生异常: {}", e.getMessage());
} }
}
log.info("待审批流程查找完成,共 {} 个流程", urls.size());
long endTime = System.currentTimeMillis();
log.info("待审批流程查找完成,共 {} 个流程,耗时: {} ms", urls.size(), endTime - startTime); if (urls.isEmpty()) {
return List.of("没有找到待审批的流程");
if (urls.isEmpty()) {
return List.of("没有找到待审批的流程");
}
return urls;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "查找待审批流程失败: " + e.getMessage();
log.error("查找待审批流程失败,耗时: {} ms", endTime - startTime, e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
try {
takeScreenshotAndSave(page, "performance_pending_fail_" + ssoUsername);
} catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
} }
}
return urls;
return List.of(errorMsg); } catch (Exception e) {
} finally { String errorMsg = "查找待审批流程失败: " + e.getMessage();
// 不立即关闭页面,让服务器完成处理 log.error("查找待审批流程失败", e);
// 保留页面、上下文和浏览器实例供后续操作使用
if (page != null) { // 如果页面对象存在,截图保存错误页面
try { if (page != null) {
log.debug("保留页面实例供后续使用"); try {
} catch (Exception e) { takeScreenshotAndSave(page, "performance_pending_fail_" + ssoUsername);
log.warn("处理页面实例时发生异常: {}", e.getMessage()); } catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
}
}
return List.of(errorMsg);
} finally {
// 不立即关闭页面,让服务器完成处理
// 保留页面、上下文和浏览器实例供后续操作使用
if (page != null) {
try {
log.debug("保留页面实例供后续使用");
} catch (Exception e) {
log.warn("处理页面实例时发生异常: {}", e.getMessage());
}
} }
} }
} });
} }
/** /**
...@@ -227,71 +230,72 @@ public class HisensePerformanceApprovalTool { ...@@ -227,71 +230,72 @@ public class HisensePerformanceApprovalTool {
*/ */
@Tool(description = "获取海信绩效系统的审批页面内容,需要先使用HisenseSsoLoginTool登录") @Tool(description = "获取海信绩效系统的审批页面内容,需要先使用HisenseSsoLoginTool登录")
public String getHisensePerformancePageContent(String approvalUrl) { public String getHisensePerformancePageContent(String approvalUrl) {
String ssoUsername = getSsoUsername(); Map<String, Object> params = new HashMap<>();
log.info("开始为用户 {} 获取绩效审批页面内容,URL: {}", ssoUsername, approvalUrl); params.put("approvalUrl", approvalUrl);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalUrl == null || approvalUrl.isEmpty()) { return execute("getHisensePerformancePageContent", params, () -> {
String errorMsg = "审批页面URL不能为空"; String ssoUsername = getSsoUsername();
log.error(errorMsg); log.info("开始为用户 {} 获取绩效审批页面内容,URL: {}", ssoUsername, approvalUrl);
return errorMsg;
}
Page page = null;
try {
// 获取用户专用的浏览器上下文
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
// 创建新页面
page = userContext.newPage();
// 访问审批页面
log.info("正在访问审批页面: {}", approvalUrl);
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) { // 参数校验
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录"; if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg); log.error(errorMsg);
return errorMsg; return errorMsg;
} }
// 等待页面完全加载完成 if (approvalUrl == null || approvalUrl.isEmpty()) {
page.waitForLoadState(LoadState.NETWORKIDLE); String errorMsg = "审批页面URL不能为空";
log.error(errorMsg);
// 提取页面内容 return errorMsg;
String content = page.locator("body").innerText(); }
long endTime = System.currentTimeMillis();
log.info("成功获取绩效审批页面内容,耗时: {} ms", endTime - startTime); Page page = null;
return content; try {
} catch (Exception e) { // 获取用户专用的浏览器上下文
long endTime = System.currentTimeMillis(); BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
String errorMsg = "获取绩效审批页面内容失败: " + e.getMessage();
log.error("获取绩效审批页面内容失败,耗时: {} ms", endTime - startTime, e); // 创建新页面
return errorMsg; page = userContext.newPage();
} finally {
// 释放页面资源,但保留浏览器上下文供后续使用 // 访问审批页面
if (page != null) { log.info("正在访问审批页面: {}", approvalUrl);
try { page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
page.close();
} catch (Exception e) { // 检查是否重定向到了SSO登录页面
log.warn("关闭页面时发生异常: {}", e.getMessage()); String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg);
return errorMsg;
}
// 等待页面完全加载完成
page.waitForLoadState(LoadState.NETWORKIDLE);
// 提取页面内容
String content = page.locator("body").innerText();
log.info("成功获取绩效审批页面内容");
return content;
} catch (Exception e) {
String errorMsg = "获取绩效审批页面内容失败: " + e.getMessage();
log.error("获取绩效审批页面内容失败", e);
return errorMsg;
} finally {
// 释放页面资源,但保留浏览器上下文供后续使用
if (page != null) {
try {
page.close();
} catch (Exception e) {
log.warn("关闭页面时发生异常: {}", e.getMessage());
}
} }
} }
} });
} }
/** /**
...@@ -304,100 +308,103 @@ public class HisensePerformanceApprovalTool { ...@@ -304,100 +308,103 @@ public class HisensePerformanceApprovalTool {
*/ */
@Tool(description = "处理绩效系统单个审批流程,需要先使用HisenseSsoLoginTool登录") @Tool(description = "处理绩效系统单个审批流程,需要先使用HisenseSsoLoginTool登录")
public String performSinglePerformanceApproval(String approvalUrl, boolean isApproved, String approvalOpinion) { public String performSinglePerformanceApproval(String approvalUrl, boolean isApproved, String approvalOpinion) {
String ssoUsername = getSsoUsername(); Map<String, Object> params = new HashMap<>();
log.info("开始为用户 {} 处理绩效审批,URL: {}, 是否通过: {}", ssoUsername, approvalUrl, isApproved); params.put("approvalUrl", approvalUrl);
params.put("isApproved", isApproved);
params.put("approvalOpinion", approvalOpinion);
long startTime = System.currentTimeMillis(); return execute("performSinglePerformanceApproval", params, () -> {
String ssoUsername = getSsoUsername();
// 参数校验 log.info("开始为用户 {} 处理绩效审批,URL: {}, 是否通过: {}", ssoUsername, approvalUrl, isApproved);
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalUrl == null || approvalUrl.isEmpty()) {
String errorMsg = "审批页面URL不能为空";
log.error(errorMsg);
return errorMsg;
}
if (approvalOpinion == null || approvalOpinion.isEmpty()) {
String errorMsg = "审批意见不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 获取用户专用的浏览器上下文
BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
// 创建新页面
page = userContext.newPage();
// 访问审批页面 // 参数校验
log.info("正在访问审批页面: {}", approvalUrl); if (ssoUsername == null || ssoUsername.isEmpty()) {
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE)); String errorMsg = "用户ID不能为空";
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg); log.error(errorMsg);
return errorMsg; return errorMsg;
} }
// 等待页面完全加载完成 if (approvalUrl == null || approvalUrl.isEmpty()) {
page.waitForLoadState(LoadState.NETWORKIDLE); String errorMsg = "审批页面URL不能为空";
log.error(errorMsg);
// 等待关键元素加载完成,确保页面完全就绪 return errorMsg;
try {
page.waitForSelector("input[name='radioGroup'][type='radio'][class='ant-radio-input']",
new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(5000));
} catch (com.microsoft.playwright.TimeoutError e) {
log.warn("关键审批元素未在预期时间内加载完成,继续执行审批操作");
} }
// 执行审批操作 if (approvalOpinion == null || approvalOpinion.isEmpty()) {
performPerformanceApprovalOperation(page, isApproved, approvalOpinion); String errorMsg = "审批意见不能为空";
log.error(errorMsg);
// 截图并保存 return errorMsg;
takeScreenshotAndSave(page, "performance_approval_success_" + ssoUsername); }
long endTime = System.currentTimeMillis();
log.info("绩效审批处理完成,耗时: {} ms", endTime - startTime);
return "绩效审批处理成功"; Page page = null;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "绩效审批处理失败: " + e.getMessage();
log.error("绩效审批处理失败,耗时: {} ms", endTime - startTime, e);
// 如果页面对象存在,截图保存错误页面 try {
if (page != null) { // 获取用户专用的浏览器上下文
try { BrowserContext userContext = playwrightManager.getUserContext(ssoUsername);
takeScreenshotAndSave(page, "performance_approval_fail_" + ssoUsername);
} catch (Exception screenshotException) { // 创建新页面
log.warn("截图保存失败: {}", screenshotException.getMessage()); page = userContext.newPage();
// 访问审批页面
log.info("正在访问审批页面: {}", approvalUrl);
page.navigate(approvalUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
String errorMsg = "用户未登录或会话已过期,请先使用HisenseSsoLoginTool进行登录";
log.error(errorMsg);
return errorMsg;
} }
}
// 等待页面完全加载完成
return errorMsg; page.waitForLoadState(LoadState.NETWORKIDLE);
} finally {
// 不立即关闭页面,让服务器完成审批处理 // 等待关键元素加载完成,确保页面完全就绪
// 保留页面、上下文和浏览器实例供后续操作使用
if (page != null) {
try { try {
log.debug("保留页面实例以等待服务器完成审批处理"); page.waitForSelector("input[name='radioGroup'][type='radio'][class='ant-radio-input']",
} catch (Exception e) { new Page.WaitForSelectorOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE).setTimeout(5000));
log.warn("处理页面实例时发生异常: {}", e.getMessage()); } catch (com.microsoft.playwright.TimeoutError e) {
log.warn("关键审批元素未在预期时间内加载完成,继续执行审批操作");
}
// 执行审批操作
performPerformanceApprovalOperation(page, isApproved, approvalOpinion);
// 截图并保存
takeScreenshotAndSave(page, "performance_approval_success_" + ssoUsername);
log.info("绩效审批处理完成");
return "绩效审批处理成功";
} catch (Exception e) {
String errorMsg = "绩效审批处理失败: " + e.getMessage();
log.error("绩效审批处理失败", e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
try {
takeScreenshotAndSave(page, "performance_approval_fail_" + ssoUsername);
} catch (Exception screenshotException) {
log.warn("截图保存失败: {}", screenshotException.getMessage());
}
}
return errorMsg;
} finally {
// 不立即关闭页面,让服务器完成审批处理
// 保留页面、上下文和浏览器实例供后续操作使用
if (page != null) {
try {
log.debug("保留页面实例以等待服务器完成审批处理");
} catch (Exception e) {
log.warn("处理页面实例时发生异常: {}", e.getMessage());
}
} }
} }
} });
} }
/** /**
......
...@@ -13,6 +13,9 @@ import jakarta.annotation.PreDestroy; ...@@ -13,6 +13,9 @@ import jakarta.annotation.PreDestroy;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.HashMap;
import java.util.Map;
import pangea.hiagent.tool.BaseTool;
import pangea.hiagent.web.service.ToolConfigService; import pangea.hiagent.web.service.ToolConfigService;
import pangea.hiagent.workpanel.playwright.PlaywrightManager; import pangea.hiagent.workpanel.playwright.PlaywrightManager;
...@@ -22,7 +25,7 @@ import pangea.hiagent.workpanel.playwright.PlaywrightManager; ...@@ -22,7 +25,7 @@ import pangea.hiagent.workpanel.playwright.PlaywrightManager;
*/ */
@Slf4j @Slf4j
@Component @Component
public class HisenseSsoLoginTool { public class HisenseSsoLoginTool extends BaseTool {
// SSO登录页面URL // SSO登录页面URL
private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/"; private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/";
...@@ -176,99 +179,100 @@ public class HisenseSsoLoginTool { ...@@ -176,99 +179,100 @@ public class HisenseSsoLoginTool {
@Tool(description = "获取任意海信业务系统的网页内容(自动处理SSO认证)") @Tool(description = "获取任意海信业务系统的网页内容(自动处理SSO认证)")
public String getHisenseBusinessSystemContent( public String getHisenseBusinessSystemContent(
@JsonPropertyDescription("海信业务系统的页面URL") String businessSystemUrl) { @JsonPropertyDescription("海信业务系统的页面URL") String businessSystemUrl) {
// initializeIfNeeded(); Map<String, Object> params = new HashMap<>();
log.info("开始获取海信业务系统内容,URL: {}", businessSystemUrl); params.put("businessSystemUrl", businessSystemUrl);
String ssoUsername = getUserName(); return execute("getHisenseBusinessSystemContent", params, () -> {
String ssoPassword = getPassword(); // initializeIfNeeded();
// 校验SSO凭证是否配置 log.info("开始获取海信业务系统内容,URL: {}", businessSystemUrl);
if (ssoUsername == null || ssoUsername.isEmpty() || ssoPassword == null || ssoPassword.isEmpty()) {
String errorMsg = "SSO用户名或密码未配置,海信SSO工具不可用"; String ssoUsername = getUserName();
log.warn(errorMsg); String ssoPassword = getPassword();
return errorMsg; // 校验SSO凭证是否配置
} if (ssoUsername == null || ssoUsername.isEmpty() || ssoPassword == null || ssoPassword.isEmpty()) {
String errorMsg = "SSO用户名或密码未配置,海信SSO工具不可用";
long startTime = System.currentTimeMillis(); log.warn(errorMsg);
return errorMsg;
// 参数校验 }
if (businessSystemUrl == null || businessSystemUrl.isEmpty()) {
String errorMsg = "业务系统URL不能为空";
log.error(errorMsg);
return errorMsg;
}
Page page = null;
try {
// 检查是否已有有效的登录会话
boolean sessionValid = isSessionLoggedIn() && validateSession(businessSystemUrl);
if (sessionValid) { // 参数校验
log.info("检测到有效会话,直接使用共享上下文"); if (businessSystemUrl == null || businessSystemUrl.isEmpty()) {
page = getUSerContext().newPage(); String errorMsg = "业务系统URL不能为空";
} else { log.error(errorMsg);
log.info("未检测到有效会话,使用共享上下文并重新登录"); return errorMsg;
page = getUSerContext().newPage(); }
// 访问业务系统页面 Page page = null;
log.info("正在访问业务系统页面: {}", businessSystemUrl);
page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面 try {
String currentUrl = page.url(); // 检查是否已有有效的登录会话
log.info("当前页面URL: {}", currentUrl); boolean sessionValid = isSessionLoggedIn() && validateSession(businessSystemUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) { if (sessionValid) {
log.info("检测到SSO登录页面,开始自动登录..."); log.info("检测到有效会话,直接使用共享上下文");
// 执行SSO登录 page = getUSerContext().newPage();
performLoginAndUpdateStatus(page); } else {
log.info("未检测到有效会话,使用共享上下文并重新登录");
// 等待登录完成并重定向回业务系统 page = getUSerContext().newPage();
boolean redirected = waitForUrlWithMultipleOptions(page, new String[]{businessSystemUrl}, WAIT_FOR_URL_TIMEOUT);
if (!redirected) { // 访问业务系统页面
log.warn("未能在指定时间内重定向到业务系统页面,当前URL: {}", page.url()); log.info("正在访问业务系统页面: {}", businessSystemUrl);
page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
// 检查是否重定向到了SSO登录页面
String currentUrl = page.url();
log.info("当前页面URL: {}", currentUrl);
if (currentUrl.startsWith(SSO_LOGIN_URL)) {
log.info("检测到SSO登录页面,开始自动登录...");
// 执行SSO登录
performLoginAndUpdateStatus(page);
// 等待登录完成并重定向回业务系统
boolean redirected = waitForUrlWithMultipleOptions(page, new String[]{businessSystemUrl}, WAIT_FOR_URL_TIMEOUT);
if (!redirected) {
log.warn("未能在指定时间内重定向到业务系统页面,当前URL: {}", page.url());
} else {
log.info("登录成功,已重定向回业务系统页面");
}
} else { } else {
log.info("登录成功,已重定向回业务系统页面"); // 即使没有跳转到登录页面,也更新登录时间
lastLoginTime = System.currentTimeMillis();
log.info("直接访问业务系统页面成功,无需SSO登录,更新会话时间");
} }
} else {
// 即使没有跳转到登录页面,也更新登录时间
lastLoginTime = System.currentTimeMillis();
log.info("直接访问业务系统页面成功,无需SSO登录,更新会话时间");
} }
}
// 如果页面尚未导航到业务系统URL,则导航到该URL // 如果页面尚未导航到业务系统URL,则导航到该URL
if (!page.url().equals(businessSystemUrl) && !page.url().startsWith(businessSystemUrl)) { if (!page.url().equals(businessSystemUrl) && !page.url().startsWith(businessSystemUrl)) {
log.info("正在访问业务系统页面: {}", businessSystemUrl); log.info("正在访问业务系统页面: {}", businessSystemUrl);
page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE)); page.navigate(businessSystemUrl, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
} }
// 提取页面内容 // 提取页面内容
String content = page.locator("body").innerText(); String content = page.locator("body").innerText();
long endTime = System.currentTimeMillis(); log.info("成功获取业务系统页面内容");
log.info("成功获取业务系统页面内容,耗时: {} ms", endTime - startTime);
// 检查是否包含错误信息 // 检查是否包含错误信息
if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) { if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) {
log.warn("检测到页面中可能存在JavaScript错误,但这不会影响主要功能"); log.warn("检测到页面中可能存在JavaScript错误,但这不会影响主要功能");
} }
return content; return content;
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis(); String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage(); log.error("获取海信业务系统内容失败", e);
log.error("获取海信业务系统内容失败,耗时: {} ms", endTime - startTime, e); return errorMsg;
return errorMsg; } finally {
} finally { // 释放页面资源
// 释放页面资源 if (page != null) {
if (page != null) { try {
try { page.close();
page.close(); } catch (Exception e) {
} catch (Exception e) { log.warn("关闭页面时发生异常: {}", e.getMessage());
log.warn("关闭页面时发生异常: {}", e.getMessage()); }
} }
} }
} });
} }
/** /**
...@@ -280,89 +284,89 @@ public class HisenseSsoLoginTool { ...@@ -280,89 +284,89 @@ public class HisenseSsoLoginTool {
*/ */
@Tool(description = "海信SSO登录工具,用于登录海信SSO系统") @Tool(description = "海信SSO登录工具,用于登录海信SSO系统")
public String hisenseSsoLogin() { public String hisenseSsoLogin() {
String username = getUserName(); Map<String, Object> params = new HashMap<>();
String password = getPassword();
// 校验SSO凭证是否配置 return execute("hisenseSsoLogin", params, () -> {
if (username == null || username.isEmpty() || password == null || password.isEmpty()) { String username = getUserName();
String errorMsg = "SSO用户名或密码未配置,海信SSO工具不可用"; String password = getPassword();
log.warn(errorMsg); // 校验SSO凭证是否配置
return errorMsg; if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
} String errorMsg = "SSO用户名或密码未配置,海信SSO工具不可用";
log.info("开始执行海信SSO登录,用户名: {}", username); log.warn(errorMsg);
return errorMsg;
long startTime = System.currentTimeMillis(); }
log.info("开始执行海信SSO登录,用户名: {}", username);
// 参数校验 // 参数校验
if (username == null || username.isEmpty()) { if (username == null || username.isEmpty()) {
String errorMsg = "用户名不能为空"; String errorMsg = "用户名不能为空";
log.error(errorMsg); log.error(errorMsg);
return errorMsg; return errorMsg;
} }
if (password == null || password.isEmpty()) { if (password == null || password.isEmpty()) {
String errorMsg = "密码不能为空"; String errorMsg = "密码不能为空";
log.error(errorMsg); log.error(errorMsg);
return errorMsg; return errorMsg;
} }
Page page = null; Page page = null;
try { try {
// 访问SSO登录页面 // 访问SSO登录页面
log.info("正在访问SSO登录页面: {}", SSO_LOGIN_URL); log.info("正在访问SSO登录页面: {}", SSO_LOGIN_URL);
page = getUSerContext().newPage(); page = getUSerContext().newPage();
page.navigate(SSO_LOGIN_URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE)); page.navigate(SSO_LOGIN_URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
if (page.url().equals(SSO_MFA_URL)) { if (page.url().equals(SSO_MFA_URL)) {
log.info("检测到MFA页面,自动发送验证码..."); log.info("检测到MFA页面,自动发送验证码...");
// 执行MFA登录,传递Context以保证生命周期管理 // 执行MFA登录,传递Context以保证生命周期管理
return sendVerificationCode(username, page, getUSerContext()); return sendVerificationCode(username, page, getUSerContext());
} }
if (!SSO_LOGIN_URL.equals(page.url())) { if (!SSO_LOGIN_URL.equals(page.url())) {
return "海信SSO在之前已登录成功"; return "海信SSO在之前已登录成功";
} }
// 执行SSO登录 // 执行SSO登录
String loginResult = performLoginAndUpdateStatus(page); String loginResult = performLoginAndUpdateStatus(page);
if(!loginResult.equals("SSO登录成功")){ if(!loginResult.equals("SSO登录成功")){
return loginResult; return loginResult;
} }
// 等待登录完成并重定向到SSO配置页面,使用更灵活的URL匹配和更长的超时时间 // 等待登录完成并重定向到SSO配置页面,使用更灵活的URL匹配和更长的超时时间
boolean profileRedirected = waitForSpecificUrl(page, SSO_PROFILE_URL, WAIT_FOR_URL_TIMEOUT); boolean profileRedirected = waitForSpecificUrl(page, SSO_PROFILE_URL, WAIT_FOR_URL_TIMEOUT);
if (!profileRedirected) { if (!profileRedirected) {
// 如果没有跳转到预期的配置页面,检查是否跳转到了其他可能的登录成功页面 // 如果没有跳转到预期的配置页面,检查是否跳转到了其他可能的登录成功页面
boolean alternativeRedirected = waitForUrlWithMultipleOptions(page, SUCCESS_REDIRECT_URLS, WAIT_FOR_URL_TIMEOUT); boolean alternativeRedirected = waitForUrlWithMultipleOptions(page, SUCCESS_REDIRECT_URLS, WAIT_FOR_URL_TIMEOUT);
if (!alternativeRedirected) { if (!alternativeRedirected) {
log.warn("未能在指定时间内重定向到SSO配置页面,当前URL: {}", page.url()); log.warn("未能在指定时间内重定向到SSO配置页面,当前URL: {}", page.url());
} else {
log.info("登录成功,已重定向到SSO相关页面");
}
} else { } else {
log.info("登录成功,已重定向到SSO相关页面"); log.info("登录成功,已重定向回SSO配置页面");
} }
} else {
log.info("登录成功,已重定向回SSO配置页面");
}
long endTime = System.currentTimeMillis(); log.info("海信SSO登录完成");
log.info("海信SSO登录完成,耗时: {} ms", endTime - startTime);
return "海信SSO登录成功"; return "海信SSO登录成功";
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis(); String errorMsg = "海信SSO登录失败: " + e.getMessage();
String errorMsg = "海信SSO登录失败: " + e.getMessage(); log.error("海信SSO登录失败", e);
log.error("海信SSO登录失败,耗时: {} ms", endTime - startTime, e); return errorMsg;
return errorMsg; } finally {
} finally { // 释放页面资源
// 释放页面资源 if (page != null && !page.url().equals(SSO_MFA_URL)) {
if (page != null && !page.url().equals(SSO_MFA_URL)) { try {
try { page.close();
page.close(); } catch (Exception e) {
} catch (Exception e) { log.warn("关闭页面时发生异常: {}", e.getMessage());
log.warn("关闭页面时发生异常: {}", e.getMessage()); }
} }
} }
} });
} }
/** /**
...@@ -618,173 +622,171 @@ public class HisenseSsoLoginTool { ...@@ -618,173 +622,171 @@ public class HisenseSsoLoginTool {
@Tool(description = "处理MFA验证码验证,完成海信SSO登录") @Tool(description = "处理MFA验证码验证,完成海信SSO登录")
public String handleMfaVerification( public String handleMfaVerification(
@JsonPropertyDescription("短信验证码") String verificationCode) { @JsonPropertyDescription("短信验证码") String verificationCode) {
log.info("开始处理MFA验证码验证"); Map<String, Object> params = new HashMap<>();
params.put("verificationCode", verificationCode);
String username = getUserName();
// 参数校验
if (verificationCode == null || verificationCode.isEmpty()) {
String errorMsg = "验证码不能为空";
log.error(errorMsg);
return errorMsg;
}
long startTime = System.currentTimeMillis();
// 清理过期的MFA会话
cleanupExpiredMfaSessions();
// 获取当前用户的MFA会话并更新访问时间
MfaSession mfaSession = getMfaSessionAndUpdateTime(username);
if (mfaSession == null) {
String errorMsg = "未找到当前用户的MFA验证会话,请先触发验证码发送。如果30分钟内未使用验证码,会话将自动过期。";
log.error(errorMsg);
return errorMsg;
}
Page mfaPage = mfaSession.page; return execute("handleMfaVerification", params, () -> {
BrowserContext context = mfaSession.context; log.info("开始处理MFA验证码验证");
// 检查MFA页面和Context是否仍然有效,如果已关闭则返回错误 String username = getUserName();
if (!isPageValid(mfaPage) || !isContextValid(context)) {
String errorMsg = "MFA验证页面或Context已关闭,BrowserContext可能已被释放,请重新触发验证码发送流程";
log.error(errorMsg);
mfaSessions.remove(username);
return errorMsg;
}
try { // 参数校验
// 等待页面加载完成,添加异常处理 if (verificationCode == null || verificationCode.isEmpty()) {
try { String errorMsg = "验证码不能为空";
mfaPage.waitForLoadState(LoadState.NETWORKIDLE);
} catch (Exception e) {
log.warn("等待页面加载时发生异常(可能页面已关闭),尝试继续: {}", e.getMessage());
}
// 查找验证码输入框并填入验证码
// 在访问元素前再次检查页面有效性
if (!isPageValid(mfaPage) || !isContextValid(context)) {
String errorMsg = "MFA验证页面或Context在操作过程中被关闭";
log.error(errorMsg); log.error(errorMsg);
mfaSessions.remove(username);
return errorMsg; return errorMsg;
} }
// 清理过期的MFA会话
cleanupExpiredMfaSessions();
Locator verificationInput = mfaPage.locator("input[placeholder='请输入短信验证码'][name='']"); // 获取当前用户的MFA会话并更新访问时间
if (verificationInput.count() == 0) { MfaSession mfaSession = getMfaSessionAndUpdateTime(username);
String errorMsg = "未找到验证码输入框"; if (mfaSession == null) {
String errorMsg = "未找到当前用户的MFA验证会话,请先触发验证码发送。如果30分钟内未使用验证码,会话将自动过期。";
log.error(errorMsg); log.error(errorMsg);
return errorMsg; return errorMsg;
} }
verificationInput.fill(verificationCode);
log.info("验证码已填入输入框"); Page mfaPage = mfaSession.page;
BrowserContext context = mfaSession.context;
// 再次检查页面有效性 // 检查MFA页面和Context是否仍然有效,如果已关闭则返回错误
if (!isPageValid(mfaPage) || !isContextValid(context)) { if (!isPageValid(mfaPage) || !isContextValid(context)) {
String errorMsg = "验证码填入后页面或Context已关闭"; String errorMsg = "MFA验证页面或Context已关闭,BrowserContext可能已被释放,请重新触发验证码发送流程";
log.error(errorMsg); log.error(errorMsg);
mfaSessions.remove(username); mfaSessions.remove(username);
return errorMsg; return errorMsg;
} }
// 点击登录按钮提交验证码
Locator loginButton = mfaPage.locator("button#login-button.para-btn.para-btn-login[hk-ripple='']");
if (loginButton.count() == 0) {
String errorMsg = "未找到登录按钮";
log.error(errorMsg);
return errorMsg;
}
loginButton.click();
log.info("已点击登录按钮提交验证码");
// 等待页面跳转,确认登录结果
try { try {
// 等待页面离开MFA页面,使用轮询方式检查URL变化 // 等待页面加载完成,添加异常处理
boolean pageLeftMfa = false; try {
long mfaStartTime = System.currentTimeMillis(); mfaPage.waitForLoadState(LoadState.NETWORKIDLE);
long mfaEndTime = mfaStartTime + MFA_WAIT_FOR_URL_TIMEOUT; } catch (Exception e) {
log.warn("等待页面加载时发生异常(可能页面已关闭),尝试继续: {}", e.getMessage());
while (System.currentTimeMillis() < mfaEndTime) {
String currentUrl = mfaPage.url();
if (!currentUrl.equals(SSO_MFA_URL)) {
pageLeftMfa = true;
log.info("MFA验证成功,已跳转到: {}", currentUrl);
break;
}
// 短暂休眠后继续检查
try {
Thread.sleep(1000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.warn("MFA等待过程中被中断");
break;
}
} }
if (pageLeftMfa) { // 查找验证码输入框并填入验证码
// 从缓存中移除会话,因为登录已完成 // 在访问元素前再次检查页面有效性
if (!isPageValid(mfaPage) || !isContextValid(context)) {
String errorMsg = "MFA验证页面或Context在操作过程中被关闭";
log.error(errorMsg);
mfaSessions.remove(username); mfaSessions.remove(username);
return errorMsg;
}
// 更新登录时间 Locator verificationInput = mfaPage.locator("input[placeholder='请输入短信验证码'][name='']");
lastLoginTime = System.currentTimeMillis(); if (verificationInput.count() == 0) {
String errorMsg = "未找到验证码输入框";
log.error(errorMsg);
return errorMsg;
}
verificationInput.fill(verificationCode);
log.info("验证码已填入输入框");
long endTime = System.currentTimeMillis(); // 再次检查页面有效性
log.info("MFA验证完成,耗时: {} ms", endTime - startTime); if (!isPageValid(mfaPage) || !isContextValid(context)) {
String errorMsg = "验证码填入后页面或Context已关闭";
log.error(errorMsg);
mfaSessions.remove(username);
return errorMsg;
}
return "MFA验证成功,登录完成"; // 点击登录按钮提交验证码
} else { Locator loginButton = mfaPage.locator("button#login-button.para-btn.para-btn-login[hk-ripple='']");
// 如果仍在MFA页面,说明可能超时但验证仍在进行中,也认为成功 if (loginButton.count() == 0) {
log.info("MFA验证可能仍在进行中,当前仍在MFA页面"); String errorMsg = "未找到登录按钮";
log.error(errorMsg);
return errorMsg;
}
loginButton.click();
log.info("已点击登录按钮提交验证码");
// 等待页面跳转,确认登录结果
try {
// 等待页面离开MFA页面,使用轮询方式检查URL变化
boolean pageLeftMfa = false;
long mfaStartTime = System.currentTimeMillis();
long mfaEndTime = mfaStartTime + MFA_WAIT_FOR_URL_TIMEOUT;
// 从缓存中移除会话 while (System.currentTimeMillis() < mfaEndTime) {
mfaSessions.remove(username); String currentUrl = mfaPage.url();
if (!currentUrl.equals(SSO_MFA_URL)) {
pageLeftMfa = true;
log.info("MFA验证成功,已跳转到: {}", currentUrl);
break;
}
// 短暂休眠后继续检查
try {
Thread.sleep(1000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.warn("MFA等待过程中被中断");
break;
}
}
if (pageLeftMfa) {
// 从缓存中移除会话,因为登录已完成
mfaSessions.remove(username);
// 更新登录时间 // 更新登录时间
lastLoginTime = System.currentTimeMillis(); lastLoginTime = System.currentTimeMillis();
long endTime = System.currentTimeMillis(); log.info("MFA验证完成");
log.info("MFA验证处理完成,耗时: {} ms", endTime - startTime);
return "MFA验证已处理"; return "MFA验证成功,登录完成";
} } else {
} catch (Exception urlException) { // 如果仍在MFA页面,说明可能超时但验证仍在进行中,也认为成功
// 检查是否仍然是MFA页面,表示登录失败 log.info("MFA验证可能仍在进行中,当前仍在MFA页面");
String currentUrl = mfaPage.url();
if (currentUrl.equals(SSO_MFA_URL)) { // 从缓存中移除会话
String errorMsg = "MFA验证失败,验证码可能错误或已过期,请重试"; mfaSessions.remove(username);
log.error(errorMsg);
return errorMsg;
} else {
// 页面已跳转,说明登录成功
log.info("MFA验证成功,已跳转到: {}", currentUrl);
// 从缓存中移除会话 // 更新登录时间
mfaSessions.remove(username); lastLoginTime = System.currentTimeMillis();
log.info("MFA验证处理完成");
return "MFA验证已处理";
}
} catch (Exception urlException) {
// 检查是否仍然是MFA页面,表示登录失败
String currentUrl = mfaPage.url();
if (currentUrl.equals(SSO_MFA_URL)) {
String errorMsg = "MFA验证失败,验证码可能错误或已过期,请重试";
log.error(errorMsg);
return errorMsg;
} else {
// 页面已跳转,说明登录成功
log.info("MFA验证成功,已跳转到: {}", currentUrl);
// 更新登录时间 // 从缓存中移除会话
lastLoginTime = System.currentTimeMillis(); mfaSessions.remove(username);
long endTime = System.currentTimeMillis(); // 更新登录时间
log.info("MFA验证完成,耗时: {} ms", endTime - startTime); lastLoginTime = System.currentTimeMillis();
return "MFA验证成功,登录完成"; log.info("MFA验证完成");
return "MFA验证成功,登录完成";
}
} }
} catch (com.microsoft.playwright.impl.TargetClosedError e) {
// 专门处理TargetClosedError
String errorMsg = "MFA验证时BrowserContext已关闭,请重新触发验证码发送流程";
log.error("MFA验证失败 - TargetClosedError,完整错误堆栈: ", e);
mfaSessions.remove(username);
return errorMsg;
} catch (Exception e) {
String errorMsg = "MFA验证过程发生异常: " + e.getMessage();
log.error("MFA验证失败,错误类型: {},完整错误堆栈: ", e.getClass().getName(), e);
mfaSessions.remove(username);
return errorMsg;
} }
} catch (com.microsoft.playwright.impl.TargetClosedError e) { });
// 专门处理TargetClosedError
long endTime = System.currentTimeMillis();
String errorMsg = "MFA验证时BrowserContext已关闭,请重新触发验证码发送流程";
log.error("MFA验证失败 - TargetClosedError,耗时: {} ms,完整错误堆栈: ", endTime - startTime, e);
mfaSessions.remove(username);
return errorMsg;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "MFA验证过程发生异常: " + e.getMessage();
log.error("MFA验证失败,耗时: {} ms,错误类型: {},完整错误堆栈: ", endTime - startTime, e.getClass().getName(), e);
mfaSessions.remove(username);
return errorMsg;
}
} }
/** /**
...@@ -794,32 +796,32 @@ public class HisenseSsoLoginTool { ...@@ -794,32 +796,32 @@ public class HisenseSsoLoginTool {
*/ */
@Tool(description = "海信SSO登出工具,用于退出海信SSO系统") @Tool(description = "海信SSO登出工具,用于退出海信SSO系统")
public String hisenseSsoLogout() { public String hisenseSsoLogout() {
// initializeIfNeeded(); Map<String, Object> params = new HashMap<>();
log.info("开始执行海信SSO登出");
return execute("hisenseSsoLogout", params, () -> {
long startTime = System.currentTimeMillis(); // initializeIfNeeded();
log.info("开始执行海信SSO登出");
try { try {
// 关闭共享上下文 // 关闭共享上下文
if (getUSerContext() != null) { if (getUSerContext() != null) {
getUSerContext().close(); getUSerContext().close();
log.info("共享上下文已关闭"); log.info("共享上下文已关闭");
} }
// 重置登录时间 // 重置登录时间
lastLoginTime = 0; lastLoginTime = 0;
log.info("登录时间已重置"); log.info("登录时间已重置");
long endTime = System.currentTimeMillis(); log.info("海信SSO登出完成");
log.info("海信SSO登出完成,耗时: {} ms", endTime - startTime);
return "海信SSO登出成功"; return "海信SSO登出成功";
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis(); String errorMsg = "海信SSO登出失败: " + e.getMessage();
String errorMsg = "海信SSO登出失败: " + e.getMessage(); log.error("海信SSO登出失败", e);
log.error("海信SSO登出失败,耗时: {} ms", endTime - startTime, e); return errorMsg;
return errorMsg; }
} });
} }
/** /**
...@@ -827,23 +829,23 @@ public class HisenseSsoLoginTool { ...@@ -827,23 +829,23 @@ public class HisenseSsoLoginTool {
*/ */
@Tool(description = "检查海信SSO登录状态") @Tool(description = "检查海信SSO登录状态")
public String checkHisenseSsoLoginStatus() { public String checkHisenseSsoLoginStatus() {
// initializeIfNeeded(); Map<String, Object> params = new HashMap<>();
log.info("开始检查海信SSO登录状态");
return execute("checkHisenseSsoLoginStatus", params, () -> {
long startTime = System.currentTimeMillis(); // initializeIfNeeded();
log.info("开始检查海信SSO登录状态");
try { try {
boolean isLoggedIn = isSessionLoggedIn(); boolean isLoggedIn = isSessionLoggedIn();
long endTime = System.currentTimeMillis(); log.info("海信SSO登录状态检查完成:{}", isLoggedIn);
log.info("海信SSO登录状态检查完成:{},耗时: {} ms", isLoggedIn, endTime - startTime);
return isLoggedIn ? "已登录" : "未登录"; return isLoggedIn ? "已登录" : "未登录";
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis(); String errorMsg = "海信SSO登录状态检查失败: " + e.getMessage();
String errorMsg = "海信SSO登录状态检查失败: " + e.getMessage(); log.error("海信SSO登录状态检查失败", e);
log.error("海信SSO登录状态检查失败,耗时: {} ms", endTime - startTime, e); return errorMsg;
return errorMsg; }
} });
} }
/** /**
......
// package pangea.hiagent.web.controller;
// import lombok.extern.slf4j.Slf4j;
// import org.springframework.web.bind.annotation.*;
// import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
// import pangea.hiagent.agent.sse.UserSseService;
// import pangea.hiagent.common.utils.UserUtils;
// import pangea.hiagent.workpanel.event.EventService;
// /**
// * 时间轴事件控制器
// * 提供ReAct过程的实时事件推送功能
// */
// @Slf4j
// @RestController
// @RequestMapping("/api/v1/agent")
// public class TimelineEventController {
// private final UserSseService workPanelSseService;
// public TimelineEventController(UserSseService workPanelSseService, EventService eventService) {
// this.workPanelSseService = workPanelSseService;
// }
// /**
// * 订阅时间轴事件
// * 支持 SSE (Server-Sent Events) 格式的实时事件推送
// *
// * @return SSE emitter
// */
// @GetMapping("/timeline-events")
// public SseEmitter subscribeTimelineEvents() {
// log.info("开始处理时间轴事件订阅请求");
// // 获取当前认证用户ID
// String userId = UserUtils.getCurrentUserId();
// if (userId == null) {
// log.warn("用户未认证,无法创建时间轴事件订阅");
// throw new org.springframework.security.access.AccessDeniedException("用户未认证");
// }
// log.info("开始为用户 {} 创建SSE连接", userId);
// // 创建并注册SSE连接
// return workPanelSseService.createAndRegisterConnection(userId);
// }
// }
\ No newline at end of file
package pangea.hiagent.web.dto; package pangea.hiagent.web.dto;
import java.util.Map; import java.util.Map;
import java.util.UUID;
/** /**
* 时间轴事件工厂类 * 时间轴事件工厂类
...@@ -8,6 +9,14 @@ import java.util.Map; ...@@ -8,6 +9,14 @@ import java.util.Map;
*/ */
public class TimelineEventFactory { public class TimelineEventFactory {
/**
* 生成唯一事件ID
* @return 唯一事件ID
*/
private static String generateEventId() {
return "evt_" + UUID.randomUUID().toString().replace("-", "");
}
/** /**
* 根据事件类型创建相应的事件DTO对象 * 根据事件类型创建相应的事件DTO对象
* 这是工厂类的唯一公共入口方法,确保所有事件对象创建都通过工厂完成 * 这是工厂类的唯一公共入口方法,确保所有事件对象创建都通过工厂完成
...@@ -21,6 +30,11 @@ public class TimelineEventFactory { ...@@ -21,6 +30,11 @@ public class TimelineEventFactory {
return null; return null;
} }
// 确保事件数据中包含唯一ID
if (!eventData.containsKey("id")) {
eventData.put("id", generateEventId());
}
switch (eventType) { switch (eventType) {
case "thought": case "thought":
return createThoughtEvent(eventData); return createThoughtEvent(eventData);
......
...@@ -56,6 +56,26 @@ import { ElMessage, ElMessageBox } from "element-plus"; ...@@ -56,6 +56,26 @@ import { ElMessage, ElMessageBox } from "element-plus";
import MessageItem from "./MessageItem.vue"; import MessageItem from "./MessageItem.vue";
import request from "@/utils/request"; import request from "@/utils/request";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import type { TimelineEvent } from '../types/timeline';
// 接收从父组件传递的添加事件到时间轴的方法
const props = defineProps<{
addEventToTimeline?: (event: TimelineEvent) => void;
}>();
// 生成唯一事件ID
const generateEventId = (): string => {
return `event-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
};
// 添加事件到时间轴
const addEventToTimeline = (event: TimelineEvent) => {
if (props.addEventToTimeline) {
props.addEventToTimeline(event);
} else {
console.warn('[ChatArea] addEventToTimeline prop is not provided');
}
};
interface Message { interface Message {
content: string; content: string;
...@@ -508,6 +528,17 @@ const processSSELine = async ( ...@@ -508,6 +528,17 @@ const processSSELine = async (
// 只设置流状态和加载状态 // 只设置流状态和加载状态
messages.value[aiMessageIndex].isStreaming = false; messages.value[aiMessageIndex].isStreaming = false;
isLoading.value = false; isLoading.value = false;
// 添加完成事件到时间轴
const completeEvent: TimelineEvent = {
id: generateEventId(),
type: "complete",
title: "对话完成",
content: "智能体已完成回答",
timestamp: Date.now(),
};
addEventToTimeline(completeEvent);
return true; // 返回true表示流已完成 return true; // 返回true表示流已完成
case "error": case "error":
...@@ -527,22 +558,32 @@ const processSSELine = async ( ...@@ -527,22 +558,32 @@ const processSSELine = async (
isLoading.value = false; isLoading.value = false;
// 记录错误日志便于调试 // 记录错误日志便于调试
console.error("[SSE错误事件]", data); console.error("[SSE错误事件]", data);
// 添加错误事件到时间轴
const errorEvent: TimelineEvent = {
id: generateEventId(),
type: "error",
title: "对话错误",
content: errorMsg || "未知错误",
timestamp: Date.now(),
};
addEventToTimeline(errorEvent);
return true; // 返回true表示流已完成 return true; // 返回true表示流已完成
case "thinking": case "thinking":
// 处理思考事件,将其发送到时间轴面板 // 处理思考事件,将其发送到时间轴面板
const event = { const thoughtEvent: TimelineEvent = {
id: generateEventId(),
type: "thought", type: "thought",
title: title:
data.thinkingType === "final_answer" ? "最终答案" : "思考过程", data.thinkingType === "final_answer" ? "最终答案" : "思考过程",
content: data.content, content: data.content,
timestamp: data.timestamp, timestamp: data.timestamp || Date.now(),
}; };
// 通过事件总线将事件发送到时间轴 // 调用添加事件到时间轴的方法
window.dispatchEvent( addEventToTimeline(thoughtEvent);
new CustomEvent("timeline-event", { detail: event })
);
// 如果是最终答案,也应该显示在主要对话框中 // 如果是最终答案,也应该显示在主要对话框中
// 修复:确保最终答案只添加一次,避免重复显示 // 修复:确保最终答案只添加一次,避免重复显示
...@@ -578,22 +619,24 @@ const processSSELine = async ( ...@@ -578,22 +619,24 @@ const processSSELine = async (
if (eventType === "tool_call") { if (eventType === "tool_call") {
if (data.toolName) metadata["工具"] = data.toolName; if (data.toolName) metadata["工具"] = data.toolName;
if (data.toolAction) metadata["操作"] = data.toolAction; if (data.toolAction) metadata["操作"] = data.toolAction;
if (data.toolInput) if (data.toolInput) {
metadata["输入"] = JSON.stringify(data.toolInput).substring( try {
0, metadata["输入"] = JSON.stringify(data.toolInput).substring(0, 100);
100 } catch (e) {
); metadata["输入"] = String(data.toolInput).substring(0, 100);
if (data.toolOutput) }
metadata["输出"] = String(data.toolOutput).substring(0, 100); }
if (data.toolOutput) metadata["输出"] = String(data.toolOutput).substring(0, 100);
if (data.toolStatus) metadata["状态"] = data.toolStatus; if (data.toolStatus) metadata["状态"] = data.toolStatus;
if (data.executionTime) if (data.executionTime) metadata["耗时"] = `${data.executionTime}ms`;
metadata["耗时"] = `${data.executionTime}ms`;
} else if (eventType === "embed") { } else if (eventType === "embed") {
if (data.embedUrl) metadata["URL"] = data.embedUrl; if (data.embedUrl) metadata["URL"] = data.embedUrl;
if (data.embedType) metadata["类型"] = data.embedType; if (data.embedType) metadata["类型"] = data.embedType;
} }
const timelineEvent = { // 构建时间轴事件
const timelineEvent: TimelineEvent = {
id: generateEventId(),
type: eventType, type: eventType,
title: title, title: title,
content: data.content, content: data.content,
...@@ -611,11 +654,8 @@ const processSSELine = async ( ...@@ -611,11 +654,8 @@ const processSSELine = async (
timestamp: data.timestamp || Date.now(), timestamp: data.timestamp || Date.now(),
}; };
// 通过事件总线将事件发送到时间轴 // 调用添加事件到时间轴的方法
console.log("[ChatArea] 发送timeline-event事件:", timelineEvent); addEventToTimeline(timelineEvent);
window.dispatchEvent(
new CustomEvent("timeline-event", { detail: timelineEvent })
);
// 对于embed事件,还需要触发embed-event事件 // 对于embed事件,还需要触发embed-event事件
if (eventType === "embed" && data.embedUrl) { if (eventType === "embed" && data.embedUrl) {
......
<template> <template>
<TimelinePanel <div class="timeline-manager">
:events="events" <!-- 过滤和搜索面板 -->
:getEventTypeLabel="getEventTypeLabel" <div class="timeline-filter-panel">
:formatTime="formatTime" <div class="filter-row">
:getExpandedState="getExpandedState" <el-input
:toggleExpand="toggleExpand" v-model="searchQuery"
:isToolEventType="isToolEventType" placeholder="搜索事件..."
:hasValidToolInput="hasValidToolInput" clearable
:hasValidToolOutput="hasValidToolOutput" size="small"
:onClearTimeline="handleClearTimeline" prefix-icon="Search"
/> />
<el-select
v-model="selectedEventTypes"
placeholder="事件类型"
multiple
size="small"
class="filter-select"
>
<el-option
v-for="(label, type) in eventTypeLabels"
:key="type"
:label="label"
:value="type"
/>
</el-select>
</div>
<div class="filter-row">
<el-button
type="primary"
size="small"
@click="applyFilters"
>
应用过滤
</el-button>
<el-button
size="small"
@click="resetFilters"
>
重置
</el-button>
<el-button
size="small"
@click="exportEvents"
>
导出事件
</el-button>
<el-dropdown @command="handleExport">
<el-button size="small">
导出格式 <el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="json">JSON</el-dropdown-item>
<el-dropdown-item command="csv">CSV</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 时间轴面板 -->
<TimelinePanel
:events="filteredEvents"
:getEventTypeLabel="getEventTypeLabel"
:formatTime="formatTime"
:getExpandedState="getExpandedState"
:toggleExpand="toggleExpand"
:isToolEventType="isToolEventType"
:hasValidToolInput="hasValidToolInput"
:hasValidToolOutput="hasValidToolOutput"
:onClearTimeline="handleClearTimeline"
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onUnmounted, onMounted } from 'vue' import { computed, ref, onUnmounted, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { TimelineEvent, ToolResultEvent } from '../types/timeline' import type { TimelineEvent, ToolResultEvent } from '../types/timeline'
import { eventTypeLabels } from '../types/timeline' import { eventTypeLabels } from '../types/timeline'
import { TimelineService } from '../services/TimelineService'
import TimelinePanel from './TimelinePanel.vue' import TimelinePanel from './TimelinePanel.vue'
import { isToolEventType, hasValidToolInput, hasValidToolOutput } from '../utils/timelineUtils';
import sseService from '../services/sseService';
// 事件数据 // 事件数据
const events = ref<TimelineEvent[]>([]); const events = ref<TimelineEvent[]>([]);
// 过滤和搜索状态
const searchQuery = ref('');
const selectedEventTypes = ref<string[]>([]);
const activeFilters = ref({
searchQuery: '',
eventTypes: [] as string[]
});
// 持久化配置
const persistenceEnabled = ref(true);
const STORAGE_KEY = 'timeline_events';
// 从本地存储加载事件
const loadEventsFromStorage = () => {
if (!persistenceEnabled.value) return;
try {
const storedEvents = localStorage.getItem(STORAGE_KEY);
if (storedEvents) {
const parsedEvents = JSON.parse(storedEvents) as TimelineEvent[];
events.value = parsedEvents;
console.log('[TimelineContainer] 从本地存储加载了', parsedEvents.length, '个事件');
}
} catch (error) {
console.error('[TimelineContainer] 从本地存储加载事件失败:', error);
}
};
// 保存事件到本地存储
const saveEventsToStorage = () => {
if (!persistenceEnabled.value) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(events.value));
console.log('[TimelineContainer] 事件已保存到本地存储');
} catch (error) {
console.error('[TimelineContainer] 保存事件到本地存储失败:', error);
}
};
// 获取事件类型标签 // 获取事件类型标签
const getEventTypeLabel = (type: string): string => { const getEventTypeLabel = (type: string): string => {
return eventTypeLabels[type] || type; return eventTypeLabels[type] || type;
...@@ -49,19 +153,121 @@ const toggleExpand = (index: number): void => { ...@@ -49,19 +153,121 @@ const toggleExpand = (index: number): void => {
console.log('切换展开状态:', index); console.log('切换展开状态:', index);
}; };
// 工具事件类型判断 // 过滤后的事件列表
const isToolEventType = (type: string): boolean => { const filteredEvents = computed(() => {
return ['tool_call', 'tool_result', 'tool_error'].includes(type); let result = [...events.value];
// 应用搜索过滤
if (activeFilters.value.searchQuery) {
const query = activeFilters.value.searchQuery.toLowerCase();
result = result.filter(event => {
return (
event.title.toLowerCase().includes(query) ||
('content' in event && event.content && event.content.toLowerCase().includes(query)) ||
(event.type && event.type.toLowerCase().includes(query))
);
});
}
// 应用事件类型过滤
if (activeFilters.value.eventTypes.length > 0) {
result = result.filter(event => {
return activeFilters.value.eventTypes.includes(event.type);
});
}
return result;
});
// 应用过滤
const applyFilters = () => {
activeFilters.value = {
searchQuery: searchQuery.value,
eventTypes: [...selectedEventTypes.value]
};
};
// 重置过滤
const resetFilters = () => {
searchQuery.value = '';
selectedEventTypes.value = [];
activeFilters.value = {
searchQuery: '',
eventTypes: []
};
};
// 导出事件(默认JSON格式)
const exportEvents = () => {
handleExport('json');
};
// 处理导出
const handleExport = (format: 'json' | 'csv') => {
const exportData = filteredEvents.value;
if (exportData.length === 0) {
ElMessage.warning('没有可导出的事件');
return;
}
let content: string;
let fileName: string;
let mimeType: string;
if (format === 'json') {
content = JSON.stringify(exportData, null, 2);
fileName = `timeline_events_${new Date().toISOString().slice(0, 10)}.json`;
mimeType = 'application/json';
} else {
content = convertToCSV(exportData);
fileName = `timeline_events_${new Date().toISOString().slice(0, 10)}.csv`;
mimeType = 'text/csv';
}
downloadFile(content, fileName, mimeType);
}; };
// 工具输入有效性检查 // 转换为CSV格式
const hasValidToolInput = (event: TimelineEvent): boolean => { const convertToCSV = (events: TimelineEvent[]): string => {
return event.type === 'tool_call' && (event as any).toolInput !== null && (event as any).toolInput !== undefined; // 定义CSV表头
const headers = ['ID', 'Type', 'Title', 'Timestamp', 'Content', 'Metadata'];
// 转换数据行
const rows = events.map(event => {
// 安全获取content字段,只有部分事件类型有content字段
const content = 'content' in event ? event.content || '' : '';
return [
event.id,
event.type,
event.title,
new Date(event.timestamp).toISOString(),
content,
event.metadata ? JSON.stringify(event.metadata) : ''
];
});
// 合并表头和数据行
const csvContent = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\n');
return csvContent;
}; };
// 工具输出有效性检查 // 下载文件
const hasValidToolOutput = (event: TimelineEvent): boolean => { const downloadFile = (content: string, fileName: string, mimeType: string) => {
return event.type === 'tool_result' && (event as any).toolOutput !== null && (event as any).toolOutput !== undefined; const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}; };
// 时间轴服务不再需要,因为我们现在直接处理事件 // 时间轴服务不再需要,因为我们现在直接处理事件
...@@ -71,16 +277,30 @@ const hasValidToolOutput = (event: TimelineEvent): boolean => { ...@@ -71,16 +277,30 @@ const hasValidToolOutput = (event: TimelineEvent): boolean => {
// console.log('[TimelineContainer] 成功添加事件:', event.type, event.title); // console.log('[TimelineContainer] 成功添加事件:', event.type, event.title);
// }); // });
// 生成唯一事件ID
const generateEventId = (): string => {
return 'evt_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
};
// 添加时间轴事件 // 添加时间轴事件
const addEvent = (event: any) => { const addEvent = (event: any) => {
// 确保事件有唯一ID
const eventWithId = {
...event,
id: event.id || generateEventId()
};
// 直接添加事件到列表,而不是通过时间轴服务 // 直接添加事件到列表,而不是通过时间轴服务
events.value.push(event); events.value.push(eventWithId);
console.log('[TimelineContainer] 成功添加事件:', event.type, event.title); console.log('[TimelineContainer] 成功添加事件:', eventWithId.type, eventWithId.title);
// 保存到本地存储
saveEventsToStorage();
}; };
// 清除时间轴 // 清除时间轴
const handleClearTimeline = () => { const handleClearTimeline = () => {
events.value = []; events.value = [];
// 保存到本地存储
saveEventsToStorage();
// 不再调用timelineService.clearTimeline(),因为它是空实现 // 不再调用timelineService.clearTimeline(),因为它是空实现
// stateManager.clearAllStates(); // stateManager.clearAllStates();
// cacheService.clearAllCaches(); // cacheService.clearAllCaches();
...@@ -137,43 +357,122 @@ defineExpose({ ...@@ -137,43 +357,122 @@ defineExpose({
showPerformanceStats showPerformanceStats
}); });
// 组件卸载时清理资源 // 组件挂载时启动定期性能监控
onUnmounted(() => { let intervalId: number;
// 移除事件监听器
window.removeEventListener('timeline-event', handleTimelineEvent as EventListener);
});
// 处理timeline-event事件
const handleTimelineEvent = (e: Event) => {
const customEvent = e as CustomEvent;
const eventData = customEvent.detail;
console.log('[TimelineContainer] 接收到timeline-event事件:', eventData);
// 直接添加事件到列表
events.value.push(eventData);
console.log('[TimelineContainer] 成功添加事件:', eventData.type, eventData.title);
};
// 组件挂载时启动定期性能监控和事件监听
onMounted(() => { onMounted(() => {
// 监听timeline-event事件 // 从本地存储加载事件
window.addEventListener('timeline-event', handleTimelineEvent as EventListener); loadEventsFromStorage();
// 连接SSE服务
sseService.connect();
// 添加SSE事件监听器
sseService.on('timeline-event', handleTimelineEvent);
// 启动定期性能监控(每30秒输出一次) // 启动定期性能监控(每30秒输出一次)
const intervalId = setInterval(() => { intervalId = setInterval(() => {
if (events.value.length > 0) { if (events.value.length > 0) {
console.log('[TimelineContainer] 定期性能统计 - 事件总数:', events.value.length); console.log('[TimelineContainer] 定期性能统计 - 事件总数:', events.value.length);
} }
}, 30000); }, 30000);
});
// 组件卸载时清理资源
onUnmounted(() => {
// 清除定期性能监控定时器
clearInterval(intervalId);
// 保存事件到本地存储
saveEventsToStorage();
// 在组件卸载时清除定时器 // 移除SSE事件监听器
onUnmounted(() => { sseService.off('timeline-event', handleTimelineEvent);
clearInterval(intervalId); // 断开SSE连接
}); // 注意:这里不要断开SSE连接,因为其他组件可能也在使用
// sseService.disconnect();
}); });
// 处理SSE时间轴事件
const handleTimelineEvent = (eventData: any): void => {
try {
console.log('[TimelineContainer] 处理时间轴事件:', eventData);
// 验证事件数据格式
if (!eventData || typeof eventData !== 'object') {
console.error('[TimelineContainer] 无效的事件数据格式:', eventData);
return;
}
// 确保事件有基本属性
if (!eventData.type || !eventData.timestamp) {
console.error('[TimelineContainer] 事件缺少必要属性:', eventData);
return;
}
// 转换为TimelineEvent类型
const timelineEvent: TimelineEvent = {
id: eventData.id || ('evt_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)),
type: eventData.type,
title: eventData.title || eventData.type,
timestamp: eventData.timestamp,
content: eventData.content,
metadata: eventData.metadata,
// 工具事件特有属性
...(eventData.toolName && { toolName: eventData.toolName }),
...(eventData.toolInput && { toolInput: eventData.toolInput }),
...(eventData.toolOutput && { toolOutput: eventData.toolOutput }),
...(eventData.toolStatus && { toolStatus: eventData.toolStatus }),
...(eventData.executionTime !== undefined && { executionTime: eventData.executionTime }),
// 嵌入事件特有属性
...(eventData.embedUrl && { embedUrl: eventData.embedUrl }),
...(eventData.embedType && { embedType: eventData.embedType }),
...(eventData.embedTitle && { embedTitle: eventData.embedTitle }),
...(eventData.embedHtmlContent && { embedHtmlContent: eventData.embedHtmlContent })
};
// 添加事件到列表
addEvent(timelineEvent);
} catch (error) {
console.error('[TimelineContainer] 处理时间轴事件失败:', error);
}
};
</script> </script>
<style scoped> <style scoped>
.timeline-container-wrapper { .timeline-manager {
display: flex;
flex-direction: column;
height: 100%; height: 100%;
} }
.timeline-filter-panel {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-2);
border-radius: var(--border-radius-base);
margin-bottom: var(--spacing-2);
}
.filter-row {
display: flex;
gap: var(--spacing-2);
align-items: center;
margin-bottom: var(--spacing-1);
flex-wrap: wrap;
}
.filter-row:last-child {
margin-bottom: 0;
}
.filter-select {
min-width: 150px;
}
.timeline-filter-panel .el-input {
max-width: 300px;
}
.timeline-filter-panel .el-button {
margin-right: var(--spacing-1);
}
</style> </style>
\ No newline at end of file
<template>
<div class="timeline-event-detail">
<div class="detail-header">
<h3>事件详情</h3>
<el-button type="primary" size="small" @click="handleClose">关闭</el-button>
</div>
<div v-if="event" class="detail-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ event.id }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ eventTypeLabels[event.type] || event.type }}</el-descriptions-item>
<el-descriptions-item label="标题">{{ event.title }}</el-descriptions-item>
<el-descriptions-item label="时间">
<span>{{ formatTime(event.timestamp) }}</span>
<span class="full-time">{{ new Date(event.timestamp).toISOString() }}</span>
</el-descriptions-item>
<el-descriptions-item label="内容" :span="2">
<div v-if="event.content" class="event-content">
{{ event.content }}
</div>
<div v-else class="empty-content">无内容</div>
</el-descriptions-item>
<el-descriptions-item label="元数据" :span="2">
<div v-if="event.metadata" class="metadata-section">
<pre>{{ JSON.stringify(event.metadata, null, 2) }}</pre>
</div>
<div v-else class="empty-content">无元数据</div>
</el-descriptions-item>
<!-- 工具事件特有字段 -->
<template v-if="isToolEventType(event.type)">
<el-descriptions-item label="工具名称" v-if="(event as any).toolName">{{ (event as any).toolName }}</el-descriptions-item>
<el-descriptions-item label="工具状态" v-if="(event as any).toolStatus">{{ (event as any).toolStatus }}</el-descriptions-item>
<el-descriptions-item label="执行时间" v-if="(event as any).executionTime">
{{ (event as any).executionTime }} ms
</el-descriptions-item>
<el-descriptions-item label="工具输入" :span="2" v-if="(event as any).toolInput">
<pre>{{ JSON.stringify((event as any).toolInput, null, 2) }}</pre>
</el-descriptions-item>
<el-descriptions-item label="工具输出" :span="2" v-if="(event as any).toolOutput">
<pre>{{ JSON.stringify((event as any).toolOutput, null, 2) }}</pre>
</el-descriptions-item>
<el-descriptions-item label="错误信息" :span="2" v-if="(event as any).errorMessage">
{{ (event as any).errorMessage }}
</el-descriptions-item>
</template>
<!-- Embed事件特有字段 -->
<template v-if="event.type === 'embed'">
<el-descriptions-item label="嵌入URL" v-if="(event as any).embedUrl">{{ (event as any).embedUrl }}</el-descriptions-item>
<el-descriptions-item label="嵌入类型" v-if="(event as any).embedType">{{ (event as any).embedType }}</el-descriptions-item>
<el-descriptions-item label="嵌入标题" v-if="(event as any).embedTitle">{{ (event as any).embedTitle }}</el-descriptions-item>
</template>
</el-descriptions>
</div>
<div v-else class="no-event">
<div class="empty-icon">📋</div>
<div class="empty-text">未选择事件</div>
</div>
</div>
</template>
<script setup lang="ts">
import { eventTypeLabels } from '../types/timeline'
import type { TimelineEvent } from '../types/timeline'
import { isToolEventType } from '../utils/timelineUtils'
// 定义组件属性
const props = defineProps<{
event: TimelineEvent | null
}>()
// 定义组件事件
const emit = defineEmits<{
close: []
}>()
// 格式化时间
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp)
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
}
// 关闭详情页
const handleClose = () => {
emit('close')
}
</script>
<style scoped>
.timeline-event-detail {
background-color: var(--bg-primary);
border-radius: var(--border-radius-base);
padding: var(--spacing-4);
height: 100%;
overflow-y: auto;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-3);
}
.detail-header h3 {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.detail-content {
margin-top: var(--spacing-2);
}
.event-content {
background-color: var(--bg-tertiary);
padding: var(--spacing-2);
border-radius: var(--border-radius-sm);
font-family: var(--font-family-mono);
white-space: pre-wrap;
word-break: break-all;
}
.metadata-section {
background-color: var(--bg-tertiary);
padding: var(--spacing-2);
border-radius: var(--border-radius-sm);
overflow-x: auto;
}
.metadata-section pre {
margin: 0;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-normal);
}
.empty-content {
color: var(--text-tertiary);
font-style: italic;
padding: var(--spacing-2);
background-color: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
}
.full-time {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin-left: var(--spacing-2);
}
.no-event {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-tertiary);
}
.empty-icon {
font-size: 48px;
margin-bottom: var(--spacing-2);
}
.empty-text {
font-size: var(--font-size-sm);
}
/* 滚动样式 */
.timeline-event-detail::-webkit-scrollbar {
width: 6px;
}
.timeline-event-detail::-webkit-scrollbar-track {
background: transparent;
}
.timeline-event-detail::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: 3px;
}
.timeline-event-detail::-webkit-scrollbar-thumb:hover {
background: var(--gray-400);
}
</style>
\ No newline at end of file
...@@ -11,8 +11,14 @@ ...@@ -11,8 +11,14 @@
<div class="empty-text">等待执行过程...</div> <div class="empty-text">等待执行过程...</div>
</div> </div>
<div v-else class="timeline-list"> <div v-else class="timeline-list" v-el-infinite-scroll="loadMore" :infinite-scroll-distance="50">
<div v-for="(event, index) in reversedEvents" :key="event.timestamp + '-' + index" class="timeline-item" :class="event.type"> <div
v-for="(event, index) in displayedEvents"
:key="event.id || (event.timestamp + '-' + index)"
class="timeline-item"
:class="event.type"
@click="showEventDetail(event)"
>
<div class="timeline-dot"></div> <div class="timeline-dot"></div>
<div class="timeline-content"> <div class="timeline-content">
<div class="event-header"> <div class="event-header">
...@@ -24,7 +30,7 @@ ...@@ -24,7 +30,7 @@
<div v-if="event.content" class="event-content"> <div v-if="event.content" class="event-content">
<div <div
class="content-text-wrapper" class="content-text-wrapper"
@click="shouldShowToggle(event.timestamp) && toggleContentExpand(event.timestamp)" @click.stop="shouldShowToggle(event.timestamp) && toggleContentExpand(event.timestamp)"
> >
<div <div
class="content-text" class="content-text"
...@@ -37,7 +43,7 @@ ...@@ -37,7 +43,7 @@
<div <div
v-if="shouldShowToggle(event.timestamp)" v-if="shouldShowToggle(event.timestamp)"
class="content-toggle" class="content-toggle"
@click="toggleContentExpand(event.timestamp)" @click.stop="toggleContentExpand(event.timestamp)"
> >
{{ getContentExpandedState(event.timestamp) ? '收起' : '展开' }} {{ getContentExpandedState(event.timestamp) ? '收起' : '展开' }}
</div> </div>
...@@ -49,13 +55,13 @@ ...@@ -49,13 +55,13 @@
class="tool-details" class="tool-details"
> >
<!-- 展开/折叠按钮 --> <!-- 展开/折叠按钮 -->
<div class="detail-toggle" @click="props.toggleExpand(props.events.length - 1 - index)"> <div class="detail-toggle" @click.stop="props.toggleExpand(displayedEvents.length - 1 - index)">
<span class="toggle-text">{{ props.getExpandedState(props.events.length - 1 - index) ? '收起详情' : '查看详情' }}</span> <span class="toggle-text">{{ props.getExpandedState(displayedEvents.length - 1 - index) ? '收起详情' : '查看详情' }}</span>
<span class="toggle-icon">{{ props.getExpandedState(props.events.length - 1 - index) ? '▲' : '▼' }}</span> <span class="toggle-icon">{{ props.getExpandedState(displayedEvents.length - 1 - index) ? '▲' : '▼' }}</span>
</div> </div>
<!-- 详细信息内容 --> <!-- 详细信息内容 -->
<div v-show="getExpandedState(props.events.length - 1 - index)" class="detail-content"> <div v-show="getExpandedState(displayedEvents.length - 1 - index)" class="detail-content">
<!-- 输入参数段 --> <!-- 输入参数段 -->
<ToolDataSection <ToolDataSection
v-if="props.hasValidToolInput(event)" v-if="props.hasValidToolInput(event)"
...@@ -82,14 +88,31 @@ ...@@ -82,14 +88,31 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="loading" class="loading-more">加载中...</div>
<div v-else-if="displayedEvents.length >= reversedEvents.length" class="no-more">没有更多了</div>
</div> </div>
</div> </div>
<!-- 事件详情抽屉 -->
<el-drawer
v-model="isDetailVisible"
title="事件详情"
size="50%"
direction="rtl"
>
<TimelineEventDetail
:event="selectedEvent"
@close="isDetailVisible = false"
/>
</el-drawer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { ElInfiniteScroll } from 'element-plus'
import ToolDataSection from './ToolDataSection.vue' import ToolDataSection from './ToolDataSection.vue'
import TimelineEventDetail from './TimelineEventDetail.vue'
import type { TimelineEvent } from '../types/timeline' import type { TimelineEvent } from '../types/timeline'
import { useContentExpansion } from '../composables/useContentExpansion' import { useContentExpansion } from '../composables/useContentExpansion'
import { truncateTitle } from '../utils/timelineUtils' import { truncateTitle } from '../utils/timelineUtils'
...@@ -107,9 +130,47 @@ const props = defineProps<{ ...@@ -107,9 +130,47 @@ const props = defineProps<{
onClearTimeline: () => void onClearTimeline: () => void
}>() }>()
// 虚拟滚动相关状态
const loading = ref(false)
const pageSize = ref(20) // 每次加载的事件数量
// 事件详情相关状态
const selectedEvent = ref<TimelineEvent | null>(null)
const isDetailVisible = ref(false)
// 计算反转后的事件列表(最新事件在顶部) // 计算反转后的事件列表(最新事件在顶部)
const reversedEvents = computed(() => [...props.events].reverse()) const reversedEvents = computed(() => [...props.events].reverse())
// 计算当前显示的事件
const displayedEvents = computed(() => {
return reversedEvents.value.slice(0, pageSize.value)
})
// 显示事件详情
const showEventDetail = (event: TimelineEvent) => {
selectedEvent.value = event
isDetailVisible.value = true
}
// 加载更多事件
const loadMore = () => {
if (loading.value) return
if (displayedEvents.value.length >= reversedEvents.value.length) return
loading.value = true
// 模拟异步加载,实际是直接从reversedEvents中截取
setTimeout(() => {
pageSize.value += 20
loading.value = false
}, 300)
}
// 监听事件变化,重置分页
watch(() => props.events.length, () => {
pageSize.value = 20 // 重置为初始值
updateLineCounts()
})
// 使用内容展开管理hook // 使用内容展开管理hook
const { const {
getContentExpandedState, getContentExpandedState,
...@@ -556,6 +617,15 @@ watch(() => props.events, () => { ...@@ -556,6 +617,15 @@ watch(() => props.events, () => {
background: var(--gray-400); background: var(--gray-400);
} }
/* 虚拟滚动相关样式 */
.loading-more,
.no-more {
text-align: center;
padding: var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
/* 响应式设计 */ /* 响应式设计 */
@media (max-width: 768px) { @media (max-width: 768px) {
.timeline-header { .timeline-header {
......
<template> <template>
<div class="work-area"> <div class="work-area">
<el-tabs v-model="activeTab" class="work-tabs"> <el-tabs v-model="activeTab" class="work-tabs">
<el-tab-pane label="表单" name="form">
<form-render ref="formRender" />
</el-tab-pane>
<el-tab-pane label="📋 时间轴" name="timeline"> <el-tab-pane label="📋 时间轴" name="timeline">
<timeline-container ref="timelineContainerRef" /> <timeline-container ref="timelineContainerRef" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="🌐 网页浏览" name="browser"> <el-tab-pane label="🌐 网页浏览" name="browser">
<webpage-browser ref="webBrowser" /> <webpage-browser ref="webBrowser" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="表单" name="form">
<form-render ref="formRender" />
</el-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
</template> </template>
...@@ -19,30 +19,19 @@ import { ref, onMounted, onUnmounted } from "vue"; ...@@ -19,30 +19,19 @@ import { ref, onMounted, onUnmounted } from "vue";
import FormRender from "./FormRender.vue"; import FormRender from "./FormRender.vue";
import TimelineContainer from "./TimelineContainer.vue"; import TimelineContainer from "./TimelineContainer.vue";
import WebpageBrowser from "./WebpageBrowser.vue"; import WebpageBrowser from "./WebpageBrowser.vue";
import { TimelineService } from "../services/TimelineService";
const activeTab = ref("form"); const activeTab = ref("timeline");
const formRender = ref(); const formRender = ref();
const timelineContainerRef = ref<InstanceType<typeof TimelineContainer> | null>( const timelineContainerRef = ref<InstanceType<typeof TimelineContainer> | null>(
null null
); );
const webBrowser = ref(); const webBrowser = ref();
let timelineService: TimelineService | null = null;
// 添加事件到时间轴 // 添加事件到时间轴
const addEvent = (event: any): void => { const addEvent = (event: any): void => {
timelineContainerRef.value?.addEvent(event); timelineContainerRef.value?.addEvent(event);
}; };
// 初始化Timeline服务
const initTimelineService = () => {
if (timelineContainerRef.value) {
timelineService = new TimelineService((event: any) => {
addEvent(event);
});
timelineService.connectSSE();
}
};
// 清除时间轴 // 清除时间轴
const clearTimeline = (): void => { const clearTimeline = (): void => {
timelineContainerRef.value?.clearTimeline(); timelineContainerRef.value?.clearTimeline();
...@@ -98,18 +87,10 @@ const handleEmbedEvent = (e: Event) => { ...@@ -98,18 +87,10 @@ const handleEmbedEvent = (e: Event) => {
onMounted(() => { onMounted(() => {
// 监听embed事件 // 监听embed事件
window.addEventListener("embed-event", handleEmbedEvent as EventListener); window.addEventListener("embed-event", handleEmbedEvent as EventListener);
// 初始化Timeline服务
initTimelineService();
}); });
onUnmounted(() => { onUnmounted(() => {
// 移除事件监听 // 移除事件监听
window.removeEventListener("embed-event", handleEmbedEvent as EventListener); window.removeEventListener("embed-event", handleEmbedEvent as EventListener);
// 清理Timeline服务
if (timelineService) {
timelineService.cleanup();
}
}); // 暴露方法供父组件调用 }); // 暴露方法供父组件调用
defineExpose({ defineExpose({
formRender, formRender,
......
// 内容展开管理hook // 内容展开管理hook
import { ref, nextTick } from 'vue' import { nextTick, ref, type Ref } from 'vue'
import type { Ref } from 'vue'
import type { TimelineEvent } from '../types/timeline' import type { TimelineEvent } from '../types/timeline'
export function useContentExpansion(props: { export function useContentExpansion(props: {
events: TimelineEvent[] events: TimelineEvent[]
}) { }) {
// 内容展开状态管理 // 内容展开状态管理 - 使用WeakMap提高性能
const contentExpandedStates = ref<Record<number, boolean>>({}) const contentExpandedStates = new WeakMap<HTMLElement, boolean>()
const contentLineCounts = ref<Record<number, number>>({}) const contentLineCounts = ref<Record<string, number>>({})
const contentElements = ref<Record<number, HTMLElement>>({}) const contentElements = new Map<string, HTMLElement>()
// 事件ID到时间戳的映射,用于快速查找
const eventIdToTimestamp = ref<Record<string, number>>({})
// 更新事件ID映射
const updateEventIdMapping = () => {
props.events.forEach(event => {
if (event.id) {
eventIdToTimestamp.value[event.id] = event.timestamp
}
})
}
// 获取内容展开状态 // 获取内容展开状态
const getContentExpandedState = (timestamp: number): boolean => { const getContentExpandedState = (timestamp: number): boolean => {
return contentExpandedStates.value[timestamp] || false const key = timestamp.toString()
const element = contentElements.get(key)
return element ? (contentExpandedStates.get(element) || false) : false
} }
// 注册内容元素引用 // 注册内容元素引用
const setContentRef = (el: HTMLElement | null, timestamp: number) => { const setContentRef = (el: HTMLElement | null, timestamp: number) => {
if (el) { if (el) {
contentElements.value[timestamp] = el const key = timestamp.toString()
contentElements.set(key, el)
// 初始化展开状态为false
if (!contentExpandedStates.has(el)) {
contentExpandedStates.set(el, false)
}
// 更新行数计算 // 更新行数计算
updateLineCountForElement(timestamp) updateLineCountForElement(timestamp)
} }
...@@ -28,18 +46,24 @@ export function useContentExpansion(props: { ...@@ -28,18 +46,24 @@ export function useContentExpansion(props: {
// 为特定元素更新行数计算 // 为特定元素更新行数计算
const updateLineCountForElement = (timestamp: number) => { const updateLineCountForElement = (timestamp: number) => {
const event = props.events.find(e => e.timestamp === timestamp) const event = props.events.find(e => e.timestamp === timestamp)
if (event && event.content && contentElements.value[timestamp]) { const key = timestamp.toString()
contentLineCounts.value[timestamp] = calculateLineCount(event.content, contentElements.value[timestamp]) const element = contentElements.get(key)
// 如果内容超过两行,初始化为折叠状态
if (contentLineCounts.value[timestamp] > 2 && contentExpandedStates.value[timestamp] === undefined) { if (event && 'content' in event && event.content && element) {
contentExpandedStates.value[timestamp] = false const lineCount = calculateLineCount(event.content, element)
} const contentKey = event.id || key
contentLineCounts.value[contentKey] = lineCount
} }
} }
// 切换内容展开状态 // 切换内容展开状态
const toggleContentExpand = (timestamp: number) => { const toggleContentExpand = (timestamp: number) => {
contentExpandedStates.value[timestamp] = !getContentExpandedState(timestamp) const key = timestamp.toString()
const element = contentElements.get(key)
if (element) {
const currentState = contentExpandedStates.get(element) || false
contentExpandedStates.set(element, !currentState)
}
} }
// 检查是否应该显示切换按钮 // 检查是否应该显示切换按钮
...@@ -65,18 +89,24 @@ export function useContentExpansion(props: { ...@@ -65,18 +89,24 @@ export function useContentExpansion(props: {
} }
const shouldShowToggle = (timestamp: number): boolean => { const shouldShowToggle = (timestamp: number): boolean => {
return contentLineCounts.value[timestamp] > 2 const event = props.events.find(e => e.timestamp === timestamp)
if (!event) return false
const key = event.id || timestamp.toString()
return (contentLineCounts.value[key] || 0) > 2
} }
// 更新内容行数计数 // 更新内容行数计数
const updateLineCounts = () => { const updateLineCounts = () => {
nextTick(() => { nextTick(() => {
updateEventIdMapping()
props.events.forEach((event) => { props.events.forEach((event) => {
if (event.content) { if ('content' in event && event.content) {
// 行数将在元素引用设置时计算 const key = event.timestamp.toString()
// 这里只初始化展开状态 const element = contentElements.get(key)
if (contentExpandedStates.value[event.timestamp] === undefined) { if (element) {
contentExpandedStates.value[event.timestamp] = false updateLineCountForElement(event.timestamp)
} }
} }
}) })
...@@ -84,9 +114,6 @@ export function useContentExpansion(props: { ...@@ -84,9 +114,6 @@ export function useContentExpansion(props: {
} }
return { return {
contentExpandedStates,
contentLineCounts,
contentElements,
getContentExpandedState, getContentExpandedState,
setContentRef, setContentRef,
toggleContentExpand, toggleContentExpand,
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div class="new-chat-page"> <div class="new-chat-page">
<!-- 左侧对话区 --> <!-- 左侧对话区 -->
<div class="left-panel"> <div class="left-panel">
<chat-area ref="chatArea" /> <chat-area ref="chatArea" :add-event-to-timeline="addEventToTimeline" />
</div> </div>
<!-- 中间分割线 --> <!-- 中间分割线 -->
...@@ -37,6 +37,13 @@ watch(() => route.query.agentId, (newAgentId) => { ...@@ -37,6 +37,13 @@ watch(() => route.query.agentId, (newAgentId) => {
} }
}, { immediate: true }) }, { immediate: true })
// 添加事件到时间轴
const addEventToTimeline = (event: any) => {
if (workArea.value && typeof workArea.value.addEvent === 'function') {
workArea.value.addEvent(event)
}
}
// 开始拖动分割线 // 开始拖动分割线
const startResize = (e: MouseEvent) => { const startResize = (e: MouseEvent) => {
isResizing.value = true isResizing.value = true
......
/**
* Timeline服务类
* 整合了SSE管理功能,减少服务层级
*/
export class TimelineService {
private eventSource: EventSource | null = null;
private retryCount = 0;
private maxRetries = 5;
private retryDelay = 3000;
private addEventCallback: Function;
private messageQueue: any[] = [];
private processingQueue = false;
constructor(addEvent: Function) {
this.addEventCallback = addEvent;
}
/**
* 建立SSE连接
*/
connectSSE() {
// 构造带认证参数的URL
let eventSourceUrl = '/api/v1/agent/timeline-events';
// 从localStorage获取token
const token = localStorage.getItem('token');
// 使用请求头而不是URL参数传递token
if (token) {
// 创建自定义的EventSource实现,支持添加请求头
const eventSource = new EventSourceWithAuth(eventSourceUrl, token);
this.eventSource = eventSource as unknown as EventSource;
} else {
// 如果没有token,仍然使用标准EventSource
this.eventSource = new EventSource(eventSourceUrl);
}
this.eventSource.onmessage = this.handleMessage.bind(this);
this.eventSource.onerror = this.handleError.bind(this);
this.eventSource.onopen = this.handleOpen.bind(this);
return this.eventSource;
}
/**
* 处理SSE消息队列
*/
private processMessageQueue() {
// 如果未连接或正在处理队列,则跳过
if (!this.eventSource || this.eventSource.readyState !== EventSource.OPEN || this.processingQueue || this.messageQueue.length === 0) {
return;
}
this.processingQueue = true;
// 批量处理消息以提高性能
const batchSize = 10;
const batch = this.messageQueue.splice(0, batchSize);
batch.forEach(data => {
this.handleSingleSseMessage(data);
});
this.processingQueue = false;
// 如果还有消息,继续处理
if (this.messageQueue.length > 0) {
setTimeout(() => this.processMessageQueue(), 0);
}
}
/**
* 处理单个SSE消息
*/
private handleSingleSseMessage(data: any) {
this.addEvent(data);
// 触发embed事件给父组件(如果需要)
if (data.type === 'embed') {
window.dispatchEvent(new CustomEvent('embed-event', { detail: data }));
}
// 重置重试计数
this.retryCount = 0;
}
/**
* 处理消息事件
*/
private handleMessage(event: MessageEvent) {
try {
// 的消息解析
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
if (data) {
// 将消息加入队列进行批处理,但限制队列大小以避免内存泄漏
if (this.messageQueue.length < 100) {
this.messageQueue.push(data);
this.processMessageQueue();
} else {
console.warn('[TimelinePanel] 消息队列已满,丢弃新消息');
}
}
} catch (err) {
console.error('解析时间轴事件失败:', err);
}
}
/**
* 处理错误事件
*/
private handleError(event: Event) {
console.error('[SSE] 连接错误:', event);
// 尝试重新连接
if (this.retryCount < this.maxRetries) {
this.retryCount++;
setTimeout(() => {
console.log(`[SSE] 尝试重新连接 (${this.retryCount}/${this.maxRetries})`);
this.connectSSE();
}, this.retryDelay * this.retryCount);
this.addEvent({
type: 'observation',
title: '重新连接中',
content: `正在尝试重新连接 (${this.retryCount}/${this.maxRetries})`,
timestamp: Date.now()
});
} else {
this.addEvent({
type: 'error',
title: '连接失败',
content: '无法连接到服务器事件流,请刷新页面重试',
timestamp: Date.now()
});
// 显示用户通知
// if (typeof window !== 'undefined' && window.alert) {
// window.alert('时间轴连接失败,请刷新页面重试');
// }
}
}
/**
* 处理连接成功事件
*/
private handleOpen() {
console.log('[SSE] 连接已建立');
// 添加连接成功事件到时间轴
this.addEvent({
type: 'observation',
title: 'SSE连接已建立',
content: '成功连接到服务器事件流',
timestamp: Date.now()
});
// 处理队列中积压的消息
this.processMessageQueue();
}
/**
* 组件卸载时清理资源
*/
cleanup() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
/**
* 处理来自ChatArea的思考事件
*/
handleTimelineEvent(e: CustomEvent) {
const eventData = e.detail;
console.log('[TimelinePanel] 接收到timeline-event事件:', eventData);
// 确保时间戳存在且有效
const timestamp = eventData.timestamp || Date.now();
this.addEvent({
type: eventData.type || 'thought',
title: eventData.title || '思考过程',
content: eventData.content,
toolName: eventData.toolName,
toolAction: eventData.toolAction,
toolInput: eventData.toolInput,
toolOutput: eventData.toolOutput,
toolStatus: eventData.toolStatus,
executionTime: eventData.executionTime,
embedUrl: eventData.embedUrl,
embedType: eventData.embedType,
embedTitle: eventData.embedTitle,
embedHtmlContent: eventData.embedHtmlContent,
metadata: eventData.metadata,
timestamp: timestamp
});
}
/**
* 处理SSE连接失败事件
*/
handleSseConnectionFailed() {
console.error('[SSE] 时间轴事件连接失败,已达到最大重试次数');
// 添加连接失败事件到时间轴
this.addEvent({
type: 'error',
title: 'SSE连接失败',
content: '无法连接到服务器事件流,请刷新页面重试',
timestamp: Date.now()
});
// 显示用户通知
// if (typeof window !== 'undefined' && window.alert) {
// window.alert('时间轴连接失败,请刷新页面重试');
// }
}
/**
* 获取当前EventSource实例
*/
getEventSource(): EventSource | null {
return this.eventSource;
}
/**
* 添加事件
*/
addEvent(event: any) {
if (this.addEventCallback) {
this.addEventCallback(event);
}
}
/**
* 清除Timeline
*/
clearTimeline() {
// 实现清除逻辑
}
/**
* 获取事件类型标签
*/
getEventTypeLabel(eventTypeLabels: Record<string, string>, type: string): string {
return eventTypeLabels[type] || type;
}
/**
* 格式化时间
*/
formatTime(timestamp: number): string {
const date = new Date(timestamp);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
/**
* 获取事件的展开状态
*/
getExpandedState(): boolean {
// 实现
return false;
}
/**
* 切换事件详细信息的展开状态
*/
toggleExpand() {
// 实现
}
/**
* 工具事件类型判断
*/
isToolEventType(type: string): boolean {
return ['tool_call', 'tool_result', 'tool_error'].includes(type);
}
/**
* 工具输入有效性检查
*/
hasValidToolInput(event: any): boolean {
return event.type === 'tool_call' && event.toolInput !== null && event.toolInput !== undefined;
}
/**
* 工具输出有效性检查
*/
hasValidToolOutput(event: any): boolean {
return event.type === 'tool_result' && event.toolOutput !== null && event.toolOutput !== undefined;
}
}
// 自定义EventSource实现,支持添加Authorization请求头
class EventSourceWithAuth extends EventTarget {
private xhr: XMLHttpRequest | null = null;
private timeoutId: number | null = null;
private _readyState: number;
private _url: string;
private _token: string;
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 2;
constructor(url: string, token: string) {
super();
this._url = url;
this._token = token;
this._readyState = EventSourceWithAuth.CONNECTING;
this.connect();
}
private connect() {
if (this.xhr) {
this.xhr.abort();
}
this.xhr = new XMLHttpRequest();
this.xhr.open('GET', this._url, true);
this.xhr.setRequestHeader('Accept', 'text/event-stream');
this.xhr.setRequestHeader('Cache-Control', 'no-cache');
this.xhr.setRequestHeader('Authorization', `Bearer ${this._token}`);
this.xhr.withCredentials = true;
this.xhr.onreadystatechange = () => {
if (this.xhr?.readyState === XMLHttpRequest.HEADERS_RECEIVED) {
if (this.xhr.status === 200) {
this._readyState = EventSourceWithAuth.OPEN;
this.dispatchEvent(new Event('open'));
} else {
this.handleError();
}
}
};
this.xhr.onprogress = () => {
if (this.xhr) {
const lines = this.xhr.responseText.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
const event = new MessageEvent('message', { data });
this.dispatchEvent(event);
}
}
}
};
this.xhr.onload = () => {
this._readyState = EventSourceWithAuth.CLOSED;
this.dispatchEvent(new Event('close'));
};
this.xhr.onerror = () => {
this.handleError();
};
this.xhr.send();
// 每30秒重新连接一次,保持连接活跃
this.timeoutId = window.setTimeout(() => {
this.reconnect();
}, 30000);
}
private handleError() {
this._readyState = EventSourceWithAuth.CLOSED;
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.dispatchEvent(new Event('error'));
// 尝试重新连接
setTimeout(() => this.reconnect(), 3000);
}
private reconnect() {
if (this._readyState !== EventSourceWithAuth.CLOSED) {
this.connect();
}
}
close() {
this._readyState = EventSourceWithAuth.CLOSED;
if (this.xhr) {
this.xhr.abort();
}
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.dispatchEvent(new Event('close'));
}
get readyState() {
return this._readyState;
}
get url() {
return this._url;
}
get withCredentials() {
return false;
}
}
\ No newline at end of file
import type { TimelineEvent } from '../types/timeline';
/**
* SSE服务类,用于处理与后端的Server-Sent Events连接
*/
export class SseService {
private eventSource: EventSource | null = null;
private eventListeners: Map<string, Array<(data: any) => void>> = new Map();
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
private url: string;
private isConnecting = false;
constructor(url: string = '/api/v1/events') {
this.url = url;
}
/**
* 连接到SSE服务器
*/
connect(): void {
if (this.eventSource || this.isConnecting) {
return;
}
this.isConnecting = true;
this.reconnectAttempts = 0;
try {
// 创建EventSource连接
this.eventSource = new EventSource(this.url);
// 监听open事件
this.eventSource.onopen = () => {
console.log('[SSE] 连接已建立');
this.reconnectAttempts = 0;
this.isConnecting = false;
this.dispatchEvent('connect', {});
};
// 监听message事件(默认事件)
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('[SSE] 收到消息:', data);
this.dispatchEvent('message', data);
// 如果是时间轴事件,分发特定事件
if (data.type) {
this.dispatchEvent('timeline-event', data);
}
} catch (error) {
console.error('[SSE] 解析消息失败:', error);
}
};
// 监听error事件
this.eventSource.onerror = (error) => {
console.error('[SSE] 连接错误:', error);
this.isConnecting = false;
this.dispatchEvent('error', error);
this.handleReconnect();
};
// 监听特定事件类型
this.eventSource.addEventListener('error', (event) => {
try {
const data = JSON.parse((event as MessageEvent).data);
console.error('[SSE] 服务器错误:', data);
this.dispatchEvent('server-error', data);
} catch (error) {
console.error('[SSE] 解析错误消息失败:', error);
}
});
this.eventSource.addEventListener('token', (event) => {
try {
const data = JSON.parse((event as MessageEvent).data);
console.log('[SSE] 收到Token:', data);
this.dispatchEvent('token', data);
} catch (error) {
console.error('[SSE] 解析Token消息失败:', error);
}
});
} catch (error) {
console.error('[SSE] 创建连接失败:', error);
this.isConnecting = false;
this.handleReconnect();
}
}
/**
* 断开SSE连接
*/
disconnect(): void {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
console.log('[SSE] 连接已断开');
this.dispatchEvent('disconnect', {});
}
this.isConnecting = false;
this.reconnectAttempts = 0;
}
/**
* 处理重连逻辑
*/
private handleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[SSE] 达到最大重连次数,停止重连');
this.dispatchEvent('connection-failed', { attempts: this.reconnectAttempts });
window.dispatchEvent(new CustomEvent('sse-connection-failed', { detail: { attempts: this.reconnectAttempts } }));
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`[SSE] 尝试重连... (${this.reconnectAttempts}/${this.maxReconnectAttempts}),延迟 ${delay}ms`);
setTimeout(() => {
this.connect();
}, delay);
}
/**
* 添加事件监听器
* @param eventType 事件类型
* @param callback 回调函数
*/
on(eventType: string, callback: (data: any) => void): void {
if (!this.eventListeners.has(eventType)) {
this.eventListeners.set(eventType, []);
}
this.eventListeners.get(eventType)?.push(callback);
}
/**
* 移除事件监听器
* @param eventType 事件类型
* @param callback 回调函数(可选,如果不提供则移除所有该类型的监听器)
*/
off(eventType: string, callback?: (data: any) => void): void {
if (!this.eventListeners.has(eventType)) {
return;
}
if (callback) {
const callbacks = this.eventListeners.get(eventType)?.filter(cb => cb !== callback) || [];
this.eventListeners.set(eventType, callbacks);
} else {
this.eventListeners.delete(eventType);
}
}
/**
* 分发事件
* @param eventType 事件类型
* @param data 事件数据
*/
private dispatchEvent(eventType: string, data: any): void {
const callbacks = this.eventListeners.get(eventType) || [];
callbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`[SSE] 执行事件监听器失败 (${eventType}):`, error);
}
});
}
/**
* 检查连接状态
*/
isConnected(): boolean {
return this.eventSource !== null && this.eventSource.readyState === EventSource.OPEN;
}
}
// 创建单例实例
const sseService = new SseService();
export default sseService;
\ No newline at end of file
// 统一的时间轴事件类型定义 // 统一的时间轴事件类型定义
export interface BaseTimelineEvent { export interface BaseTimelineEvent {
id: string;
type: string; type: string;
title: string; title: string;
timestamp: number; timestamp: number;
...@@ -39,12 +40,22 @@ export interface EmbedEvent extends BaseTimelineEvent { ...@@ -39,12 +40,22 @@ export interface EmbedEvent extends BaseTimelineEvent {
embedHtmlContent?: string; embedHtmlContent?: string;
} }
export interface CompleteEvent extends BaseTimelineEvent {
content: string;
}
export interface ErrorEvent extends BaseTimelineEvent {
content: string;
}
export type TimelineEvent = export type TimelineEvent =
| ThoughtEvent | ThoughtEvent
| ToolCallEvent | ToolCallEvent
| ToolResultEvent | ToolResultEvent
| ToolErrorEvent | ToolErrorEvent
| EmbedEvent | EmbedEvent
| CompleteEvent
| ErrorEvent
| BaseTimelineEvent; | BaseTimelineEvent;
// 事件类型标签映射 // 事件类型标签映射
...@@ -56,5 +67,7 @@ export const eventTypeLabels: Record<string, string> = { ...@@ -56,5 +67,7 @@ export const eventTypeLabels: Record<string, string> = {
embed: '🌐 网页预览', embed: '🌐 网页预览',
log: '📝 日志', log: '📝 日志',
result: '🎯 最终答案', result: '🎯 最终答案',
observation: '🔍 观察' observation: '🔍 观察',
complete: '✅ 完成',
error: '❌ 错误'
}; };
\ No newline at end of file
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