Compare commits

...

2 Commits

Author SHA1 Message Date
sinohqb
5f116700d7 fix(ui): move eval delete to drawer header to stop row click opening detail
All checks were successful
CI / test (push) Successful in 3m23s
2026-08-21 17:01:39 +08:00
sinohqb
8021eab65e feat(ui): live running-session cards in intelligent-eval execution view 2026-08-21 16:13:27 +08:00
3 changed files with 201 additions and 17 deletions

View File

@ -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()
})
})
})

View File

@ -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="暂无时段分布(粗计划未提交或评估未开始执行)" />

View File

@ -146,6 +146,7 @@ export default function IntelligentEvalsPage() {
const handleDelete = async (id: string) => {
await intelligentEvalsApi.remove(id)
message.success('已删除')
closeDrawer()
void reloadList()
}
@ -190,22 +191,9 @@ export default function IntelligentEvalsPage() {
render: (v: string | null) => (v ? formatDateTime(v) : '—'),
},
{
title: '操作', key: 'action', width: 130, fixed: 'right' as const,
title: '操作', key: 'action', width: 88, fixed: 'right' as const,
render: (_, ev) => (
<Space size={4}>
<Button size="small" onClick={() => openDetail(ev.id)}></Button>
{TERMINAL_STATUSES.has(ev.status) && (
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除该评估吗?"
onConfirm={() => handleDelete(ev.id)}
okText="删除"
cancelText="取消"
>
<Button size="small" danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
)}
</Space>
<Button size="small" onClick={() => openDetail(ev.id)}></Button>
),
},
]
@ -314,6 +302,17 @@ export default function IntelligentEvalsPage() {
<Button danger icon={<StopOutlined />}></Button>
</Popconfirm>
)}
{TERMINAL_STATUSES.has(selected.status) && (
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除该评估吗?"
onConfirm={() => void handleDelete(selected.id)}
okText="删除"
cancelText="取消"
>
<Button danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
)}
</Space>
)}
>