AgentEvalTool/frontend/web/src/pages/Runs.tsx
sinohqb 050c674ee2
Some checks failed
CI / test (push) Failing after 1m10s
refactor(frontend): extract useResource/usePolling shared hooks
Seven pages repeated the same load-on-mount + loading + try/finally +
reload-button skeleton, each re-implementing tab-active refresh, silent
polling, and (in two places) a hand-rolled requestId race guard. Extract two
composable hooks: useResource(fetcher, {tabPath, deps}) owning data/loading/
reload with a built-in race guard and auto tab-active refresh, and
usePolling(fn, ms, enabled) replacing the hand-written setInterval effects.
Migrate all seven pages onto them; Targets/Scenarios/ModelConfigs also gain a
uniform tab-active refresh they previously lacked. Verified via tsc --noEmit
and npm run build (no frontend test runner exists).
2026-07-31 11:01:24 +08:00

448 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Alert, Button, Empty, message, Progress, Select, Space, Tabs, Tooltip, Typography,
} from 'antd'
import {
FileTextOutlined, PlayCircleOutlined, ReloadOutlined, StopOutlined,
} from '@ant-design/icons'
import { reportsApi, runsApi, scenariosApi, targetsApi, type Run, type Scenario, type Target } from '../api'
import RunList from '../components/RunList'
import CaseBlock from '../components/CaseBlock'
import RuleOverview from '../components/RuleOverview'
import CaseDetail from '../components/CaseDetail'
import { useRunSession } from '../hooks/useRunSession'
import { useTicker } from '../hooks/useTicker'
import { useResource } from '../hooks/useResource'
import { usePolling } from '../hooks/usePolling'
import { colors, statusColors, statusLabels } from '../tokens'
import { formatDateTime, elapsedStr } from '../utils/date'
import { passRateColor } from '../utils/colors'
interface RunsListsData {
runs: Run[]
targets: Target[]
scenarios: Scenario[]
}
export default function RunsPage() {
const navigate = useNavigate()
const [targetId, setTargetId] = useState<string>()
const [scenarioId, setScenarioId] = useState<string>()
const [starting, setStarting] = useState(false)
const [tabKey, setTabKey] = useState<string | null>(null)
const [tabOverrideId, setTabOverrideId] = useState<string | null>(null)
const [focusCaseId, setFocusCaseId] = useState<string | null>(null)
const session = useRunSession()
// Keep-alive tabs never remount: refresh the list whenever the tab is
// re-activated so runs triggered elsewhere (e.g. AI assistant) show up.
const { data, loading, reload } = useResource<RunsListsData>(
async () => {
const [r, t, s] = await Promise.all([runsApi.list(), targetsApi.list(), scenariosApi.list()])
// API 已按 started_at DESC 排序,最新的在前
return { runs: r.data, targets: t.data, scenarios: s.data }
},
{ tabPath: '/runs' },
)
const runs = data?.runs ?? []
const targets = data?.targets ?? []
const scenarios = data?.scenarios ?? []
// While a run is live, poll the lists so status/pass-rate refresh; a silent
// reload avoids flashing the loading spinner on each tick.
usePolling(() => void reload(true), 3000, session.isLive)
useEffect(() => {
if (session.completed) void reload(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.completed])
// 提取为共享函数handleStart 和 onRerun 共用
const startRun = async (tId: string, sId: string) => {
setStarting(true)
try {
const res = await runsApi.start(tId, sId)
await reload()
session.select(res.data, { live: true })
setTabOverrideId(null)
setFocusCaseId(null)
} finally {
setStarting(false)
}
}
const handleStart = async () => {
if (!targetId || !scenarioId) {
message.warning('请选择评测对象和场景')
return
}
await startRun(targetId, scenarioId)
message.success('评测已启动')
}
const handleSelect = (r: Run) => {
session.select(r)
setTabOverrideId(null)
setFocusCaseId(null)
}
const handleOpenReport = (runId: string) => {
navigate(`/reports?run=${runId}`)
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* 页头 */}
<div style={{ padding: '10px 16px 8px', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}></h2>
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} />
<span style={{ fontSize: 13, color: colors.textSecondary }}></span>
</div>
{/* 主体 grid — 贴边铺满 */}
<div style={{ flex: 1, minHeight: 0 }}>
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(360px, 28vw) 1fr',
gridTemplateRows: '1fr',
gap: 0,
height: '100%',
minHeight: 480,
}}>
{/* LEFT: launcher + list */}
<div style={{
display: 'flex',
flexDirection: 'column',
borderRight: `1px solid ${colors.border}`,
background: '#fff',
minHeight: 0,
overflow: 'hidden',
}}>
{/* 启动区:两行布局 */}
<div style={{
padding: '12px 12px 10px',
borderBottom: `1px solid ${colors.border}`,
background: colors.bgSubtle,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}>
<Select
value={targetId}
onChange={setTargetId}
allowClear
showSearch
filterOption={(input, opt) =>
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
placeholder="选择评测对象"
options={targets.map((t) => ({ label: t.name, value: t.id }))}
style={{ width: '100%' }}
size="middle"
/>
<div style={{ display: 'flex', gap: 8 }}>
<Select
value={scenarioId}
onChange={setScenarioId}
allowClear
showSearch
filterOption={(input, opt) =>
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())}
placeholder="选择评测场景"
options={scenarios.map((s) => ({ label: `${s.name}v${s.version ?? 1}`, value: s.id }))}
style={{ flex: 1 }}
size="middle"
/>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handleStart}
loading={starting}
>
</Button>
</div>
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<RunList
runs={runs}
loading={loading}
targets={targets}
scenarios={scenarios}
selectedId={session.run?.id}
onSelect={handleSelect}
onOpenReport={handleOpenReport}
liveRunId={session.isLive ? session.run?.id : undefined}
liveProgress={session.isLive ? session.progress : null}
/>
</div>
</div>
{/* RIGHT: detail workspace */}
<div style={{
display: 'flex',
flexDirection: 'column',
background: '#fff',
minHeight: 0,
overflow: 'hidden',
}}>
<DetailWorkspace
run={session.run}
cases={session.cases}
progress={session.progress}
snapshots={session.scenarioSnapshot}
isLive={session.isLive}
finalStatus={session.finalStatus}
errorInfo={session.errorInfo}
tabKey={tabKey}
tabOverrideId={tabOverrideId}
onTabChange={(k) => {
setTabKey(k)
setTabOverrideId(session.run?.id ?? null)
}}
focusCaseId={focusCaseId}
onFocusCase={(id) => { setFocusCaseId(id); setTabKey('detail'); setTabOverrideId(session.run?.id ?? null) }}
onCancel={async () => {
try {
await session.cancel()
message.success('已发送停止请求')
} catch {
message.error('停止失败')
}
}}
onOpenReport={handleOpenReport}
onExportJson={(runId) => reportsApi.download(runId, 'json')}
onRerun={async () => {
if (!session.run) return
await startRun(session.run.target_id, session.run.scenario_id)
message.success('已重新启动')
}}
/>
</div>
</div>
</div>
</div>
)
}
// ─── Detail Workspace ─────────────────────────────────────────────────────────
interface DetailWorkspaceProps {
run: Run | null
cases: ReturnType<typeof useRunSession>['cases']
progress: ReturnType<typeof useRunSession>['progress']
snapshots: ReturnType<typeof useRunSession>['scenarioSnapshot']
isLive: boolean
finalStatus: ReturnType<typeof useRunSession>['finalStatus']
errorInfo: ReturnType<typeof useRunSession>['errorInfo']
tabKey: string | null
tabOverrideId: string | null
onTabChange: (k: string) => void
focusCaseId: string | null
onFocusCase: (id: string) => void
onCancel: () => void
onOpenReport: (runId: string) => void
onExportJson: (runId: string) => void
onRerun: () => void
}
function DetailWorkspace({
run, cases, progress, snapshots, isLive, finalStatus, errorInfo,
tabKey, tabOverrideId, onTabChange, focusCaseId, onFocusCase,
onCancel, onOpenReport, onExportJson, onRerun,
}: DetailWorkspaceProps) {
useTicker(isLive)
const status = run?.status ?? 'pending'
const autoTab = useMemo(() => {
if (isLive) return 'log'
if (status === 'completed') return 'overview'
if (status === 'failed') return 'detail'
return 'log'
}, [isLive, status])
if (!run) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<span style={{ color: colors.textMuted }}></span>}
/>
</div>
)
}
const summary = run.summary
const passRate = summary?.pass_rate ?? undefined
const totalCases = summary?.total_cases
const passedCases = summary?.passed_cases
const totalRules = summary?.total_rules
const passedRules = summary?.passed_rules
const activeKey = tabOverrideId === run.id && tabKey ? tabKey : autoTab
const finalAlert = (() => {
if (finalStatus === 'completed') return <Alert type="success" showIcon message="评测完成" style={{ padding: '4px 12px' }} />
if (finalStatus === 'cancelled') return <Alert type="warning" showIcon message={errorInfo?.message || '评测已手动停止'} style={{ padding: '4px 12px' }} />
if (finalStatus === 'failed') return <Alert type="error" showIcon message={`评测失败:${errorInfo?.message ?? '未知原因'}`} style={{ padding: '4px 12px' }} />
return null
})()
return (
<>
{/* KPI bar */}
<div style={{
padding: '12px 16px',
borderBottom: `1px solid ${colors.border}`,
background: colors.bgSubtle,
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap' }}>
<StatusBadge status={status} finalStatus={finalStatus} />
<KpiItem
label="通过率"
value={passRate != null ? `${Math.round(passRate * 100)}%` : '-'}
valueColor={passRate != null ? passRateColor(passRate) : colors.textMuted}
subtitle={
passedCases != null && totalCases != null
? `${passedCases}/${totalCases} 用例通过 · ${passedRules ?? 0}/${totalRules ?? 0} 规则通过`
: '等待评测结果'
}
/>
<KpiItem
label={isLive ? '已运行' : '用时'}
value={elapsedStr(run.started_at, run.completed_at)}
subtitle={`${formatDateTime(run.started_at)}${run.completed_at ? ' → ' + formatDateTime(run.completed_at) : ''}`}
/>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
<Tooltip title={run.id}>
<Typography.Text code copyable={{ text: run.id }} style={{ fontSize: 11 }}>
{run.id.slice(0, 8)}
</Typography.Text>
</Tooltip>
{isLive ? (
<Button danger size="small" icon={<StopOutlined />} onClick={onCancel}></Button>
) : (
<Space size={4}>
<Button size="small" icon={<ReloadOutlined />} onClick={onRerun}></Button>
{status === 'completed' && (
<>
<Button size="small" icon={<FileTextOutlined />} onClick={() => onOpenReport(run.id)}></Button>
<Button size="small" onClick={() => onExportJson(run.id)}></Button>
</>
)}
</Space>
)}
</div>
</div>
{progress && progress.total > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8 }}>
<span style={{ fontSize: 11, color: colors.textMuted, minWidth: 88 }}>
{progress.done}/{progress.total}{progress.currentIndex ? ` · 第 ${progress.currentIndex}` : ''}
</span>
<Progress
percent={progress.total ? Math.round((progress.done / progress.total) * 100) : 0}
size="small"
strokeColor={colors.primary}
showInfo={false}
style={{ flex: 1, margin: 0 }}
/>
</div>
)}
{finalAlert && <div style={{ marginTop: 8 }}>{finalAlert}</div>}
</div>
{/* Tab 导航栏(只渲染头部,内容区由下方 flex 面板独立控制) */}
<div style={{ borderBottom: `1px solid ${colors.border}`, flexShrink: 0 }}>
<Tabs
className="nav-only-tabs"
activeKey={activeKey}
onChange={onTabChange}
tabBarStyle={{ margin: '0 16px', marginBottom: 0 }}
items={[
{ key: 'log', label: `实时对话${cases.length > 0 ? ` (${cases.length})` : ''}` },
{ key: 'overview', label: '规则总览' },
{ key: 'detail', label: '用例明细' },
]}
/>
</div>
{/* 实时对话 — 独立 flex 子元素,直接 overflowY: auto */}
<div style={{
display: activeKey === 'log' ? 'block' : 'none',
flex: 1, minHeight: 0, overflowY: 'auto',
padding: '10px 16px 16px',
}}>
{cases.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={isLive ? '等待用例开始…' : '暂无对话记录'}
style={{ marginTop: 60 }}
/>
) : (
cases.map((c) => (
<CaseBlock key={c.caseId} cs={c} snapshot={snapshots[c.caseId]} />
))
)}
</div>
{/* 规则总览 */}
<div style={{
display: activeKey === 'overview' ? 'block' : 'none',
flex: 1, minHeight: 0, overflowY: 'auto',
padding: '10px 16px 16px',
}}>
<RuleOverview cases={cases} onSelectCase={(id) => onFocusCase(id)} />
</div>
{/* 用例明细 — CaseDetail 内部两栏各自独立滚动 */}
<div style={{
display: activeKey === 'detail' ? 'flex' : 'none',
flex: 1, minHeight: 0, overflow: 'hidden',
padding: '10px 16px 16px',
}}>
<CaseDetail cases={cases} snapshots={snapshots} focusCaseId={focusCaseId} />
</div>
</>
)
}
function StatusBadge({ status, finalStatus }: { status: string; finalStatus: ReturnType<typeof useRunSession>['finalStatus'] }) {
const effective = finalStatus ?? status
const color = statusColors[effective] ?? colors.textMuted
const label = statusLabels[effective] ?? effective
const isRunning = effective === 'running' || effective === 'pending'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{isRunning
? <span className="pulse-dot" style={{ background: color }} />
: <span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />}
<span style={{ fontSize: 13, fontWeight: 600, color }}>{label}</span>
</div>
)
}
function KpiItem({ label, value, subtitle, valueColor }: {
label: string
value: string
subtitle?: string
valueColor?: string
}) {
return (
<div style={{ minWidth: 120 }}>
<div style={{ fontSize: 11, color: colors.textMuted, marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 20, fontWeight: 600, lineHeight: 1.2, color: valueColor ?? colors.text }}>{value}</div>
{subtitle && <div style={{ fontSize: 11, color: colors.textMuted, marginTop: 2 }}>{subtitle}</div>}
</div>
)
}