AgentEvalTool/frontend/web/src/components/intelligent_eval/ExecutionProcess.tsx

477 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from 'react'
import { Alert, Card, Empty, Spin, Tag, Timeline } from 'antd'
import {
CheckCircleOutlined,
ClockCircleOutlined,
EditOutlined,
FileTextOutlined,
PlayCircleOutlined,
} from '@ant-design/icons'
import {
intelligentEvalsApi,
type DecisionLog,
type ExecutionProgress,
type IntelligentEval,
type IntelligentEvalMessage,
type IntelligentEvalSession,
type TaskQueueItem,
} from '../../api'
import { SESSION_STATUS, decisionTypeOf } from './status'
import { colors, statusColors } from '../../tokens'
import { formatDateTime } from '../../utils/date'
import { usePolling } from '../../hooks/usePolling'
const ACTIVE_STATUSES = new Set(['planning', 'pending_approval', 'executing'])
const STAGE_CONFIG = [
{ key: 'planning', title: '规划', icon: EditOutlined },
{ key: 'approval', title: '审批', icon: FileTextOutlined },
{ key: 'executing', title: '执行', icon: PlayCircleOutlined },
{ key: 'done', title: '完成', icon: CheckCircleOutlined },
]
interface ActivityEvent {
time: string
color: string
text: string
}
function personaLabel(persona: Record<string, unknown>): string {
if (typeof persona.name === 'string' && persona.name) return persona.name
if (typeof persona.background === 'string' && persona.background) return persona.background
return '虚拟用户'
}
function sessionEvents(ev: IntelligentEval): ActivityEvent[] {
const events: ActivityEvent[] = []
for (const s of ev.sessions ?? []) {
if (s.created_at) {
events.push({
time: s.created_at,
color: 'blue',
text: `创建会话:${personaLabel(s.persona)} · 目标「${s.goal}`,
})
}
if (s.closed_at) {
const meta = SESSION_STATUS[s.status] ?? { label: s.status, color: 'default' }
events.push({
time: s.closed_at,
color: s.status === 'completed' ? 'green' : s.status === 'failed' ? 'red' : 'gray',
text: `会话结束(${meta.label}${personaLabel(s.persona)} · ${s.turn_count}`,
})
}
}
return events
}
function taskEvents(tasks: TaskQueueItem[]): ActivityEvent[] {
const events: ActivityEvent[] = []
for (const t of tasks) {
if (t.created_at) {
events.push({ time: t.created_at, color: 'gray', text: `任务入队:${t.reason}` })
}
if (t.assigned_at) {
events.push({ time: t.assigned_at, color: 'blue', text: '任务被 worker 认领' })
}
if (t.status === 'completed' && t.completed_at) {
events.push({ time: t.completed_at, color: 'green', text: '任务完成' })
}
if (t.status === 'failed' && t.completed_at) {
events.push({ time: t.completed_at, color: 'red', text: `任务失败:${t.error ?? '未知原因'}` })
}
}
return events
}
function decisionEvents(logs: DecisionLog[]): ActivityEvent[] {
return logs
.filter((log) => log.created_at != null)
.map((log) => ({
time: log.created_at as string,
color: log.decision_type === 'start_analysis' ? 'green' : log.decision_type === 'wait' ? 'gray' : 'blue',
text: `决策·${decisionTypeOf(log.decision_type).label}${log.reason}`,
}))
}
function LifecycleTimeline({ ev, currentStage, abnormal }: { ev: IntelligentEval; currentStage: string; abnormal: boolean }) {
const currentIndex = STAGE_CONFIG.findIndex((s) => s.key === currentStage)
// Map stages to timestamps
const stageTimes: Record<string, string | null> = {
planning: ev.created_at,
approval: ev.started_at, // Approval completed when execution started
executing: ev.started_at,
done: ev.completed_at,
}
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '12px 0' }}>
{STAGE_CONFIG.map((stage, idx) => {
const Icon = stage.icon
const isCompleted = idx < currentIndex
const isCurrent = idx === currentIndex
const isError = isCurrent && abnormal
const bgColor = isError
? statusColors.failed
: isCompleted
? statusColors.completed
: isCurrent
? colors.primary
: colors.bgSubtle
const textColor = isCompleted || isCurrent ? '#fff' : colors.textSecondary
const time = stageTimes[stage.key]
return (
<div key={stage.key} style={{ display: 'flex', alignItems: 'flex-start', flex: idx < STAGE_CONFIG.length - 1 ? 1 : 'none' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, minWidth: 80 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
background: bgColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: textColor,
fontSize: 18,
transition: 'all 0.3s',
border: isCurrent ? `2px solid ${colors.primary}` : 'none',
position: 'relative',
}}
>
{isCompleted ? <CheckCircleOutlined /> : <Icon />}
{isCurrent && (
<div style={{
position: 'absolute',
top: -8,
right: -8,
background: colors.primary,
color: '#fff',
fontSize: 10,
padding: '2px 6px',
borderRadius: 10,
whiteSpace: 'nowrap',
border: '2px solid #fff',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}>
</div>
)}
</div>
<div style={{ fontSize: 12, color: isCurrent ? colors.primary : colors.textSecondary, fontWeight: isCurrent ? 600 : 400, textAlign: 'center' }}>
{stage.title}
</div>
{time && (
<div style={{ fontSize: 11, color: colors.textMuted, textAlign: 'center', marginTop: 2 }}>
{formatDateTime(time)}
</div>
)}
</div>
{idx < STAGE_CONFIG.length - 1 && (
<div
style={{
flex: 1,
height: 2,
background: isCompleted ? statusColors.completed : colors.border,
margin: '20px 8px 0',
transition: 'all 0.3s',
}}
/>
)}
</div>
)
})}
</div>
)
}
function TimeDistributionTable({ slots }: { slots: ExecutionProgress['slots'] }) {
return (
<div style={{ border: `1px solid ${colors.border}`, borderRadius: 8, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: colors.bgSubtle, borderBottom: `1px solid ${colors.border}` }}>
<th style={{ padding: '10px 12px', textAlign: 'left', fontWeight: 500, fontSize: 12, color: colors.textSecondary }}></th>
<th style={{ padding: '10px 12px', textAlign: 'center', fontWeight: 500, fontSize: 12, color: colors.textSecondary }}></th>
<th style={{ padding: '10px 12px', textAlign: 'center', fontWeight: 500, fontSize: 12, color: colors.textSecondary }}></th>
<th style={{ padding: '10px 12px', textAlign: 'center', fontWeight: 500, fontSize: 12, color: colors.textSecondary }}></th>
<th style={{ padding: '10px 12px', textAlign: 'left', fontWeight: 500, fontSize: 12, color: colors.textSecondary, minWidth: 120 }}></th>
<th style={{ padding: '10px 12px', textAlign: 'center', fontWeight: 500, fontSize: 12, color: colors.textSecondary }}></th>
</tr>
</thead>
<tbody>
{slots.map((slot) => {
const pct = slot.planned > 0 ? Math.round((slot.completed / slot.planned) * 100) : 0
const isComplete = slot.completed >= slot.planned && slot.planned > 0
const isPast = slot.is_past
return (
<tr
key={slot.time_slot}
style={{
borderBottom: `1px solid ${colors.border}`,
background: slot.is_current ? `${colors.primary}08` : 'transparent',
}}
>
<td style={{ padding: '12px', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 500 }}>{slot.time_slot}</span>
{slot.is_current && (
<Tag color={colors.primary} style={{ fontSize: 11, margin: 0, padding: '0 6px' }}>
<ClockCircleOutlined style={{ marginRight: 2 }} />
</Tag>
)}
</td>
<td style={{ padding: '12px', textAlign: 'center' }}>{slot.planned}</td>
<td style={{ padding: '12px', textAlign: 'center', color: slot.created > 0 ? colors.text : colors.textMuted }}>
{slot.created}
</td>
<td style={{ padding: '12px', textAlign: 'center', color: slot.completed > 0 ? statusColors.completed : colors.textMuted }}>
{slot.completed}
</td>
<td style={{ padding: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1, height: 6, background: colors.bgSubtle, borderRadius: 3, overflow: 'hidden' }}>
<div
style={{
width: `${pct}%`,
height: '100%',
background: isComplete ? statusColors.completed : colors.primary,
borderRadius: 3,
transition: 'width 0.3s',
}}
/>
</div>
<span style={{ fontSize: 12, color: colors.textSecondary, minWidth: 36, textAlign: 'right' }}>{pct}%</span>
</div>
</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
{isComplete ? (
<Tag color={statusColors.completed} style={{ fontSize: 11, margin: 0 }}></Tag>
) : isPast ? (
<Tag color={statusColors.failed} style={{ fontSize: 11, margin: 0 }}></Tag>
) : slot.is_current ? (
<Tag color={colors.primary} style={{ fontSize: 11, margin: 0 }}></Tag>
) : (
<Tag style={{ fontSize: 11, margin: 0 }}></Tag>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
const PREVIEW_MESSAGE_COUNT = 3
const PREVIEW_MAX_CHARS = 80
function truncate(text: string): string {
return text.length > PREVIEW_MAX_CHARS ? `${text.slice(0, PREVIEW_MAX_CHARS)}` : text
}
const ROLE_LABEL: Record<string, string> = { user: '虚拟用户', assistant: '数字员工' }
function LiveSessionsCard({
stage,
runningSessions,
messagesBySession,
nextAction,
}: {
stage: string
runningSessions: IntelligentEvalSession[]
messagesBySession: Record<string, IntelligentEvalMessage[]>
nextAction: string | null
}) {
let body
if (runningSessions.length > 0) {
body = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{runningSessions.map((s) => {
const messages = messagesBySession[s.id] ?? []
return (
<div key={s.id} style={{ border: `1px solid ${colors.border}`, borderRadius: 8, padding: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Tag color="processing" style={{ margin: 0 }}></Tag>
<span style={{ fontWeight: 500 }}>{personaLabel(s.persona)}</span>
<span style={{ fontSize: 12, color: colors.textSecondary }}>{s.goal}</span>
<span style={{ fontSize: 12, color: colors.textSecondary, marginLeft: 'auto' }}>
{messages.length}
</span>
</div>
{messages.length === 0 ? (
<div style={{ fontSize: 12, color: colors.textMuted }}></div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{messages.slice(-PREVIEW_MESSAGE_COUNT).map((m) => (
<div key={m.id} style={{ fontSize: 13, lineHeight: 1.5 }}>
<Tag style={{ fontSize: 11, margin: '0 6px 0 0', padding: '0 6px' }}>
{ROLE_LABEL[m.role] ?? m.role}
</Tag>
{truncate(m.content)}
</div>
))}
</div>
)}
</div>
)
})}
</div>
)
} else if (stage === 'executing') {
body = (
<div style={{ fontSize: 13, color: colors.textSecondary }}>
{nextAction ?? '等待下一步'}
</div>
)
} else if (stage === 'done') {
body = <div style={{ fontSize: 13, color: colors.textSecondary }}></div>
} else {
body = <div style={{ fontSize: 13, color: colors.textSecondary }}></div>
}
return (
<Card size="small" title="进行中的会话" style={{ marginBottom: 16 }}>
{body}
</Card>
)
}
export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) {
const [progress, setProgress] = useState<ExecutionProgress | null>(null)
const [logs, setLogs] = useState<DecisionLog[]>([])
const [tasks, setTasks] = useState<TaskQueueItem[]>([])
const [messagesBySession, setMessagesBySession] = useState<Record<string, IntelligentEvalMessage[]>>({})
const [loading, setLoading] = useState(true)
// 依赖稳定的 id 键而非 ev.sessions 数组身份:父组件轮询会不断替换 ev
// 若以数组为依赖load 会每个轮询周期重建并触发重复全量加载。
const runningKey = (ev.sessions ?? [])
.filter((s) => s.status === 'running')
.map((s) => s.id)
.sort()
.join('|')
const load = useCallback(async () => {
try {
const [progressRes, logsRes, tasksRes] = await Promise.all([
intelligentEvalsApi.getExecutionProgress(ev.id),
intelligentEvalsApi.listDecisionLogs(ev.id),
intelligentEvalsApi.listTasks({ eval_id: ev.id, limit: 100 }),
])
setProgress(progressRes.data)
setLogs(logsRes.data.logs)
setTasks(tasksRes.data.tasks)
const runningIds = runningKey === '' ? [] : runningKey.split('|')
if (runningIds.length > 0) {
const msgResults = await Promise.all(runningIds.map((sid) => intelligentEvalsApi.listMessages(ev.id, sid)))
const next: Record<string, IntelligentEvalMessage[]> = {}
runningIds.forEach((sid, i) => {
next[sid] = msgResults[i].data.messages
})
setMessagesBySession(next)
} else {
setMessagesBySession({})
}
} finally {
setLoading(false)
}
}, [ev.id, runningKey])
useEffect(() => {
setLoading(true)
void load()
}, [load])
usePolling(() => { void load() }, 5000, ACTIVE_STATUSES.has(ev.status))
const activities = useMemo(() => {
const all = [...sessionEvents(ev), ...taskEvents(tasks), ...decisionEvents(logs)]
all.sort((a, b) => (a.time < b.time ? 1 : -1))
return all
}, [ev, tasks, logs])
if (loading && progress == null) {
return <div style={{ padding: 48, textAlign: 'center' }}><Spin /></div>
}
if (progress == null) {
return <Empty description="执行过程数据不可用" style={{ padding: 48 }} />
}
const abnormal = progress.abnormal_outcome != null
const runningSessions = (ev.sessions ?? []).filter((s) => s.status === 'running')
return (
<div style={{ height: '100%', overflowY: 'auto', padding: '16px 16px 24px' }}>
<Card size="small" title="生命周期" style={{ marginBottom: 16 }}>
<LifecycleTimeline ev={ev} currentStage={progress.current_stage} abnormal={abnormal} />
{progress.blocker != null && (
<Alert
type={abnormal ? 'error' : 'warning'}
showIcon
style={{ marginTop: 16 }}
message={progress.blocker}
/>
)}
{progress.next_action != null && (
<Alert
type="info"
showIcon
style={{ marginTop: 12 }}
message={`下一步:${progress.next_action}`}
/>
)}
</Card>
<LiveSessionsCard
stage={progress.current_stage}
runningSessions={runningSessions}
messagesBySession={messagesBySession}
nextAction={progress.next_action}
/>
<Card size="small" title="时段分布:计划 vs 实际" style={{ marginBottom: 16 }}>
{progress.slots.length === 0 ? (
<Empty description="暂无时段分布(粗计划未提交或评估未开始执行)" />
) : (
<TimeDistributionTable slots={progress.slots} />
)}
</Card>
<Card size="small" title="活动流" style={{ marginBottom: 16 }}>
{activities.length === 0 ? (
<Empty description="暂无活动" />
) : (
<>
<Timeline
items={activities.map((a) => ({
color: a.color,
children: (
<div>
<div style={{ fontSize: 13, lineHeight: 1.5 }}>{a.text}</div>
<div style={{ fontSize: 12, color: colors.textSecondary }}>{formatDateTime(a.time)}</div>
</div>
),
}))}
/>
<div style={{ fontSize: 12, color: colors.textMuted, marginTop: 8 }}>
{`${activities.length} 条活动,完整决策历史见「决策过程」`}
</div>
</>
)}
{tasks.some((t) => t.status === 'failed') && (
<div style={{ marginTop: 8 }}>
<Tag color={statusColors.failed}></Tag>
</div>
)}
</Card>
</div>
)
}