Commit 31d57411 authored by ligaowei's avatar ligaowei

Refactor TimelineContainer and TimelinePanel components to remove duplicate...

Refactor TimelineContainer and TimelinePanel components to remove duplicate logic and use shared utilities
parent 39fed4a2
<template>
<TimelinePanel
:events="events"
:getEventTypeLabel="getEventTypeLabel"
:formatTime="formatTime"
:getExpandedState="getExpandedState"
:toggleExpand="toggleExpand"
:isToolEventType="isToolEventType"
:hasValidToolInput="hasValidToolInput"
:hasValidToolOutput="hasValidToolOutput"
:onClearTimeline="handleClearTimeline"
/>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import TimelinePanel from './TimelinePanel.vue'
import { TimelineEventStateManager } from '../services/timelineEventStateManager'
import { CacheService } from '../services/CacheService'
import type { TimelineEvent } from '../types/timeline'
import { eventTypeLabels } from '../types/timeline'
import { isToolEventType, hasValidToolInput, hasValidToolOutput } from '../utils/timelineUtils'
import { UnifiedEventProcessor } from '../services/UnifiedEventProcessor'
// 状态管理器
const stateManager = new TimelineEventStateManager();
// 缓存服务
const cacheService = new CacheService();
// 统一事件处理器
const eventProcessor = new UnifiedEventProcessor();
// 事件数据
const events = ref<TimelineEvent[]>([]);
// 注册事件处理器
eventProcessor.registerHandler((event: TimelineEvent) => {
// 添加事件到列表
events.value.push(event);
console.log('[TimelineContainer] 成功添加事件:', event.type, event.title);
});
// 添加时间轴事件
const addEvent = (event: any) => {
// 使用统一事件处理器处理并分发事件
eventProcessor.processAndDispatch(event);
};
// 获取事件类型标签
const getEventTypeLabel = (type: string): string => {
return cacheService.getCachedEventTypeLabel(type, eventTypeLabels,
(labels, t) => labels[t] || t);
};
// 格式化时间
const formatTime = (timestamp: number): string => {
return cacheService.getCachedFormattedTime(timestamp, (t) => {
const date = new Date(t);
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 getExpandedState = (index: number): boolean => {
return stateManager.getExpandedState(index);
};
// 切换事件详细信息的展开状态
const toggleExpand = (index: number) => {
stateManager.toggleExpand(index);
};
// 清除时间轴
const handleClearTimeline = () => {
events.value = [];
eventProcessor.clearProcessedEvents();
stateManager.clearAllStates();
cacheService.clearAllCaches();
};
// 暴露方法供父组件调用
defineExpose({
addEvent,
clearTimeline: handleClearTimeline
});
// 组件卸载时清理资源
onUnmounted(() => {
stateManager.clearAllStates();
cacheService.clearAllCaches();
});
</script>
<style scoped>
.timeline-container-wrapper {
height: 100%;
}
</style>
\ No newline at end of file
......@@ -2,45 +2,63 @@
<div class="timeline-panel">
<div class="timeline-header">
<h3>执行过程</h3>
<el-button text @click="clearTimeline" :disabled="events.length === 0">清除</el-button>
<el-button text @click="props.onClearTimeline" :disabled="!props.events || props.events.length === 0">清除</el-button>
</div>
<div class="timeline-container" ref="timelineContainer">
<div v-if="events.length === 0" class="empty-timeline">
<div v-if="!props.events || props.events.length === 0" class="empty-timeline">
<div class="empty-icon">📋</div>
<div class="empty-text">等待执行过程...</div>
</div>
<div v-else class="timeline-list">
<div v-for="(event, index) in events" :key="index" class="timeline-item" :class="event.type">
<div v-for="(event, index) in reversedEvents" :key="event.timestamp + '-' + index" class="timeline-item" :class="event.type">
<div class="timeline-dot"></div>
<div class="timeline-content">
<div class="event-header">
<span class="event-type-badge" :class="event.type">{{ getEventTypeLabel(event.type) }}</span>
<span class="event-time">{{ formatTime(event.timestamp) }}</span>
<span class="event-type-badge" :class="event.type">{{ props.getEventTypeLabel(event.type) }}</span>
<span class="event-title">{{ truncateTitle(event.title) }}</span>
<span class="event-time">{{ props.formatTime(event.timestamp) }}</span>
</div>
<div class="event-body">
<div class="event-title">{{ event.title }}</div>
<div v-if="event.content" class="event-content">
<pre><code>{{ event.content }}</code></pre>
<div
class="content-text-wrapper"
@click="shouldShowToggle(event.timestamp) && toggleContentExpand(event.timestamp)"
>
<div
class="content-text"
:class="{ 'collapsed': !getContentExpandedState(event.timestamp), 'expanded': getContentExpandedState(event.timestamp) }"
:ref="(el) => setContentRef(el as HTMLElement, event.timestamp)"
>
{{ event.content }}
</div>
</div>
<div
v-if="shouldShowToggle(event.timestamp)"
class="content-toggle"
@click="toggleContentExpand(event.timestamp)"
>
{{ getContentExpandedState(event.timestamp) ? '收起' : '展开' }}
</div>
</div>
<!-- 工具调用输入输出详情 -->
<div
v-if="(event.type === 'tool_call' || event.type === 'tool_result' || event.type === 'tool_error') && isToolDataVisible(event)"
v-if="props.isToolEventType(event.type)"
class="tool-details"
>
<!-- 展开/折叠按钮 -->
<div class="detail-toggle" @click="toggleExpand(index)">
<span class="toggle-text">{{ getExpandedState(index) ? '收起详情' : '查看详情' }}</span>
<span class="toggle-icon">{{ getExpandedState(index) ? '▲' : '▼' }}</span>
<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>
<!-- 详细信息内容 -->
<div v-show="getExpandedState(index)" class="detail-content">
<div v-show="getExpandedState(props.events.length - 1 - index)" class="detail-content">
<!-- 输入参数段 -->
<ToolDataSection
v-if="'toolInput' in event"
v-if="props.hasValidToolInput(event)"
title="输入参数"
:data="event.toolInput"
type="input"
......@@ -48,14 +66,13 @@
<!-- 输出结果段 -->
<ToolDataSection
v-if="'toolOutput' in event"
v-if="props.hasValidToolOutput(event)"
title="输出结果"
:data="event.toolOutput"
type="output"
/>
</div>
</div>
<div v-if="event.metadata" class="event-metadata">
<div v-for="(value, key) in event.metadata" :key="key" class="metadata-item">
<span class="metadata-key">{{ key }}:</span>
......@@ -71,306 +88,46 @@
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { computed, onMounted, watch } from 'vue'
import ToolDataSection from './ToolDataSection.vue'
import { formatToolData } from '../utils/functionUtils'
interface TimelineEvent {
type: 'thought' | 'action' | 'observation' | 'result' | 'error' | 'tool_call' | 'tool_result' | 'tool_error' | 'embed'
title: string
content?: string
metadata?: Record<string, any>
timestamp: number
toolName?: string
toolAction?: string
toolInput?: Record<string, any>
toolOutput?: any
toolStatus?: string
executionTime?: number
embedUrl?: string
embedType?: string
embedTitle?: string
embedHtmlContent?: string
}
// 为每个事件维护展开状态
const expandedStates = ref<Record<number, boolean>>({})
// 获取事件的展开状态,确保始终返回布尔值
const getExpandedState = (index: number): boolean => {
return !!expandedStates.value[index]
}
const events = ref<TimelineEvent[]>([])
const timelineContainer = ref<HTMLElement>()
// 切换事件详细信息的展开状态
const toggleExpand = (index: number) => {
expandedStates.value = {
...expandedStates.value,
[index]: !expandedStates.value[index]
}
}
// 事件类型标签映射
const EVENT_TYPE_LABELS: Record<string, string> = {
'thought': '💭 思考',
'action': '🎬 行动',
'observation': '👀 观察',
'result': '✅ 结果',
'error': '❌ 错误',
'tool_call': '🔧 工具调用',
'tool_result': '📤 工具结果',
'tool_error': '⚠️ 工具错误',
'embed': '🌐 网页预览'
}
// 获取事件类型标签
const getEventTypeLabel = (type: string): string => {
return EVENT_TYPE_LABELS[type] || type
}
// 格式化时间
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}`
}
// 检查是否为Empty数据
const isEmpty = (data: any): boolean => {
if (data === null || data === undefined) return true;
if (typeof data === 'string') return data.trim().length === 0;
if (Array.isArray(data)) return data.length === 0;
if (typeof data === 'object') return Object.keys(data).length === 0;
return false;
};
// 检查是否有工具数据
const hasToolData = (event: TimelineEvent): boolean => {
// 检查字段是否存在且值不为Empty
const hasInput = 'toolInput' in event && !isEmpty(event.toolInput);
const hasOutput = 'toolOutput' in event && !isEmpty(event.toolOutput);
return hasInput || hasOutput;
};
// 强化地检查是否有工具数据(应用于界面渲染)
const isToolDataVisible = (event: TimelineEvent): boolean => {
if (event.type !== 'tool_call' && event.type !== 'tool_result' && event.type !== 'tool_error') {
return false;
}
return hasToolData(event);
};
// 添加时间轴事件
const addEvent = (event: Omit<TimelineEvent, 'timestamp'> & { timestamp?: number }) => {
// 对于thinking事件,检查是否与上一个事件内容相同,避免重复添加
if (event.type === 'thought' && events.value.length > 0) {
const lastEvent = events.value[events.value.length - 1]
if (lastEvent.type === 'thought' && lastEvent.content === event.content) {
// 如果内容相同,不添加重复事件
return
}
}
const newIndex = events.value.length;
const finalEvent = {
...event,
timestamp: event.timestamp || Date.now(),
...(('toolInput' in event) ? { toolInput: event.toolInput } : {}),
...(('toolOutput' in event) ? { toolOutput: event.toolOutput } : {})
} as TimelineEvent;
events.value.push(finalEvent);
// 初始化新事件的展开状态
expandedStates.value[newIndex] = false;
scrollToBottom()
}
// 滚动到底部
const scrollToBottom = async () => {
await nextTick()
if (timelineContainer.value) {
timelineContainer.value.scrollTop = timelineContainer.value.scrollHeight
}
}
// 清除时间轴
const clearTimeline = () => {
events.value = []
expandedStates.value = {}
}
// 暴露方法供父组件调用
defineExpose({
addEvent,
clearTimeline
})
import type { TimelineEvent } from '../types/timeline'
import { useContentExpansion } from '../composables/useContentExpansion'
import { truncateTitle } from '../utils/timelineUtils'
// 定义组件属性
const props = defineProps<{
events: TimelineEvent[]
getEventTypeLabel: (type: string) => string
formatTime: (timestamp: number) => string
getExpandedState: (index: number) => boolean
toggleExpand: (index: number) => void
isToolEventType: (type: string) => boolean
hasValidToolInput: (event: TimelineEvent) => boolean
hasValidToolOutput: (event: TimelineEvent) => boolean
onClearTimeline: () => void
}>()
// 计算反转后的事件列表(最新事件在顶部)
const reversedEvents = computed(() => [...props.events].reverse())
// 使用内容展开管理hook
const {
getContentExpandedState,
setContentRef,
toggleContentExpand,
shouldShowToggle,
updateLineCounts
} = useContentExpansion(props)
// 在组件挂载时更新行数计数
onMounted(() => {
// SSE连接重试相关变量
let eventSource: EventSource | null = null;
let retryCount = 0;
const maxRetries = 5;
const retryDelay = 3000; // 3秒
// 建立SSE连接的函数
const connectSSE = () => {
// 从localStorage获取token
const token = localStorage.getItem('token')
// 构造带认证参数的URL
let eventSourceUrl = '/api/v1/agent/timeline-events'
if (token) {
eventSourceUrl += `?token=${encodeURIComponent(token)}`
}
// 监听工作面板事件
eventSource = new EventSource(eventSourceUrl)
eventSource.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data)
// 构建事件标题
let title = data.title || '事件'
if (data.type === 'tool_call' && data.toolName) {
title = `调用工具: ${data.toolName}`
} else if (data.type === 'tool_result' && data.toolName) {
title = `${data.toolName} 执行成功`
} else if (data.type === 'tool_error' && data.toolName) {
title = `${data.toolName} 执行失败`
}
// 构建元数据
const metadata: Record<string, any> = data.metadata || {}
if (data.type === 'tool_call' || data.type === 'tool_result' || data.type === 'tool_error') {
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.toolStatus) metadata['状态'] = data.toolStatus
if (data.executionTime) metadata['耗时'] = `${data.executionTime}ms`
} else if (data.type === 'embed') {
if (data.embedUrl) metadata['URL'] = data.embedUrl
if (data.embedType) metadata['类型'] = data.embedType
if (data.embedTitle) title = data.embedTitle
}
const timelineEventData = {
type: data.type || 'observation',
title: title,
content: data.content,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
toolName: data.toolName,
toolAction: data.toolAction,
...(('toolInput' in data) ? { toolInput: data.toolInput } : {}),
...(('toolOutput' in data) ? { toolOutput: data.toolOutput } : {}),
toolStatus: data.toolStatus,
executionTime: data.executionTime,
embedUrl: data.embedUrl,
embedType: data.embedType,
embedTitle: data.embedTitle,
embedHtmlContent: data.embedHtmlContent,
timestamp: data.timestamp
}
addEvent(timelineEventData)
// 触发embed事件给父组件
if (data.type === 'embed' && data.embedUrl) {
window.dispatchEvent(new CustomEvent('embed-event', {
detail: {
url: data.embedUrl,
type: data.embedType,
title: data.embedTitle,
htmlContent: data.embedHtmlContent
}
}))
}
// 重置重试计数
retryCount = 0;
} catch (err) {
console.error('解析时间轴事件失败:', err)
}
})
eventSource.addEventListener('error', (error) => {
console.error('[SSE] 连接错误:', error);
// 关闭当前连接
if (eventSource) {
eventSource.close();
eventSource = null;
}
// 如果重试次数未达到最大值,尝试重新连接
if (retryCount < maxRetries) {
retryCount++;
console.log(`[SSE] 尝试重新连接 (${retryCount}/${maxRetries})...`);
setTimeout(connectSSE, retryDelay);
} else {
console.error('[SSE] 达到最大重试次数,停止重连');
// 可以在这里触发一个全局事件通知用户连接失败
window.dispatchEvent(new CustomEvent('sse-connection-failed'));
}
});
// 监听连接成功事件
eventSource.addEventListener('open', () => {
console.log('[SSE] 连接已建立');
retryCount = 0; // 重置重试计数
});
};
// 初始连接
connectSSE();
// 在组件卸载时清理连接
onUnmounted(() => {
if (eventSource) {
eventSource.close();
eventSource = null;
}
});
// 监听来自ChatArea的思考事件
const handleTimelineEvent = (e: CustomEvent) => {
const eventData = e.detail
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: eventData.timestamp
})
}
window.addEventListener('timeline-event', handleTimelineEvent as EventListener)
// 在组件卸载时移除事件监听器
onUnmounted(() => {
window.removeEventListener('timeline-event', handleTimelineEvent as EventListener)
})
updateLineCounts()
})
// 监听事件变化,重新计算行数
watch(() => props.events, () => {
updateLineCounts()
}, { deep: true })
</script>
<style scoped>
......@@ -402,7 +159,7 @@ onMounted(() => {
.timeline-container {
flex: 1;
overflow-y: auto;
padding: var(--spacing-4);
padding: var(--spacing-3);
min-height: 0; /* 允许容器收缩 */
}
......@@ -444,9 +201,9 @@ onMounted(() => {
.timeline-item {
position: relative;
margin-bottom: var(--spacing-4);
margin-bottom: var(--spacing-2);
display: flex;
gap: var(--spacing-3);
gap: var(--spacing-2);
}
.timeline-dot {
......@@ -499,7 +256,8 @@ onMounted(() => {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-bottom: var(--spacing-2);
margin-bottom: var(--spacing-1);
line-height: 1.2;
}
.event-type-badge {
......@@ -568,36 +326,77 @@ onMounted(() => {
}
.event-title {
font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
margin-bottom: var(--spacing-2);
font-size: var(--font-size-xs);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-content {
margin: var(--spacing-2) 0;
margin: var(--spacing-1) 0;
background-color: var(--bg-tertiary);
border-radius: var(--border-radius-md);
overflow-x: auto;
border-radius: var(--border-radius-sm);
overflow-x: hidden;
border: 1px solid var(--border-color-light);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.content-text-wrapper {
cursor: pointer;
}
.event-content pre {
.content-text {
margin: 0;
padding: var(--spacing-3);
padding: var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--text-primary);
font-family: var(--font-family-mono);
background-color: transparent;
line-height: var(--line-height-snug);
white-space: pre-wrap;
word-wrap: break-word;
max-height: calc(2 * var(--line-height-snug) * 1em);
overflow: hidden;
transition: max-height 0.3s ease;
position: relative;
}
.event-content code {
all: unset;
font-family: var(--font-family-mono);
.content-text.collapsed {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
position: relative;
}
.content-text.collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1.2em;
background: linear-gradient(to bottom, transparent, var(--bg-tertiary));
}
.content-text.expanded {
max-height: none;
transition: max-height 0.3s ease;
}
.content-toggle {
padding: var(--spacing-1) var(--spacing-2);
text-align: center;
color: var(--primary-color);
font-size: var(--font-size-xs);
color: var(--text-primary);
line-height: var(--line-height-snug);
white-space: pre-wrap;
word-wrap: break-word;
cursor: pointer;
user-select: none;
background-color: rgba(102, 126, 234, 0.05);
border-top: 1px solid var(--border-color-light);
transition: background-color 0.2s ease;
}
/* JSON显示区域 */
......@@ -628,9 +427,9 @@ onMounted(() => {
/* 工具调用详情样式 */
.tool-details {
margin: var(--spacing-3) 0;
margin: var(--spacing-2) 0;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
border-radius: var(--border-radius-sm);
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
......@@ -640,7 +439,7 @@ onMounted(() => {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-2) var(--spacing-3);
padding: var(--spacing-1) var(--spacing-2);
background-color: var(--bg-secondary);
cursor: pointer;
user-select: none;
......@@ -651,8 +450,12 @@ onMounted(() => {
background-color: var(--bg-tertiary);
}
.content-toggle:hover {
background-color: rgba(102, 126, 234, 0.1);
}
.toggle-text {
font-size: var(--font-size-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
......@@ -664,6 +467,7 @@ onMounted(() => {
.detail-content {
border-top: 1px solid var(--border-color);
padding: var(--spacing-1);
}
.tool-input, .tool-output {
......@@ -672,13 +476,13 @@ onMounted(() => {
.tool-input .detail-title {
background-color: rgba(24, 144, 255, 0.1);
padding: var(--spacing-2) var(--spacing-3);
padding: var(--spacing-1) var(--spacing-2);
border-bottom: 1px solid var(--border-color);
}
.tool-output .detail-title {
background-color: rgba(82, 196, 26, 0.1);
padding: var(--spacing-2) var(--spacing-3);
padding: var(--spacing-1) var(--spacing-2);
border-bottom: 1px solid var(--border-color);
}
......@@ -690,9 +494,9 @@ onMounted(() => {
.tool-input pre, .tool-output pre {
margin: 0;
padding: var(--spacing-2);
padding: var(--spacing-1);
background-color: var(--bg-secondary);
border-radius: var(--border-radius-base);
border-radius: var(--border-radius-sm);
overflow-x: auto;
}
......@@ -700,22 +504,22 @@ onMounted(() => {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--text-primary);
line-height: var(--line-height-normal);
line-height: var(--line-height-tight);
white-space: pre-wrap;
word-wrap: break-word;
}
.event-metadata {
margin-top: var(--spacing-2);
padding: var(--spacing-2);
margin-top: var(--spacing-1);
padding: var(--spacing-1);
background-color: var(--bg-tertiary);
border-radius: var(--border-radius-md);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-xs);
}
.metadata-item {
display: flex;
gap: var(--spacing-2);
gap: var(--spacing-1);
margin-bottom: var(--spacing-1);
}
......
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