AgentEvalTool/frontend/web/src/pages/IntelligentEvals.tsx
sinohqb 2d2c5a2904
All checks were successful
CI / test (push) Successful in 3m24s
feat(intelligent-eval): logical delete for terminal evals
Add `deleted` terminal status: completed/cancelled/failed → deleted via
DELETE /api/intelligent-evals/{id} (idempotent, 409 for non-terminal).
Deleted evals are hidden from list, detail, stats, and all sub-resource
endpoints (sessions/report/decision-logs/config-snapshots); child tables
are untouched (audit-safe). Frontend shows a Popconfirm-guarded delete
button for terminal evals only.
2026-08-21 14:34:04 +08:00

410 lines
16 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 { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Button, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Progress, Select, Space, Spin, Table, Tabs, Tag, Tooltip, message,
} from 'antd'
import type { TabsProps } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
DeleteOutlined, PlusOutlined, ReloadOutlined, StopOutlined, UnorderedListOutlined,
} from '@ant-design/icons'
import FormDrawer from '../components/FormDrawer'
import PageWrapper from '../components/PageWrapper'
import EvalOverview from '../components/intelligent_eval/EvalOverview'
import ExecutionProcess from '../components/intelligent_eval/ExecutionProcess'
import DecisionProcess from '../components/intelligent_eval/DecisionProcess'
import ConfigSnapshots from '../components/intelligent_eval/ConfigSnapshots'
import EvalReport from '../components/intelligent_eval/EvalReport'
import { EVAL_STATUS, TERMINAL_STATUSES } from '../components/intelligent_eval/status'
import { useResource } from '../hooks/useResource'
import {
intelligentEvalsApi, targetsApi,
type CreateIntelligentEvalPayload, type IntelligentEval, type Target,
} from '../api'
import { useTabStore, type TabItem } from '../stores/tabStore'
import { colors, statusColors } from '../tokens'
import { formatDateTime } from '../utils/date'
import { useIntelligentEvalRead } from '../read/useIntelligentEvalRead'
interface CreateFormValues {
name: string
target_id: string
goal: string
intent?: string
role_description?: string
seeds_json?: string
time_window_hours: number
}
type DetailTab = 'overview' | 'execution' | 'decision' | 'history' | 'report'
const TASKS_TAB: TabItem = {
key: '/intelligent-evals/tasks', title: '任务队列', icon: <UnorderedListOutlined />, closable: true,
}
// 状态统计条:全部 + 各状态(含 cancelled保证 全部 = 各状态之和draft 为 0 不单列)
// 标签从 EVAL_STATUS 单一出口派生,不与 status.ts 平行维护
const STAT_CHIPS: { key: string; label: string; color?: string }[] = [
{ key: 'all', label: '全部评估' },
...(['planning', 'pending_approval', 'executing', 'completed', 'cancelled', 'failed'] as const).map((s) => ({
key: s,
label: EVAL_STATUS[s].label,
color: s === 'completed' ? statusColors.completed : s === 'failed' ? statusColors.failed : undefined,
})),
]
export default function IntelligentEvalsPage() {
const navigate = useNavigate()
const openTab = useTabStore((s) => s.openTab)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
const [createOpen, setCreateOpen] = useState(false)
const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [submitting, setSubmitting] = useState(false)
const [form] = Form.useForm<CreateFormValues>()
const { list, detail, reloadList, reloadDetail } = useIntelligentEvalRead(
selectedId,
undefined,
page,
pageSize,
statusFilter === 'all' ? undefined : statusFilter,
)
const evals = list.value
const loading = list.phase === 'loading'
const stats = list.stats
const { data: targets } = useResource(
() => targetsApi.list().then((r) => r.data),
{ tabPath: '/intelligent-evals' },
)
const selected = selectedId != null && detail.value?.id === selectedId ? detail.value : null
const targetName = (id: string) =>
targets?.find((t) => t.id === id)?.name ?? id.slice(0, 8)
const openDetail = (id: string, tab: DetailTab = 'overview') => {
setSelectedId(id)
setDetailTab(tab)
}
const closeDrawer = () => {
setSelectedId(null)
void reloadList()
}
const openTasks = () => {
openTab(TASKS_TAB)
navigate('/intelligent-evals/tasks')
}
const setFilter = (key: string) => {
setStatusFilter(key)
setPage(1)
}
const submitCreate = async () => {
const values = await form.validateFields()
let seeds: Record<string, unknown> = {}
const raw = (values.seeds_json ?? '').trim()
if (raw) {
try {
const parsed = JSON.parse(raw) as unknown
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
message.error('种子集必须是 JSON 对象')
return
}
seeds = parsed as Record<string, unknown>
} catch {
message.error('种子集不是合法 JSON')
return
}
}
const payload: CreateIntelligentEvalPayload = {
name: values.name,
target_id: values.target_id,
goal: values.goal,
seeds,
intent: values.intent ?? '',
role_description: values.role_description ?? '',
time_window_hours: values.time_window_hours,
}
setSubmitting(true)
try {
await intelligentEvalsApi.create(payload)
message.success('已创建OpenClaw 开始规划')
setCreateOpen(false)
form.resetFields()
void reloadList()
} finally {
setSubmitting(false)
}
}
const handleDelete = async (id: string) => {
await intelligentEvalsApi.remove(id)
message.success('已删除')
void reloadList()
}
const columns: ColumnsType<IntelligentEval> = [
{
title: '名称', dataIndex: 'name', key: 'name', width: 300, ellipsis: true,
render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>,
},
{
title: '评测对象', dataIndex: 'target_id', key: 'target', width: 180, ellipsis: true,
render: (id: string) => <span style={{ fontSize: 13, color: colors.textSecondary }}>{targetName(id)}</span>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 96,
render: (status: IntelligentEval['status']) => {
const meta = EVAL_STATUS[status] ?? { label: status, color: 'default' }
return <Tag color={meta.color}>{meta.label}</Tag>
},
},
{
title: '会话进度', key: 'progress', width: 200,
render: (_, ev) => {
const estimated = ev.plan?.estimated_sessions ?? ev.session_count
const running = (ev.sessions ?? []).filter((s) => s.status === 'running').length
const pct = estimated ? Math.round((ev.completed_sessions / estimated) * 100) : 0
const tooltipText = `已完成 ${ev.completed_sessions} 个会话 / 预估 ${estimated} 个会话${running > 0 ? `${running} 个进行中)` : ''}`
return (
<Tooltip title={tooltipText}>
<Space size={6}>
<Progress percent={pct} size="small" style={{ width: 90 }} />
<span style={{ fontSize: 12, color: colors.textSecondary }}>
{ev.completed_sessions}/{estimated}
{running > 0 && <span style={{ color: statusColors.running, marginLeft: 4 }}>({running} )</span>}
</span>
</Space>
</Tooltip>
)
},
},
{
title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160,
render: (v: string | null) => (v ? formatDateTime(v) : '—'),
},
{
title: '操作', key: 'action', width: 130, fixed: 'right' as const,
render: (_, ev) => (
<Space size={4}>
<Button size="small" onClick={() => openDetail(ev.id)}></Button>
{TERMINAL_STATUSES.has(ev.status) && (
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除该评估吗?"
onConfirm={() => handleDelete(ev.id)}
okText="删除"
cancelText="取消"
>
<Button size="small" danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
)}
</Space>
),
},
]
const drawerItems: TabsProps['items'] = selected
? [
{
key: 'overview',
label: '概览',
children: (
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 24px' }}>
<EvalOverview ev={selected} targetName={targetName(selected.target_id)} />
</div>
),
},
{
key: 'execution',
label: '执行过程',
children: <ExecutionProcess ev={selected} />,
},
{ key: 'decision', label: '决策过程', children: <DecisionProcess evalId={selected.id} /> },
{ key: 'history', label: '配置历史', children: <ConfigSnapshots evalId={selected.id} /> },
...(selected.status === 'completed' ? [{ key: 'report' as const, label: '评估报告', children: <EvalReport ev={selected} /> }] : []),
]
: []
return (
<PageWrapper
title="评估列表"
description="描述评估目标,由 OpenClaw 自主规划、执行并产出结构化报告"
inline
fullHeight
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void reloadList()} />
<Button icon={<UnorderedListOutlined />} onClick={openTasks}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
</Button>
</Space>
}
>
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
<div className="stat-bar">
{STAT_CHIPS.map((c) => {
const n = c.key === 'all'
? (stats ? Object.values(stats).reduce((a, b) => a + b, 0) : list.total)
: (stats?.[c.key] ?? 0)
return (
<div key={c.key} className={`stat-chip ${statusFilter === c.key ? 'active' : ''}`} onClick={() => setFilter(c.key)}>
<div className="n" style={{ color: c.color ?? colors.text }}>{n}</div>
<div className="l">{c.label}</div>
</div>
)
})}
</div>
<div style={{ background: colors.bgContainer, borderRadius: 8, padding: 16 }}>
<Table
rowKey="id"
loading={loading}
dataSource={evals ?? []}
columns={columns}
onRow={(record) => ({ onClick: () => openDetail(record.id), style: { cursor: 'pointer' } })}
pagination={{
current: page,
pageSize,
total: list.total,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50],
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}}
locale={{ emptyText: <Empty description="该状态下暂无评估" /> }}
/>
</div>
</div>
<Drawer
title={selected?.name ?? '评估详情'}
open={selectedId != null}
onClose={closeDrawer}
width={1200}
destroyOnClose
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
extra={selected && (
<Space>
<Tag color={EVAL_STATUS[selected.status]?.color ?? 'default'}>
{EVAL_STATUS[selected.status]?.label ?? selected.status}
</Tag>
{selected.status === 'pending_approval' && (
<Button type="primary" onClick={() => void intelligentEvalsApi.approve(selected.id).then(() => { message.success('已批准,进入执行'); void reloadDetail() })}>
</Button>
)}
{(selected.status === 'pending_approval' || selected.status === 'executing') && (
<Popconfirm
title="取消该智能评估?"
onConfirm={() => void intelligentEvalsApi.cancel(selected.id).then(() => { message.success('已取消'); void reloadDetail() })}
>
<Button danger icon={<StopOutlined />}></Button>
</Popconfirm>
)}
</Space>
)}
>
{selected == null ? (
<div style={{ padding: 48, textAlign: 'center' }}><Spin /></div>
) : (
<div style={{ flex: 1, minHeight: 0, padding: '0 16px', background: colors.bgLayout }}>
<Tabs
className="intelligent-detail-tabs"
activeKey={detailTab}
onChange={(k) => setDetailTab(k as DetailTab)}
items={drawerItems}
/>
</div>
)}
</Drawer>
<FormDrawer
title="新建智能评估"
open={createOpen}
onClose={() => setCreateOpen(false)}
onSubmit={submitCreate}
submitting={submitting}
okText="创建并开始规划"
>
<Form
form={form}
layout="vertical"
initialValues={{ time_window_hours: 24, seeds_json: '{}' }}
>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="如:数字员工 24h 服务质量评估" />
</Form.Item>
<Form.Item name="target_id" label="评测对象" rules={[{ required: true, message: '请选择对象' }]}>
<Select
placeholder="选择评测对象"
showSearch
optionFilterProp="label"
options={(targets ?? []).map((t: Target) => ({ label: t.name, value: t.id }))}
/>
</Form.Item>
<Form.Item
name="goal"
label="评估目标"
tooltip="你希望这次评估回答什么问题OpenClaw 据此规划"
rules={[{ required: true, message: '请输入评估目标' }]}
>
<Input.TextArea rows={3} placeholder="如:评估数字员工在真实咨询场景下的服务态度与专业度" />
</Form.Item>
<Form.Item name="intent" label="考察意图">
<Input.TextArea rows={2} placeholder="(可选)重点关注的能力或风险" />
</Form.Item>
<Form.Item name="role_description" label="角色描述">
<Input.TextArea rows={2} placeholder="(可选)被评对象的角色设定,供 OpenClaw 规划参考" />
</Form.Item>
<Form.Item
name="seeds_json"
label="种子集JSON"
tooltip="提供给 OpenClaw 的种子数据如真实问题样例、SOP 摘录等;必须是 JSON 对象"
rules={[{
validator: async (_, v: string | undefined) => {
const raw = (v ?? '').trim()
if (!raw) return
let parsed: unknown
try { parsed = JSON.parse(raw) } catch { throw new Error('不是合法 JSON') }
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('必须是 JSON 对象')
}
},
}]}
>
<Input.TextArea rows={5} style={{ fontFamily: 'monospace', fontSize: 12 }} />
</Form.Item>
<Form.Item
name="time_window_hours"
label="时间窗口(小时)"
tooltip="评估覆盖的服务时间跨度"
rules={[{ required: true, message: '请输入时间窗口' }]}
>
<InputNumber min={1} max={720} style={{ width: 160 }} />
</Form.Item>
<div style={{
background: colors.bgSubtle, borderRadius: 8, padding: '8px 12px',
fontSize: 12, color: colors.textSecondary,
}}
>
OpenClaw OpenClaw
</div>
</Form>
</FormDrawer>
</PageWrapper>
)
}