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 {
try {
// 对于SSE端点的特殊处理
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格式的错误事件
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
......@@ -175,9 +174,8 @@ public class SecurityConfig {
try {
// 对于SSE端点的特殊处理
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格式的错误事件
response.setContentType("text/event-stream;charset=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;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
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;
*/
@Slf4j
@Component
public class CalculatorTools {
public class CalculatorTools extends BaseTool {
@Tool(description = "执行两个数字的加法运算")
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;
log.debug("执行加法运算: {} + {} = {}", a, b, result);
return result;
});
}
@Tool(description = "执行两个数字的减法运算")
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;
log.debug("执行减法运算: {} - {} = {}", a, b, result);
return result;
});
}
@Tool(description = "执行两个数字的乘法运算")
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;
log.debug("执行乘法运算: {} * {} = {}", a, b, result);
return result;
});
}
@Tool(description = "执行两个数字的除法运算")
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);
if (b == 0) {
log.warn("除法运算错误:除数不能为零");
......@@ -42,5 +68,6 @@ public class CalculatorTools {
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;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.ai.tool.annotation.Tool;
import pangea.hiagent.tool.BaseTool;
import java.time.LocalDateTime;
import java.time.LocalDate;
......@@ -16,7 +16,7 @@ import java.time.format.DateTimeFormatter;
*/
@Slf4j
@Component
public class DateTimeTools {
public class DateTimeTools extends BaseTool {
private String dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
......@@ -26,6 +26,7 @@ public class DateTimeTools {
@Tool(description = "获取当前日期和时间,返回格式为 'yyyy-MM-dd HH:mm:ss'")
public String getCurrentDateTime() {
return execute("getCurrentDateTime", () -> {
try {
if (dateTimeFormat == null || dateTimeFormat.trim().isEmpty()) {
dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
......@@ -38,10 +39,12 @@ public class DateTimeTools {
// 发生错误时回退到默认格式
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
});
}
@Tool(description = "获取当前日期,返回格式为 'yyyy-MM-dd'")
public String getCurrentDate() {
return execute("getCurrentDate", () -> {
try {
if (dateFormat == null || dateFormat.trim().isEmpty()) {
dateFormat = "yyyy-MM-dd";
......@@ -54,10 +57,12 @@ public class DateTimeTools {
// 发生错误时回退到默认格式
return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
});
}
@Tool(description = "获取当前时间,返回格式为 'HH:mm:ss'")
public String getCurrentTime() {
return execute("getCurrentTime", () -> {
try {
if (timeFormat == null || timeFormat.trim().isEmpty()) {
timeFormat = "HH:mm:ss";
......@@ -70,10 +75,12 @@ public class DateTimeTools {
// 发生错误时回退到默认格式
return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
}
});
}
@Tool(description = "获取当前时间戳(毫秒),返回自1970年1月1日00:00:00 UTC以来的毫秒数")
public String getCurrentTimeMillis() {
return execute("getCurrentTimeMillis", () -> {
try {
long timestamp = System.currentTimeMillis();
log.info("【时间工具】获取当前时间戳: {}", timestamp);
......@@ -82,5 +89,6 @@ public class DateTimeTools {
log.error("获取当前时间戳时发生错误: {}", e.getMessage(), e);
return String.valueOf(System.currentTimeMillis());
}
});
}
}
......@@ -14,7 +14,10 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import pangea.hiagent.tool.BaseTool;
import pangea.hiagent.web.service.ToolConfigService;
import pangea.hiagent.workpanel.playwright.PlaywrightManager;
......@@ -25,7 +28,7 @@ import pangea.hiagent.workpanel.playwright.PlaywrightManager;
*/
@Slf4j
@Component
public class HisenseLbpmApprovalTool {
public class HisenseLbpmApprovalTool extends BaseTool {
// SSO登录页面URL
private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/";
......@@ -62,12 +65,14 @@ public class HisenseLbpmApprovalTool {
*/
@Tool(description = "处理海信请假审批、自驾车审批、调休审批,需要先使用HisenseSsoLoginTool登录,提供用户ID以区分会话")
public String processHisenseLeaveApproval(String approvalUrl, String approvalOpinion) {
Map<String, Object> params = new HashMap<>();
params.put("approvalUrl", approvalUrl);
params.put("approvalOpinion", approvalOpinion);
return execute("processHisenseLeaveApproval", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理海信请假审批,URL: {}", ssoUsername, approvalUrl);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
......@@ -112,6 +117,7 @@ public class HisenseLbpmApprovalTool {
// 等待页面完全加载完成
page.waitForLoadState(LoadState.NETWORKIDLE);
......@@ -129,14 +135,12 @@ public class HisenseLbpmApprovalTool {
// 截图并保存
takeScreenshotAndSave(page, "lbpm_approval_success_" + ssoUsername);
long endTime = System.currentTimeMillis();
log.info("请假审批处理完成,耗时: {} ms", endTime - startTime);
log.info("请假审批处理完成");
return "请假审批处理成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "请假审批处理失败: " + e.getMessage();
log.error("请假审批处理失败,耗时: {} ms", endTime - startTime, e);
log.error("请假审批处理失败", e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
......@@ -162,6 +166,7 @@ public class HisenseLbpmApprovalTool {
}
}
}
});
}
/**
......@@ -172,11 +177,13 @@ public class HisenseLbpmApprovalTool {
*/
@Tool(description = "获取海信LBPM业务系统的网页内容,需要先使用HisenseSsoLoginTool登录")
public String getHisenseLbpmBusinessSystemContent(String businessSystemUrl) {
Map<String, Object> params = new HashMap<>();
params.put("businessSystemUrl", businessSystemUrl);
return execute("getHisenseLbpmBusinessSystemContent", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 获取海信业务系统内容,URL: {}", ssoUsername, businessSystemUrl);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
......@@ -215,8 +222,7 @@ public class HisenseLbpmApprovalTool {
// 提取页面内容
String content = page.locator("body").innerText();
long endTime = System.currentTimeMillis();
log.info("成功获取业务系统页面内容,耗时: {} ms", endTime - startTime);
log.info("成功获取业务系统页面内容");
// 检查是否包含错误信息
if (content.contains("InvalidStateError") && content.contains("setRequestHeader")) {
......@@ -225,9 +231,8 @@ public class HisenseLbpmApprovalTool {
return content;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "获取海信业务系统内容失败: " + e.getMessage();
log.error("获取海信业务系统内容失败,耗时: {} ms", endTime - startTime, e);
log.error("获取海信业务系统内容失败", e);
return errorMsg;
} finally {
// 释放页面资源,但保留浏览器上下文供后续使用
......@@ -239,6 +244,7 @@ public class HisenseLbpmApprovalTool {
}
}
}
});
}
/**
......@@ -370,12 +376,12 @@ public class HisenseLbpmApprovalTool {
*/
@Tool(description = "自动查找所有待审批的请假流程的网址,需要先使用HisenseSsoLoginTool登录")
public List<String> processAllPendingLeaveApprovals() {
Map<String, Object> params = new HashMap<>();
return execute("processAllPendingLeaveApprovals", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理所有待审批的请假流程", ssoUsername);
long startTime = System.currentTimeMillis();
int processedCount = 0;
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
......@@ -451,15 +457,12 @@ public class HisenseLbpmApprovalTool {
}
}
long endTime = System.currentTimeMillis();
String resultMessage = String.format("待审批处理完成,共 %d 个项目,耗时: %d ms", processedCount, endTime - startTime);
log.info(resultMessage);
log.info("待审批处理完成");
return urls;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "处理待审批项目失败: " + e.getMessage();
log.error("处理待审批项目失败,耗时: {} ms", endTime - startTime, e);
log.error("处理待审批项目失败", e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
......@@ -483,5 +486,6 @@ public class HisenseLbpmApprovalTool {
}
}
}
});
}
}
......@@ -14,7 +14,10 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import pangea.hiagent.tool.BaseTool;
import pangea.hiagent.web.service.ToolConfigService;
import pangea.hiagent.workpanel.playwright.PlaywrightManager;
......@@ -25,7 +28,7 @@ import pangea.hiagent.workpanel.playwright.PlaywrightManager;
*/
@Slf4j
@Component
public class HisensePerformanceApprovalTool {
public class HisensePerformanceApprovalTool extends BaseTool {
// SSO登录页面URL
private static final String SSO_LOGIN_URL = "https://sso.hisense.com/login/";
......@@ -60,11 +63,12 @@ public class HisensePerformanceApprovalTool {
*/
@Tool(description = "自动查找所有待审批的绩效流程的网址,需要先使用HisenseSsoLoginTool登录")
public List<String> checkHisensePerformancePendingTasks() {
Map<String, Object> params = new HashMap<>();
return execute("checkHisensePerformancePendingTasks", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 查找所有待审批的绩效流程", ssoUsername);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
......@@ -183,8 +187,7 @@ public class HisensePerformanceApprovalTool {
}
}
long endTime = System.currentTimeMillis();
log.info("待审批流程查找完成,共 {} 个流程,耗时: {} ms", urls.size(), endTime - startTime);
log.info("待审批流程查找完成,共 {} 个流程", urls.size());
if (urls.isEmpty()) {
return List.of("没有找到待审批的流程");
......@@ -192,9 +195,8 @@ public class HisensePerformanceApprovalTool {
return urls;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "查找待审批流程失败: " + e.getMessage();
log.error("查找待审批流程失败,耗时: {} ms", endTime - startTime, e);
log.error("查找待审批流程失败", e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
......@@ -217,6 +219,7 @@ public class HisensePerformanceApprovalTool {
}
}
}
});
}
/**
......@@ -227,11 +230,13 @@ public class HisensePerformanceApprovalTool {
*/
@Tool(description = "获取海信绩效系统的审批页面内容,需要先使用HisenseSsoLoginTool登录")
public String getHisensePerformancePageContent(String approvalUrl) {
Map<String, Object> params = new HashMap<>();
params.put("approvalUrl", approvalUrl);
return execute("getHisensePerformancePageContent", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 获取绩效审批页面内容,URL: {}", ssoUsername, approvalUrl);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
......@@ -273,14 +278,12 @@ public class HisensePerformanceApprovalTool {
// 提取页面内容
String content = page.locator("body").innerText();
long endTime = System.currentTimeMillis();
log.info("成功获取绩效审批页面内容,耗时: {} ms", endTime - startTime);
log.info("成功获取绩效审批页面内容");
return content;
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "获取绩效审批页面内容失败: " + e.getMessage();
log.error("获取绩效审批页面内容失败,耗时: {} ms", endTime - startTime, e);
log.error("获取绩效审批页面内容失败", e);
return errorMsg;
} finally {
// 释放页面资源,但保留浏览器上下文供后续使用
......@@ -292,6 +295,7 @@ public class HisensePerformanceApprovalTool {
}
}
}
});
}
/**
......@@ -304,11 +308,15 @@ public class HisensePerformanceApprovalTool {
*/
@Tool(description = "处理绩效系统单个审批流程,需要先使用HisenseSsoLoginTool登录")
public String performSinglePerformanceApproval(String approvalUrl, boolean isApproved, String approvalOpinion) {
Map<String, Object> params = new HashMap<>();
params.put("approvalUrl", approvalUrl);
params.put("isApproved", isApproved);
params.put("approvalOpinion", approvalOpinion);
return execute("performSinglePerformanceApproval", params, () -> {
String ssoUsername = getSsoUsername();
log.info("开始为用户 {} 处理绩效审批,URL: {}, 是否通过: {}", ssoUsername, approvalUrl, isApproved);
long startTime = System.currentTimeMillis();
// 参数校验
if (ssoUsername == null || ssoUsername.isEmpty()) {
String errorMsg = "用户ID不能为空";
......@@ -368,14 +376,12 @@ public class HisensePerformanceApprovalTool {
// 截图并保存
takeScreenshotAndSave(page, "performance_approval_success_" + ssoUsername);
long endTime = System.currentTimeMillis();
log.info("绩效审批处理完成,耗时: {} ms", endTime - startTime);
log.info("绩效审批处理完成");
return "绩效审批处理成功";
} catch (Exception e) {
long endTime = System.currentTimeMillis();
String errorMsg = "绩效审批处理失败: " + e.getMessage();
log.error("绩效审批处理失败,耗时: {} ms", endTime - startTime, e);
log.error("绩效审批处理失败", e);
// 如果页面对象存在,截图保存错误页面
if (page != null) {
......@@ -398,6 +404,7 @@ public class HisensePerformanceApprovalTool {
}
}
}
});
}
/**
......
// 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;
import java.util.Map;
import java.util.UUID;
/**
* 时间轴事件工厂类
......@@ -8,6 +9,14 @@ import java.util.Map;
*/
public class TimelineEventFactory {
/**
* 生成唯一事件ID
* @return 唯一事件ID
*/
private static String generateEventId() {
return "evt_" + UUID.randomUUID().toString().replace("-", "");
}
/**
* 根据事件类型创建相应的事件DTO对象
* 这是工厂类的唯一公共入口方法,确保所有事件对象创建都通过工厂完成
......@@ -21,6 +30,11 @@ public class TimelineEventFactory {
return null;
}
// 确保事件数据中包含唯一ID
if (!eventData.containsKey("id")) {
eventData.put("id", generateEventId());
}
switch (eventType) {
case "thought":
return createThoughtEvent(eventData);
......
......@@ -56,6 +56,26 @@ import { ElMessage, ElMessageBox } from "element-plus";
import MessageItem from "./MessageItem.vue";
import request from "@/utils/request";
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 {
content: string;
......@@ -508,6 +528,17 @@ const processSSELine = async (
// 只设置流状态和加载状态
messages.value[aiMessageIndex].isStreaming = false;
isLoading.value = false;
// 添加完成事件到时间轴
const completeEvent: TimelineEvent = {
id: generateEventId(),
type: "complete",
title: "对话完成",
content: "智能体已完成回答",
timestamp: Date.now(),
};
addEventToTimeline(completeEvent);
return true; // 返回true表示流已完成
case "error":
......@@ -527,22 +558,32 @@ const processSSELine = async (
isLoading.value = false;
// 记录错误日志便于调试
console.error("[SSE错误事件]", data);
// 添加错误事件到时间轴
const errorEvent: TimelineEvent = {
id: generateEventId(),
type: "error",
title: "对话错误",
content: errorMsg || "未知错误",
timestamp: Date.now(),
};
addEventToTimeline(errorEvent);
return true; // 返回true表示流已完成
case "thinking":
// 处理思考事件,将其发送到时间轴面板
const event = {
const thoughtEvent: TimelineEvent = {
id: generateEventId(),
type: "thought",
title:
data.thinkingType === "final_answer" ? "最终答案" : "思考过程",
content: data.content,
timestamp: data.timestamp,
timestamp: data.timestamp || Date.now(),
};
// 通过事件总线将事件发送到时间轴
window.dispatchEvent(
new CustomEvent("timeline-event", { detail: event })
);
// 调用添加事件到时间轴的方法
addEventToTimeline(thoughtEvent);
// 如果是最终答案,也应该显示在主要对话框中
// 修复:确保最终答案只添加一次,避免重复显示
......@@ -578,22 +619,24 @@ const processSSELine = async (
if (eventType === "tool_call") {
if (data.toolName) metadata["工具"] = data.toolName;
if (data.toolAction) metadata["操作"] = data.toolAction;
if (data.toolInput)
metadata["输入"] = JSON.stringify(data.toolInput).substring(
0,
100
);
if (data.toolOutput)
metadata["输出"] = String(data.toolOutput).substring(0, 100);
if (data.toolInput) {
try {
metadata["输入"] = JSON.stringify(data.toolInput).substring(0, 100);
} catch (e) {
metadata["输入"] = String(data.toolInput).substring(0, 100);
}
}
if (data.toolOutput) metadata["输出"] = String(data.toolOutput).substring(0, 100);
if (data.toolStatus) metadata["状态"] = data.toolStatus;
if (data.executionTime)
metadata["耗时"] = `${data.executionTime}ms`;
if (data.executionTime) metadata["耗时"] = `${data.executionTime}ms`;
} else if (eventType === "embed") {
if (data.embedUrl) metadata["URL"] = data.embedUrl;
if (data.embedType) metadata["类型"] = data.embedType;
}
const timelineEvent = {
// 构建时间轴事件
const timelineEvent: TimelineEvent = {
id: generateEventId(),
type: eventType,
title: title,
content: data.content,
......@@ -611,11 +654,8 @@ const processSSELine = async (
timestamp: data.timestamp || Date.now(),
};
// 通过事件总线将事件发送到时间轴
console.log("[ChatArea] 发送timeline-event事件:", timelineEvent);
window.dispatchEvent(
new CustomEvent("timeline-event", { detail: timelineEvent })
);
// 调用添加事件到时间轴的方法
addEventToTimeline(timelineEvent);
// 对于embed事件,还需要触发embed-event事件
if (eventType === "embed" && data.embedUrl) {
......
<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 @@
<div class="empty-text">等待执行过程...</div>
</div>
<div v-else class="timeline-list">
<div v-for="(event, index) in reversedEvents" :key="event.timestamp + '-' + index" class="timeline-item" :class="event.type">
<div v-else class="timeline-list" v-el-infinite-scroll="loadMore" :infinite-scroll-distance="50">
<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-content">
<div class="event-header">
......@@ -24,7 +30,7 @@
<div v-if="event.content" class="event-content">
<div
class="content-text-wrapper"
@click="shouldShowToggle(event.timestamp) && toggleContentExpand(event.timestamp)"
@click.stop="shouldShowToggle(event.timestamp) && toggleContentExpand(event.timestamp)"
>
<div
class="content-text"
......@@ -37,7 +43,7 @@
<div
v-if="shouldShowToggle(event.timestamp)"
class="content-toggle"
@click="toggleContentExpand(event.timestamp)"
@click.stop="toggleContentExpand(event.timestamp)"
>
{{ getContentExpandedState(event.timestamp) ? '收起' : '展开' }}
</div>
......@@ -49,13 +55,13 @@
class="tool-details"
>
<!-- 展开/折叠按钮 -->
<div class="detail-toggle" @click="props.toggleExpand(props.events.length - 1 - index)">
<span class="toggle-text">{{ props.getExpandedState(props.events.length - 1 - index) ? '收起详情' : '查看详情' }}</span>
<span class="toggle-icon">{{ props.getExpandedState(props.events.length - 1 - index) ? '▲' : '▼' }}</span>
<div class="detail-toggle" @click.stop="props.toggleExpand(displayedEvents.length - 1 - index)">
<span class="toggle-text">{{ props.getExpandedState(displayedEvents.length - 1 - index) ? '收起详情' : '查看详情' }}</span>
<span class="toggle-icon">{{ props.getExpandedState(displayedEvents.length - 1 - index) ? '▲' : '▼' }}</span>
</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
v-if="props.hasValidToolInput(event)"
......@@ -82,14 +88,31 @@
</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>
<!-- 事件详情抽屉 -->
<el-drawer
v-model="isDetailVisible"
title="事件详情"
size="50%"
direction="rtl"
>
<TimelineEventDetail
:event="selectedEvent"
@close="isDetailVisible = false"
/>
</el-drawer>
</div>
</template>
<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 TimelineEventDetail from './TimelineEventDetail.vue'
import type { TimelineEvent } from '../types/timeline'
import { useContentExpansion } from '../composables/useContentExpansion'
import { truncateTitle } from '../utils/timelineUtils'
......@@ -107,9 +130,47 @@ const props = defineProps<{
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 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
const {
getContentExpandedState,
......@@ -556,6 +617,15 @@ watch(() => props.events, () => {
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) {
.timeline-header {
......
<template>
<div class="work-area">
<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">
<timeline-container ref="timelineContainerRef" />
</el-tab-pane>
<el-tab-pane label="🌐 网页浏览" name="browser">
<webpage-browser ref="webBrowser" />
</el-tab-pane>
<el-tab-pane label="表单" name="form">
<form-render ref="formRender" />
</el-tab-pane>
</el-tabs>
</div>
</template>
......@@ -19,30 +19,19 @@ import { ref, onMounted, onUnmounted } from "vue";
import FormRender from "./FormRender.vue";
import TimelineContainer from "./TimelineContainer.vue";
import WebpageBrowser from "./WebpageBrowser.vue";
import { TimelineService } from "../services/TimelineService";
const activeTab = ref("form");
const activeTab = ref("timeline");
const formRender = ref();
const timelineContainerRef = ref<InstanceType<typeof TimelineContainer> | null>(
null
);
const webBrowser = ref();
let timelineService: TimelineService | null = null;
// 添加事件到时间轴
const addEvent = (event: any): void => {
timelineContainerRef.value?.addEvent(event);
};
// 初始化Timeline服务
const initTimelineService = () => {
if (timelineContainerRef.value) {
timelineService = new TimelineService((event: any) => {
addEvent(event);
});
timelineService.connectSSE();
}
};
// 清除时间轴
const clearTimeline = (): void => {
timelineContainerRef.value?.clearTimeline();
......@@ -98,18 +87,10 @@ const handleEmbedEvent = (e: Event) => {
onMounted(() => {
// 监听embed事件
window.addEventListener("embed-event", handleEmbedEvent as EventListener);
// 初始化Timeline服务
initTimelineService();
});
onUnmounted(() => {
// 移除事件监听
window.removeEventListener("embed-event", handleEmbedEvent as EventListener);
// 清理Timeline服务
if (timelineService) {
timelineService.cleanup();
}
}); // 暴露方法供父组件调用
defineExpose({
formRender,
......
// 内容展开管理hook
import { ref, nextTick } from 'vue'
import type { Ref } from 'vue'
import { nextTick, ref, type Ref } from 'vue'
import type { TimelineEvent } from '../types/timeline'
export function useContentExpansion(props: {
events: TimelineEvent[]
}) {
// 内容展开状态管理
const contentExpandedStates = ref<Record<number, boolean>>({})
const contentLineCounts = ref<Record<number, number>>({})
const contentElements = ref<Record<number, HTMLElement>>({})
// 内容展开状态管理 - 使用WeakMap提高性能
const contentExpandedStates = new WeakMap<HTMLElement, boolean>()
const contentLineCounts = ref<Record<string, number>>({})
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 => {
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) => {
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)
}
......@@ -28,18 +46,24 @@ export function useContentExpansion(props: {
// 为特定元素更新行数计算
const updateLineCountForElement = (timestamp: number) => {
const event = props.events.find(e => e.timestamp === timestamp)
if (event && event.content && contentElements.value[timestamp]) {
contentLineCounts.value[timestamp] = calculateLineCount(event.content, contentElements.value[timestamp])
// 如果内容超过两行,初始化为折叠状态
if (contentLineCounts.value[timestamp] > 2 && contentExpandedStates.value[timestamp] === undefined) {
contentExpandedStates.value[timestamp] = false
}
const key = timestamp.toString()
const element = contentElements.get(key)
if (event && 'content' in event && event.content && element) {
const lineCount = calculateLineCount(event.content, element)
const contentKey = event.id || key
contentLineCounts.value[contentKey] = lineCount
}
}
// 切换内容展开状态
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: {
}
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 = () => {
nextTick(() => {
updateEventIdMapping()
props.events.forEach((event) => {
if (event.content) {
// 行数将在元素引用设置时计算
// 这里只初始化展开状态
if (contentExpandedStates.value[event.timestamp] === undefined) {
contentExpandedStates.value[event.timestamp] = false
if ('content' in event && event.content) {
const key = event.timestamp.toString()
const element = contentElements.get(key)
if (element) {
updateLineCountForElement(event.timestamp)
}
}
})
......@@ -84,9 +114,6 @@ export function useContentExpansion(props: {
}
return {
contentExpandedStates,
contentLineCounts,
contentElements,
getContentExpandedState,
setContentRef,
toggleContentExpand,
......
......@@ -2,7 +2,7 @@
<div class="new-chat-page">
<!-- 左侧对话区 -->
<div class="left-panel">
<chat-area ref="chatArea" />
<chat-area ref="chatArea" :add-event-to-timeline="addEventToTimeline" />
</div>
<!-- 中间分割线 -->
......@@ -37,6 +37,13 @@ watch(() => route.query.agentId, (newAgentId) => {
}
}, { immediate: true })
// 添加事件到时间轴
const addEventToTimeline = (event: any) => {
if (workArea.value && typeof workArea.value.addEvent === 'function') {
workArea.value.addEvent(event)
}
}
// 开始拖动分割线
const startResize = (e: MouseEvent) => {
isResizing.value = true
......
This diff is collapsed.
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 {
id: string;
type: string;
title: string;
timestamp: number;
......@@ -39,12 +40,22 @@ export interface EmbedEvent extends BaseTimelineEvent {
embedHtmlContent?: string;
}
export interface CompleteEvent extends BaseTimelineEvent {
content: string;
}
export interface ErrorEvent extends BaseTimelineEvent {
content: string;
}
export type TimelineEvent =
| ThoughtEvent
| ToolCallEvent
| ToolResultEvent
| ToolErrorEvent
| EmbedEvent
| CompleteEvent
| ErrorEvent
| BaseTimelineEvent;
// 事件类型标签映射
......@@ -56,5 +67,7 @@ export const eventTypeLabels: Record<string, string> = {
embed: '🌐 网页预览',
log: '📝 日志',
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