AgentEvalTool/frontend/web/src/components/intelligent_eval/EvalReport.tsx
sinohqb 804880f1e3 fix(intelligent-eval): add top padding in drawer bodies
抽屉 body padding 为 0,顶栏按钮与提示贴住头部分割线;
详情/报告根容器补 16px 顶部内边距。
2026-08-06 01:52:46 +08:00

332 lines
12 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, useState } from 'react'
import {
Button, Card, Col, Collapse, Empty, Row, Space, Spin, Statistic, Tag, message,
} from 'antd'
import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons'
import { Bar } from '@ant-design/charts'
import ChatBubble from '../ChatBubble'
import {
intelligentEvalsApi,
type IntelligentEval,
type IntelligentEvalMessage,
type IntelligentEvalReport,
type IntelligentEvalSession,
type ReportEvidence,
} from '../../api'
import { colors } from '../../tokens'
import { shortDateTime } from '../../utils/date'
import { SESSION_STATUS, severityOf } from './status'
const sectionCard: React.CSSProperties = { marginBottom: 16 }
function personaLabel(persona: Record<string, unknown>): string {
if (typeof persona.name === 'string' && persona.name) return persona.name
if (typeof persona.background === 'string' && persona.background) return persona.background
return persona.id != null ? String(persona.id) : '虚拟用户'
}
function EvidenceBlock({ evidence }: { evidence: ReportEvidence }) {
return (
<div style={{
background: colors.bgSubtle, borderLeft: `3px solid ${colors.border}`,
padding: '6px 10px', borderRadius: 4, fontSize: 12, lineHeight: 1.7,
}}
>
{evidence.session_id && (
<div style={{ color: colors.textMuted }}>
{evidence.session_id.slice(0, 8)}
{evidence.turn_index != null ? ` · 第 ${evidence.turn_index + 1}` : ''}
</div>
)}
{evidence.user_said && <div>{evidence.user_said}</div>}
{evidence.assistant_replied && <div>{evidence.assistant_replied}</div>}
</div>
)
}
function VerdictView({ verdict }: { verdict: Record<string, unknown> }) {
const entries = Object.entries(verdict)
if (entries.length === 0) return null
return (
<div style={{
marginTop: 8, border: `1px solid ${colors.border}`, borderRadius: 8,
background: colors.bgSubtle, padding: '8px 12px', fontSize: 12, lineHeight: 1.8,
}}
>
<div style={{ fontWeight: 500, marginBottom: 4 }}></div>
{entries.map(([k, v]) => (
<div key={k} style={{ color: colors.textSecondary }}>
<b style={{ color: colors.text }}>{k}</b>
{typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v)}
</div>
))}
</div>
)
}
function SessionMessages({ evalId, sessionId, verdict }: {
evalId: string
sessionId: string
verdict: Record<string, unknown> | null
}) {
const [messages, setMessages] = useState<IntelligentEvalMessage[] | null>(null)
useEffect(() => {
let cancelled = false
intelligentEvalsApi.listMessages(evalId, sessionId)
.then((res) => { if (!cancelled) setMessages(res.data.messages) })
.catch(() => undefined) // 拦截器已弹错;收起再展开可重试
return () => { cancelled = true }
}, [evalId, sessionId])
return (
<div>
{!messages && <Spin size="small" />}
{messages && messages.length === 0 && (
<Empty description="暂无对话记录" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
{messages?.map((m) => (
<ChatBubble
key={m.id}
role={m.role === 'user' ? 'user' : 'agent'}
mirrored
content={m.content}
meta={m.role !== 'user' && m.latency_ms != null ? (
<span style={{ color: colors.textMuted, fontSize: 11 }}>{m.latency_ms}ms</span>
) : undefined}
/>
))}
{verdict && <VerdictView verdict={verdict} />}
</div>
)
}
interface EvalReportProps {
ev: IntelligentEval
onBack: () => void
}
export default function EvalReport({ ev, onBack }: EvalReportProps) {
const [report, setReport] = useState<IntelligentEvalReport | null>(ev.report)
const [reportLoading, setReportLoading] = useState(!ev.report)
const [sessions, setSessions] = useState<IntelligentEvalSession[] | null>(null)
const [sessionsLoading, setSessionsLoading] = useState(true)
const [exporting, setExporting] = useState(false)
useEffect(() => {
let cancelled = false
setReport(ev.report)
setReportLoading(!ev.report)
if (!ev.report) {
intelligentEvalsApi.getReport(ev.id)
.then((res) => { if (!cancelled) setReport(res.data) })
.catch(() => undefined)
.finally(() => { if (!cancelled) setReportLoading(false) })
}
setSessionsLoading(true)
intelligentEvalsApi.listSessions(ev.id)
.then((res) => { if (!cancelled) setSessions(res.data.sessions) })
.catch(() => undefined)
.finally(() => { if (!cancelled) setSessionsLoading(false) })
return () => { cancelled = true }
}, [ev.id, ev.report])
const exportMarkdown = async () => {
setExporting(true)
try {
await intelligentEvalsApi.downloadReportMarkdown(ev.id)
message.success('已导出 Markdown 报告')
} finally {
setExporting(false)
}
}
const findings = [...(report?.findings ?? [])]
.sort((a, b) => severityOf(a.severity).order - severityOf(b.severity).order)
const scores = Object.entries(report?.scores ?? {})
const overall = scores.length > 0
? (scores.reduce((sum, [, v]) => sum + Number(v), 0) / scores.length).toFixed(1)
: null
const sevCounts = findings.reduce((acc, f) => {
acc[f.severity] = (acc[f.severity] ?? 0) + 1
return acc
}, {} as Record<string, number>)
const scoreData = scores.map(([dimension, score]) => ({ dimension, score: Number(score) }))
const scoreConfig = {
data: scoreData,
xField: 'dimension',
yField: 'score',
coordinate: { transform: [{ type: 'transpose' }] },
height: Math.max(160, scoreData.length * 44),
color: colors.primary,
}
return (
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Button icon={<ArrowLeftOutlined />} onClick={onBack}></Button>
<span style={{ fontSize: 16, fontWeight: 600, color: colors.text }}>
{ev.name} ·
</span>
<div style={{ flex: 1 }} />
{report && (
<Button icon={<DownloadOutlined />} loading={exporting} onClick={exportMarkdown}>
Markdown
</Button>
)}
</div>
{reportLoading && <Spin />}
{!reportLoading && !report && (
<Empty description="暂无报告" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
{report && (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card size="small"><Statistic title="综合评分" value={overall ?? '—'} /></Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="问题发现" value={findings.length} />
<div style={{ fontSize: 12, color: colors.textMuted }}>
{sevCounts.high ?? 0} · {sevCounts.medium ?? 0} · {sevCounts.low ?? 0}
</div>
</Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="亮点" value={report.highlights.length} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="会话数" value={ev.session_count} /></Card>
</Col>
</Row>
{scoreData.length > 0 && (
<Card size="small" title="维度得分" style={sectionCard}>
<Bar {...scoreConfig} />
</Card>
)}
<Card size="small" title="总结" style={sectionCard}>
<div style={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.8 }}>
{report.summary}
</div>
</Card>
<Card size="small" title={`问题发现(${findings.length}`} style={sectionCard}>
{findings.length === 0 && (
<div style={{ fontSize: 13, color: colors.textSecondary }}></div>
)}
<Collapse
size="small"
items={findings.map((f, i) => {
const sev = severityOf(f.severity)
return {
key: i,
label: (
<Space size={8} wrap>
<Tag color={sev.color} style={{ margin: 0 }}>{sev.label}</Tag>
<Tag style={{ margin: 0 }}>{f.dimension}</Tag>
<span style={{ fontWeight: 500 }}>{f.issue}</span>
</Space>
),
children: (
<div>
{f.evidence.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 6 }}>
{f.evidence.length}
</div>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
{f.evidence.map((e, j) => <EvidenceBlock key={j} evidence={e} />)}
</Space>
</div>
)}
{f.suggestion && (
<div style={{ fontSize: 13, marginBottom: 4 }}>
<b></b>{f.suggestion}
</div>
)}
{f.related_sop && (
<div style={{ fontSize: 12, color: colors.textMuted }}>
SOP{f.related_sop}
</div>
)}
</div>
),
}
})}
/>
</Card>
{report.highlights.length > 0 && (
<Card size="small" title="亮点" style={sectionCard}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{report.highlights.map((h, i) => (
<div key={i} style={{ fontSize: 13 }}>
{h.dimension && <Tag color="green" style={{ margin: 0 }}>{h.dimension}</Tag>}
{h.dimension ? ' ' : ''}{h.description}
</div>
))}
</Space>
</Card>
)}
{report.priority_recommendations.length > 0 && (
<Card size="small" title="优先改进建议" style={sectionCard}>
{report.priority_recommendations.map((r, i) => (
<div key={i} style={{ fontSize: 13, marginBottom: 4 }}>
<Tag color="blue" style={{ margin: 0 }}>{i + 1}</Tag> {r}
</div>
))}
</Card>
)}
</>
)}
<Card size="small" title="会话记录" style={sectionCard}>
{sessionsLoading && <Spin size="small" />}
{!sessionsLoading && sessions && sessions.length === 0 && (
<Empty description="暂无会话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
{!sessionsLoading && sessions && sessions.length > 0 && (
<Collapse
size="small"
items={sessions.map((s) => {
const meta = SESSION_STATUS[s.status] ?? SESSION_STATUS.running
return {
key: s.id,
label: (
<Space size={8} wrap>
<span style={{ fontWeight: 500 }}>{personaLabel(s.persona)}</span>
<Tag color={meta.color}>{meta.label}</Tag>
<Tag>{s.turn_count} </Tag>
{s.dimension && <Tag>{s.dimension}</Tag>}
{s.created_at && (
<span style={{ fontSize: 12, color: colors.textMuted }}>
{shortDateTime(s.created_at)}
</span>
)}
</Space>
),
children: (
<div>
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 8 }}>
{s.goal}
</div>
<SessionMessages evalId={ev.id} sessionId={s.id} verdict={s.verdict} />
</div>
),
}
})}
/>
)}
</Card>
</div>
)
}