477 lines
18 KiB
TypeScript
477 lines
18 KiB
TypeScript
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>
|
||
)
|
||
}
|