feat(intelligent-eval): frontend list/detail/report pages (tickets 05-07)
Some checks failed
CI / test (push) Failing after 1m31s
Some checks failed
CI / test (push) Failing after 1m31s
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.
This commit is contained in:
parent
da6ccc265d
commit
cbee749da2
@ -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<string, unknown>
|
||||
goal: string
|
||||
dimension: string | null
|
||||
status: IntelligentEvalSessionStatus
|
||||
verdict: Record<string, unknown> | null
|
||||
turn_count: number
|
||||
created_at: string | null
|
||||
closed_at: string | null
|
||||
}
|
||||
|
||||
export interface IntelligentEvalVirtualUser {
|
||||
persona: Record<string, unknown>
|
||||
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<string, number> | null
|
||||
findings: ReportFinding[]
|
||||
highlights: ReportHighlight[]
|
||||
priority_recommendations: string[]
|
||||
}
|
||||
|
||||
export interface IntelligentEval {
|
||||
id: string
|
||||
name: string
|
||||
target_id: string
|
||||
status: IntelligentEvalStatus
|
||||
goal: string
|
||||
seeds: Record<string, unknown>
|
||||
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<string, unknown>
|
||||
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<IntelligentEval>(`/intelligent-evals/${id}`),
|
||||
create: (data: CreateIntelligentEvalPayload) => api.post<IntelligentEval>('/intelligent-evals', data),
|
||||
submitPlan: (id: string, plan: Record<string, unknown>) =>
|
||||
api.put<IntelligentEval>(`/intelligent-evals/${id}/plan`, { plan }),
|
||||
approve: (id: string) => api.post<IntelligentEval>(`/intelligent-evals/${id}/approve`),
|
||||
reject: (id: string, feedback: string) =>
|
||||
api.post<IntelligentEval>(`/intelligent-evals/${id}/reject`, { feedback }),
|
||||
cancel: (id: string) => api.post<IntelligentEval>(`/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<IntelligentEvalReport>(`/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 {
|
||||
|
||||
209
frontend/web/src/components/intelligent_eval/EvalDetail.tsx
Normal file
209
frontend/web/src/components/intelligent_eval/EvalDetail.tsx
Normal file
@ -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, unknown>): 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 <Empty description="暂无粗计划" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
return (
|
||||
<div>
|
||||
<Descriptions column={3} size="small" bordered style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="预估会话数">{plan.estimated_sessions}</Descriptions.Item>
|
||||
<Descriptions.Item label="单会话最大轮数">{plan.budget?.max_turns_per_session ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="总轮数上限">{plan.budget?.total_max_turns ?? '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>评测维度</div>
|
||||
<Space size={6} wrap style={{ marginBottom: 12 }}>
|
||||
{(plan.dimensions ?? []).map((d) => <Tag key={d} color="blue">{d}</Tag>)}
|
||||
</Space>
|
||||
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>虚拟用户</div>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%', marginBottom: 12 }}>
|
||||
{(plan.virtual_users ?? []).map((u, i) => (
|
||||
<div key={i} style={{ fontSize: 13, color: colors.textSecondary }}>
|
||||
<b style={{ color: colors.text }}>{personaLabel(u.persona)}</b> · 目标:{u.goal}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>时间分布</div>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%', marginBottom: 12 }}>
|
||||
{(plan.time_distribution ?? []).map((t, i) => (
|
||||
<div key={i} style={{ fontSize: 13, color: colors.textSecondary }}>
|
||||
<Tag style={{ margin: 0 }}>{t.time_slot}</Tag> {t.sessions} 个会话 · {t.scenario}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
{plan.completion_criteria && (
|
||||
<div style={{ fontSize: 13, color: colors.textSecondary }}>
|
||||
<b style={{ color: colors.text }}>完成标准:</b>{plan.completion_criteria}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<unknown>, 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 (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={onBack}>返回列表</Button>
|
||||
<span style={{ fontSize: 16, fontWeight: 600, color: colors.text }}>{ev.name}</span>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
{ev.status === 'completed' && (
|
||||
<Button type="primary" icon={<FileTextOutlined />} onClick={onOpenReport}>查看报告</Button>
|
||||
)}
|
||||
{(ev.status === 'pending_approval' || ev.status === 'executing') && (
|
||||
<Popconfirm title="取消该智能评估?" onConfirm={cancel}>
|
||||
<Button danger icon={<StopOutlined />} loading={busy}>取消</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Card size="small" title="基本信息" style={sectionCard}>
|
||||
<Descriptions column={3} size="small">
|
||||
<Descriptions.Item label="评测对象">{targetName}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间窗口">{ev.time_window_hours} 小时</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{formatDateTime(ev.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="开始时间">{formatDateTime(ev.started_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">{formatDateTime(ev.completed_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="会话进度">{ev.completed_sessions}/{ev.session_count}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="用户输入" style={sectionCard}>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="评估目标">{ev.goal || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="考察意图">{ev.intent || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="角色描述">
|
||||
<span style={{ whiteSpace: 'pre-wrap' }}>{ev.role_description || '—'}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="种子集">
|
||||
{Object.keys(ev.seeds ?? {}).length === 0
|
||||
? '—'
|
||||
: (
|
||||
<pre style={{
|
||||
margin: 0, fontSize: 12, background: colors.bgSubtle,
|
||||
padding: 8, borderRadius: 6, overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(ev.seeds, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{ev.status === 'planning' && (
|
||||
ev.plan_feedback ? (
|
||||
<Alert
|
||||
style={sectionCard}
|
||||
type="warning"
|
||||
showIcon
|
||||
message="计划已打回,OpenClaw 正在根据反馈重新规划"
|
||||
description={ev.plan_feedback}
|
||||
/>
|
||||
) : (
|
||||
<Card size="small" style={sectionCard}>
|
||||
<Spin size="small" />
|
||||
<span style={{ marginLeft: 10, color: colors.textSecondary }}>
|
||||
OpenClaw 正在规划,产出粗计划后会自动进入待审批…
|
||||
</span>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
|
||||
{(ev.status === 'pending_approval' || ev.status === 'executing' || ev.status === 'completed') && (
|
||||
<Card
|
||||
size="small"
|
||||
title="粗计划"
|
||||
style={sectionCard}
|
||||
extra={ev.status === 'pending_approval' && (
|
||||
<Space>
|
||||
<Button size="small" danger onClick={() => setRejectOpen(true)}>打回</Button>
|
||||
<Button size="small" type="primary" loading={busy} onClick={approve}>批准并执行</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<PlanView ev={ev} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{ev.status === 'executing' && (
|
||||
<Card size="small" title="执行进度" style={sectionCard}>
|
||||
<div style={{ fontSize: 13, color: colors.textSecondary, marginBottom: 8 }}>
|
||||
已完成会话 {ev.completed_sessions} / 已创建 {ev.session_count}
|
||||
{ev.plan?.estimated_sessions ? ` / 预估 ${ev.plan.estimated_sessions}` : ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: colors.textMuted }}>
|
||||
会话由 OpenClaw 按粗计划的时间分布自唤醒创建,最近活动于 {shortDateTime(ev.updated_at)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="打回计划"
|
||||
open={rejectOpen}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
onOk={submitReject}
|
||||
okText="打回"
|
||||
okButtonProps={{ danger: true, disabled: !feedback.trim(), loading: busy }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="请填写打回原因与改进方向,OpenClaw 会据此重新规划"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
293
frontend/web/src/components/intelligent_eval/EvalReport.tsx
Normal file
293
frontend/web/src/components/intelligent_eval/EvalReport.tsx
Normal file
@ -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, unknown>): 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 (
|
||||
<div style={{
|
||||
background: colors.bgSubtle, borderLeft: `3px solid ${colors.border}`,
|
||||
padding: '6px 10px', borderRadius: 4, fontSize: 12, lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
{evidence.session_id && (
|
||||
<div style={{ color: colors.textMuted }}>
|
||||
会话 {evidence.session_id.slice(0, 8)}
|
||||
{evidence.turn_index != null ? ` · 第 ${evidence.turn_index + 1} 轮` : ''}
|
||||
</div>
|
||||
)}
|
||||
{evidence.user_said && <div>用户:{evidence.user_said}</div>}
|
||||
{evidence.assistant_replied && <div>回复:{evidence.assistant_replied}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VerdictView({ verdict }: { verdict: Record<string, unknown> }) {
|
||||
const entries = Object.entries(verdict)
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 8, border: `1px solid ${colors.border}`, borderRadius: 8,
|
||||
background: colors.bgSubtle, padding: '8px 12px', fontSize: 12, lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>会话结论</div>
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} style={{ color: colors.textSecondary }}>
|
||||
<b style={{ color: colors.text }}>{k}</b>:
|
||||
{typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionMessages({ evalId, sessionId, verdict }: {
|
||||
evalId: string
|
||||
sessionId: string
|
||||
verdict: Record<string, unknown> | null
|
||||
}) {
|
||||
const [messages, setMessages] = useState<IntelligentEvalMessage[] | null>(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 (
|
||||
<div>
|
||||
{!messages && <Spin size="small" />}
|
||||
{messages && messages.length === 0 && (
|
||||
<Empty description="暂无对话记录" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
{messages?.map((m) => (
|
||||
<ChatBubble
|
||||
key={m.id}
|
||||
role={m.role === 'user' ? 'user' : 'agent'}
|
||||
mirrored
|
||||
content={m.content}
|
||||
meta={m.role !== 'user' && m.latency_ms != null ? (
|
||||
<span style={{ color: colors.textMuted, fontSize: 11 }}>{m.latency_ms}ms</span>
|
||||
) : undefined}
|
||||
/>
|
||||
))}
|
||||
{verdict && <VerdictView verdict={verdict} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface EvalReportProps {
|
||||
ev: IntelligentEval
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export default function EvalReport({ ev, onBack }: EvalReportProps) {
|
||||
const [report, setReport] = useState<IntelligentEvalReport | null>(ev.report)
|
||||
const [reportLoading, setReportLoading] = useState(!ev.report)
|
||||
const [sessions, setSessions] = useState<IntelligentEvalSession[] | null>(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 (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={onBack}>返回详情</Button>
|
||||
<span style={{ fontSize: 16, fontWeight: 600, color: colors.text }}>
|
||||
{ev.name} · 评估报告
|
||||
</span>
|
||||
{report && (
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={exportMarkdown}>
|
||||
导出 Markdown
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{reportLoading && <Spin />}
|
||||
|
||||
{!reportLoading && !report && (
|
||||
<Empty description="暂无报告" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
<Card size="small" title="总结" style={sectionCard}>
|
||||
<div style={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.8 }}>
|
||||
{report.summary}
|
||||
</div>
|
||||
{scores.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 32, marginTop: 12, flexWrap: 'wrap' }}>
|
||||
{scores.map(([dimension, score]) => (
|
||||
<Statistic key={dimension} title={dimension} value={score} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title={`问题发现(${findings.length})`} style={sectionCard}>
|
||||
{findings.length === 0 && (
|
||||
<div style={{ fontSize: 13, color: colors.textSecondary }}>未发现问题</div>
|
||||
)}
|
||||
<Collapse
|
||||
size="small"
|
||||
items={findings.map((f, i) => {
|
||||
const sev = severityOf(f.severity)
|
||||
return {
|
||||
key: i,
|
||||
label: (
|
||||
<Space size={8} wrap>
|
||||
<Tag color={sev.color} style={{ margin: 0 }}>{sev.label}</Tag>
|
||||
<Tag style={{ margin: 0 }}>{f.dimension}</Tag>
|
||||
<span style={{ fontWeight: 500 }}>{f.issue}</span>
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{f.evidence.length > 0 && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 6 }}>
|
||||
证据({f.evidence.length} 条)
|
||||
</div>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
{f.evidence.map((e, j) => <EvidenceBlock key={j} evidence={e} />)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{f.suggestion && (
|
||||
<div style={{ fontSize: 13, marginBottom: 4 }}>
|
||||
<b>建议:</b>{f.suggestion}
|
||||
</div>
|
||||
)}
|
||||
{f.related_sop && (
|
||||
<div style={{ fontSize: 12, color: colors.textMuted }}>
|
||||
关联 SOP:{f.related_sop}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{report.highlights.length > 0 && (
|
||||
<Card size="small" title="亮点" style={sectionCard}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{report.highlights.map((h, i) => (
|
||||
<div key={i} style={{ fontSize: 13 }}>
|
||||
{h.dimension && <Tag color="green" style={{ margin: 0 }}>{h.dimension}</Tag>}
|
||||
{h.dimension ? ' ' : ''}{h.description}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report.priority_recommendations.length > 0 && (
|
||||
<Card size="small" title="优先改进建议" style={sectionCard}>
|
||||
{report.priority_recommendations.map((r, i) => (
|
||||
<div key={i} style={{ fontSize: 13, marginBottom: 4 }}>
|
||||
<Tag color="blue" style={{ margin: 0 }}>{i + 1}</Tag> {r}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card size="small" title="会话记录" style={sectionCard}>
|
||||
{sessionsLoading && <Spin size="small" />}
|
||||
{!sessionsLoading && sessions && sessions.length === 0 && (
|
||||
<Empty description="暂无会话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
{!sessionsLoading && sessions && sessions.length > 0 && (
|
||||
<Collapse
|
||||
size="small"
|
||||
items={sessions.map((s) => {
|
||||
const meta = SESSION_STATUS[s.status] ?? SESSION_STATUS.running
|
||||
return {
|
||||
key: s.id,
|
||||
label: (
|
||||
<Space size={8} wrap>
|
||||
<span style={{ fontWeight: 500 }}>{personaLabel(s.persona)}</span>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<Tag>{s.turn_count} 轮</Tag>
|
||||
{s.dimension && <Tag>{s.dimension}</Tag>}
|
||||
{s.created_at && (
|
||||
<span style={{ fontSize: 12, color: colors.textMuted }}>
|
||||
{shortDateTime(s.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 8 }}>
|
||||
目标:{s.goal}
|
||||
</div>
|
||||
<SessionMessages evalId={ev.id} sessionId={s.id} verdict={s.verdict} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
frontend/web/src/components/intelligent_eval/status.ts
Normal file
28
frontend/web/src/components/intelligent_eval/status.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { IntelligentEvalSessionStatus, IntelligentEvalStatus } from '../../api'
|
||||
|
||||
export const EVAL_STATUS: Record<IntelligentEvalStatus, { label: string; color: string }> = {
|
||||
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<IntelligentEvalSessionStatus, { label: string; color: string }> = {
|
||||
running: { label: '进行中', color: 'processing' },
|
||||
completed: { label: '已完成', color: 'success' },
|
||||
failed: { label: '失败', color: 'error' },
|
||||
expired: { label: '已过期', color: 'default' },
|
||||
}
|
||||
|
||||
export const SEVERITY_META: Record<string, { label: string; color: string; order: number }> = {
|
||||
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 }
|
||||
}
|
||||
@ -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<View>('list')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [detailTick, setDetailTick] = useState(0)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [form] = Form.useForm<CreateFormValues>()
|
||||
|
||||
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<string, unknown> = {}
|
||||
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<string, unknown>
|
||||
} 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<IntelligentEval> = [
|
||||
{
|
||||
title: '名称', dataIndex: 'name', key: 'name',
|
||||
render: (name: string, ev) => (
|
||||
<a onClick={() => openDetail(ev.id)}>{name}</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <Tag color={meta.color}>{meta.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<PageWrapper title="智能评估" inline fullHeight>
|
||||
<div style={{ padding: 16 }}>加载中…</div>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<PageWrapper title="智能评估" inline fullHeight>
|
||||
<EvalDetail
|
||||
ev={selected}
|
||||
targetName={targetName(selected.target_id)}
|
||||
onBack={() => { setView('list'); void reload() }}
|
||||
onOpenReport={() => setView('report')}
|
||||
onChanged={() => setDetailTick((t) => t + 1)}
|
||||
/>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
if (view === 'report' && selected) {
|
||||
return (
|
||||
<PageWrapper title="智能评估" inline fullHeight>
|
||||
<EvalReport ev={selected} onBack={() => setView('detail')} />
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Result
|
||||
icon={<BulbOutlined style={{ color: '#8c8c8c' }} />}
|
||||
title="智能评估"
|
||||
subTitle="OpenClaw 驱动的独立评测体系(开发中)"
|
||||
/>
|
||||
</div>
|
||||
<PageWrapper
|
||||
title="智能评估"
|
||||
description="描述评估目标,由 OpenClaw 自主规划、执行并产出结构化报告"
|
||||
inline
|
||||
fullHeight
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => reload()} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
新建智能评估
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={evals ?? []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
onRow={(ev) => ({ onClick: () => openDetail(ev.id), style: { cursor: 'pointer' } })}
|
||||
locale={{ emptyText: <Empty description="还没有智能评估" /> }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title="新建智能评估"
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={submitting} onClick={submitCreate}>
|
||||
创建并开始规划
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ time_window_hours: 24, seeds_json: '{}' }}
|
||||
>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="如:数字员工 24h 服务质量评估" />
|
||||
</Form.Item>
|
||||
<Form.Item name="target_id" label="评测对象" rules={[{ required: true, message: '请选择对象' }]}>
|
||||
<Select
|
||||
placeholder="选择评测对象"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={(targets ?? []).map((t: Target) => ({ label: t.name, value: t.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="goal"
|
||||
label="评估目标"
|
||||
tooltip="你希望这次评估回答什么问题,OpenClaw 据此规划"
|
||||
rules={[{ required: true, message: '请输入评估目标' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="如:评估数字员工在真实咨询场景下的服务态度与专业度" />
|
||||
</Form.Item>
|
||||
<Form.Item name="intent" label="考察意图">
|
||||
<Input.TextArea rows={2} placeholder="(可选)重点关注的能力或风险" />
|
||||
</Form.Item>
|
||||
<Form.Item name="role_description" label="角色描述">
|
||||
<Input.TextArea rows={2} placeholder="(可选)被评对象的角色设定,供 OpenClaw 规划参考" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="seeds_json"
|
||||
label="种子集(JSON)"
|
||||
tooltip="提供给 OpenClaw 的种子数据,如真实问题样例、SOP 摘录等;必须是 JSON 对象"
|
||||
rules={[{
|
||||
validator: async (_, v: string | undefined) => {
|
||||
const raw = (v ?? '').trim()
|
||||
if (!raw) return
|
||||
let parsed: unknown
|
||||
try { parsed = JSON.parse(raw) } catch { throw new Error('不是合法 JSON') }
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('必须是 JSON 对象')
|
||||
}
|
||||
},
|
||||
}]}
|
||||
>
|
||||
<Input.TextArea rows={5} style={{ fontFamily: 'monospace', fontSize: 12 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="time_window_hours"
|
||||
label="时间窗口(小时)"
|
||||
tooltip="评估覆盖的服务时间跨度"
|
||||
rules={[{ required: true, message: '请输入时间窗口' }]}
|
||||
>
|
||||
<InputNumber min={1} max={720} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<div style={{
|
||||
background: colors.bgSubtle, borderRadius: 8, padding: '8px 12px',
|
||||
fontSize: 12, color: colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
创建后进入规划:OpenClaw 产出粗计划 → 你审批 → 批准后 OpenClaw 自唤醒执行会话 → 完成后提交结构化报告。
|
||||
</div>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user