All checks were successful
CI / test (push) Successful in 4m1s
- NaN root cause: report.scores is nested { overall, dimensions: {...} };
the old code treated every value as a number, so Number({object}) → NaN.
Now: dimScores from scores.dimensions, overall prefers scores.overall.
- Header summary cards restyled: icon + colored big number + responsive
(xs=12 md=6), colored score (>0.8 green / 0.6-0.8 amber / else red).
372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import {
|
||
Button, Card, Col, Collapse, Empty, Row, Space, Spin, Tag, message,
|
||
} from 'antd'
|
||
import {
|
||
ArrowLeftOutlined, BulbOutlined, DownloadOutlined, MessageOutlined,
|
||
StarOutlined, WarningOutlined,
|
||
} 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 [exporting, setExporting] = useState(false)
|
||
const report: IntelligentEvalReport | null = ev.report
|
||
const reportLoading = false
|
||
const sessions: IntelligentEvalSession[] = ev.sessions ?? []
|
||
const sessionsLoading = false
|
||
|
||
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)
|
||
// report.scores 是嵌套结构:{ overall: number, dimensions: { 维度: 分 } }
|
||
// 只把 dimensions 的值当作数字维度,overall 单独取,避免 Number(对象) 产生 NaN。
|
||
const rawScores = report?.scores ?? {}
|
||
const dimScores = Object.entries(rawScores.dimensions ?? {}).filter(
|
||
([, v]) => typeof v === 'number',
|
||
)
|
||
const rawOverall = rawScores.overall
|
||
const overall = typeof rawOverall === 'number'
|
||
? rawOverall.toFixed(1)
|
||
: dimScores.length > 0
|
||
? (dimScores.reduce((sum, [, v]) => sum + Number(v), 0) / dimScores.length).toFixed(1)
|
||
: null
|
||
const overallNum = overall != null ? Number(overall) : null
|
||
const overallColor = overallNum == null ? colors.textMuted
|
||
: overallNum >= 0.8 ? '#52c41a'
|
||
: overallNum >= 0.6 ? colors.warning : '#ff4d4f'
|
||
const sevCounts = findings.reduce((acc, f) => {
|
||
acc[f.severity] = (acc[f.severity] ?? 0) + 1
|
||
return acc
|
||
}, {} as Record<string, number>)
|
||
|
||
const scoreData = dimScores.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,
|
||
}
|
||
|
||
const summaryCard: React.CSSProperties = {
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
border: `1px solid ${colors.border}`,
|
||
borderRadius: 10, padding: '14px 16px', background: colors.bgContainer,
|
||
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||
height: '100%',
|
||
}
|
||
const summaryIcon = (color: string): React.CSSProperties => ({
|
||
width: 42, height: 42, borderRadius: 10, flexShrink: 0,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
background: `${color}1a`, color, fontSize: 20,
|
||
})
|
||
|
||
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={[12, 12]} style={{ marginBottom: 16 }}>
|
||
<Col xs={12} md={6}>
|
||
<div style={summaryCard}>
|
||
<div style={summaryIcon(overallColor)}><StarOutlined /></div>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: colors.textMuted }}>综合评分</div>
|
||
<div style={{ fontSize: 26, fontWeight: 700, color: overallColor, lineHeight: 1.2 }}>
|
||
{overall ?? '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Col>
|
||
<Col xs={12} md={6}>
|
||
<div style={summaryCard}>
|
||
<div style={summaryIcon('#ff4d4f')}><WarningOutlined /></div>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: colors.textMuted }}>问题发现</div>
|
||
<div style={{ fontSize: 26, fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
|
||
{findings.length}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: colors.textMuted }}>
|
||
高 {sevCounts.high ?? 0} · 中 {sevCounts.medium ?? 0} · 低 {sevCounts.low ?? 0}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Col>
|
||
<Col xs={12} md={6}>
|
||
<div style={summaryCard}>
|
||
<div style={summaryIcon(colors.warning)}><BulbOutlined /></div>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: colors.textMuted }}>亮点</div>
|
||
<div style={{ fontSize: 26, fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
|
||
{report.highlights.length}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Col>
|
||
<Col xs={12} md={6}>
|
||
<div style={summaryCard}>
|
||
<div style={summaryIcon(colors.primary)}><MessageOutlined /></div>
|
||
<div>
|
||
<div style={{ fontSize: 12, color: colors.textMuted }}>会话数</div>
|
||
<div style={{ fontSize: 26, fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
|
||
{ev.session_count}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
)
|
||
}
|