feat(ui): live running-session cards in intelligent-eval execution view
This commit is contained in:
parent
2d2c5a2904
commit
8021eab65e
@ -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<ExecutionProgress> = {}, 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(<ExecutionProcess ev={makeEval({ sessions: [...(makeEval().sessions ?? []), running] })} />)
|
||||
|
||||
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(<ExecutionProcess ev={makeEval()} />)
|
||||
|
||||
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(<ExecutionProcess ev={makeEval({ status: 'planning', sessions: [] })} />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('进行中的会话')).toBeInTheDocument())
|
||||
expect(screen.getByText('评估尚未开始执行')).toBeInTheDocument()
|
||||
expect(listMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<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([
|
||||
@ -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<string, IntelligentEvalMessage[]> = {}
|
||||
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 (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: '16px 16px 24px' }}>
|
||||
@ -331,6 +429,13 @@ export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) {
|
||||
)}
|
||||
</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="暂无时段分布(粗计划未提交或评估未开始执行)" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user