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
...@@ -135,9 +135,8 @@ public class SecurityConfig { ...@@ -135,9 +135,8 @@ 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 || isTimelineEndpoint) { if (isStreamEndpoint) {
// 对于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");
...@@ -175,9 +174,8 @@ public class SecurityConfig { ...@@ -175,9 +174,8 @@ 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 || isTimelineEndpoint) { if (isStreamEndpoint) {
// 对于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");
......
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,31 +12,54 @@ import org.springframework.ai.tool.annotation.Tool; ...@@ -9,31 +12,54 @@ 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) {
Map<String, Object> params = new HashMap<>();
params.put("a", a);
params.put("b", b);
return execute("add", params, () -> {
double result = a + b; double result = a + b;
log.debug("执行加法运算: {} + {} = {}", a, b, result); log.debug("执行加法运算: {} + {} = {}", a, b, result);
return result; return result;
});
} }
@Tool(description = "执行两个数字的减法运算") @Tool(description = "执行两个数字的减法运算")
public double subtract(double a, double b) { public double subtract(double a, double b) {
Map<String, Object> params = new HashMap<>();
params.put("a", a);
params.put("b", b);
return execute("subtract", params, () -> {
double result = a - b; double result = a - b;
log.debug("执行减法运算: {} - {} = {}", a, b, result); log.debug("执行减法运算: {} - {} = {}", a, b, result);
return result; return result;
});
} }
@Tool(description = "执行两个数字的乘法运算") @Tool(description = "执行两个数字的乘法运算")
public double multiply(double a, double b) { public double multiply(double a, double b) {
Map<String, Object> params = new HashMap<>();
params.put("a", a);
params.put("b", b);
return execute("multiply", params, () -> {
double result = a * b; double result = a * b;
log.debug("执行乘法运算: {} * {} = {}", a, b, result); log.debug("执行乘法运算: {} * {} = {}", a, b, result);
return result; return result;
});
} }
@Tool(description = "执行两个数字的除法运算") @Tool(description = "执行两个数字的除法运算")
public String divide(double a, double b) { public String divide(double a, double b) {
Map<String, Object> params = new HashMap<>();
params.put("a", a);
params.put("b", b);
return execute("divide", params, () -> {
log.debug("执行除法运算: {} / {}", a, b); log.debug("执行除法运算: {} / {}", a, b);
if (b == 0) { if (b == 0) {
log.warn("除法运算错误:除数不能为零"); log.warn("除法运算错误:除数不能为零");
...@@ -42,5 +68,6 @@ public class CalculatorTools { ...@@ -42,5 +68,6 @@ public class CalculatorTools {
double result = a / b; double result = a / b;
log.debug("除法运算结果: {}", result); log.debug("除法运算结果: {}", result);
return String.valueOf(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,6 +26,7 @@ public class DateTimeTools { ...@@ -26,6 +26,7 @@ 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() {
return execute("getCurrentDateTime", () -> {
try { try {
if (dateTimeFormat == null || dateTimeFormat.trim().isEmpty()) { if (dateTimeFormat == null || dateTimeFormat.trim().isEmpty()) {
dateTimeFormat = "yyyy-MM-dd HH:mm:ss"; dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
...@@ -38,10 +39,12 @@ public class DateTimeTools { ...@@ -38,10 +39,12 @@ public class DateTimeTools {
// 发生错误时回退到默认格式 // 发生错误时回退到默认格式
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); 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() {
return execute("getCurrentDate", () -> {
try { try {
if (dateFormat == null || dateFormat.trim().isEmpty()) { if (dateFormat == null || dateFormat.trim().isEmpty()) {
dateFormat = "yyyy-MM-dd"; dateFormat = "yyyy-MM-dd";
...@@ -54,10 +57,12 @@ public class DateTimeTools { ...@@ -54,10 +57,12 @@ public class DateTimeTools {
// 发生错误时回退到默认格式 // 发生错误时回退到默认格式
return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); 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() {
return execute("getCurrentTime", () -> {
try { try {
if (timeFormat == null || timeFormat.trim().isEmpty()) { if (timeFormat == null || timeFormat.trim().isEmpty()) {
timeFormat = "HH:mm:ss"; timeFormat = "HH:mm:ss";
...@@ -70,10 +75,12 @@ public class DateTimeTools { ...@@ -70,10 +75,12 @@ public class DateTimeTools {
// 发生错误时回退到默认格式 // 发生错误时回退到默认格式
return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")); 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() {
return execute("getCurrentTimeMillis", () -> {
try { try {
long timestamp = System.currentTimeMillis(); long timestamp = System.currentTimeMillis();
log.info("【时间工具】获取当前时间戳: {}", timestamp); log.info("【时间工具】获取当前时间戳: {}", timestamp);
...@@ -82,5 +89,6 @@ public class DateTimeTools { ...@@ -82,5 +89,6 @@ public class DateTimeTools {
log.error("获取当前时间戳时发生错误: {}", e.getMessage(), e); log.error("获取当前时间戳时发生错误: {}", e.getMessage(), e);
return String.valueOf(System.currentTimeMillis()); 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,12 +65,14 @@ public class HisenseLbpmApprovalTool { ...@@ -62,12 +65,14 @@ 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<>();
params.put("approvalUrl", approvalUrl);
params.put("approvalOpinion", approvalOpinion);
return execute("processHisenseLeaveApproval", params, () -> {
String ssoUsername = getSsoUsername(); String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理海信请假审批,URL: {}", ssoUsername, approvalUrl); log.info("开始为用户 {} 处理海信请假审批,URL: {}", ssoUsername, approvalUrl);
long startTime = System.currentTimeMillis();
// 参数校验 // 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) { if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空"; String errorMsg = "用户ID不能为空";
...@@ -112,6 +117,7 @@ public class HisenseLbpmApprovalTool { ...@@ -112,6 +117,7 @@ public class HisenseLbpmApprovalTool {
// 等待页面完全加载完成 // 等待页面完全加载完成
page.waitForLoadState(LoadState.NETWORKIDLE); page.waitForLoadState(LoadState.NETWORKIDLE);
...@@ -129,14 +135,12 @@ public class HisenseLbpmApprovalTool { ...@@ -129,14 +135,12 @@ public class HisenseLbpmApprovalTool {
// 截图并保存 // 截图并保存
takeScreenshotAndSave(page, "lbpm_approval_success_" + ssoUsername); takeScreenshotAndSave(page, "lbpm_approval_success_" + ssoUsername);
long endTime = System.currentTimeMillis(); log.info("请假审批处理完成");
log.info("请假审批处理完成,耗时: {} ms", endTime - startTime);
return "请假审批处理成功"; return "请假审批处理成功";
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "请假审批处理失败: " + e.getMessage(); String errorMsg = "请假审批处理失败: " + e.getMessage();
log.error("请假审批处理失败,耗时: {} ms", endTime - startTime, e); log.error("请假审批处理失败", e);
// 如果页面对象存在,截图保存错误页面 // 如果页面对象存在,截图保存错误页面
if (page != null) { if (page != null) {
...@@ -162,6 +166,7 @@ public class HisenseLbpmApprovalTool { ...@@ -162,6 +166,7 @@ public class HisenseLbpmApprovalTool {
} }
} }
} }
});
} }
/** /**
...@@ -172,11 +177,13 @@ public class HisenseLbpmApprovalTool { ...@@ -172,11 +177,13 @@ public class HisenseLbpmApprovalTool {
*/ */
@Tool(description = "获取海信LBPM业务系统的网页内容,需要先使用HisenseSsoLoginTool登录") @Tool(description = "获取海信LBPM业务系统的网页内容,需要先使用HisenseSsoLoginTool登录")
public String getHisenseLbpmBusinessSystemContent(String businessSystemUrl) { public String getHisenseLbpmBusinessSystemContent(String businessSystemUrl) {
Map<String, Object> params = new HashMap<>();
params.put("businessSystemUrl", businessSystemUrl);
return execute("getHisenseLbpmBusinessSystemContent", params, () -> {
String ssoUsername = getSsoUsername(); String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 获取海信业务系统内容,URL: {}", ssoUsername, businessSystemUrl); log.info("开始为用户 {} 获取海信业务系统内容,URL: {}", ssoUsername, businessSystemUrl);
long startTime = System.currentTimeMillis();
// 参数校验 // 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) { if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空"; String errorMsg = "用户ID不能为空";
...@@ -215,8 +222,7 @@ public class HisenseLbpmApprovalTool { ...@@ -215,8 +222,7 @@ public class HisenseLbpmApprovalTool {
// 提取页面内容 // 提取页面内容
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")) {
...@@ -225,9 +231,8 @@ public class HisenseLbpmApprovalTool { ...@@ -225,9 +231,8 @@ public class HisenseLbpmApprovalTool {
return content; return content;
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage(); String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
log.error("获取海信业务系统内容失败,耗时: {} ms", endTime - startTime, e); log.error("获取海信业务系统内容失败", e);
return errorMsg; return errorMsg;
} finally { } finally {
// 释放页面资源,但保留浏览器上下文供后续使用 // 释放页面资源,但保留浏览器上下文供后续使用
...@@ -239,6 +244,7 @@ public class HisenseLbpmApprovalTool { ...@@ -239,6 +244,7 @@ public class HisenseLbpmApprovalTool {
} }
} }
} }
});
} }
/** /**
...@@ -370,12 +376,12 @@ public class HisenseLbpmApprovalTool { ...@@ -370,12 +376,12 @@ public class HisenseLbpmApprovalTool {
*/ */
@Tool(description = "自动查找所有待审批的请假流程的网址,需要先使用HisenseSsoLoginTool登录") @Tool(description = "自动查找所有待审批的请假流程的网址,需要先使用HisenseSsoLoginTool登录")
public List<String> processAllPendingLeaveApprovals() { public List<String> processAllPendingLeaveApprovals() {
Map<String, Object> params = new HashMap<>();
return execute("processAllPendingLeaveApprovals", params, () -> {
String ssoUsername = getSsoUsername(); String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理所有待审批的请假流程", ssoUsername); log.info("开始为用户 {} 处理所有待审批的请假流程", ssoUsername);
long startTime = System.currentTimeMillis();
int processedCount = 0;
// 参数校验 // 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) { if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空"; String errorMsg = "用户ID不能为空";
...@@ -451,15 +457,12 @@ public class HisenseLbpmApprovalTool { ...@@ -451,15 +457,12 @@ public class HisenseLbpmApprovalTool {
} }
} }
long endTime = System.currentTimeMillis(); log.info("待审批处理完成");
String resultMessage = String.format("待审批处理完成,共 %d 个项目,耗时: %d ms", processedCount, endTime - startTime);
log.info(resultMessage);
return urls; return urls;
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "处理待审批项目失败: " + e.getMessage(); String errorMsg = "处理待审批项目失败: " + e.getMessage();
log.error("处理待审批项目失败,耗时: {} ms", endTime - startTime, e); log.error("处理待审批项目失败", e);
// 如果页面对象存在,截图保存错误页面 // 如果页面对象存在,截图保存错误页面
if (page != null) { if (page != null) {
...@@ -483,5 +486,6 @@ public class HisenseLbpmApprovalTool { ...@@ -483,5 +486,6 @@ public class HisenseLbpmApprovalTool {
} }
} }
} }
});
} }
} }
...@@ -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,11 +63,12 @@ public class HisensePerformanceApprovalTool { ...@@ -60,11 +63,12 @@ public class HisensePerformanceApprovalTool {
*/ */
@Tool(description = "自动查找所有待审批的绩效流程的网址,需要先使用HisenseSsoLoginTool登录") @Tool(description = "自动查找所有待审批的绩效流程的网址,需要先使用HisenseSsoLoginTool登录")
public List<String> checkHisensePerformancePendingTasks() { public List<String> checkHisensePerformancePendingTasks() {
Map<String, Object> params = new HashMap<>();
return execute("checkHisensePerformancePendingTasks", params, () -> {
String ssoUsername = getSsoUsername(); String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 查找所有待审批的绩效流程", ssoUsername); log.info("开始为用户 {} 查找所有待审批的绩效流程", ssoUsername);
long startTime = System.currentTimeMillis();
// 参数校验 // 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) { if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空"; String errorMsg = "用户ID不能为空";
...@@ -183,8 +187,7 @@ public class HisensePerformanceApprovalTool { ...@@ -183,8 +187,7 @@ public class HisensePerformanceApprovalTool {
} }
} }
long endTime = System.currentTimeMillis(); log.info("待审批流程查找完成,共 {} 个流程", urls.size());
log.info("待审批流程查找完成,共 {} 个流程,耗时: {} ms", urls.size(), endTime - startTime);
if (urls.isEmpty()) { if (urls.isEmpty()) {
return List.of("没有找到待审批的流程"); return List.of("没有找到待审批的流程");
...@@ -192,9 +195,8 @@ public class HisensePerformanceApprovalTool { ...@@ -192,9 +195,8 @@ public class HisensePerformanceApprovalTool {
return urls; return urls;
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "查找待审批流程失败: " + e.getMessage(); String errorMsg = "查找待审批流程失败: " + e.getMessage();
log.error("查找待审批流程失败,耗时: {} ms", endTime - startTime, e); log.error("查找待审批流程失败", e);
// 如果页面对象存在,截图保存错误页面 // 如果页面对象存在,截图保存错误页面
if (page != null) { if (page != null) {
...@@ -217,6 +219,7 @@ public class HisensePerformanceApprovalTool { ...@@ -217,6 +219,7 @@ public class HisensePerformanceApprovalTool {
} }
} }
} }
});
} }
/** /**
...@@ -227,11 +230,13 @@ public class HisensePerformanceApprovalTool { ...@@ -227,11 +230,13 @@ public class HisensePerformanceApprovalTool {
*/ */
@Tool(description = "获取海信绩效系统的审批页面内容,需要先使用HisenseSsoLoginTool登录") @Tool(description = "获取海信绩效系统的审批页面内容,需要先使用HisenseSsoLoginTool登录")
public String getHisensePerformancePageContent(String approvalUrl) { public String getHisensePerformancePageContent(String approvalUrl) {
Map<String, Object> params = new HashMap<>();
params.put("approvalUrl", approvalUrl);
return execute("getHisensePerformancePageContent", params, () -> {
String ssoUsername = getSsoUsername(); String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 获取绩效审批页面内容,URL: {}", ssoUsername, approvalUrl); log.info("开始为用户 {} 获取绩效审批页面内容,URL: {}", ssoUsername, approvalUrl);
long startTime = System.currentTimeMillis();
// 参数校验 // 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) { if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空"; String errorMsg = "用户ID不能为空";
...@@ -273,14 +278,12 @@ public class HisensePerformanceApprovalTool { ...@@ -273,14 +278,12 @@ public class HisensePerformanceApprovalTool {
// 提取页面内容 // 提取页面内容
String content = page.locator("body").innerText(); String content = page.locator("body").innerText();
long endTime = System.currentTimeMillis(); log.info("成功获取绩效审批页面内容");
log.info("成功获取绩效审批页面内容,耗时: {} ms", endTime - startTime);
return content; return content;
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "获取绩效审批页面内容失败: " + e.getMessage(); String errorMsg = "获取绩效审批页面内容失败: " + e.getMessage();
log.error("获取绩效审批页面内容失败,耗时: {} ms", endTime - startTime, e); log.error("获取绩效审批页面内容失败", e);
return errorMsg; return errorMsg;
} finally { } finally {
// 释放页面资源,但保留浏览器上下文供后续使用 // 释放页面资源,但保留浏览器上下文供后续使用
...@@ -292,6 +295,7 @@ public class HisensePerformanceApprovalTool { ...@@ -292,6 +295,7 @@ public class HisensePerformanceApprovalTool {
} }
} }
} }
});
} }
/** /**
...@@ -304,11 +308,15 @@ public class HisensePerformanceApprovalTool { ...@@ -304,11 +308,15 @@ 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) {
Map<String, Object> params = new HashMap<>();
params.put("approvalUrl", approvalUrl);
params.put("isApproved", isApproved);
params.put("approvalOpinion", approvalOpinion);
return execute("performSinglePerformanceApproval", params, () -> {
String ssoUsername = getSsoUsername(); String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理绩效审批,URL: {}, 是否通过: {}", ssoUsername, approvalUrl, isApproved); log.info("开始为用户 {} 处理绩效审批,URL: {}, 是否通过: {}", ssoUsername, approvalUrl, isApproved);
long startTime = System.currentTimeMillis();
// 参数校验 // 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) { if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空"; String errorMsg = "用户ID不能为空";
...@@ -368,14 +376,12 @@ public class HisensePerformanceApprovalTool { ...@@ -368,14 +376,12 @@ public class HisensePerformanceApprovalTool {
// 截图并保存 // 截图并保存
takeScreenshotAndSave(page, "performance_approval_success_" + ssoUsername); takeScreenshotAndSave(page, "performance_approval_success_" + ssoUsername);
long endTime = System.currentTimeMillis(); log.info("绩效审批处理完成");
log.info("绩效审批处理完成,耗时: {} ms", endTime - startTime);
return "绩效审批处理成功"; return "绩效审批处理成功";
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "绩效审批处理失败: " + e.getMessage(); String errorMsg = "绩效审批处理失败: " + e.getMessage();
log.error("绩效审批处理失败,耗时: {} ms", endTime - startTime, e); log.error("绩效审批处理失败", e);
// 如果页面对象存在,截图保存错误页面 // 如果页面对象存在,截图保存错误页面
if (page != null) { if (page != null) {
...@@ -398,6 +404,7 @@ public class HisensePerformanceApprovalTool { ...@@ -398,6 +404,7 @@ public class HisensePerformanceApprovalTool {
} }
} }
} }
});
} }
/** /**
......
...@@ -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,6 +179,10 @@ public class HisenseSsoLoginTool { ...@@ -176,6 +179,10 @@ public class HisenseSsoLoginTool {
@Tool(description = "获取任意海信业务系统的网页内容(自动处理SSO认证)") @Tool(description = "获取任意海信业务系统的网页内容(自动处理SSO认证)")
public String getHisenseBusinessSystemContent( public String getHisenseBusinessSystemContent(
@JsonPropertyDescription("海信业务系统的页面URL") String businessSystemUrl) { @JsonPropertyDescription("海信业务系统的页面URL") String businessSystemUrl) {
Map<String, Object> params = new HashMap<>();
params.put("businessSystemUrl", businessSystemUrl);
return execute("getHisenseBusinessSystemContent", params, () -> {
// initializeIfNeeded(); // initializeIfNeeded();
log.info("开始获取海信业务系统内容,URL: {}", businessSystemUrl); log.info("开始获取海信业务系统内容,URL: {}", businessSystemUrl);
...@@ -188,8 +195,6 @@ public class HisenseSsoLoginTool { ...@@ -188,8 +195,6 @@ public class HisenseSsoLoginTool {
return errorMsg; return errorMsg;
} }
long startTime = System.currentTimeMillis();
// 参数校验 // 参数校验
if (businessSystemUrl == null || businessSystemUrl.isEmpty()) { if (businessSystemUrl == null || businessSystemUrl.isEmpty()) {
String errorMsg = "业务系统URL不能为空"; String errorMsg = "业务系统URL不能为空";
...@@ -245,8 +250,7 @@ public class HisenseSsoLoginTool { ...@@ -245,8 +250,7 @@ public class HisenseSsoLoginTool {
// 提取页面内容 // 提取页面内容
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")) {
...@@ -255,9 +259,8 @@ public class HisenseSsoLoginTool { ...@@ -255,9 +259,8 @@ public class HisenseSsoLoginTool {
return content; return content;
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage(); String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
log.error("获取海信业务系统内容失败,耗时: {} ms", endTime - startTime, e); log.error("获取海信业务系统内容失败", e);
return errorMsg; return errorMsg;
} finally { } finally {
// 释放页面资源 // 释放页面资源
...@@ -269,6 +272,7 @@ public class HisenseSsoLoginTool { ...@@ -269,6 +272,7 @@ public class HisenseSsoLoginTool {
} }
} }
} }
});
} }
/** /**
...@@ -280,6 +284,9 @@ public class HisenseSsoLoginTool { ...@@ -280,6 +284,9 @@ public class HisenseSsoLoginTool {
*/ */
@Tool(description = "海信SSO登录工具,用于登录海信SSO系统") @Tool(description = "海信SSO登录工具,用于登录海信SSO系统")
public String hisenseSsoLogin() { public String hisenseSsoLogin() {
Map<String, Object> params = new HashMap<>();
return execute("hisenseSsoLogin", params, () -> {
String username = getUserName(); String username = getUserName();
String password = getPassword(); String password = getPassword();
// 校验SSO凭证是否配置 // 校验SSO凭证是否配置
...@@ -290,8 +297,6 @@ public class HisenseSsoLoginTool { ...@@ -290,8 +297,6 @@ public class HisenseSsoLoginTool {
} }
log.info("开始执行海信SSO登录,用户名: {}", username); log.info("开始执行海信SSO登录,用户名: {}", username);
long startTime = System.currentTimeMillis();
// 参数校验 // 参数校验
if (username == null || username.isEmpty()) { if (username == null || username.isEmpty()) {
String errorMsg = "用户名不能为空"; String errorMsg = "用户名不能为空";
...@@ -344,14 +349,12 @@ public class HisenseSsoLoginTool { ...@@ -344,14 +349,12 @@ public class HisenseSsoLoginTool {
log.info("登录成功,已重定向回SSO配置页面"); 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登录失败,耗时: {} ms", endTime - startTime, e); log.error("海信SSO登录失败", e);
return errorMsg; return errorMsg;
} finally { } finally {
// 释放页面资源 // 释放页面资源
...@@ -363,6 +366,7 @@ public class HisenseSsoLoginTool { ...@@ -363,6 +366,7 @@ public class HisenseSsoLoginTool {
} }
} }
} }
});
} }
/** /**
...@@ -618,6 +622,10 @@ public class HisenseSsoLoginTool { ...@@ -618,6 +622,10 @@ public class HisenseSsoLoginTool {
@Tool(description = "处理MFA验证码验证,完成海信SSO登录") @Tool(description = "处理MFA验证码验证,完成海信SSO登录")
public String handleMfaVerification( public String handleMfaVerification(
@JsonPropertyDescription("短信验证码") String verificationCode) { @JsonPropertyDescription("短信验证码") String verificationCode) {
Map<String, Object> params = new HashMap<>();
params.put("verificationCode", verificationCode);
return execute("handleMfaVerification", params, () -> {
log.info("开始处理MFA验证码验证"); log.info("开始处理MFA验证码验证");
String username = getUserName(); String username = getUserName();
...@@ -629,8 +637,6 @@ public class HisenseSsoLoginTool { ...@@ -629,8 +637,6 @@ public class HisenseSsoLoginTool {
return errorMsg; return errorMsg;
} }
long startTime = System.currentTimeMillis();
// 清理过期的MFA会话 // 清理过期的MFA会话
cleanupExpiredMfaSessions(); cleanupExpiredMfaSessions();
...@@ -729,8 +735,7 @@ public class HisenseSsoLoginTool { ...@@ -729,8 +735,7 @@ public class HisenseSsoLoginTool {
// 更新登录时间 // 更新登录时间
lastLoginTime = System.currentTimeMillis(); lastLoginTime = System.currentTimeMillis();
long endTime = System.currentTimeMillis(); log.info("MFA验证完成");
log.info("MFA验证完成,耗时: {} ms", endTime - startTime);
return "MFA验证成功,登录完成"; return "MFA验证成功,登录完成";
} else { } else {
...@@ -743,8 +748,7 @@ public class HisenseSsoLoginTool { ...@@ -743,8 +748,7 @@ public class HisenseSsoLoginTool {
// 更新登录时间 // 更新登录时间
lastLoginTime = System.currentTimeMillis(); lastLoginTime = System.currentTimeMillis();
long endTime = System.currentTimeMillis(); log.info("MFA验证处理完成");
log.info("MFA验证处理完成,耗时: {} ms", endTime - startTime);
return "MFA验证已处理"; return "MFA验证已处理";
} }
...@@ -765,26 +769,24 @@ public class HisenseSsoLoginTool { ...@@ -765,26 +769,24 @@ public class HisenseSsoLoginTool {
// 更新登录时间 // 更新登录时间
lastLoginTime = System.currentTimeMillis(); lastLoginTime = System.currentTimeMillis();
long endTime = System.currentTimeMillis(); log.info("MFA验证完成");
log.info("MFA验证完成,耗时: {} ms", endTime - startTime);
return "MFA验证成功,登录完成"; return "MFA验证成功,登录完成";
} }
} }
} catch (com.microsoft.playwright.impl.TargetClosedError e) { } catch (com.microsoft.playwright.impl.TargetClosedError e) {
// 专门处理TargetClosedError // 专门处理TargetClosedError
long endTime = System.currentTimeMillis();
String errorMsg = "MFA验证时BrowserContext已关闭,请重新触发验证码发送流程"; String errorMsg = "MFA验证时BrowserContext已关闭,请重新触发验证码发送流程";
log.error("MFA验证失败 - TargetClosedError,耗时: {} ms,完整错误堆栈: ", endTime - startTime, e); log.error("MFA验证失败 - TargetClosedError,完整错误堆栈: ", e);
mfaSessions.remove(username); mfaSessions.remove(username);
return errorMsg; return errorMsg;
} catch (Exception e) { } catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "MFA验证过程发生异常: " + e.getMessage(); String errorMsg = "MFA验证过程发生异常: " + e.getMessage();
log.error("MFA验证失败,耗时: {} ms,错误类型: {},完整错误堆栈: ", endTime - startTime, e.getClass().getName(), e); log.error("MFA验证失败,错误类型: {},完整错误堆栈: ", e.getClass().getName(), e);
mfaSessions.remove(username); mfaSessions.remove(username);
return errorMsg; return errorMsg;
} }
});
} }
/** /**
...@@ -794,11 +796,12 @@ public class HisenseSsoLoginTool { ...@@ -794,11 +796,12 @@ public class HisenseSsoLoginTool {
*/ */
@Tool(description = "海信SSO登出工具,用于退出海信SSO系统") @Tool(description = "海信SSO登出工具,用于退出海信SSO系统")
public String hisenseSsoLogout() { public String hisenseSsoLogout() {
Map<String, Object> params = new HashMap<>();
return execute("hisenseSsoLogout", params, () -> {
// initializeIfNeeded(); // initializeIfNeeded();
log.info("开始执行海信SSO登出"); log.info("开始执行海信SSO登出");
long startTime = System.currentTimeMillis();
try { try {
// 关闭共享上下文 // 关闭共享上下文
if (getUSerContext() != null) { if (getUSerContext() != null) {
...@@ -810,16 +813,15 @@ public class HisenseSsoLoginTool { ...@@ -810,16 +813,15 @@ public class HisenseSsoLoginTool {
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登出失败,耗时: {} ms", endTime - startTime, e); log.error("海信SSO登出失败", 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() {
Map<String, Object> params = new HashMap<>();
return execute("checkHisenseSsoLoginStatus", params, () -> {
// initializeIfNeeded(); // initializeIfNeeded();
log.info("开始检查海信SSO登录状态"); log.info("开始检查海信SSO登录状态");
long startTime = System.currentTimeMillis();
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登录状态检查失败,耗时: {} ms", endTime - startTime, e); log.error("海信SSO登录状态检查失败", 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>
<div class="timeline-manager">
<!-- 过滤和搜索面板 -->
<div class="timeline-filter-panel">
<div class="filter-row">
<el-input
v-model="searchQuery"
placeholder="搜索事件..."
clearable
size="small"
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 <TimelinePanel
:events="events" :events="filteredEvents"
:getEventTypeLabel="getEventTypeLabel" :getEventTypeLabel="getEventTypeLabel"
:formatTime="formatTime" :formatTime="formatTime"
:getExpandedState="getExpandedState" :getExpandedState="getExpandedState"
...@@ -10,19 +71,62 @@ ...@@ -10,19 +71,62 @@
:hasValidToolOutput="hasValidToolOutput" :hasValidToolOutput="hasValidToolOutput"
:onClearTimeline="handleClearTimeline" :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 hasValidToolInput = (event: TimelineEvent): boolean => { const handleExport = (format: 'json' | 'csv') => {
return event.type === 'tool_call' && (event as any).toolInput !== null && (event as any).toolInput !== undefined; 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 convertToCSV = (events: TimelineEvent[]): string => {
// 定义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;
// 移除事件监听器 onMounted(() => {
window.removeEventListener('timeline-event', handleTimelineEvent as EventListener); // 从本地存储加载事件
}); loadEventsFromStorage();
// 处理timeline-event事件
const handleTimelineEvent = (e: Event) => {
const customEvent = e as CustomEvent;
const eventData = customEvent.detail;
console.log('[TimelineContainer] 接收到timeline-event事件:', eventData);
// 直接添加事件到列表 // 连接SSE服务
events.value.push(eventData); sseService.connect();
console.log('[TimelineContainer] 成功添加事件:', eventData.type, eventData.title);
};
// 组件挂载时启动定期性能监控和事件监听 // 添加SSE事件监听器
onMounted(() => { sseService.on('timeline-event', handleTimelineEvent);
// 监听timeline-event事件
window.addEventListener('timeline-event', handleTimelineEvent as EventListener);
// 启动定期性能监控(每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(() => { onUnmounted(() => {
// 清除定期性能监控定时器
clearInterval(intervalId); clearInterval(intervalId);
}); // 保存事件到本地存储
saveEventsToStorage();
// 移除SSE事件监听器
sseService.off('timeline-event', handleTimelineEvent);
// 断开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