将 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 副作用迁出 接口定义出口)。
438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
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>
|
||
)
|
||
}
|