AgentEvalTool/frontend/web/src/pages/Scenarios.tsx
sinohqb 050c674ee2
Some checks failed
CI / test (push) Failing after 1m10s
refactor(frontend): extract useResource/usePolling shared hooks
Seven pages repeated the same load-on-mount + loading + try/finally +
reload-button skeleton, each re-implementing tab-active refresh, silent
polling, and (in two places) a hand-rolled requestId race guard. Extract two
composable hooks: useResource(fetcher, {tabPath, deps}) owning data/loading/
reload with a built-in race guard and auto tab-active refresh, and
usePolling(fn, ms, enabled) replacing the hand-written setInterval effects.
Migrate all seven pages onto them; Targets/Scenarios/ModelConfigs also gain a
uniform tab-active refresh they previously lacked. Verified via tsc --noEmit
and npm run build (no frontend test runner exists).
2026-07-31 11:01:24 +08:00

379 lines
13 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 { useEffect, useState } from 'react'
import {
Button, Card, Drawer, Form, Input, message, Modal, Popconfirm,
Select, Space, Table, Tag, Tooltip,
} from 'antd'
import {
PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,
ReloadOutlined, AppstoreAddOutlined,
} from '@ant-design/icons'
import Editor from '@monaco-editor/react'
import { modelConfigsApi, scenariosApi, type Scenario } from '../api'
import PageWrapper from '../components/PageWrapper'
import { useResource } from '../hooks/useResource'
import { colors } from '../tokens'
import { formatDateTime } from '../utils/date'
import { MODEL_CONFIGS_CHANGED_EVENT } from '../utils/modelConfigEvents'
const YAML_TEMPLATE = `# 场景用例示例JSON 格式)
[
{
"id": "case-1",
"type": "single",
"messages": ["你好,请问挂号怎么操作?"],
"eval_rules": [
{ "type": "response_time", "params": { "max_ms": 30000 } }
]
},
{
"id": "case-dynamic-1",
"type": "dynamic",
"prompt": "你是一个来社区医院咨询的患者,请围绕挂号流程提几个问题",
"turns": 3,
"eval_rules": [
{ "type": "response_time", "params": { "max_ms": 30000 } }
]
}
]`
export default function ScenariosPage() {
const { data: scenarios, loading, reload } = useResource(
() => scenariosApi.list().then((r) => r.data),
{ tabPath: '/scenarios' },
)
const {
data: modelConfigs,
loading: modelConfigsLoading,
reload: reloadModelConfigs,
} = useResource(() => modelConfigsApi.list({ enabled: true }).then((r) => r.data))
const [drawerOpen, setDrawerOpen] = useState(false)
const [previewOpen, setPreviewOpen] = useState(false)
const [previewData, setPreviewData] = useState<any>(null)
const [editingScenario, setEditingScenario] = useState<Scenario | null>(null)
const [casesJson, setCasesJson] = useState(YAML_TEMPLATE)
const [modelBindings, setModelBindings] = useState<Record<string, string>>({})
const [form] = Form.useForm()
// Template picker
const [tplModalOpen, setTplModalOpen] = useState(false)
const [templates, setTemplates] = useState<any[]>([])
const [tplLoading, setTplLoading] = useState(false)
useEffect(() => {
const onChanged = () => void reloadModelConfigs()
window.addEventListener(MODEL_CONFIGS_CHANGED_EVENT, onChanged)
return () => window.removeEventListener(MODEL_CONFIGS_CHANGED_EVENT, onChanged)
}, [reloadModelConfigs])
const openCreate = () => {
reloadModelConfigs()
setEditingScenario(null)
form.resetFields()
setCasesJson(YAML_TEMPLATE)
setModelBindings({})
setDrawerOpen(true)
}
const openTemplateModal = async () => {
setTplModalOpen(true)
if (templates.length === 0) {
setTplLoading(true)
try {
const res = await scenariosApi.listTemplates()
setTemplates(res.data)
} catch { /* handled by interceptor */ }
finally { setTplLoading(false) }
}
}
const applyTemplate = (tpl: any) => {
reloadModelConfigs()
setTplModalOpen(false)
setEditingScenario(null)
form.resetFields()
form.setFieldsValue({ name: `${tpl.name}(副本)`, description: tpl.description, tags: tpl.tags.join(', ') })
setCasesJson(JSON.stringify(tpl.cases, null, 2))
setModelBindings(tpl.model_bindings || {})
setDrawerOpen(true)
}
const openEdit = (scenario: Scenario) => {
reloadModelConfigs()
setEditingScenario(scenario)
form.setFieldsValue({
name: scenario.name,
description: scenario.description,
tags: scenario.tags.join(', '),
})
setCasesJson(JSON.stringify(scenario.cases, null, 2))
setModelBindings(scenario.model_bindings || {})
setDrawerOpen(true)
}
const openPreview = (scenario: Scenario) => {
setPreviewData(scenario)
setPreviewOpen(true)
}
const handleSubmit = async () => {
const values = await form.validateFields()
let cases: any[]
try {
cases = JSON.parse(casesJson)
} catch {
message.error('用例 JSON 格式错误')
return
}
const tags = values.tags
? values.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: []
const payload = {
name: values.name,
description: values.description || '',
tags,
cases,
model_bindings: modelBindings,
}
if (editingScenario) {
await scenariosApi.update(editingScenario.id, payload)
message.success('更新成功')
} else {
await scenariosApi.create(payload)
message.success('创建成功')
}
setDrawerOpen(false)
reload()
}
const handleDelete = async (id: string) => {
await scenariosApi.delete(id)
message.success('已删除')
reload()
}
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 200,
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
},
{ title: '版本', dataIndex: 'version', key: 'version', width: 70,
render: (v: number) => <Tag color="geekblue">v{v ?? 1}</Tag>,
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '标签', dataIndex: 'tags', key: 'tags', width: 200,
render: (tags: string[]) => tags.map((t) => <Tag key={t}>{t}</Tag>),
},
{ title: '用例数', key: 'cases', width: 80,
render: (_: any, r: Scenario) => r.cases.length,
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180,
render: (t: string) => formatDateTime(t),
},
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const,
render: (_: any, record: Scenario) => (
<Space size={4}>
<Tooltip title="预览">
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => openPreview(record)} />
</Tooltip>
<Tooltip title="编辑">
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
</Tooltip>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Tooltip title="删除">
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
</Space>
),
},
]
return (
<PageWrapper
title="评测场景"
description="管理评测用例集合与评测规则"
inline
fullHeight
extra={
<Space>
<Tooltip title="刷新">
<Button
size="middle"
icon={<ReloadOutlined />}
onClick={() => {
reload()
reloadModelConfigs()
}}
/>
</Tooltip>
<Button icon={<AppstoreAddOutlined />} onClick={openTemplateModal}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</Space>
}
>
{/* 表格区 — 撑满剩余高度 */}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<Table
dataSource={scenarios ?? []}
columns={columns}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showSizeChanger: true, showTotal: (t) => `${t}` }}
scroll={{ y: 'calc(100vh - 200px)' }}
style={{ height: '100%' }}
/>
</div>
<Drawer
title={editingScenario ? `编辑评测场景(当前 v${editingScenario.version ?? 1}` : '新增评测场景'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={720}
extra={
<Button type="primary" onClick={handleSubmit}>
{editingScenario ? '更新' : '创建'}
</Button>
}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入场景名称' }]}>
<Input placeholder="例如:健康咨询基础场景" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="场景描述" />
</Form.Item>
<Form.Item name="tags" label="标签(逗号分隔)">
<Input placeholder="例如health, basic, single-turn" />
</Form.Item>
</Form>
<div style={{
border: `1px solid ${colors.border}`, borderRadius: 8,
padding: '12px 16px', marginBottom: 16,
}}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 12, color: colors.text }}>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{[
{ purpose: 'generator', label: '动态用例生成', capability: 'chat' },
{ purpose: 'judge', label: 'LLM 评分', capability: 'chat' },
{ purpose: 'embedding', label: '语义相似度', capability: 'embedding' },
{ purpose: 'moderation', label: '内容审核', capability: 'moderation' },
].map((item) => (
<div key={item.purpose}>
<label style={{ fontSize: 12, color: '#666', display: 'block', marginBottom: 4 }}>{item.label}</label>
<Select
allowClear
showSearch
loading={modelConfigsLoading}
optionFilterProp="label"
style={{ width: '100%' }}
placeholder="未绑定"
value={modelBindings[item.purpose]}
onChange={(value) => {
const next = { ...modelBindings }
if (value) next[item.purpose] = value
else delete next[item.purpose]
setModelBindings(next)
}}
options={(modelConfigs ?? [])
.filter((config) => config.capability === item.capability)
.map((config) => ({ value: config.id, label: `${config.name} · ${config.model_name || config.capability}` }))}
/>
</div>
))}
</div>
</div>
<div style={{ marginBottom: 8, fontWeight: 500, fontSize: 13, color: '#666' }}>JSON</div>
<div style={{ border: '1px solid #d9d9d9', borderRadius: 8, overflow: 'hidden' }}>
<Editor
height="400px"
defaultLanguage="json"
theme="vs-dark"
value={casesJson}
onChange={(v) => setCasesJson(v || '')}
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
</div>
</Drawer>
<Modal
title={`场景预览: ${previewData?.name || ''}${previewData?.version != null ? `v${previewData.version}` : ''}`}
open={previewOpen}
onCancel={() => setPreviewOpen(false)}
footer={null}
width={700}
>
{previewData && (
<div>
<p style={{ marginBottom: 8 }}><strong></strong>{previewData.description || '无'}</p>
<p style={{ marginBottom: 8 }}><strong></strong>{previewData.tags?.join(', ') || '无'}</p>
<p style={{ marginBottom: 12 }}><strong></strong>{previewData.cases?.length || 0}</p>
<div style={{ background: '#1e1e2e', borderRadius: 8, padding: 16, maxHeight: 400, overflow: 'auto' }}>
<pre style={{ color: '#d4d4d4', fontSize: 12, margin: 0 }}>
{JSON.stringify(previewData.cases, null, 2)}
</pre>
</div>
</div>
)}
</Modal>
{/* Template picker */}
<Modal
title="从模板新建场景"
open={tplModalOpen}
onCancel={() => setTplModalOpen(false)}
footer={null}
width={680}
>
{tplLoading ? (
<div style={{ textAlign: 'center', padding: 40, color: colors.textMuted }}></div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{templates.map((tpl) => (
<Card
key={tpl.id}
size="small"
hoverable
onClick={() => applyTemplate(tpl)}
style={{ cursor: 'pointer', borderColor: colors.border }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: colors.text, marginBottom: 4 }}>
{tpl.name}
</div>
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 6 }}>
{tpl.description}
</div>
<Space size={4} wrap>
{tpl.tags.map((t: string) => <Tag key={t} style={{ margin: 0 }}>{t}</Tag>)}
</Space>
</div>
<div style={{ fontSize: 12, color: colors.textMuted, flexShrink: 0, marginLeft: 12 }}>
{tpl.cases?.length ?? 0}
</div>
</div>
</Card>
))}
</div>
)}
</Modal>
</PageWrapper>
)
}