diff --git a/frontend/web/src/components/intelligent_eval/ExecutionProcess.test.tsx b/frontend/web/src/components/intelligent_eval/ExecutionProcess.test.tsx index 0d46d88..0a79144 100644 --- a/frontend/web/src/components/intelligent_eval/ExecutionProcess.test.tsx +++ b/frontend/web/src/components/intelligent_eval/ExecutionProcess.test.tsx @@ -1,17 +1,19 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExecutionProgress, IntelligentEval } from '../../api' +import type { ExecutionProgress, IntelligentEval, IntelligentEvalMessage, IntelligentEvalSession } from '../../api' import ExecutionProcess from './ExecutionProcess' const getExecutionProgress = vi.fn() const listDecisionLogs = vi.fn() const listTasks = vi.fn() +const listMessages = vi.fn() vi.mock('../../api', () => ({ intelligentEvalsApi: { getExecutionProgress: (...args: unknown[]) => getExecutionProgress(...args), listDecisionLogs: (...args: unknown[]) => listDecisionLogs(...args), listTasks: (...args: unknown[]) => listTasks(...args), + listMessages: (...args: unknown[]) => listMessages(...args), }, })) @@ -78,6 +80,34 @@ function mockApi(progressOverrides: Partial = {}, logCount = getExecutionProgress.mockResolvedValue({ data: { ...baseProgress, ...progressOverrides } }) listDecisionLogs.mockResolvedValue({ data: { logs } }) listTasks.mockResolvedValue({ data: { tasks: [], stats: {} } }) + listMessages.mockResolvedValue({ data: { messages: [] } }) +} + +function makeRunningSession(id = 's-run'): IntelligentEvalSession { + return { + id, + eval_id: 'eval-1', + target_id: 't-1', + persona: { name: '新客户' }, + goal: '咨询价保', + dimension: null, + status: 'running', + verdict: null, + turn_count: 0, + created_at: '2026-01-01T09:00:00Z', + closed_at: null, + } +} + +function makeMessage(i: number, role: string, content: string): IntelligentEvalMessage { + return { + id: `m-${i}`, + session_id: 's-run', + role, + content, + latency_ms: null, + created_at: `2026-01-01T09:${String(i).padStart(2, '0')}:00Z`, + } } beforeEach(() => { @@ -122,4 +152,54 @@ describe('ExecutionProcess', () => { await waitFor(() => expect(listTasks).toHaveBeenCalledWith({ eval_id: 'eval-1', limit: 100 })) }) + + describe('进行中的会话卡片', () => { + it('renders live turn count and the latest 3 messages for running sessions', async () => { + mockApi() + listMessages.mockResolvedValue({ + data: { + messages: [ + makeMessage(1, 'user', '最早的一条消息'), + makeMessage(2, 'assistant', '较早的回复'), + makeMessage(3, 'user', '我想问下价保怎么申请'), + makeMessage(4, 'assistant', '您可以在订单页发起价保申请'), + makeMessage(5, 'user', '好的,那需要什么凭证'.padEnd(120, '字')), + ], + }, + }) + const running = makeRunningSession() + render() + + await waitFor(() => expect(screen.getByText('进行中的会话')).toBeInTheDocument()) + await waitFor(() => expect(listMessages).toHaveBeenCalledWith('eval-1', 's-run')) + + expect(screen.getAllByText('新客户').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText(/5 条消息/)).toBeInTheDocument() + expect(screen.getByText(/我想问下价保怎么申请/)).toBeInTheDocument() + expect(screen.getByText(/您可以在订单页发起价保申请/)).toBeInTheDocument() + // 最新一条超长截断到 80 字 + … + expect(screen.queryByText(/好的,那需要什么凭证字+$/)).not.toBeInTheDocument() + expect(screen.getByText(/^好的,那需要什么凭证.*…$/)).toBeInTheDocument() + // 只预览最近 3 条,更早的不渲染 + expect(screen.queryByText('最早的一条消息')).not.toBeInTheDocument() + expect(screen.queryByText('较早的回复')).not.toBeInTheDocument() + }) + + it('shows next action hint when executing but no running session', async () => { + mockApi({ next_action: '等待平台触发 worker 补足当前欠账 2 个会话' }) + render() + + await waitFor(() => expect(screen.getByText('进行中的会话')).toBeInTheDocument()) + expect(screen.getByText(/当前没有进行中的会话:等待平台触发 worker 补足当前欠账 2 个会话/)).toBeInTheDocument() + }) + + it('shows placeholder text when evaluation has not started executing', async () => { + mockApi({ current_stage: 'planning', next_action: null }) + render() + + await waitFor(() => expect(screen.getByText('进行中的会话')).toBeInTheDocument()) + expect(screen.getByText('评估尚未开始执行')).toBeInTheDocument() + expect(listMessages).not.toHaveBeenCalled() + }) + }) }) diff --git a/frontend/web/src/components/intelligent_eval/ExecutionProcess.tsx b/frontend/web/src/components/intelligent_eval/ExecutionProcess.tsx index 12bbfbe..d77f068 100644 --- a/frontend/web/src/components/intelligent_eval/ExecutionProcess.tsx +++ b/frontend/web/src/components/intelligent_eval/ExecutionProcess.tsx @@ -12,6 +12,8 @@ import { type DecisionLog, type ExecutionProgress, type IntelligentEval, + type IntelligentEvalMessage, + type IntelligentEvalSession, type TaskQueueItem, } from '../../api' import { SESSION_STATUS, decisionTypeOf } from './status' @@ -266,12 +268,95 @@ function TimeDistributionTable({ slots }: { slots: ExecutionProgress['slots'] }) ) } +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 = { user: '虚拟用户', assistant: '数字员工' } + +function LiveSessionsCard({ + stage, + runningSessions, + messagesBySession, + nextAction, +}: { + stage: string + runningSessions: IntelligentEvalSession[] + messagesBySession: Record + nextAction: string | null +}) { + let body + if (runningSessions.length > 0) { + body = ( +
+ {runningSessions.map((s) => { + const messages = messagesBySession[s.id] ?? [] + return ( +
+
+ 进行中 + {personaLabel(s.persona)} + 目标「{s.goal}」 + + {messages.length} 条消息 + +
+ {messages.length === 0 ? ( +
等待首条消息…
+ ) : ( +
+ {messages.slice(-PREVIEW_MESSAGE_COUNT).map((m) => ( +
+ + {ROLE_LABEL[m.role] ?? m.role} + + {truncate(m.content)} +
+ ))} +
+ )} +
+ ) + })} +
+ ) + } else if (stage === 'executing') { + body = ( +
+ 当前没有进行中的会话:{nextAction ?? '等待下一步'} +
+ ) + } else if (stage === 'done') { + body =
执行已结束,无进行中的会话
+ } else { + body =
评估尚未开始执行
+ } + + return ( + + {body} + + ) +} + export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) { const [progress, setProgress] = useState(null) const [logs, setLogs] = useState([]) const [tasks, setTasks] = useState([]) + const [messagesBySession, setMessagesBySession] = useState>({}) 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([ @@ -282,10 +367,22 @@ export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) { 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 = {} + runningIds.forEach((sid, i) => { + next[sid] = msgResults[i].data.messages + }) + setMessagesBySession(next) + } else { + setMessagesBySession({}) + } } finally { setLoading(false) } - }, [ev.id]) + }, [ev.id, runningKey]) useEffect(() => { setLoading(true) @@ -308,6 +405,7 @@ export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) { } const abnormal = progress.abnormal_outcome != null + const runningSessions = (ev.sessions ?? []).filter((s) => s.status === 'running') return (
@@ -331,6 +429,13 @@ export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) { )} + + {progress.slots.length === 0 ? (