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:
parent
2e7d419f05
commit
4b8afa892b
@ -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 ──────────────────────────────────────────────
|
||||
|
||||
266
frontend/web/src/components/intelligent_eval/ConfigSnapshots.tsx
Normal file
266
frontend/web/src/components/intelligent_eval/ConfigSnapshots.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user