AgentEvalTool/frontend/web/src/pages/Runs.tsx
sinohqb cbab55843b
Some checks failed
CI / test (push) Failing after 2m50s
fix(reports): export via authed axios blob download instead of window.open
启用登录鉴权后,导出 HTML/MD/JSON 用 window.open 直连 API 无法携带
X-Auth-Token,服务端返回 401 导致导出失效。改为经 axios 拉取 blob
(拦截器自动附加凭据)后触发浏览器下载。
2026-07-29 15:35:34 +08:00

459 lines
17 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 { useOnTabActive } from '../hooks/useOnTabActive'
import { colors, statusColors, statusLabels } from '../tokens'
import { formatDateTime, elapsedStr } from '../utils/date'
import { passRateColor } from '../utils/colors'
export default function RunsPage() {
const navigate = useNavigate()
const [runs, setRuns] = useState<Run[]>([])
const [targets, setTargets] = useState<Target[]>([])
const [scenarios, setScenarios] = useState<Scenario[]>([])
const [loading, setLoading] = useState(true)
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()
const loadLists = async () => {
setLoading(true)
try {
const [r, t, s] = await Promise.all([
runsApi.list(),
targetsApi.list(),
scenariosApi.list(),
])
// API 已按 started_at DESC 排序,直接使用,最新的在前
setRuns(r.data)
setTargets(t.data)
setScenarios(s.data)
} finally {
setLoading(false)
}
}
useEffect(() => { loadLists() }, [])
// Keep-alive tabs never remount: refresh the list whenever the tab is
// re-activated so runs triggered elsewhere (e.g. AI assistant) show up.
useOnTabActive('/runs', loadLists)
useEffect(() => {
if (!session.isLive) return
const id = window.setInterval(() => {
runsApi.list().then((r) => setRuns(r.data)).catch(() => {})
}, 3000)
return () => window.clearInterval(id)
}, [session.isLive])
useEffect(() => {
if (session.completed) {
runsApi.list().then((r) => setRuns(r.data)).catch(() => {})
}
}, [session.completed])
// 提取为共享函数handleStart 和 onRerun 共用
const startRun = async (tId: string, sId: string) => {
setStarting(true)
try {
const res = await runsApi.start(tId, sId)
await loadLists()
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 || {}) as Record<string, unknown>
const passRate = summary.pass_rate as number | undefined
const totalCases = summary.total_cases as number | undefined
const passedCases = summary.passed_cases as number | undefined
const totalRules = summary.total_rules as number | undefined
const passedRules = summary.passed_rules as number | undefined
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>
)
}