Compare commits
No commits in common. "5f116700d71d7cf42785c03575ac97b5f5b522cd" and "2d2c5a2904a95213fa89fa3cc71c673440fc75be" have entirely different histories.
5f116700d7
...
2d2c5a2904
@ -1,19 +1,17 @@
|
|||||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { ExecutionProgress, IntelligentEval, IntelligentEvalMessage, IntelligentEvalSession } from '../../api'
|
import type { ExecutionProgress, IntelligentEval } from '../../api'
|
||||||
import ExecutionProcess from './ExecutionProcess'
|
import ExecutionProcess from './ExecutionProcess'
|
||||||
|
|
||||||
const getExecutionProgress = vi.fn()
|
const getExecutionProgress = vi.fn()
|
||||||
const listDecisionLogs = vi.fn()
|
const listDecisionLogs = vi.fn()
|
||||||
const listTasks = vi.fn()
|
const listTasks = vi.fn()
|
||||||
const listMessages = vi.fn()
|
|
||||||
|
|
||||||
vi.mock('../../api', () => ({
|
vi.mock('../../api', () => ({
|
||||||
intelligentEvalsApi: {
|
intelligentEvalsApi: {
|
||||||
getExecutionProgress: (...args: unknown[]) => getExecutionProgress(...args),
|
getExecutionProgress: (...args: unknown[]) => getExecutionProgress(...args),
|
||||||
listDecisionLogs: (...args: unknown[]) => listDecisionLogs(...args),
|
listDecisionLogs: (...args: unknown[]) => listDecisionLogs(...args),
|
||||||
listTasks: (...args: unknown[]) => listTasks(...args),
|
listTasks: (...args: unknown[]) => listTasks(...args),
|
||||||
listMessages: (...args: unknown[]) => listMessages(...args),
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -80,34 +78,6 @@ function mockApi(progressOverrides: Partial<ExecutionProgress> = {}, logCount =
|
|||||||
getExecutionProgress.mockResolvedValue({ data: { ...baseProgress, ...progressOverrides } })
|
getExecutionProgress.mockResolvedValue({ data: { ...baseProgress, ...progressOverrides } })
|
||||||
listDecisionLogs.mockResolvedValue({ data: { logs } })
|
listDecisionLogs.mockResolvedValue({ data: { logs } })
|
||||||
listTasks.mockResolvedValue({ data: { tasks: [], stats: {} } })
|
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(() => {
|
beforeEach(() => {
|
||||||
@ -152,54 +122,4 @@ describe('ExecutionProcess', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(listTasks).toHaveBeenCalledWith({ eval_id: 'eval-1', limit: 100 }))
|
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,8 +12,6 @@ import {
|
|||||||
type DecisionLog,
|
type DecisionLog,
|
||||||
type ExecutionProgress,
|
type ExecutionProgress,
|
||||||
type IntelligentEval,
|
type IntelligentEval,
|
||||||
type IntelligentEvalMessage,
|
|
||||||
type IntelligentEvalSession,
|
|
||||||
type TaskQueueItem,
|
type TaskQueueItem,
|
||||||
} from '../../api'
|
} from '../../api'
|
||||||
import { SESSION_STATUS, decisionTypeOf } from './status'
|
import { SESSION_STATUS, decisionTypeOf } from './status'
|
||||||
@ -268,95 +266,12 @@ 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 }) {
|
export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) {
|
||||||
const [progress, setProgress] = useState<ExecutionProgress | null>(null)
|
const [progress, setProgress] = useState<ExecutionProgress | null>(null)
|
||||||
const [logs, setLogs] = useState<DecisionLog[]>([])
|
const [logs, setLogs] = useState<DecisionLog[]>([])
|
||||||
const [tasks, setTasks] = useState<TaskQueueItem[]>([])
|
const [tasks, setTasks] = useState<TaskQueueItem[]>([])
|
||||||
const [messagesBySession, setMessagesBySession] = useState<Record<string, IntelligentEvalMessage[]>>({})
|
|
||||||
const [loading, setLoading] = useState(true)
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [progressRes, logsRes, tasksRes] = await Promise.all([
|
const [progressRes, logsRes, tasksRes] = await Promise.all([
|
||||||
@ -367,22 +282,10 @@ export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) {
|
|||||||
setProgress(progressRes.data)
|
setProgress(progressRes.data)
|
||||||
setLogs(logsRes.data.logs)
|
setLogs(logsRes.data.logs)
|
||||||
setTasks(tasksRes.data.tasks)
|
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 {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [ev.id, runningKey])
|
}, [ev.id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@ -405,7 +308,6 @@ export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const abnormal = progress.abnormal_outcome != null
|
const abnormal = progress.abnormal_outcome != null
|
||||||
const runningSessions = (ev.sessions ?? []).filter((s) => s.status === 'running')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', overflowY: 'auto', padding: '16px 16px 24px' }}>
|
<div style={{ height: '100%', overflowY: 'auto', padding: '16px 16px 24px' }}>
|
||||||
@ -429,13 +331,6 @@ export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<LiveSessionsCard
|
|
||||||
stage={progress.current_stage}
|
|
||||||
runningSessions={runningSessions}
|
|
||||||
messagesBySession={messagesBySession}
|
|
||||||
nextAction={progress.next_action}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card size="small" title="时段分布:计划 vs 实际" style={{ marginBottom: 16 }}>
|
<Card size="small" title="时段分布:计划 vs 实际" style={{ marginBottom: 16 }}>
|
||||||
{progress.slots.length === 0 ? (
|
{progress.slots.length === 0 ? (
|
||||||
<Empty description="暂无时段分布(粗计划未提交或评估未开始执行)" />
|
<Empty description="暂无时段分布(粗计划未提交或评估未开始执行)" />
|
||||||
|
|||||||
@ -146,7 +146,6 @@ export default function IntelligentEvalsPage() {
|
|||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
await intelligentEvalsApi.remove(id)
|
await intelligentEvalsApi.remove(id)
|
||||||
message.success('已删除')
|
message.success('已删除')
|
||||||
closeDrawer()
|
|
||||||
void reloadList()
|
void reloadList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,9 +190,22 @@ export default function IntelligentEvalsPage() {
|
|||||||
render: (v: string | null) => (v ? formatDateTime(v) : '—'),
|
render: (v: string | null) => (v ? formatDateTime(v) : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作', key: 'action', width: 88, fixed: 'right' as const,
|
title: '操作', key: 'action', width: 130, fixed: 'right' as const,
|
||||||
render: (_, ev) => (
|
render: (_, ev) => (
|
||||||
<Button size="small" onClick={() => openDetail(ev.id)}>查看</Button>
|
<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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -302,17 +314,6 @@ export default function IntelligentEvalsPage() {
|
|||||||
<Button danger icon={<StopOutlined />}>取消</Button>
|
<Button danger icon={<StopOutlined />}>取消</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
{TERMINAL_STATUSES.has(selected.status) && (
|
|
||||||
<Popconfirm
|
|
||||||
title="确认删除"
|
|
||||||
description="删除后不可恢复,确定要删除该评估吗?"
|
|
||||||
onConfirm={() => void handleDelete(selected.id)}
|
|
||||||
okText="删除"
|
|
||||||
cancelText="取消"
|
|
||||||
>
|
|
||||||
<Button danger icon={<DeleteOutlined />}>删除</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user