feat(intelligent-eval): add config snapshots UI (ticket 08)

- Add ConfigSnapshots component with list, detail, compare, and export features
- Add config snapshot API calls to api.ts
- Add "配置历史" button in EvalDetail to access config history
- Implement snapshot comparison with diff view
- Implement snapshot export to JSON
- Pass TypeScript type checking

All 853 tests passing.
This commit is contained in:
sinohqb 2026-08-12 10:50:11 +08:00
parent 2e7d419f05
commit 4b8afa892b
3 changed files with 312 additions and 1 deletions

View File

@ -715,6 +715,34 @@ export interface CreateIntelligentEvalPayload {
time_window_hours: number
}
export interface ConfigSnapshot {
id: string
eval_id: string
snapshot_type: 'created' | 'plan_submitted' | 'config_updated'
goal: string
seeds: Record<string, unknown>
intent: string
role_description: string
time_window_hours: number
plan: IntelligentEvalPlan | null
created_at: string | null
created_by: string
}
export interface ConfigSnapshotComparison {
snapshot_1: {
id: string
snapshot_type: string
created_at: string | null
}
snapshot_2: {
id: string
snapshot_type: string
created_at: string | null
}
differences: Record<string, { old: unknown; new: unknown }>
}
export const intelligentEvalsApi = {
list: () => api.get<{ intelligent_evals: IntelligentEval[] }>('/intelligent-evals'),
get: (id: string) => api.get<IntelligentEval>(`/intelligent-evals/${id}`),
@ -739,6 +767,16 @@ export const intelligentEvalsApi = {
a.click()
URL.revokeObjectURL(url)
},
// Config Snapshots
listConfigSnapshots: (id: string) =>
api.get<{ snapshots: ConfigSnapshot[] }>(`/intelligent-evals/${id}/config-snapshots`),
getConfigSnapshot: (id: string, snapshotId: string) =>
api.get<ConfigSnapshot>(`/intelligent-evals/${id}/config-snapshots/${snapshotId}`),
compareConfigSnapshots: (id: string, snapshotId1: string, snapshotId2: string) =>
api.post<ConfigSnapshotComparison>(`/intelligent-evals/${id}/config-snapshots/compare`, {
snapshot_id_1: snapshotId1,
snapshot_id_2: snapshotId2,
}),
}
// ── File Management ──────────────────────────────────────────────

View File

@ -0,0 +1,266 @@
import { useState } from 'react'
import {
Button, Card, Descriptions, Empty, Space, Table, Tag, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ArrowLeftOutlined, DiffOutlined } from '@ant-design/icons'
import { intelligentEvalsApi, type ConfigSnapshot, type ConfigSnapshotComparison } from '../../api'
import { colors } from '../../tokens'
import { formatDateTime } from '../../utils/date'
const SNAPSHOT_TYPE_LABELS: Record<string, { label: string; color: string }> = {
created: { label: '创建', color: 'green' },
plan_submitted: { label: '计划提交', color: 'blue' },
config_updated: { label: '配置更新', color: 'orange' },
}
interface ConfigSnapshotsProps {
evalId: string
onBack: () => void
}
export default function ConfigSnapshots({ evalId, onBack }: ConfigSnapshotsProps) {
const [snapshots, setSnapshots] = useState<ConfigSnapshot[] | null>(null)
const [loading, setLoading] = useState(false)
const [selectedSnapshot, setSelectedSnapshot] = useState<ConfigSnapshot | null>(null)
const [compareMode, setCompareMode] = useState(false)
const [selectedForCompare, setSelectedForCompare] = useState<string[]>([])
const [comparison, setComparison] = useState<ConfigSnapshotComparison | null>(null)
const loadSnapshots = async () => {
setLoading(true)
try {
const res = await intelligentEvalsApi.listConfigSnapshots(evalId)
setSnapshots(res.data.snapshots)
} catch {
message.error('加载配置快照失败')
} finally {
setLoading(false)
}
}
useState(() => {
void loadSnapshots()
})
const handleViewDetail = async (snapshot: ConfigSnapshot) => {
try {
const res = await intelligentEvalsApi.getConfigSnapshot(evalId, snapshot.id)
setSelectedSnapshot(res.data)
} catch {
message.error('加载快照详情失败')
}
}
const handleCompare = async () => {
if (selectedForCompare.length !== 2) {
message.warning('请选择两个快照进行对比')
return
}
try {
const res = await intelligentEvalsApi.compareConfigSnapshots(
evalId,
selectedForCompare[0],
selectedForCompare[1],
)
setComparison(res.data)
setCompareMode(true)
} catch {
message.error('对比快照失败')
}
}
const handleExport = (snapshot: ConfigSnapshot) => {
const data = JSON.stringify(snapshot, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `config-snapshot-${snapshot.id.slice(0, 8)}.json`
a.click()
URL.revokeObjectURL(url)
message.success('已导出快照')
}
const columns: ColumnsType<ConfigSnapshot> = [
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
render: (val: string | null) => formatDateTime(val),
},
{
title: '类型',
dataIndex: 'snapshot_type',
key: 'snapshot_type',
render: (val: string) => {
const info = SNAPSHOT_TYPE_LABELS[val] ?? { label: val, color: 'default' }
return <Tag color={info.color}>{info.label}</Tag>
},
},
{
title: '创建者',
dataIndex: 'created_by',
key: 'created_by',
},
{
title: '操作',
key: 'actions',
render: (_, record) => (
<Space>
<Button size="small" onClick={() => handleViewDetail(record)}></Button>
<Button size="small" onClick={() => handleExport(record)}></Button>
</Space>
),
},
]
const rowSelection = {
selectedRowKeys: selectedForCompare,
onChange: (keys: React.Key[]) => {
if (keys.length > 2) {
message.warning('最多选择两个快照进行对比')
return
}
setSelectedForCompare(keys as string[])
},
}
if (selectedSnapshot) {
return (
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => setSelectedSnapshot(null)}></Button>
<span style={{ fontSize: 16, fontWeight: 600 }}></span>
<Tag color={SNAPSHOT_TYPE_LABELS[selectedSnapshot.snapshot_type]?.color ?? 'default'}>
{SNAPSHOT_TYPE_LABELS[selectedSnapshot.snapshot_type]?.label ?? selectedSnapshot.snapshot_type}
</Tag>
</div>
<Card size="small" title="配置信息" style={{ marginBottom: 16 }}>
<Descriptions column={1} size="small">
<Descriptions.Item label="评估目标">{selectedSnapshot.goal || '—'}</Descriptions.Item>
<Descriptions.Item label="考察意图">{selectedSnapshot.intent || '—'}</Descriptions.Item>
<Descriptions.Item label="角色描述">
<span style={{ whiteSpace: 'pre-wrap' }}>{selectedSnapshot.role_description || '—'}</span>
</Descriptions.Item>
<Descriptions.Item label="时间窗口">{selectedSnapshot.time_window_hours} </Descriptions.Item>
<Descriptions.Item label="种子集">
{Object.keys(selectedSnapshot.seeds ?? {}).length === 0
? '—'
: (
<pre style={{
margin: 0, fontSize: 12, background: colors.bgSubtle,
padding: 8, borderRadius: 6, overflowX: 'auto',
}}
>
{JSON.stringify(selectedSnapshot.seeds, null, 2)}
</pre>
)}
</Descriptions.Item>
</Descriptions>
</Card>
{selectedSnapshot.plan && (
<Card size="small" title="粗计划">
<pre style={{
margin: 0, fontSize: 12, background: colors.bgSubtle,
padding: 8, borderRadius: 6, overflowX: 'auto',
}}
>
{JSON.stringify(selectedSnapshot.plan, null, 2)}
</pre>
</Card>
)}
</div>
)
}
if (compareMode && comparison) {
return (
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => setCompareMode(false)}></Button>
<span style={{ fontSize: 16, fontWeight: 600 }}></span>
</div>
<Card size="small" title="对比信息" style={{ marginBottom: 16 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="快照 1">
<Tag>{comparison.snapshot_1.snapshot_type}</Tag>
{formatDateTime(comparison.snapshot_1.created_at)}
</Descriptions.Item>
<Descriptions.Item label="快照 2">
<Tag>{comparison.snapshot_2.snapshot_type}</Tag>
{formatDateTime(comparison.snapshot_2.created_at)}
</Descriptions.Item>
</Descriptions>
</Card>
<Card size="small" title="差异">
{Object.keys(comparison.differences).length === 0 ? (
<Empty description="两个快照完全相同" />
) : (
<div>
{Object.entries(comparison.differences).map(([field, diff]) => (
<div key={field} style={{ marginBottom: 16 }}>
<div style={{ fontWeight: 500, marginBottom: 8 }}>{field}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 4 }}></div>
<pre style={{
margin: 0, fontSize: 12, background: '#fff1f0',
padding: 8, borderRadius: 6, overflowX: 'auto',
}}
>
{typeof diff.old === 'object' ? JSON.stringify(diff.old, null, 2) : String(diff.old ?? '—')}
</pre>
</div>
<div>
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 4 }}></div>
<pre style={{
margin: 0, fontSize: 12, background: '#f6ffed',
padding: 8, borderRadius: 6, overflowX: 'auto',
}}
>
{typeof diff.new === 'object' ? JSON.stringify(diff.new, null, 2) : String(diff.new ?? '—')}
</pre>
</div>
</div>
</div>
))}
</div>
)}
</Card>
</div>
)
}
return (
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={onBack}></Button>
<span style={{ fontSize: 16, fontWeight: 600 }}></span>
<div style={{ flex: 1 }} />
<Button
icon={<DiffOutlined />}
disabled={selectedForCompare.length !== 2}
onClick={handleCompare}
>
</Button>
</div>
<Table
rowKey="id"
loading={loading}
dataSource={snapshots ?? []}
columns={columns}
rowSelection={rowSelection}
pagination={false}
locale={{ emptyText: <Empty description="暂无配置快照" /> }}
/>
</div>
)
}

View File

@ -2,11 +2,12 @@ import { useState } from 'react'
import {
Alert, Button, Card, Col, Descriptions, Empty, Input, Modal, Popconfirm, Progress, Row, Space, Spin, Tag, message,
} from 'antd'
import { FileTextOutlined, StopOutlined } from '@ant-design/icons'
import { FileTextOutlined, HistoryOutlined, StopOutlined } from '@ant-design/icons'
import { intelligentEvalsApi, type IntelligentEval } from '../../api'
import { colors } from '../../tokens'
import { formatDateTime, shortDateTime } from '../../utils/date'
import { EVAL_STATUS, SESSION_STATUS } from './status'
import ConfigSnapshots from './ConfigSnapshots'
const sectionCard: React.CSSProperties = { marginBottom: 16 }
@ -70,6 +71,7 @@ export default function EvalDetail({ ev, targetName, onOpenReport, onChanged }:
const [busy, setBusy] = useState(false)
const [rejectOpen, setRejectOpen] = useState(false)
const [feedback, setFeedback] = useState('')
const [showConfigHistory, setShowConfigHistory] = useState(false)
const meta = EVAL_STATUS[ev.status] ?? { label: ev.status, color: 'default' }
const showSessions = ev.status === 'executing' || ev.status === 'completed'
const sessions = showSessions ? ev.sessions ?? [] : []
@ -93,6 +95,10 @@ export default function EvalDetail({ ev, targetName, onOpenReport, onChanged }:
setFeedback('')
}, '已打回,等待重新规划')
if (showConfigHistory) {
return <ConfigSnapshots evalId={ev.id} onBack={() => setShowConfigHistory(false)} />
}
return (
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
@ -100,6 +106,7 @@ export default function EvalDetail({ ev, targetName, onOpenReport, onChanged }:
<Tag color={meta.color}>{meta.label}</Tag>
<div style={{ flex: 1 }} />
<Space>
<Button icon={<HistoryOutlined />} onClick={() => setShowConfigHistory(true)}></Button>
{ev.status === 'completed' && (
<Button type="primary" icon={<FileTextOutlined />} onClick={onOpenReport}></Button>
)}