From cbee749da282ea8a1ee204a32b4828a05924dc67 Mon Sep 17 00:00:00 2001 From: sinohqb Date: Wed, 5 Aug 2026 03:49:13 +0800 Subject: [PATCH] feat(intelligent-eval): frontend list/detail/report pages (tickets 05-07) List page with create drawer, detail panel with plan approval/rejection, and structured report view with severity-sorted findings and session chat drill-down. Views switch inside the /intelligent-evals keep-alive tab. --- frontend/web/src/api.ts | 137 ++++++++ .../intelligent_eval/EvalDetail.tsx | 209 +++++++++++++ .../intelligent_eval/EvalReport.tsx | 293 ++++++++++++++++++ .../src/components/intelligent_eval/status.ts | 28 ++ frontend/web/src/pages/IntelligentEvals.tsx | 269 +++++++++++++++- 5 files changed, 927 insertions(+), 9 deletions(-) create mode 100644 frontend/web/src/components/intelligent_eval/EvalDetail.tsx create mode 100644 frontend/web/src/components/intelligent_eval/EvalReport.tsx create mode 100644 frontend/web/src/components/intelligent_eval/status.ts diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index 526c6b2..f9a59cd 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -603,6 +603,143 @@ export const campaignsApi = { }, } +// ── Intelligent Evaluation(智能评估 v1.0) ──────────────────────── + +export type IntelligentEvalStatus = + | 'draft' | 'planning' | 'pending_approval' + | 'executing' | 'completed' | 'cancelled' | 'failed' + +export type IntelligentEvalSessionStatus = 'running' | 'completed' | 'failed' | 'expired' + +export interface IntelligentEvalMessage { + id: string + session_id: string + role: string + content: string + latency_ms: number | null + created_at: string | null +} + +export interface IntelligentEvalSession { + id: string + eval_id: string + target_id: string + persona: Record + goal: string + dimension: string | null + status: IntelligentEvalSessionStatus + verdict: Record | null + turn_count: number + created_at: string | null + closed_at: string | null +} + +export interface IntelligentEvalVirtualUser { + persona: Record + goal: string +} + +export interface IntelligentEvalTimeSlot { + time_slot: string + sessions: number + scenario: string +} + +export interface IntelligentEvalPlan { + dimensions: string[] + virtual_users: IntelligentEvalVirtualUser[] + time_distribution: IntelligentEvalTimeSlot[] + estimated_sessions: number + budget: { max_turns_per_session: number; total_max_turns: number } + completion_criteria: string +} + +export interface ReportEvidence { + session_id?: string + turn_index?: number | null + user_said?: string + assistant_replied?: string +} + +export interface ReportFinding { + issue: string + severity: string + dimension: string + evidence: ReportEvidence[] + suggestion?: string | null + related_sop?: string | null +} + +export interface ReportHighlight { + description: string + dimension?: string | null +} + +export interface IntelligentEvalReport { + summary: string + scores: Record | null + findings: ReportFinding[] + highlights: ReportHighlight[] + priority_recommendations: string[] +} + +export interface IntelligentEval { + id: string + name: string + target_id: string + status: IntelligentEvalStatus + goal: string + seeds: Record + intent: string + role_description: string + plan: IntelligentEvalPlan | null + plan_feedback: string | null + time_window_hours: number + report: IntelligentEvalReport | null + created_at: string | null + updated_at: string | null + started_at: string | null + completed_at: string | null + session_count: number + completed_sessions: number +} + +export interface CreateIntelligentEvalPayload { + name: string + target_id: string + goal: string + seeds: Record + intent: string + role_description: string + time_window_hours: number +} + +export const intelligentEvalsApi = { + list: () => api.get<{ intelligent_evals: IntelligentEval[] }>('/intelligent-evals'), + get: (id: string) => api.get(`/intelligent-evals/${id}`), + create: (data: CreateIntelligentEvalPayload) => api.post('/intelligent-evals', data), + submitPlan: (id: string, plan: Record) => + api.put(`/intelligent-evals/${id}/plan`, { plan }), + approve: (id: string) => api.post(`/intelligent-evals/${id}/approve`), + reject: (id: string, feedback: string) => + api.post(`/intelligent-evals/${id}/reject`, { feedback }), + cancel: (id: string) => api.post(`/intelligent-evals/${id}/cancel`), + listSessions: (id: string) => + api.get<{ sessions: IntelligentEvalSession[] }>(`/intelligent-evals/${id}/sessions`), + listMessages: (id: string, sessionId: string) => + api.get<{ messages: IntelligentEvalMessage[] }>(`/intelligent-evals/${id}/sessions/${sessionId}/messages`), + getReport: (id: string) => api.get(`/intelligent-evals/${id}/report`), + downloadReportMarkdown: async (id: string) => { + const res = await api.get(`/intelligent-evals/${id}/report/markdown`, { responseType: 'blob' }) + const url = URL.createObjectURL(res.data as Blob) + const a = document.createElement('a') + a.href = url + a.download = `intelligent-eval-report-${id.slice(0, 8)}.md` + a.click() + URL.revokeObjectURL(url) + }, +} + // ── File Management ────────────────────────────────────────────── export interface FileCategory { diff --git a/frontend/web/src/components/intelligent_eval/EvalDetail.tsx b/frontend/web/src/components/intelligent_eval/EvalDetail.tsx new file mode 100644 index 0000000..adcf2ec --- /dev/null +++ b/frontend/web/src/components/intelligent_eval/EvalDetail.tsx @@ -0,0 +1,209 @@ +import { useState } from 'react' +import { + Alert, Button, Card, Descriptions, Empty, Input, Modal, Popconfirm, Space, Spin, Tag, message, +} from 'antd' +import { ArrowLeftOutlined, FileTextOutlined, StopOutlined } from '@ant-design/icons' +import { intelligentEvalsApi, type IntelligentEval } from '../../api' +import { colors } from '../../tokens' +import { formatDateTime, shortDateTime } from '../../utils/date' +import { EVAL_STATUS } from './status' + +const sectionCard: React.CSSProperties = { marginBottom: 16 } + +function personaLabel(persona: Record): string { + if (typeof persona.name === 'string' && persona.name) return persona.name + if (typeof persona.background === 'string' && persona.background) return persona.background + return JSON.stringify(persona) +} + +function PlanView({ ev }: { ev: IntelligentEval }) { + const plan = ev.plan + if (!plan) return + return ( +
+ + {plan.estimated_sessions} + {plan.budget?.max_turns_per_session ?? '—'} + {plan.budget?.total_max_turns ?? '—'} + + +
评测维度
+ + {(plan.dimensions ?? []).map((d) => {d})} + + +
虚拟用户
+ + {(plan.virtual_users ?? []).map((u, i) => ( +
+ {personaLabel(u.persona)} · 目标:{u.goal} +
+ ))} +
+ +
时间分布
+ + {(plan.time_distribution ?? []).map((t, i) => ( +
+ {t.time_slot} {t.sessions} 个会话 · {t.scenario} +
+ ))} +
+ + {plan.completion_criteria && ( +
+ 完成标准:{plan.completion_criteria} +
+ )} +
+ ) +} + +interface EvalDetailProps { + ev: IntelligentEval + targetName: string + onBack: () => void + onOpenReport: () => void + onChanged: () => void +} + +export default function EvalDetail({ ev, targetName, onBack, onOpenReport, onChanged }: EvalDetailProps) { + const [busy, setBusy] = useState(false) + const [rejectOpen, setRejectOpen] = useState(false) + const [feedback, setFeedback] = useState('') + const meta = EVAL_STATUS[ev.status] ?? { label: ev.status, color: 'default' } + + const runAction = async (fn: () => Promise, okMsg: string) => { + setBusy(true) + try { + await fn() + message.success(okMsg) + onChanged() + } finally { + setBusy(false) + } + } + + const approve = () => runAction(() => intelligentEvalsApi.approve(ev.id), '已批准,进入执行') + const cancel = () => runAction(() => intelligentEvalsApi.cancel(ev.id), '已取消') + const submitReject = () => runAction(async () => { + await intelligentEvalsApi.reject(ev.id, feedback) + setRejectOpen(false) + setFeedback('') + }, '已打回,等待重新规划') + + return ( +
+ + + {ev.name} + {meta.label} + {ev.status === 'completed' && ( + + )} + {(ev.status === 'pending_approval' || ev.status === 'executing') && ( + + + + )} + + + + + {targetName} + {ev.time_window_hours} 小时 + {formatDateTime(ev.created_at)} + {formatDateTime(ev.started_at)} + {formatDateTime(ev.completed_at)} + {ev.completed_sessions}/{ev.session_count} + + + + + + {ev.goal || '—'} + {ev.intent || '—'} + + {ev.role_description || '—'} + + + {Object.keys(ev.seeds ?? {}).length === 0 + ? '—' + : ( +
+                  {JSON.stringify(ev.seeds, null, 2)}
+                
+ )} +
+
+
+ + {ev.status === 'planning' && ( + ev.plan_feedback ? ( + + ) : ( + + + + OpenClaw 正在规划,产出粗计划后会自动进入待审批… + + + ) + )} + + {(ev.status === 'pending_approval' || ev.status === 'executing' || ev.status === 'completed') && ( + + + + + )} + > + + + )} + + {ev.status === 'executing' && ( + +
+ 已完成会话 {ev.completed_sessions} / 已创建 {ev.session_count} + {ev.plan?.estimated_sessions ? ` / 预估 ${ev.plan.estimated_sessions}` : ''} +
+
+ 会话由 OpenClaw 按粗计划的时间分布自唤醒创建,最近活动于 {shortDateTime(ev.updated_at)} +
+
+ )} + + setRejectOpen(false)} + onOk={submitReject} + okText="打回" + okButtonProps={{ danger: true, disabled: !feedback.trim(), loading: busy }} + > + setFeedback(e.target.value)} + placeholder="请填写打回原因与改进方向,OpenClaw 会据此重新规划" + /> + +
+ ) +} diff --git a/frontend/web/src/components/intelligent_eval/EvalReport.tsx b/frontend/web/src/components/intelligent_eval/EvalReport.tsx new file mode 100644 index 0000000..ce13c1e --- /dev/null +++ b/frontend/web/src/components/intelligent_eval/EvalReport.tsx @@ -0,0 +1,293 @@ +import { useEffect, useState } from 'react' +import { + Button, Card, Collapse, Empty, Space, Spin, Statistic, Tag, message, +} from 'antd' +import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons' +import ChatBubble from '../ChatBubble' +import { + intelligentEvalsApi, + type IntelligentEval, + type IntelligentEvalMessage, + type IntelligentEvalReport, + type IntelligentEvalSession, + type ReportEvidence, +} from '../../api' +import { colors } from '../../tokens' +import { shortDateTime } from '../../utils/date' +import { SESSION_STATUS, severityOf } from './status' + +const sectionCard: React.CSSProperties = { marginBottom: 16 } + +function personaLabel(persona: Record): string { + if (typeof persona.name === 'string' && persona.name) return persona.name + if (typeof persona.background === 'string' && persona.background) return persona.background + return persona.id != null ? String(persona.id) : '虚拟用户' +} + +function EvidenceBlock({ evidence }: { evidence: ReportEvidence }) { + return ( +
+ {evidence.session_id && ( +
+ 会话 {evidence.session_id.slice(0, 8)} + {evidence.turn_index != null ? ` · 第 ${evidence.turn_index + 1} 轮` : ''} +
+ )} + {evidence.user_said &&
用户:{evidence.user_said}
} + {evidence.assistant_replied &&
回复:{evidence.assistant_replied}
} +
+ ) +} + +function VerdictView({ verdict }: { verdict: Record }) { + const entries = Object.entries(verdict) + if (entries.length === 0) return null + return ( +
+
会话结论
+ {entries.map(([k, v]) => ( +
+ {k}: + {typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v)} +
+ ))} +
+ ) +} + +function SessionMessages({ evalId, sessionId, verdict }: { + evalId: string + sessionId: string + verdict: Record | null +}) { + const [messages, setMessages] = useState(null) + + useEffect(() => { + let cancelled = false + intelligentEvalsApi.listMessages(evalId, sessionId) + .then((res) => { if (!cancelled) setMessages(res.data.messages) }) + .catch(() => undefined) // 拦截器已弹错;收起再展开可重试 + return () => { cancelled = true } + }, [evalId, sessionId]) + + return ( +
+ {!messages && } + {messages && messages.length === 0 && ( + + )} + {messages?.map((m) => ( + {m.latency_ms}ms + ) : undefined} + /> + ))} + {verdict && } +
+ ) +} + +interface EvalReportProps { + ev: IntelligentEval + onBack: () => void +} + +export default function EvalReport({ ev, onBack }: EvalReportProps) { + const [report, setReport] = useState(ev.report) + const [reportLoading, setReportLoading] = useState(!ev.report) + const [sessions, setSessions] = useState(null) + const [sessionsLoading, setSessionsLoading] = useState(true) + const [exporting, setExporting] = useState(false) + + useEffect(() => { + let cancelled = false + setReport(ev.report) + setReportLoading(!ev.report) + if (!ev.report) { + intelligentEvalsApi.getReport(ev.id) + .then((res) => { if (!cancelled) setReport(res.data) }) + .catch(() => undefined) + .finally(() => { if (!cancelled) setReportLoading(false) }) + } + setSessionsLoading(true) + intelligentEvalsApi.listSessions(ev.id) + .then((res) => { if (!cancelled) setSessions(res.data.sessions) }) + .catch(() => undefined) + .finally(() => { if (!cancelled) setSessionsLoading(false) }) + return () => { cancelled = true } + }, [ev.id, ev.report]) + + const exportMarkdown = async () => { + setExporting(true) + try { + await intelligentEvalsApi.downloadReportMarkdown(ev.id) + message.success('已导出 Markdown 报告') + } finally { + setExporting(false) + } + } + + const findings = [...(report?.findings ?? [])] + .sort((a, b) => severityOf(a.severity).order - severityOf(b.severity).order) + const scores = Object.entries(report?.scores ?? {}) + + return ( +
+ + + + {ev.name} · 评估报告 + + {report && ( + + )} + + + {reportLoading && } + + {!reportLoading && !report && ( + + )} + + {report && ( + <> + +
+ {report.summary} +
+ {scores.length > 0 && ( +
+ {scores.map(([dimension, score]) => ( + + ))} +
+ )} +
+ + + {findings.length === 0 && ( +
未发现问题
+ )} + { + const sev = severityOf(f.severity) + return { + key: i, + label: ( + + {sev.label} + {f.dimension} + {f.issue} + + ), + children: ( +
+ {f.evidence.length > 0 && ( +
+
+ 证据({f.evidence.length} 条) +
+ + {f.evidence.map((e, j) => )} + +
+ )} + {f.suggestion && ( +
+ 建议:{f.suggestion} +
+ )} + {f.related_sop && ( +
+ 关联 SOP:{f.related_sop} +
+ )} +
+ ), + } + })} + /> +
+ + {report.highlights.length > 0 && ( + + + {report.highlights.map((h, i) => ( +
+ {h.dimension && {h.dimension}} + {h.dimension ? ' ' : ''}{h.description} +
+ ))} +
+
+ )} + + {report.priority_recommendations.length > 0 && ( + + {report.priority_recommendations.map((r, i) => ( +
+ {i + 1} {r} +
+ ))} +
+ )} + + )} + + + {sessionsLoading && } + {!sessionsLoading && sessions && sessions.length === 0 && ( + + )} + {!sessionsLoading && sessions && sessions.length > 0 && ( + { + const meta = SESSION_STATUS[s.status] ?? SESSION_STATUS.running + return { + key: s.id, + label: ( + + {personaLabel(s.persona)} + {meta.label} + {s.turn_count} 轮 + {s.dimension && {s.dimension}} + {s.created_at && ( + + {shortDateTime(s.created_at)} + + )} + + ), + children: ( +
+
+ 目标:{s.goal} +
+ +
+ ), + } + })} + /> + )} +
+
+ ) +} diff --git a/frontend/web/src/components/intelligent_eval/status.ts b/frontend/web/src/components/intelligent_eval/status.ts new file mode 100644 index 0000000..98650b0 --- /dev/null +++ b/frontend/web/src/components/intelligent_eval/status.ts @@ -0,0 +1,28 @@ +import type { IntelligentEvalSessionStatus, IntelligentEvalStatus } from '../../api' + +export const EVAL_STATUS: Record = { + draft: { label: '草稿', color: 'default' }, + planning: { label: '规划中', color: 'processing' }, + pending_approval: { label: '待审批', color: 'warning' }, + executing: { label: '执行中', color: 'processing' }, + completed: { label: '已完成', color: 'success' }, + cancelled: { label: '已取消', color: 'default' }, + failed: { label: '失败', color: 'error' }, +} + +export const SESSION_STATUS: Record = { + running: { label: '进行中', color: 'processing' }, + completed: { label: '已完成', color: 'success' }, + failed: { label: '失败', color: 'error' }, + expired: { label: '已过期', color: 'default' }, +} + +export const SEVERITY_META: Record = { + high: { label: '高', color: 'red', order: 0 }, + medium: { label: '中', color: 'orange', order: 1 }, + low: { label: '低', color: 'blue', order: 2 }, +} + +export function severityOf(severity: string) { + return SEVERITY_META[severity?.toLowerCase()] ?? { label: severity, color: 'default', order: 99 } +} diff --git a/frontend/web/src/pages/IntelligentEvals.tsx b/frontend/web/src/pages/IntelligentEvals.tsx index 360d919..f89689c 100644 --- a/frontend/web/src/pages/IntelligentEvals.tsx +++ b/frontend/web/src/pages/IntelligentEvals.tsx @@ -1,14 +1,265 @@ -import { Result } from 'antd' -import { BulbOutlined } from '@ant-design/icons' +import { useState } from 'react' +import { + Button, Drawer, Empty, Form, Input, InputNumber, Select, Space, Table, Tag, message, +} from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { PlusOutlined, ReloadOutlined } from '@ant-design/icons' +import PageWrapper from '../components/PageWrapper' +import EvalDetail from '../components/intelligent_eval/EvalDetail' +import EvalReport from '../components/intelligent_eval/EvalReport' +import { EVAL_STATUS } from '../components/intelligent_eval/status' +import { useResource } from '../hooks/useResource' +import { + intelligentEvalsApi, targetsApi, + type CreateIntelligentEvalPayload, type IntelligentEval, type Target, +} from '../api' +import { colors } from '../tokens' +import { formatDateTime } from '../utils/date' + +type View = 'list' | 'detail' | 'report' + +interface CreateFormValues { + name: string + target_id: string + goal: string + intent?: string + role_description?: string + seeds_json?: string + time_window_hours: number +} export default function IntelligentEvalsPage() { + const [view, setView] = useState('list') + const [selectedId, setSelectedId] = useState(null) + const [detailTick, setDetailTick] = useState(0) + const [createOpen, setCreateOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [form] = Form.useForm() + + const { data: evals, loading, reload } = useResource( + () => intelligentEvalsApi.list().then((r) => r.data.intelligent_evals), + { tabPath: '/intelligent-evals' }, + ) + const { data: targets } = useResource( + () => targetsApi.list().then((r) => r.data), + { tabPath: '/intelligent-evals' }, + ) + + const { data: selected } = useResource( + () => (selectedId ? intelligentEvalsApi.get(selectedId).then((r) => r.data) : Promise.resolve(null)), + { deps: [selectedId, detailTick] }, + ) + + const targetName = (id: string) => + targets?.find((t) => t.id === id)?.name ?? id.slice(0, 8) + + const openDetail = (id: string) => { + setSelectedId(id) + setView('detail') + } + + const submitCreate = async () => { + const values = await form.validateFields() + let seeds: Record = {} + 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 + } 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 reload() + } finally { + setSubmitting(false) + } + } + + const columns: ColumnsType = [ + { + title: '名称', dataIndex: 'name', key: 'name', + render: (name: string, ev) => ( + openDetail(ev.id)}>{name} + ), + }, + { + title: '评测对象', dataIndex: 'target_id', key: 'target', width: 180, + render: (id: string) => targetName(id), + }, + { + title: '状态', dataIndex: 'status', key: 'status', width: 110, + render: (status: IntelligentEval['status']) => { + const meta = EVAL_STATUS[status] ?? { label: status, color: 'default' } + return {meta.label} + }, + }, + { + title: '会话进度', key: 'progress', width: 120, + render: (_, ev) => `${ev.completed_sessions}/${ev.session_count}`, + }, + { + title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, + render: (v: string | null) => (v ? formatDateTime(v) : '—'), + }, + ] + + if (view === 'detail' && selectedId) { + if (!selected) { + return ( + +
加载中…
+
+ ) + } + return ( + + { setView('list'); void reload() }} + onOpenReport={() => setView('report')} + onChanged={() => setDetailTick((t) => t + 1)} + /> + + ) + } + + if (view === 'report' && selected) { + return ( + + setView('detail')} /> + + ) + } + return ( -
- } - title="智能评估" - subTitle="OpenClaw 驱动的独立评测体系(开发中)" - /> -
+ + + + } + > +
+ ({ onClick: () => openDetail(ev.id), style: { cursor: 'pointer' } })} + locale={{ emptyText: }} + /> + + + setCreateOpen(false)} + width={640} + destroyOnClose + footer={ + + + + + } + > +
+ + + + +