AgentEvalTool/frontend/web/src/pages/Reports.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

605 lines
24 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 { useSearchParams } from 'react-router-dom'
import {
Alert, Button, Card, Col, Collapse, Descriptions, Empty, Row, Segmented,
Select, Space, Spin, Statistic, Table, Tag, Tooltip, Badge, message,
} from 'antd'
import {
CheckCircleOutlined, CloseCircleOutlined, ClearOutlined,
DownloadOutlined, UserOutlined, RobotOutlined, ReloadOutlined,
DiffOutlined, FileMarkdownOutlined,
} from '@ant-design/icons'
import { reportsApi, runsApi, type Run } from '../api'
import { colors, triggerColors, triggerLabels } from '../tokens'
import { formatDateTime } from '../utils/date'
import { useResource } from '../hooks/useResource'
interface TurnData {
sent_text: string
reply_text: string | null
latency_ms: number | null
}
interface RuleResultData {
rule_type: string
passed: boolean
score: number | null
reason: string
}
interface CaseReport {
case_id: string
connectivity: boolean
turns: TurnData[]
results: RuleResultData[]
}
interface Report {
run_id: string
target_name: string
scenario_name: string
scenario_version?: number
triggered_by?: string
status: string
started_at: string
completed_at: string | null
summary: {
total_cases: number
passed_cases: number
failed_cases: number
total_rules: number
passed_rules: number
pass_rate: number
connectivity_cases: number
judged_pass_rate: number | null
}
cases: CaseReport[]
}
interface CompareResult {
run_a: { run_id: string; target_name: string; scenario_name: string; scenario_version?: number; triggered_by?: string; status: string; started_at: string; summary: Report['summary'] }
run_b: { run_id: string; target_name: string; scenario_name: string; scenario_version?: number; triggered_by?: string; status: string; started_at: string; summary: Report['summary'] }
delta: { pass_rate: number; passed_cases: number; passed_rules: number }
cases: Array<{
case_id: string
connectivity: boolean
run_a_passed: boolean | null
run_b_passed: boolean | null
changed: boolean
run_a_results: RuleResultData[]
run_b_results: RuleResultData[]
}>
changed_cases: number
}
type ViewMode = 'single' | 'compare'
export default function ReportsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const runQuery = searchParams.get('run') ?? ''
const [scenarioFilter, setScenarioFilter] = useState<string>('')
const [selectedRunId, setSelectedRunId] = useState<string>('')
const [compareRunId, setCompareRunId] = useState<string>('')
const [report, setReport] = useState<Report | null>(null)
const [compareResult, setCompareResult] = useState<CompareResult | null>(null)
const [loading, setLoading] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('single')
// Keep-alive tabs never remount: refresh whenever this tab is re-activated
// so runs triggered elsewhere (e.g. AI assistant) show up.
const { data: runsData, reload: reloadRuns } = useResource(
() => runsApi.list().then((r) => r.data.filter((run) => run.status === 'completed')),
{ tabPath: '/reports' },
)
const runs = useMemo(() => runsData ?? [], [runsData])
const loadReport = async (runId: string) => {
setSelectedRunId(runId)
setCompareResult(null)
setLoading(true)
try {
const res = await reportsApi.get(runId)
setReport(res.data as Report)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (runQuery && runQuery !== selectedRunId) {
loadReport(runQuery)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runQuery])
const handleView = (runId: string) => {
setSearchParams({ run: runId })
loadReport(runId)
// 对比报告要求同场景同版本A 变更后若 B 不可比则清空
const a = runs.find((r) => r.id === runId)
const b = runs.find((r) => r.id === compareRunId)
if (a && b && (a.scenario_id !== b.scenario_id
|| (a.scenario_version ?? 1) !== (b.scenario_version ?? 1))) setCompareRunId('')
}
const handleCompare = async () => {
if (!selectedRunId || !compareRunId) return
setLoading(true)
try {
const res = await reportsApi.compare(selectedRunId, compareRunId)
setCompareResult(res.data as CompareResult)
} catch (e: any) {
const detail = e?.response?.data?.detail
if (detail) message.error(detail)
} finally {
setLoading(false)
}
}
const clearSelection = () => {
setSelectedRunId('')
setCompareRunId('')
setReport(null)
setCompareResult(null)
setSearchParams({})
}
const resetFilters = () => {
setScenarioFilter('')
clearSelection()
}
// 场景筛选项:从 run 列表聚合(含各场景 run 数量与出现过的考纲版本)
const scenarioOptions = useMemo(() => {
const map = new Map<string, { name: string; count: number; versions: Set<number> }>()
for (const r of runs) {
const entry = map.get(r.scenario_id)
if (entry) {
entry.count += 1
entry.versions.add(r.scenario_version ?? 1)
} else {
map.set(r.scenario_id, {
name: r.scenario_name || r.scenario_id.slice(0, 8),
count: 1,
versions: new Set([r.scenario_version ?? 1]),
})
}
}
return [...map.entries()].map(([id, s]) => {
const versions = [...s.versions].sort((a, b) => a - b).map((v) => `v${v}`).join('/')
return { value: id, label: `${s.name}${s.count} · ${versions}` }
})
}, [runs])
const filteredRuns = useMemo(
() => (scenarioFilter ? runs.filter((r) => r.scenario_id === scenarioFilter) : runs),
[runs, scenarioFilter],
)
const buildOption = (r: Run) => {
const passRate = r.summary?.pass_rate
const pct = passRate != null ? `${Math.round(passRate * 100)}%` : '-'
const scenario = r.scenario_name || r.scenario_id.slice(0, 8)
const version = `v${r.scenario_version ?? 1}`
const target = r.target_name || r.target_id.slice(0, 8)
const time = r.started_at ? formatDateTime(r.started_at) : ''
const trigger = r.triggered_by ?? 'manual'
return {
value: r.id,
searchText: `${scenario} ${version} ${target} ${time} ${r.id} ${triggerLabels[trigger] ?? trigger}`.toLowerCase(),
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis' }}>{scenario}</span>
<Tag color="geekblue" style={{ marginRight: 0, fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>{version}</Tag>
<span style={{ color: colors.textMuted }}>· {target}</span>
<span style={{ color: colors.textMuted, fontSize: 12 }}>{time}</span>
<span style={{
color: passRate != null && passRate >= 0.8 ? '#3f8600' : '#cf1322',
fontSize: 12, fontWeight: 600,
}}>{pct}</span>
<Tag color={triggerColors[trigger] ?? 'default'} style={{ marginRight: 0, fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
{triggerLabels[trigger] ?? trigger}
</Tag>
</span>
),
}
}
const runSelectOptions = filteredRuns.map(buildOption)
// 报告 B 只能选与报告 A 同场景同版本(同考纲)的 run
const selectedRun = runs.find((r) => r.id === selectedRunId)
const compareOptions = runs
.filter((r) =>
r.id !== selectedRunId
&& selectedRun
&& r.scenario_id === selectedRun.scenario_id
&& (r.scenario_version ?? 1) === (selectedRun.scenario_version ?? 1))
.map(buildOption)
const optionFilter = (input: string, opt?: { searchText?: string }) =>
(opt?.searchText ?? '').includes(input.toLowerCase())
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>
{/* 选择栏:两行布局,避免对比模式下控件换行错乱 */}
<div style={{
padding: '8px 16px', flexShrink: 0,
borderBottom: `1px solid ${colors.border}`,
background: colors.bgSubtle,
display: 'flex', flexDirection: 'column', gap: 8,
}}>
{/* 第一行:视图模式 + 场景筛选 + 重置/刷新 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Segmented
value={viewMode}
onChange={(v) => { setViewMode(v as ViewMode); setCompareResult(null) }}
options={[
{ label: '单次报告', value: 'single' },
{ label: '对比报告', value: 'compare', icon: <DiffOutlined /> },
]}
/>
<Select
style={{ width: 320 }}
value={scenarioFilter || undefined}
placeholder={`全部场景(${runs.length}`}
allowClear
onChange={(v) => setScenarioFilter(v ?? '')}
options={scenarioOptions}
popupMatchSelectWidth={false}
/>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<Tooltip title="重置筛选与选择">
<Button size="middle" icon={<ClearOutlined />} onClick={resetFilters}></Button>
</Tooltip>
<Tooltip title="刷新列表">
<Button size="middle" icon={<ReloadOutlined />} onClick={() => reloadRuns()} />
</Tooltip>
</div>
</div>
{/* 第二行:报告选择 + 操作 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Select
style={{ flex: 1, minWidth: 320, maxWidth: 680 }}
placeholder={viewMode === 'compare' ? '选择报告 A' : '选择已完成的评测记录'}
value={selectedRunId || undefined}
onChange={(v) => (v ? handleView(v) : clearSelection())}
allowClear
showSearch
filterOption={optionFilter}
options={runSelectOptions}
popupMatchSelectWidth={false}
/>
{viewMode === 'compare' && (
<>
<span style={{ color: colors.textMuted, fontSize: 12, flexShrink: 0 }}>vs</span>
<Tooltip title={selectedRunId ? '仅可选择与报告 A 相同场景的记录' : '请先选择报告 A'}>
<Select
style={{ flex: 1, minWidth: 320, maxWidth: 680 }}
placeholder="选择报告 B同场景"
value={compareRunId || undefined}
onChange={(v) => { setCompareRunId(v ?? ''); if (!v) setCompareResult(null) }}
allowClear
showSearch
disabled={!selectedRunId}
filterOption={optionFilter}
options={compareOptions}
popupMatchSelectWidth={false}
notFoundContent={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有同场景同版本的其他评测记录" />}
/>
</Tooltip>
<Button
type="primary"
icon={<DiffOutlined />}
disabled={!selectedRunId || !compareRunId}
onClick={handleCompare}
style={{ flexShrink: 0 }}
>
</Button>
</>
)}
{viewMode === 'single' && report && (
<Space style={{ flexShrink: 0 }}>
<Button icon={<DownloadOutlined />} onClick={() => reportsApi.download(selectedRunId, 'html')}>
HTML
</Button>
<Button icon={<FileMarkdownOutlined />} onClick={() => reportsApi.download(selectedRunId, 'markdown')}>
MD
</Button>
</Space>
)}
</div>
</div>
{/* 报告内容区 */}
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '16px' }}>
<Spin spinning={loading}>
{viewMode === 'compare'
? <CompareView result={compareResult} />
: <SingleReportView report={report} />
}
</Spin>
</div>
</div>
)
}
// ── Single Report View ───────────────────────────────────────────────────
function SingleReportView({ report }: { report: Report | null }) {
if (!report) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}>
<Empty description="请选择一个评测记录查看报告" />
</div>
)
}
return (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={6}>
<Card><Statistic title="总用例数" value={report.summary.total_cases} /></Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title="通过用例" value={report.summary.passed_cases}
valueStyle={{ color: '#3f8600' }} prefix={<CheckCircleOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title="失败用例" value={report.summary.failed_cases}
valueStyle={{ color: '#cf1322' }} prefix={<CloseCircleOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title="通过率" value={report.summary.pass_rate * 100} precision={1} suffix="%"
valueStyle={{ color: report.summary.pass_rate >= 0.8 ? '#3f8600' : '#cf1322' }} />
</Card>
</Col>
</Row>
{report.summary.connectivity_cases > 0 && (
<Alert type="info" showIcon style={{ marginBottom: 16 }}
message={`本次运行含 ${report.summary.connectivity_cases} 个连通用例(未配置判定标准,收到回复即通过)` +
(report.summary.judged_pass_rate != null
? `,判定型通过率 ${(report.summary.judged_pass_rate * 100).toFixed(1)}%`
: ',无判定型用例')} />
)}
<Card style={{ marginBottom: 16 }}>
<Descriptions size="small" column={2}>
<Descriptions.Item label="评测对象">{report.target_name}</Descriptions.Item>
<Descriptions.Item label="评测场景">
<Space size={6}>
{report.scenario_name}
{report.scenario_version != null && <Tag color="geekblue">v{report.scenario_version}</Tag>}
</Space>
</Descriptions.Item>
<Descriptions.Item label="触发方式">
<Tag color={triggerColors[report.triggered_by as keyof typeof triggerColors] ?? 'default'}>
{triggerLabels[report.triggered_by as keyof typeof triggerLabels] ?? report.triggered_by ?? '手动'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="开始时间">{formatDateTime(report.started_at)}</Descriptions.Item>
<Descriptions.Item label="完成时间">{report.completed_at ? formatDateTime(report.completed_at) : '-'}</Descriptions.Item>
</Descriptions>
</Card>
<Card title="用例明细">
<Collapse items={report.cases.map((c) => ({
key: c.case_id,
label: (
<Space>
<span style={{ fontWeight: 500 }}>{c.case_id}</span>
{c.connectivity
? <Tag color="blue"></Tag>
: c.results.every((r) => r.passed)
? <Tag color="success"></Tag>
: <Tag color="error"></Tag>}
<Tag>{c.turns.length} </Tag>
</Space>
),
children: <CaseDetail c={c} />,
}))} />
</Card>
</>
)
}
function CaseDetail({ c }: { c: CaseReport }) {
return (
<div>
<div style={{ marginBottom: 16 }}>
{c.turns.map((turn, idx) => (
<div key={idx} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 6 }}>
<div style={{
background: colors.chatUser, padding: '8px 14px',
borderRadius: '12px 12px 12px 2px', maxWidth: '70%',
fontSize: 13, lineHeight: 1.6,
}}>
<UserOutlined style={{ marginRight: 6, color: '#1677ff' }} />
{turn.sent_text}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={{
background: colors.chatAgent, padding: '8px 14px',
borderRadius: '12px 12px 2px 12px', maxWidth: '70%',
fontSize: 13, lineHeight: 1.6,
}}>
<RobotOutlined style={{ marginRight: 6, color: '#52c41a' }} />
{turn.reply_text || '(无回复)'}
{turn.latency_ms != null && (
<span style={{ color: '#999', fontSize: 11, marginLeft: 8 }}>{turn.latency_ms}ms</span>
)}
</div>
</div>
</div>
))}
</div>
<Table size="small" pagination={false} dataSource={c.results} rowKey={(_, idx) => String(idx)}
columns={[
{ title: '规则', dataIndex: 'rule_type', width: 160 },
{ title: '结果', dataIndex: 'passed', width: 80,
render: (p: boolean) => p ? <Tag color="success"></Tag> : <Tag color="error"></Tag> },
{ title: '评分', dataIndex: 'score', width: 80,
render: (s: number | null) => s != null ? s.toFixed(2) : '-' },
{ title: '说明', dataIndex: 'reason' },
]}
/>
</div>
)
}
// ── Compare View ─────────────────────────────────────────────────────────
function CompareView({ result }: { result: CompareResult | null }) {
if (!result) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}>
<Empty description="选择两次评测记录后点击「对比」" />
</div>
)
}
const { run_a, run_b, delta, cases, changed_cases } = result
const deltaColor = (v: number) => v > 0 ? '#3f8600' : v < 0 ? '#cf1322' : colors.textMuted
const deltaSign = (v: number) => v > 0 ? `+${v}` : String(v)
return (
<>
{/* 汇总对比 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={11}>
<Card title={<span style={{ color: '#1677ff' }}> A {run_a.run_id.slice(0, 8)}</span>} size="small">
<Descriptions size="small" column={1}>
<Descriptions.Item label="场景">
{run_a.scenario_name}
{run_a.scenario_version != null && <Tag color="geekblue" style={{ marginLeft: 6 }}>v{run_a.scenario_version}</Tag>}
</Descriptions.Item>
<Descriptions.Item label="触发方式">
<Tag color={triggerColors[run_a.triggered_by as keyof typeof triggerColors] ?? 'default'}>
{triggerLabels[run_a.triggered_by as keyof typeof triggerLabels] ?? run_a.triggered_by ?? '手动'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="时间">{formatDateTime(run_a.started_at)}</Descriptions.Item>
<Descriptions.Item label="通过率">{(run_a.summary.pass_rate * 100).toFixed(1)}%</Descriptions.Item>
<Descriptions.Item label="用例">{run_a.summary.passed_cases}/{run_a.summary.total_cases}</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col span={2} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 11, color: colors.textMuted }}></div>
<div style={{ fontWeight: 700, color: deltaColor(delta.pass_rate), fontSize: 16 }}>
{deltaSign(Math.round(delta.pass_rate * 1000) / 10)}%
</div>
<Badge count={changed_cases} color={changed_cases > 0 ? 'orange' : 'green'}
title={`${changed_cases} 个用例结果变化`} />
</Col>
<Col span={11}>
<Card title={<span style={{ color: '#52c41a' }}> B {run_b.run_id.slice(0, 8)}</span>} size="small">
<Descriptions size="small" column={1}>
<Descriptions.Item label="场景">
{run_b.scenario_name}
{run_b.scenario_version != null && <Tag color="geekblue" style={{ marginLeft: 6 }}>v{run_b.scenario_version}</Tag>}
</Descriptions.Item>
<Descriptions.Item label="触发方式">
<Tag color={triggerColors[run_b.triggered_by as keyof typeof triggerColors] ?? 'default'}>
{triggerLabels[run_b.triggered_by as keyof typeof triggerLabels] ?? run_b.triggered_by ?? '手动'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="时间">{formatDateTime(run_b.started_at)}</Descriptions.Item>
<Descriptions.Item label="通过率">{(run_b.summary.pass_rate * 100).toFixed(1)}%</Descriptions.Item>
<Descriptions.Item label="用例">{run_b.summary.passed_cases}/{run_b.summary.total_cases}</Descriptions.Item>
</Descriptions>
</Card>
</Col>
</Row>
{/* 用例对比表 */}
<Card title={`用例对比(${changed_cases} 个结果变化)`}>
<Table
size="small"
pagination={false}
dataSource={cases}
rowKey="case_id"
rowClassName={(r) => r.changed ? 'run-row' : ''}
columns={[
{ title: '用例', dataIndex: 'case_id', width: 180,
render: (id: string, r) => (
<Space>
{r.changed && <Badge dot color="orange" />}
<span style={{ fontWeight: r.changed ? 600 : 400 }}>{id}</span>
{r.connectivity && <Tag color="blue"></Tag>}
</Space>
),
},
{ title: '报告 A', dataIndex: 'run_a_passed', width: 100,
render: (p: boolean | null) =>
p === null ? <Tag></Tag>
: p ? <Tag color="success"></Tag>
: <Tag color="error"></Tag>,
},
{ title: '报告 B', dataIndex: 'run_b_passed', width: 100,
render: (p: boolean | null) =>
p === null ? <Tag></Tag>
: p ? <Tag color="success"></Tag>
: <Tag color="error"></Tag>,
},
{ title: '变化', width: 80,
render: (_: any, r) => {
if (!r.changed) return <span style={{ color: colors.textMuted }}></span>
if (r.run_b_passed && !r.run_a_passed) return <Tag color="success"> </Tag>
if (!r.run_b_passed && r.run_a_passed) return <Tag color="error">退 </Tag>
return <Tag color="orange"></Tag>
},
},
]}
expandable={{
expandedRowRender: (r) => (
<Row gutter={16}>
<Col span={12}>
<div style={{ fontSize: 12, color: colors.textMuted, marginBottom: 4 }}> A </div>
{r.run_a_results.map((res, i) => (
<div key={i} style={{ fontSize: 12, marginBottom: 2 }}>
{res.passed ? '✅' : '❌'} {res.rule_type}: {res.reason}
</div>
))}
</Col>
<Col span={12}>
<div style={{ fontSize: 12, color: colors.textMuted, marginBottom: 4 }}> B </div>
{r.run_b_results.map((res, i) => (
<div key={i} style={{ fontSize: 12, marginBottom: 2 }}>
{res.passed ? '✅' : '❌'} {res.rule_type}: {res.reason}
</div>
))}
</Col>
</Row>
),
rowExpandable: (r) => r.run_a_results.length > 0 || r.run_b_results.length > 0,
}}
/>
</Card>
</>
)
}