Commit 94404fa5 authored by ligaowei's avatar ligaowei

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

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

主要变更包括:
1. 新增BaseTool基类统一工具事件处理
2. 实现事件详情面板和虚拟滚动
3. 添加时间轴事件过滤和搜索功能
4. 优化内容展开管理使用WeakMap
5. 重构SSE服务为独立模块
6. 添加事件持久化存储和导出功能
7. 改进错误处理和性能监控
parent 8c6ba975
......@@ -134,21 +134,20 @@ public class SecurityConfig {
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) {
// 对于SSE端点,发送SSE格式的错误事件
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("event: error\ndata: {\"error\": \"未授权访问\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
// 确保响应被正确提交
if (!response.isCommitted()) {
response.flushBuffer();
}
return;
boolean isStreamEndpoint = request.getRequestURI().contains("/api/v1/agent/chat-stream");
if (isStreamEndpoint) {
// 对于SSE端点,发送SSE格式的错误事件
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("event: error\ndata: {\"error\": \"未授权访问\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
// 确保响应被正确提交
if (!response.isCommitted()) {
response.flushBuffer();
}
return;
}
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
......@@ -174,21 +173,20 @@ 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) {
// 对于SSE端点,发送SSE格式的错误事件
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("event: error\ndata: {\"error\": \"访问被拒绝\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
// 确保响应被正确提交
if (!response.isCommitted()) {
response.flushBuffer();
}
return;
boolean isStreamEndpoint = request.getRequestURI().contains("/api/v1/agent/chat-stream");
if (isStreamEndpoint) {
// 对于SSE端点,发送SSE格式的错误事件
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("event: error\ndata: {\"error\": \"访问被拒绝\", \"timestamp\": " + System.currentTimeMillis() + "}\n\n");
response.getWriter().flush();
// 确保响应被正确提交
if (!response.isCommitted()) {
response.flushBuffer();
}
return;
}
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
......
package pangea.hiagent.tool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import pangea.hiagent.workpanel.event.SseEventBroadcaster;
import pangea.hiagent.web.dto.WorkPanelEvent;
import java.util.HashMap;
import java.util.Map;
/**
* 所有工具类的基础抽象类
* 提供工具执行监控和SSE事件发送功能
*/
@Slf4j
public abstract class BaseTool {
@Autowired
private SseEventBroadcaster sseEventBroadcaster;
/**
* 工具执行包装方法
* 监控工具方法的完整执行生命周期
* @param methodName 被调用的方法名称
* @param params 方法参数映射
* @param action 实际执行的工具逻辑
* @param <T> 返回类型
* @return 工具执行结果
*/
protected <T> T execute(String methodName, Map<String, Object> params, ToolAction<T> action) {
String toolName = this.getClass().getSimpleName();
long startTime = System.currentTimeMillis();
// 1. 发送工具开始执行事件
sendToolEvent(toolName, methodName, params, null, "执行中", startTime, null);
T result = null;
String status = "成功";
Exception exception = null;
try {
// 2. 执行实际的工具逻辑
result = action.run();
} catch (Exception e) {
status = "失败";
exception = e;
throw new RuntimeException("工具执行失败: " + e.getMessage(), e);
} finally {
// 记录结束时间和耗时
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
// 3. 发送工具执行完成事件
sendToolEvent(toolName, methodName, params, result, status, startTime, duration, exception);
}
return result;
}
/**
* 简化版execute方法,无需手动构建参数映射
* @param methodName 被调用的方法名称
* @param action 实际执行的工具逻辑
* @param <T> 返回类型
* @return 工具执行结果
*/
protected <T> T execute(String methodName, ToolAction<T> action) {
return execute(methodName, new HashMap<>(), action);
}
/**
* 发送工具事件给前端
* @param toolName 工具名称
* @param methodName 方法名称
* @param params 参数信息
* @param result 执行结果
* @param status 执行状态(执行中/成功/失败)
* @param startTime 开始时间戳
* @param duration 执行耗时(毫秒)
* @param exception 异常信息(可选)
*/
private void sendToolEvent(String toolName, String methodName,
Map<String, Object> params, Object result, String status,
Long startTime, Long duration, Exception... exception) {
try {
Map<String, Object> eventData = new HashMap<>();
eventData.put("toolName", toolName);
eventData.put("methodName", methodName);
eventData.put("params", params);
eventData.put("result", result);
eventData.put("status", status);
eventData.put("startTime", startTime);
eventData.put("duration", duration);
if (exception != null && exception.length > 0 && exception[0] != null) {
eventData.put("error", exception[0].getMessage());
eventData.put("errorType", exception[0].getClass().getSimpleName());
}
WorkPanelEvent event = WorkPanelEvent.builder()
.type("tool_call")
.title(toolName + "." + methodName)
.timestamp(System.currentTimeMillis())
.metadata(eventData)
.build();
sseEventBroadcaster.broadcastWorkPanelEvent(event);
log.debug("已发送工具事件: {}#{}, 状态: {}", toolName, methodName, status);
} catch (Exception e) {
log.error("发送工具事件失败: {}", e.getMessage(), e);
}
}
/**
* 工具动作函数式接口
* 用于封装实际要执行的工具逻辑
* @param <T> 返回类型
*/
@FunctionalInterface
protected interface ToolAction<T> {
T run() throws Exception;
}
}
\ No newline at end of file
......@@ -2,6 +2,9 @@ package pangea.hiagent.tool.impl;
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,38 +12,62 @@ 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) {
double result = a + b;
log.debug("执行加法运算: {} + {} = {}", a, b, result);
return result;
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) {
double result = a - b;
log.debug("执行减法运算: {} - {} = {}", a, b, result);
return result;
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) {
double result = a * b;
log.debug("执行乘法运算: {} * {} = {}", a, b, result);
return result;
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) {
log.debug("执行除法运算: {} / {}", a, b);
if (b == 0) {
log.warn("除法运算错误:除数不能为零");
return "错误:除数不能为零";
}
double result = a / b;
log.debug("除法运算结果: {}", result);
return String.valueOf(result);
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("除法运算错误:除数不能为零");
return "错误:除数不能为零";
}
double result = a / b;
log.debug("除法运算结果: {}", result);
return String.valueOf(result);
});
}
}
\ No newline at end of file
......@@ -3,7 +3,7 @@ package pangea.hiagent.tool.impl;
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,61 +26,69 @@ public class DateTimeTools {
@Tool(description = "获取当前日期和时间,返回格式为 'yyyy-MM-dd HH:mm:ss'")
public String getCurrentDateTime() {
try {
if (dateTimeFormat == null || dateTimeFormat.trim().isEmpty()) {
dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
return execute("getCurrentDateTime", () -> {
try {
if (dateTimeFormat == null || dateTimeFormat.trim().isEmpty()) {
dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
}
String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateTimeFormat));
log.info("【时间工具】获取当前日期时间: {}", dateTime);
return dateTime;
} catch (Exception e) {
log.error("获取当前日期时间时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
String dateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateTimeFormat));
log.info("【时间工具】获取当前日期时间: {}", dateTime);
return dateTime;
} catch (Exception e) {
log.error("获取当前日期时间时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
});
}
@Tool(description = "获取当前日期,返回格式为 'yyyy-MM-dd'")
public String getCurrentDate() {
try {
if (dateFormat == null || dateFormat.trim().isEmpty()) {
dateFormat = "yyyy-MM-dd";
return execute("getCurrentDate", () -> {
try {
if (dateFormat == null || dateFormat.trim().isEmpty()) {
dateFormat = "yyyy-MM-dd";
}
String date = LocalDate.now().format(DateTimeFormatter.ofPattern(dateFormat));
log.info("【时间工具】获取当前日期: {}", date);
return date;
} catch (Exception e) {
log.error("获取当前日期时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
String date = LocalDate.now().format(DateTimeFormatter.ofPattern(dateFormat));
log.info("【时间工具】获取当前日期: {}", date);
return date;
} catch (Exception e) {
log.error("获取当前日期时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
});
}
@Tool(description = "获取当前时间,返回格式为 'HH:mm:ss'")
public String getCurrentTime() {
try {
if (timeFormat == null || timeFormat.trim().isEmpty()) {
timeFormat = "HH:mm:ss";
return execute("getCurrentTime", () -> {
try {
if (timeFormat == null || timeFormat.trim().isEmpty()) {
timeFormat = "HH:mm:ss";
}
String time = LocalTime.now().format(DateTimeFormatter.ofPattern(timeFormat));
log.info("【时间工具】获取当前时间: {}", time);
return time;
} catch (Exception e) {
log.error("获取当前时间时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
}
String time = LocalTime.now().format(DateTimeFormatter.ofPattern(timeFormat));
log.info("【时间工具】获取当前时间: {}", time);
return time;
} catch (Exception e) {
log.error("获取当前时间时发生错误: {}", e.getMessage(), e);
// 发生错误时回退到默认格式
return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
}
});
}
@Tool(description = "获取当前时间戳(毫秒),返回自1970年1月1日00:00:00 UTC以来的毫秒数")
public String getCurrentTimeMillis() {
try {
long timestamp = System.currentTimeMillis();
log.info("【时间工具】获取当前时间戳: {}", timestamp);
return String.valueOf(timestamp);
} catch (Exception e) {
log.error("获取当前时间戳时发生错误: {}", e.getMessage(), e);
return String.valueOf(System.currentTimeMillis());
}
return execute("getCurrentTimeMillis", () -> {
try {
long timestamp = System.currentTimeMillis();
log.info("【时间工具】获取当前时间戳: {}", timestamp);
return String.valueOf(timestamp);
} catch (Exception e) {
log.error("获取当前时间戳时发生错误: {}", e.getMessage(), e);
return String.valueOf(System.currentTimeMillis());
}
});
}
}
// 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