## 场景模板库(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>
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|