AgentEvalTool/frontend/web/src/pages/IntelligentEvals.tsx
sinohqb f458897ab5 refactor(read): 前端泛化 ReadSlot 资源接缝(Phase 4.18-21)
将 src/read 从单一消费者扩展为通用资源接缝;drawer 标签页与
useCampaignReport / useFiles 统一消费;导出仪式下沉至工具函数。

- 新增 readResource.ts:ReadSlot<T> 相位机(idle/loading/
  refreshing/ready/error)、requestId 竞态守卫、静默刷新、
  可注入 adapter;SelectedReadSlot<T> 支持 list→detail 的
  stale-response rejection。配套 characterization 测试。
- evalActivity.ts / useIntelligentEvalRead.ts 退化为 readResource
  的特化;既有测试保持通过。
- DecisionProcess / ConfigSnapshots / TaskQueueMonitor 删除手写
  fetch 样板(useState + try/catch + useEffect 三件套),改走
  资源接缝;ExecutionProcess 的 5 个 fetch 槽同步收敛。
- 决策日志新鲜度归一:三种策略(父级 5s 详情轮询 +
  ExecutionProcess 独立 5s 轮询 + DecisionProcess 挂载取一次)
  收敛为父级 evalActivity 单一供给,子组件消费同一份数据。
- useCampaignReport 删除私有 ReadPhase / ReadSlot 定义,改
  import 自 readResource。
- ACTIVE_STATUSES 合并至 read/intelligentEval.ts 单一出口。
- 新增 utils/download.ts:downloadBlob / downloadJson 取代 api.ts
  与 useFiles 中三处重复的 Blob 导出仪式(含 DOM 副作用迁出
  接口定义出口)。
2026-08-24 05:51:53 +08:00

438 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 { useMemo, 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'
import { isEvalActive } from '../read/intelligentEval'
import { useEvalActivityRead } from '../read/evalActivity'
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(20)
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
// 抽屉子资源由父级读模块单一供给:执行过程 / 决策过程两个标签页消费
// 同一份数据(活跃评估 5s 轮询,终态停止),不再有各自的取数循环。
const runningIds = useMemo(
() => (selected?.sessions ?? []).filter((s) => s.status === 'running').map((s) => s.id).sort(),
[selected],
)
const activity = useEvalActivityRead(
selectedId,
runningIds,
selected != null && isEvalActive(selected.status),
)
const logsSlot = {
phase: activity.slot.phase,
value: activity.slot.value?.logs ?? null,
error: activity.slot.error,
}
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('已删除')
closeDrawer()
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: 88, fixed: 'right' as const,
render: (_, ev) => (
<Button size="small" onClick={() => openDetail(ev.id)}></Button>
),
},
]
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} activity={activity.slot} />,
},
{
key: 'decision',
label: '决策过程',
children: (
<DecisionProcess
evalId={selected.id}
logs={logsSlot}
onReload={() => void activity.reload()}
/>
),
},
{ 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>
)}
{TERMINAL_STATUSES.has(selected.status) && (
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除该评估吗?"
onConfirm={() => void handleDelete(selected.id)}
okText="删除"
cancelText="取消"
>
<Button danger icon={<DeleteOutlined />}></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>
)
}