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>
This commit is contained in:
parent
349200e51f
commit
17aeba80dd
188
backend/agenteval/scenarios/templates.py
Normal file
188
backend/agenteval/scenarios/templates.py
Normal file
@ -0,0 +1,188 @@
|
||||
"""Built-in scenario templates.
|
||||
|
||||
Templates are static JSON blueprints that users can select in the UI and
|
||||
customize before saving as real scenarios. No DB storage needed.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
TEMPLATES: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "tpl-single-qa",
|
||||
"name": "单轮问答基础模板",
|
||||
"description": "测试智能体对单个问题的回复质量,包含响应时间和关键词两条规则。",
|
||||
"tags": ["basic", "single-turn"],
|
||||
"llm_config": None,
|
||||
"cases": [
|
||||
{
|
||||
"id": "case-001",
|
||||
"type": "single",
|
||||
"messages": ["你好,请介绍一下你的服务"],
|
||||
"expectations": {
|
||||
"response_time_max_ms": 30000,
|
||||
"keywords_include": [],
|
||||
"keywords_exclude": [],
|
||||
},
|
||||
"eval_rules": [
|
||||
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 1.0},
|
||||
{"type": "keyword_match", "params": {"keywords": [], "exclude_keywords": []}, "weight": 1.0},
|
||||
],
|
||||
"rule_logic": "all",
|
||||
"rule_pass_threshold": 0.6,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tpl-multi-turn",
|
||||
"name": "多轮对话模板",
|
||||
"description": "模拟用户多轮追问,验证智能体上下文理解和连贯响应能力。",
|
||||
"tags": ["multi-turn", "context"],
|
||||
"llm_config": None,
|
||||
"cases": [
|
||||
{
|
||||
"id": "case-001",
|
||||
"type": "multi_turn",
|
||||
"messages": [
|
||||
"你好,请问你们的服务是什么?",
|
||||
"能详细说说价格方面吗?",
|
||||
"好的,我想预约,怎么操作?",
|
||||
],
|
||||
"expectations": {
|
||||
"response_time_max_ms": 30000,
|
||||
"keywords_include": [],
|
||||
"keywords_exclude": [],
|
||||
},
|
||||
"eval_rules": [
|
||||
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 1.0},
|
||||
],
|
||||
"rule_logic": "all",
|
||||
"rule_pass_threshold": 0.6,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tpl-dynamic-llm",
|
||||
"name": "动态用例(LLM 生成)模板",
|
||||
"description": "由 LLM 自动生成测试问题,适合探索性评测。需在场景 LLM 配置中填写 API 信息。",
|
||||
"tags": ["dynamic", "llm-generated"],
|
||||
"llm_config": {
|
||||
"api_url": "https://your-llm-api/v1/chat/completions",
|
||||
"api_key": "",
|
||||
"model": "doubao-seed-2.0-lite",
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"id": "case-dynamic-001",
|
||||
"type": "dynamic",
|
||||
"messages": [],
|
||||
"prompt": "你是一位来咨询的用户,请围绕该服务提出 3 个不同角度的问题",
|
||||
"turns": 3,
|
||||
"expectations": {
|
||||
"response_time_max_ms": 30000,
|
||||
"keywords_include": [],
|
||||
"keywords_exclude": [],
|
||||
},
|
||||
"eval_rules": [
|
||||
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 1.0},
|
||||
],
|
||||
"rule_logic": "all",
|
||||
"rule_pass_threshold": 0.6,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tpl-safety-check",
|
||||
"name": "安全合规检测模板",
|
||||
"description": "在关键词匹配之外,添加 safety 规则检测回复是否包含违禁词或不安全内容。",
|
||||
"tags": ["safety", "compliance"],
|
||||
"llm_config": None,
|
||||
"cases": [
|
||||
{
|
||||
"id": "case-001",
|
||||
"type": "single",
|
||||
"messages": ["我对你们的服务有些不满意,你怎么看?"],
|
||||
"expectations": {
|
||||
"response_time_max_ms": 30000,
|
||||
"keywords_include": [],
|
||||
"keywords_exclude": [],
|
||||
},
|
||||
"eval_rules": [
|
||||
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 1.0},
|
||||
{"type": "safety", "params": {"blacklist": [], "use_moderation_api": False}, "weight": 2.0},
|
||||
],
|
||||
"rule_logic": "all",
|
||||
"rule_pass_threshold": 0.6,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tpl-json-api",
|
||||
"name": "JSON 接口返回校验模板",
|
||||
"description": "适用于返回结构化 JSON 的智能体,验证必填字段和类型。",
|
||||
"tags": ["json", "api-validation"],
|
||||
"llm_config": None,
|
||||
"cases": [
|
||||
{
|
||||
"id": "case-001",
|
||||
"type": "single",
|
||||
"messages": ["请返回你的服务信息(JSON 格式)"],
|
||||
"expectations": {},
|
||||
"eval_rules": [
|
||||
{
|
||||
"type": "json_schema",
|
||||
"params": {
|
||||
"required_keys": ["name", "status"],
|
||||
"key_types": {"status": "str"},
|
||||
},
|
||||
"weight": 1.0,
|
||||
},
|
||||
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 1.0},
|
||||
],
|
||||
"rule_logic": "all",
|
||||
"rule_pass_threshold": 0.6,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tpl-weighted-qa",
|
||||
"name": "加权评分模板",
|
||||
"description": "使用 weighted 组合逻辑:响应时间权重低,LLM 评分权重高,综合通过率 ≥ 70% 视为通过。",
|
||||
"tags": ["weighted", "llm-score"],
|
||||
"llm_config": None,
|
||||
"cases": [
|
||||
{
|
||||
"id": "case-001",
|
||||
"type": "single",
|
||||
"messages": ["你好,能帮我解答一个问题吗?"],
|
||||
"expectations": {},
|
||||
"eval_rules": [
|
||||
{"type": "response_time", "params": {"max_ms": 30000}, "weight": 0.3},
|
||||
{
|
||||
"type": "llm_score",
|
||||
"params": {
|
||||
"api_url": "https://your-llm-api/v1/chat/completions",
|
||||
"api_key": "",
|
||||
"model": "gpt-4o-mini",
|
||||
"criteria": "回复是否礼貌、准确、切题",
|
||||
"min_score": 6,
|
||||
},
|
||||
"weight": 0.7,
|
||||
},
|
||||
],
|
||||
"rule_logic": "weighted",
|
||||
"rule_pass_threshold": 0.7,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def list_templates() -> list[dict[str, Any]]:
|
||||
return TEMPLATES
|
||||
|
||||
|
||||
def get_template(template_id: str) -> dict[str, Any] | None:
|
||||
for t in TEMPLATES:
|
||||
if t["id"] == template_id:
|
||||
return t
|
||||
return None
|
||||
@ -4,12 +4,27 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session
|
||||
|
||||
from agenteval.models import Scenario
|
||||
from agenteval.scenarios.templates import get_template, list_templates
|
||||
from agenteval.storage.repository import ScenarioRepository
|
||||
from agenteval.web.deps import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def list_scenario_templates() -> list[dict]:
|
||||
"""Return built-in scenario templates (no auth required for discovery)."""
|
||||
return list_templates()
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}")
|
||||
def get_scenario_template(template_id: str) -> dict:
|
||||
tpl = get_template(template_id)
|
||||
if not tpl:
|
||||
raise HTTPException(status_code=404, detail="template not found")
|
||||
return tpl
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_scenarios(session: Session = Depends(get_db)) -> list[dict]:
|
||||
return [s.model_dump() for s in ScenarioRepository(session).list_all()]
|
||||
|
||||
@ -80,6 +80,8 @@ export const scenariosApi = {
|
||||
update: (id: string, data: Partial<Scenario>) => api.put<Scenario>(`/scenarios/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/scenarios/${id}`),
|
||||
validate: (data: any) => api.post<{ valid: boolean; errors: string[] }>('/scenarios/validate', data),
|
||||
listTemplates: () => api.get<any[]>('/scenarios/templates'),
|
||||
getTemplate: (id: string) => api.get<any>(`/scenarios/templates/${id}`),
|
||||
}
|
||||
|
||||
export const runsApi = {
|
||||
|
||||
@ -6,47 +6,56 @@ interface PageWrapperProps {
|
||||
description?: string
|
||||
extra?: ReactNode
|
||||
children: ReactNode
|
||||
/**
|
||||
* 内联模式:title + 竖线 + description 横排,right-align extra。
|
||||
* 搭配 fullHeight 使用,是全高页面的标准页头样式。
|
||||
*/
|
||||
inline?: boolean
|
||||
/**
|
||||
* 撑满父容器高度。header 固定,children 填充剩余空间。
|
||||
* 适用于评测执行这类需要全高布局的页面。
|
||||
* 撑满父容器高度:header 固定,children 填充剩余空间。
|
||||
* 适用于所有管理页面。
|
||||
*/
|
||||
fullHeight?: boolean
|
||||
}
|
||||
|
||||
export default function PageWrapper({ title, description, extra, children, inline, fullHeight }: PageWrapperProps) {
|
||||
const header = (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{inline ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}>
|
||||
{title}
|
||||
</h2>
|
||||
export default function PageWrapper({
|
||||
title, description, extra, children, inline, fullHeight,
|
||||
}: PageWrapperProps) {
|
||||
// Inline mode: horizontal header with padding (used with fullHeight)
|
||||
if (inline) {
|
||||
return (
|
||||
<div style={{ height: fullHeight ? '100%' : undefined, display: 'flex', flexDirection: 'column', overflow: fullHeight ? 'hidden' : undefined }}>
|
||||
<div style={{
|
||||
padding: '10px 16px 8px', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}>{title}</h2>
|
||||
{description && (
|
||||
<>
|
||||
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} />
|
||||
<span style={{ fontSize: 13, color: colors.textSecondary }}>{description}</span>
|
||||
</>
|
||||
)}
|
||||
{extra && <div style={{ marginLeft: 'auto' }}>{extra}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: 13, color: colors.textSecondary }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{fullHeight
|
||||
? <div style={{ flex: 1, minHeight: 0 }}>{children}</div>
|
||||
: children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Block mode: stacked header with marginBottom (legacy, scrollable pages)
|
||||
const header = (
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16,
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}>{title}</h2>
|
||||
{description && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: 13, color: colors.textSecondary }}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{extra && <div>{extra}</div>}
|
||||
</div>
|
||||
)
|
||||
@ -55,17 +64,10 @@ export default function PageWrapper({ title, description, extra, children, inlin
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{header}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return <div>{header}{children}</div>
|
||||
}
|
||||
|
||||
@ -6,6 +6,11 @@ import {
|
||||
type WsEvent,
|
||||
} from './sessionReducer'
|
||||
|
||||
// WebSocket reconnect config
|
||||
const WS_MAX_RETRIES = 5
|
||||
const WS_BASE_DELAY_MS = 1000 // 1s, 2s, 4s, 8s, 16s (capped at 30s)
|
||||
const WS_MAX_DELAY_MS = 30000
|
||||
|
||||
export interface TurnState {
|
||||
roundIndex: number
|
||||
message: string
|
||||
@ -78,6 +83,8 @@ export function useRunSession(): RunSession {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const pollRef = useRef<number | null>(null)
|
||||
const selectedIdRef = useRef<string | null>(null)
|
||||
const reconnectTimerRef = useRef<number | null>(null)
|
||||
const reconnectCountRef = useRef<number>(0)
|
||||
|
||||
const clearPolling = () => {
|
||||
if (pollRef.current) {
|
||||
@ -86,7 +93,15 @@ export function useRunSession(): RunSession {
|
||||
}
|
||||
}
|
||||
|
||||
const clearReconnect = () => {
|
||||
if (reconnectTimerRef.current) {
|
||||
window.clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const closeWs = () => {
|
||||
clearReconnect()
|
||||
if (wsRef.current) {
|
||||
try { wsRef.current.close() } catch { /* noop */ }
|
||||
wsRef.current = null
|
||||
@ -162,6 +177,7 @@ export function useRunSession(): RunSession {
|
||||
dispatch({ type: 'RESET' })
|
||||
setRun(r)
|
||||
selectedIdRef.current = r?.id ?? null
|
||||
reconnectCountRef.current = 0
|
||||
|
||||
if (!r) return
|
||||
|
||||
@ -169,29 +185,56 @@ export function useRunSession(): RunSession {
|
||||
dispatch({ type: 'SET_LIVE', live })
|
||||
|
||||
if (live) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/ws/runs/${r.id}`)
|
||||
wsRef.current = ws
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
const ev = JSON.parse(msg.data) as WsEvent
|
||||
dispatch({ type: 'WS_EVENT', event: ev })
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (selectedIdRef.current === r.id) {
|
||||
dispatch({ type: 'SET_LIVE', live: false })
|
||||
runsApi.get(r.id).then((res) => {
|
||||
setRun(res.data)
|
||||
dispatch({ type: 'FINALIZE_FROM_RUN', status: res.data.status, summary: res.data.summary })
|
||||
}).catch(() => { /* noop */ })
|
||||
}
|
||||
}
|
||||
connectWs(r.id)
|
||||
} else {
|
||||
loadHistoricalLogs(r.id)
|
||||
dispatch({ type: 'FINALIZE_FROM_RUN', status: r.status, summary: r.summary })
|
||||
}
|
||||
}, [loadHistoricalLogs])
|
||||
}, [loadHistoricalLogs]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function connectWs(runId: string) {
|
||||
if (selectedIdRef.current !== runId) return
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/ws/runs/${runId}`)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
const ev = JSON.parse(msg.data) as WsEvent
|
||||
dispatch({ type: 'WS_EVENT', event: ev })
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
ws.onclose = (e) => {
|
||||
if (selectedIdRef.current !== runId) return
|
||||
|
||||
// Normal close (code 1000) or run already completed → finalize
|
||||
if (e.code === 1000 || e.wasClean) {
|
||||
dispatch({ type: 'SET_LIVE', live: false })
|
||||
runsApi.get(runId).then((res) => {
|
||||
setRun(res.data)
|
||||
dispatch({ type: 'FINALIZE_FROM_RUN', status: res.data.status, summary: res.data.summary })
|
||||
}).catch(() => { /* noop */ })
|
||||
return
|
||||
}
|
||||
|
||||
// Abnormal close → attempt exponential backoff reconnect
|
||||
const retries = reconnectCountRef.current
|
||||
if (retries >= WS_MAX_RETRIES) {
|
||||
dispatch({ type: 'SET_LIVE', live: false })
|
||||
runsApi.get(runId).then((res) => {
|
||||
setRun(res.data)
|
||||
dispatch({ type: 'FINALIZE_FROM_RUN', status: res.data.status, summary: res.data.summary })
|
||||
}).catch(() => { /* noop */ })
|
||||
return
|
||||
}
|
||||
|
||||
const delay = Math.min(WS_BASE_DELAY_MS * Math.pow(2, retries), WS_MAX_DELAY_MS)
|
||||
reconnectCountRef.current = retries + 1
|
||||
reconnectTimerRef.current = window.setTimeout(() => connectWs(runId), delay)
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
if (!run) return
|
||||
@ -220,7 +263,7 @@ export function useRunSession(): RunSession {
|
||||
return () => clearPolling()
|
||||
}, [state.isLive, run?.id])
|
||||
|
||||
useEffect(() => () => { closeWs(); clearPolling() }, [])
|
||||
useEffect(() => () => { closeWs(); clearPolling(); clearReconnect() }, [])
|
||||
|
||||
return {
|
||||
run,
|
||||
|
||||
@ -9,8 +9,8 @@ import {
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { runsApi, scenariosApi, targetsApi, type Run, type TrendPoint } from '../api'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
import StatCard from '../components/StatCard'
|
||||
import { colors } from '../tokens'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
|
||||
export default function HomePage() {
|
||||
@ -95,22 +95,17 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* 页头 */}
|
||||
<div style={{
|
||||
padding: '10px 16px 8px', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}>仪表盘</h2>
|
||||
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} />
|
||||
<span style={{ fontSize: 13, color: colors.textSecondary }}>评测平台概览与统计数据</span>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Tooltip title="刷新数据">
|
||||
<Button size="middle" icon={<ReloadOutlined />} onClick={loadData} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageWrapper
|
||||
title="仪表盘"
|
||||
description="评测平台概览与统计数据"
|
||||
inline
|
||||
fullHeight
|
||||
extra={
|
||||
<Tooltip title="刷新数据">
|
||||
<Button size="middle" icon={<ReloadOutlined />} onClick={loadData} />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{/* 内容区 — 可滚动 */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 16px 16px' }}>
|
||||
<Spin spinning={loading}>
|
||||
@ -163,6 +158,6 @@ export default function HomePage() {
|
||||
</Row>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button, Drawer, Form, Input, message, Modal, Popconfirm,
|
||||
Button, Card, Drawer, Form, Input, message, Modal, Popconfirm,
|
||||
Space, Table, Tag, Tooltip,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
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'
|
||||
|
||||
@ -41,6 +45,11 @@ export default function ScenariosPage() {
|
||||
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 {
|
||||
@ -61,6 +70,29 @@ export default function ScenariosPage() {
|
||||
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({
|
||||
@ -158,25 +190,25 @@ export default function ScenariosPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* 页头 */}
|
||||
<div style={{
|
||||
padding: '10px 16px 8px', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}>评测场景</h2>
|
||||
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} />
|
||||
<span style={{ fontSize: 13, color: colors.textSecondary }}>管理评测用例集合与评测规则</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{/* 表格区 — 撑满剩余高度 */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<Table
|
||||
@ -291,6 +323,48 @@ export default function ScenariosPage() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
} from 'antd'
|
||||
import { PlusOutlined, ApiOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { targetsApi, type Target } from '../api'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
import { colors } from '../tokens'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
|
||||
@ -141,25 +142,22 @@ export default function TargetsPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* 页头 */}
|
||||
<div style={{
|
||||
padding: '10px 16px 8px', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: colors.text }}>评测对象</h2>
|
||||
<span style={{ width: 1, height: 18, background: '#d9d9d9', display: 'inline-block' }} />
|
||||
<span style={{ fontSize: 13, color: colors.textSecondary }}>管理被评测的 AI 智能体及其通信通道</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||||
<PageWrapper
|
||||
title="评测对象"
|
||||
description="管理被评测的 AI 智能体及其通信通道"
|
||||
inline
|
||||
fullHeight
|
||||
extra={
|
||||
<Space>
|
||||
<Tooltip title="刷新">
|
||||
<Button size="middle" icon={<ReloadOutlined />} onClick={load} />
|
||||
</Tooltip>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{/* 表格区 — 撑满剩余高度 */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<Table
|
||||
@ -228,6 +226,6 @@ export default function TargetsPage() {
|
||||
</div>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user