AgentEvalTool/frontend/web/src/pages/Scenarios.tsx
sinohqb 17aeba80dd v0.3-s4: 场景模板库 + WS 自动重连 + PageWrapper 复用
## 场景模板库(S4-1)
- scenarios/templates.py: 6 个内置模板
  - 单轮问答基础 / 多轮对话 / 动态 LLM 生成 / 安全合规检测 / JSON 接口校验 / 加权评分
  - 每个模板附带对应规则配置(含 v0.3 新规则)
- routers/scenarios.py: GET /api/scenarios/templates + GET /api/scenarios/templates/{id}
- api.ts: scenariosApi.listTemplates() / getTemplate()
- Scenarios.tsx: 「从模板新建」按钮 + 卡片式模板选择弹窗
  - 选择后预填名称/描述/标签/cases JSON/llm_config,直接进入编辑 Drawer

## WebSocket 自动重连(S4-2)
- useRunSession.ts: connectWs() 函数 + 指数退避重连
  - 异常断开(非 1000/clean)时自动重试,最多 5 次
  - 延迟:1s → 2s → 4s → 8s → 16s(上限 30s)
  - 超出重试次数后降级 REST 获取最终状态
  - reconnectTimerRef 在组件卸载时清理,无内存泄漏

## PageWrapper 复用(S4-3)
- PageWrapper.tsx: 升级 inline 模式匹配全高页面的 padding 页头样式
- Home / Targets / Scenarios: 用 PageWrapper inline+fullHeight 替换重复内联页头
- Home.tsx: 去掉 unused `colors` import

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 12:05:18 +08:00

371 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,
Space, Table, Tag, Tooltip,
} from 'antd'
import {
PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,
ReloadOutlined, AppstoreAddOutlined,
} from '@ant-design/icons'
import Editor from '@monaco-editor/react'
import { scenariosApi, type Scenario } from '../api'
import PageWrapper from '../components/PageWrapper'
import { colors } from '../tokens'
import { formatDateTime } from '../utils/date'
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 [scenarios, setScenarios] = useState<Scenario[]>([])
const [loading, setLoading] = useState(false)
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 [llmConfig, setLlmConfig] = useState({ api_url: '', api_key: '', model: '' })
const [form] = Form.useForm()
// Template picker
const [tplModalOpen, setTplModalOpen] = useState(false)
const [templates, setTemplates] = useState<any[]>([])
const [tplLoading, setTplLoading] = useState(false)
const load = async () => {
setLoading(true)
try {
const res = await scenariosApi.list()
setScenarios(res.data)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
const openCreate = () => {
setEditingScenario(null)
form.resetFields()
setCasesJson(YAML_TEMPLATE)
setLlmConfig({ api_url: '', api_key: '', model: '' })
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) => {
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))
const lc = tpl.llm_config || {}
setLlmConfig({ api_url: lc.api_url || '', api_key: lc.api_key || '', model: lc.model || '' })
setDrawerOpen(true)
}
const openEdit = (scenario: Scenario) => {
setEditingScenario(scenario)
form.setFieldsValue({
name: scenario.name,
description: scenario.description,
tags: scenario.tags.join(', '),
})
setCasesJson(JSON.stringify(scenario.cases, null, 2))
const lc = (scenario as any).llm_config || {}
setLlmConfig({
api_url: lc.api_url || '',
api_key: lc.api_key || '',
model: lc.model || '',
})
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,
llm_config: (llmConfig.api_url || llmConfig.api_key || llmConfig.model)
? { api_url: llmConfig.api_url, api_key: llmConfig.api_key, model: llmConfig.model }
: null,
}
if (editingScenario) {
await scenariosApi.update(editingScenario.id, payload)
message.success('更新成功')
} else {
await scenariosApi.create(payload)
message.success('创建成功')
}
setDrawerOpen(false)
load()
}
const handleDelete = async (id: string) => {
await scenariosApi.delete(id)
message.success('已删除')
load()
}
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 200,
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
},
{ 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={load} />
</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 ? '编辑评测场景' : '新增评测场景'}
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 }}>
LLM 使
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={{ fontSize: 12, color: '#666', display: 'block', marginBottom: 4 }}>API URL</label>
<Input
size="small"
placeholder="https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions"
value={llmConfig.api_url}
onChange={(e) => setLlmConfig({ ...llmConfig, api_url: e.target.value })}
/>
</div>
<div>
<label style={{ fontSize: 12, color: '#666', display: 'block', marginBottom: 4 }}>API Key</label>
<Input
size="small"
type="password"
placeholder="ark-xxx"
value={llmConfig.api_key}
onChange={(e) => setLlmConfig({ ...llmConfig, api_key: e.target.value })}
/>
</div>
</div>
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, color: '#666', display: 'block', marginBottom: 4 }}></label>
<Input
size="small"
placeholder="doubao-seed-2.0-lite"
value={llmConfig.model}
onChange={(e) => setLlmConfig({ ...llmConfig, model: e.target.value })}
/>
</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 || ''}`}
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>
)
}