first-update
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions
|
||||
} from '@mui/material';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import useBlindTestDetail from '../hooks/useBlindTestDetail';
|
||||
import BlindTestHeader from '../components/BlindTestHeader';
|
||||
import ResultSummary from '../components/ResultSummary';
|
||||
import ResultDetailList from '../components/ResultDetailList';
|
||||
import BlindTestInProgress from '../components/BlindTestInProgress';
|
||||
|
||||
export default function BlindTestDetailPage() {
|
||||
const { projectId, taskId } = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
task,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
currentQuestion,
|
||||
leftAnswer,
|
||||
rightAnswer,
|
||||
answersLoading,
|
||||
streamingA,
|
||||
streamingB,
|
||||
voting,
|
||||
completed,
|
||||
fetchCurrentQuestion,
|
||||
submitVote,
|
||||
interruptTask,
|
||||
getResultStats
|
||||
} = useBlindTestDetail(projectId, taskId);
|
||||
|
||||
const [interruptDialog, setInterruptDialog] = useState(false);
|
||||
|
||||
const handleBack = () => router.push(`/projects/${projectId}/blind-test-tasks`);
|
||||
|
||||
const handleVote = async vote => {
|
||||
await submitVote(vote);
|
||||
};
|
||||
|
||||
const handleInterrupt = async () => {
|
||||
await interruptTask();
|
||||
setInterruptDialog(false);
|
||||
};
|
||||
|
||||
// 加载中
|
||||
if (loading) {
|
||||
return (
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{ py: 3, height: 'calc(100vh - 64px)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// 任务不存在
|
||||
if (!task) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
<BlindTestHeader title={t('blindTest.taskNotFound', '任务不存在')} onBack={handleBack} />
|
||||
<Alert severity="error">{t('blindTest.taskNotFound', '任务不存在')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const isResultView = completed || task.status !== 0;
|
||||
const stats = getResultStats();
|
||||
|
||||
// 结果展示页面(已完成或已中断)
|
||||
if (isResultView) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3, height: 'calc(100vh - 64px)', overflow: 'auto' }}>
|
||||
<BlindTestHeader title={t('blindTest.resultTitle', '盲测结果')} status={task.status} onBack={handleBack} />
|
||||
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
||||
<ResultSummary stats={stats} modelInfo={task.modelInfo || '{}'} />
|
||||
<ResultDetailList task={task} />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// 盲测进行中页面
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3, height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<BlindTestHeader
|
||||
title={t('blindTest.inProgress', '盲测进行中')}
|
||||
onBack={handleBack}
|
||||
actions={
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
startIcon={<StopIcon />}
|
||||
onClick={() => setInterruptDialog(true)}
|
||||
size="small"
|
||||
>
|
||||
{t('blindTest.interrupt', '中断任务')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||
<BlindTestInProgress
|
||||
task={task}
|
||||
currentQuestion={currentQuestion}
|
||||
leftAnswer={leftAnswer}
|
||||
rightAnswer={rightAnswer}
|
||||
streamingA={streamingA}
|
||||
streamingB={streamingB}
|
||||
answersLoading={answersLoading}
|
||||
voting={voting}
|
||||
onVote={handleVote}
|
||||
onReload={fetchCurrentQuestion}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 中断确认对话框 */}
|
||||
<Dialog open={interruptDialog} onClose={() => setInterruptDialog(false)}>
|
||||
<DialogTitle>{t('blindTest.interruptConfirmTitle', '确认中断')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('blindTest.interruptConfirmMessage', '确定要中断这个盲测任务吗?已完成的评判结果将保留。')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setInterruptDialog(false)}>{t('common.cancel', '取消')}</Button>
|
||||
<Button color="warning" variant="contained" onClick={handleInterrupt}>
|
||||
{t('blindTest.interrupt', '中断')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Box, Typography, IconButton, Chip, Button } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import { blindTestStyles } from '@/styles/blindTest';
|
||||
|
||||
export default function BlindTestHeader({ title, status, onBack, actions }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const styles = blindTestStyles(theme);
|
||||
|
||||
const getStatusConfig = s => {
|
||||
switch (s) {
|
||||
case 1:
|
||||
return { label: 'blindTest.statusCompleted', color: 'success' };
|
||||
case 3:
|
||||
return { label: 'blindTest.statusInterrupted', color: 'warning' };
|
||||
default:
|
||||
return { label: 'blindTest.statusProcessing', color: 'primary' };
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = status !== undefined ? getStatusConfig(status) : null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
...styles.header,
|
||||
bgcolor: 'background.paper',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
px: 3,
|
||||
py: 2,
|
||||
mb: 3
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<IconButton onClick={onBack} size="small" sx={{ bgcolor: 'action.hover' }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.1),
|
||||
p: 1,
|
||||
borderRadius: 1.5
|
||||
}}
|
||||
>
|
||||
<CompareArrowsIcon sx={{ fontSize: 24, color: 'primary.main' }} />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{statusConfig && (
|
||||
<Chip
|
||||
label={t(statusConfig.label)}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: alpha(theme.palette[statusConfig.color].main, 0.1),
|
||||
color: `${statusConfig.color}.main`,
|
||||
fontWeight: 600,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(theme.palette[statusConfig.color].main, 0.2),
|
||||
height: 24
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>{actions}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Fade,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import ThumbUpIcon from '@mui/icons-material/ThumbUp';
|
||||
import ThumbDownIcon from '@mui/icons-material/ThumbDown';
|
||||
import ThumbsUpDownIcon from '@mui/icons-material/ThumbsUpDown';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
import { blindTestStyles } from '@/styles/blindTest';
|
||||
|
||||
function AnswerBox({ title, modelLabel, answer, streaming, showThinking, setShowThinking, scrollRef, styles, theme }) {
|
||||
const { t } = useTranslation();
|
||||
const isLeft = modelLabel === 'A';
|
||||
const avatarColor = isLeft ? 'primary.main' : 'secondary.main';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
...styles.answerPaper,
|
||||
border: '1px solid',
|
||||
borderColor: streaming ? (isLeft ? 'primary.main' : 'secondary.main') : 'divider',
|
||||
boxShadow: streaming ? `0 0 0 2px ${alpha(theme.palette[isLeft ? 'primary' : 'secondary'].main, 0.1)}` : 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
...styles.answerHeader,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: avatarColor,
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
{modelLabel}
|
||||
</Avatar>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{streaming && <CircularProgress size={14} color={isLeft ? 'primary' : 'secondary'} />}
|
||||
</Box>
|
||||
{answer?.duration > 0 && !streaming && (
|
||||
<Chip
|
||||
label={`${(answer.duration / 1000).toFixed(1)}s`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.75rem', bgcolor: 'background.paper' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{answer?.error ? (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="error" variant="outlined">
|
||||
{answer.error}
|
||||
</Alert>
|
||||
</Box>
|
||||
) : (
|
||||
<Box ref={scrollRef} sx={{ ...styles.answerContent, flex: 1 }}>
|
||||
{/* 思维链渲染 */}
|
||||
{answer?.thinking && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
p: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.04),
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover': { bgcolor: alpha(theme.palette.text.primary, 0.08) },
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onClick={() => setShowThinking(!showThinking)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{answer.isThinking ? (
|
||||
<AutoFixHighIcon
|
||||
fontSize="small"
|
||||
color="primary"
|
||||
sx={{
|
||||
animation: 'thinking-pulse 1.5s infinite',
|
||||
'@keyframes thinking-pulse': {
|
||||
'0%': { opacity: 0.4 },
|
||||
'50%': { opacity: 1 },
|
||||
'100%': { opacity: 0.4 }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PsychologyIcon fontSize="small" color="action" />
|
||||
)}
|
||||
<Typography variant="caption" fontWeight="bold" color="text.secondary">
|
||||
{t('playground.reasoningProcess', '推理过程')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" sx={{ p: 0.5 }}>
|
||||
{showThinking ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Collapse in={showThinking}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'grey.50',
|
||||
borderRadius: 2,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
mb: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: 'text.secondary' }}>
|
||||
{answer.thinking}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{answer?.content ? (
|
||||
<div className="markdown-body" style={{ fontSize: '0.95rem', backgroundColor: 'transparent' }}>
|
||||
<ReactMarkdown>{answer.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : streaming ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
color: 'text.secondary',
|
||||
mt: 4,
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<SmartToyIcon sx={{ opacity: 0.5 }} />
|
||||
<Typography variant="body2">{t('blindTest.generatingAnswers', '正在生成回答...')}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlindTestInProgress({
|
||||
task,
|
||||
currentQuestion,
|
||||
leftAnswer,
|
||||
rightAnswer,
|
||||
streamingA,
|
||||
streamingB,
|
||||
answersLoading,
|
||||
voting,
|
||||
onVote,
|
||||
onReload
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const styles = blindTestStyles(theme);
|
||||
|
||||
const [showThinkingLeft, setShowThinkingLeft] = useState(true);
|
||||
const [showThinkingRight, setShowThinkingRight] = useState(true);
|
||||
|
||||
// 自动滚动引用
|
||||
const leftScrollRef = useRef(null);
|
||||
const rightScrollRef = useRef(null);
|
||||
|
||||
// 处理自动滚动
|
||||
useEffect(() => {
|
||||
if (streamingA && leftScrollRef.current) {
|
||||
leftScrollRef.current.scrollTop = leftScrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [leftAnswer?.content, leftAnswer?.thinking, streamingA]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamingB && rightScrollRef.current) {
|
||||
rightScrollRef.current.scrollTop = rightScrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [rightAnswer?.content, rightAnswer?.thinking, streamingB]);
|
||||
|
||||
const progress = task ? (task.completedCount / task.totalCount) * 100 : 0;
|
||||
|
||||
if (answersLoading && !currentQuestion) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={48} thickness={4} />
|
||||
<Typography color="text.secondary" variant="h6" sx={{ fontWeight: 500 }}>
|
||||
{t('blindTest.generatingAnswers', '正在准备题目...')}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentQuestion) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 12 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={onReload}
|
||||
sx={{ borderRadius: 3, px: 4, py: 1.5, boxShadow: 4 }}
|
||||
>
|
||||
{t('blindTest.loadQuestion', '加载题目')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, height: '100%' }}>
|
||||
{/* 顶部进度和问题 */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
...styles.questionPaper,
|
||||
p: 0,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
<Box sx={{ bgcolor: 'background.default', px: 3, py: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ minWidth: 80, fontWeight: 600 }}>
|
||||
{t('blindTest.progress', '进度')} {task.completedCount + 1}/{task.totalCount}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
bgcolor: 'action.hover',
|
||||
'& .MuiLinearProgress-bar': { borderRadius: 4 }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.5 }}>
|
||||
{currentQuestion.question}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* 回答区域 */}
|
||||
<Box sx={{ ...styles.answersContainer, gap: 3 }}>
|
||||
<AnswerBox
|
||||
title={t('blindTest.answerA', '回答 A')}
|
||||
modelLabel="A"
|
||||
answer={leftAnswer}
|
||||
streaming={streamingA}
|
||||
showThinking={showThinkingLeft}
|
||||
setShowThinking={setShowThinkingLeft}
|
||||
scrollRef={leftScrollRef}
|
||||
styles={styles}
|
||||
theme={theme}
|
||||
/>
|
||||
<AnswerBox
|
||||
title={t('blindTest.answerB', '回答 B')}
|
||||
modelLabel="B"
|
||||
answer={rightAnswer}
|
||||
streaming={streamingB}
|
||||
showThinking={showThinkingRight}
|
||||
setShowThinking={setShowThinkingRight}
|
||||
scrollRef={rightScrollRef}
|
||||
styles={styles}
|
||||
theme={theme}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 底部投票区域 */}
|
||||
<Paper
|
||||
elevation={10}
|
||||
sx={{
|
||||
...styles.voteBar,
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: alpha(theme.palette.background.paper, 0.9),
|
||||
backdropFilter: 'blur(20px)',
|
||||
width: 'fit-content',
|
||||
minWidth: 800,
|
||||
mx: 'auto'
|
||||
}}
|
||||
>
|
||||
<Box sx={styles.voteButtons}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<ThumbUpIcon />}
|
||||
onClick={() => onVote('left')}
|
||||
disabled={voting || streamingA || streamingB}
|
||||
sx={{ ...styles.voteBtn, bgcolor: 'primary.main', '&:hover': { bgcolor: 'primary.dark' } }}
|
||||
>
|
||||
{t('blindTest.leftBetter', '左边更好')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="success"
|
||||
size="large"
|
||||
startIcon={<ThumbsUpDownIcon />}
|
||||
onClick={() => onVote('both_good')}
|
||||
disabled={voting || streamingA || streamingB}
|
||||
sx={{ ...styles.voteBtn, borderWidth: 2, '&:hover': { borderWidth: 2 } }}
|
||||
>
|
||||
{t('blindTest.bothGood', '都好')}
|
||||
</Button>
|
||||
<Tooltip
|
||||
title={
|
||||
currentQuestion?.answer ? (
|
||||
<Box sx={{ p: 1, maxWidth: 400 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 0.5, fontWeight: 'bold' }}
|
||||
>
|
||||
<AssignmentIcon fontSize="small" color="primary" />
|
||||
{t('blindTest.referenceAnswer', '参考答案')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ whiteSpace: 'pre-wrap', maxHeight: 300, overflowY: 'auto', color: 'text.secondary' }}
|
||||
>
|
||||
{currentQuestion.answer}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
t('blindTest.noReferenceAnswer', '暂无参考答案')
|
||||
)
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
TransitionComponent={Fade}
|
||||
TransitionProps={{ timeout: 600 }}
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'grey.900' : 'background.paper',
|
||||
color: 'text.primary',
|
||||
boxShadow: theme.shadows[8],
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
p: 0,
|
||||
'& .MuiTooltip-arrow': {
|
||||
color: theme.palette.mode === 'dark' ? 'grey.900' : 'background.paper',
|
||||
'&::before': {
|
||||
border: `1px solid ${theme.palette.divider}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
size="large"
|
||||
startIcon={<InfoOutlinedIcon />}
|
||||
sx={{
|
||||
...styles.voteBtn,
|
||||
color: 'text.secondary',
|
||||
minWidth: 'auto',
|
||||
px: 2
|
||||
}}
|
||||
>
|
||||
{t('blindTest.referenceAnswer', '参考答案')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="large"
|
||||
startIcon={<ThumbDownIcon />}
|
||||
onClick={() => onVote('both_bad')}
|
||||
disabled={voting || streamingA || streamingB}
|
||||
sx={{ ...styles.voteBtn, borderWidth: 2, '&:hover': { borderWidth: 2 } }}
|
||||
>
|
||||
{t('blindTest.bothBad', '都不好')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
size="large"
|
||||
startIcon={<ThumbUpIcon />}
|
||||
onClick={() => onVote('right')}
|
||||
disabled={voting || streamingA || streamingB}
|
||||
sx={{ ...styles.voteBtn, bgcolor: 'secondary.main', '&:hover': { bgcolor: 'secondary.dark' } }}
|
||||
>
|
||||
{t('blindTest.rightBetter', '右边更好')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
LinearProgress,
|
||||
Avatar,
|
||||
Grid,
|
||||
Tooltip,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { alpha, useTheme } from '@mui/material/styles';
|
||||
|
||||
const STATUS_MAP = {
|
||||
0: { label: 'blindTest.statusProcessing', color: 'primary', bgColor: 'primary.main' },
|
||||
1: { label: 'blindTest.statusCompleted', color: 'success', bgColor: 'success.main' },
|
||||
2: { label: 'blindTest.statusFailed', color: 'error', bgColor: 'error.main' },
|
||||
3: { label: 'blindTest.statusInterrupted', color: 'warning', bgColor: 'warning.main' }
|
||||
};
|
||||
|
||||
export default function BlindTestTaskCard({ task, onView, onDelete, onInterrupt, onContinue }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
|
||||
const handleMenuOpen = e => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleView = e => {
|
||||
e?.stopPropagation?.();
|
||||
handleMenuClose();
|
||||
onView?.(task);
|
||||
};
|
||||
|
||||
const handleDelete = e => {
|
||||
e?.stopPropagation?.();
|
||||
handleMenuClose();
|
||||
onDelete?.(task);
|
||||
};
|
||||
|
||||
const handleInterrupt = e => {
|
||||
e?.stopPropagation?.();
|
||||
handleMenuClose();
|
||||
onInterrupt?.(task);
|
||||
};
|
||||
|
||||
const handleContinue = e => {
|
||||
e?.stopPropagation?.();
|
||||
handleMenuClose();
|
||||
onContinue?.(task);
|
||||
};
|
||||
|
||||
const statusConfig = STATUS_MAP[task.status] || STATUS_MAP[0];
|
||||
const progress = task.totalCount > 0 ? (task.completedCount / task.totalCount) * 100 : 0;
|
||||
const isProcessing = task.status === 0;
|
||||
const isCompleted = task.status === 1;
|
||||
|
||||
// 计算模型得分
|
||||
const results = task.detail?.results || [];
|
||||
const modelAScore = results.reduce((sum, r) => sum + (r.modelAScore || 0), 0);
|
||||
const modelBScore = results.reduce((sum, r) => sum + (r.modelBScore || 0), 0);
|
||||
const totalScore = modelAScore + modelBScore;
|
||||
|
||||
// Calculate win percentages for visual bar
|
||||
const modelAPercent = totalScore > 0 ? (modelAScore / totalScore) * 100 : 50;
|
||||
const modelBPercent = totalScore > 0 ? (modelBScore / totalScore) * 100 : 50;
|
||||
|
||||
const winner = isCompleted ? (modelAScore > modelBScore ? 'A' : modelBScore > modelAScore ? 'B' : 'Tie') : null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 3,
|
||||
overflow: 'visible',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
onClick={e => handleView(e)}
|
||||
>
|
||||
<CardContent sx={{ p: '20px !important' }}>
|
||||
<Grid container alignItems="center" spacing={3}>
|
||||
{/* Status & Time */}
|
||||
<Grid item xs={12} md={2} lg={1.5}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Chip
|
||||
label={t(statusConfig.label)}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: alpha(theme.palette[statusConfig.color].main, 0.1),
|
||||
color: `${statusConfig.color}.main`,
|
||||
fontWeight: 600,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(theme.palette[statusConfig.color].main, 0.2),
|
||||
width: 'fit-content'
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'text.secondary' }}>
|
||||
<AccessTimeIcon sx={{ fontSize: 14 }} />
|
||||
<Typography variant="caption" noWrap>
|
||||
{new Date(task.createAt).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Model Comparison Area */}
|
||||
<Grid item xs={12} md={9} lg={9.5}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
{/* Model A */}
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 2, minWidth: 0 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '1rem',
|
||||
boxShadow:
|
||||
winner === 'A'
|
||||
? `0 0 0 2px ${theme.palette.background.paper}, 0 0 0 4px ${theme.palette.primary.main}`
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
A
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip title={task.modelInfo?.modelA?.modelName}>
|
||||
<Typography variant="subtitle2" noWrap sx={{ fontWeight: 600 }}>
|
||||
{task.modelInfo?.modelA?.modelName || 'Model A'}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Typography variant="caption" color="text.secondary" noWrap display="block">
|
||||
{task.modelInfo?.modelA?.providerName}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isCompleted && winner === 'A' && <EmojiEventsIcon color="primary" />}
|
||||
</Box>
|
||||
|
||||
{/* Center Status/Score */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 140,
|
||||
textAlign: 'center',
|
||||
px: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, letterSpacing: 1, lineHeight: 1 }}>
|
||||
<span style={{ color: theme.palette.primary.main }}>{modelAScore.toFixed(1)}</span>
|
||||
<span style={{ color: theme.palette.text.disabled, margin: '0 4px', fontSize: '0.8em' }}>:</span>
|
||||
<span style={{ color: theme.palette.secondary.main }}>{modelBScore.toFixed(1)}</span>
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
bgcolor: 'grey.100'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: `${modelAPercent}%`, bgcolor: 'primary.main' }} />
|
||||
<Box sx={{ width: `${modelBPercent}%`, bgcolor: 'secondary.main' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
fontWeight="bold"
|
||||
sx={{ mb: 0.5, display: 'block' }}
|
||||
>
|
||||
VS
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{ height: 6, borderRadius: 3, bgcolor: 'action.hover' }}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5, display: 'block', fontSize: '0.7rem' }}
|
||||
>
|
||||
{Math.round(progress)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Model B */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
minWidth: 0,
|
||||
flexDirection: 'row-reverse'
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
bgcolor: 'secondary.main',
|
||||
fontSize: '1rem',
|
||||
boxShadow:
|
||||
winner === 'B'
|
||||
? `0 0 0 2px ${theme.palette.background.paper}, 0 0 0 4px ${theme.palette.secondary.main}`
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
B
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0, textAlign: 'right' }}>
|
||||
<Tooltip title={task.modelInfo?.modelB?.modelName}>
|
||||
<Typography variant="subtitle2" noWrap sx={{ fontWeight: 600 }}>
|
||||
{task.modelInfo?.modelB?.modelName || 'Model B'}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Typography variant="caption" color="text.secondary" noWrap display="block">
|
||||
{task.modelInfo?.modelB?.providerName}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isCompleted && winner === 'B' && <EmojiEventsIcon color="secondary" />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Menu */}
|
||||
<Grid item xs={12} md={1} lg={1} sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<IconButton size="small" onClick={handleMenuOpen} sx={{ color: 'text.secondary' }}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
|
||||
{/* 菜单 */}
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
PaperProps={{
|
||||
elevation: 2,
|
||||
sx: {
|
||||
mt: 1,
|
||||
minWidth: 160,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleView} sx={{ gap: 1.5, py: 1 }}>
|
||||
<VisibilityIcon fontSize="small" color="action" />
|
||||
<Typography variant="body2">{t('blindTest.viewDetails', '查看详情')}</Typography>
|
||||
</MenuItem>
|
||||
{isProcessing && (
|
||||
<MenuItem onClick={handleContinue} sx={{ gap: 1.5, py: 1 }}>
|
||||
<PlayArrowIcon fontSize="small" color="primary" />
|
||||
<Typography variant="body2">{t('blindTest.continue', '继续盲测')}</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
{isProcessing && (
|
||||
<MenuItem onClick={handleInterrupt} sx={{ gap: 1.5, py: 1 }}>
|
||||
<StopIcon fontSize="small" color="warning" />
|
||||
<Typography variant="body2">{t('blindTest.interrupt', '中断任务')}</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<MenuItem onClick={handleDelete} sx={{ gap: 1.5, py: 1, color: 'error.main' }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
<Typography variant="body2">{t('common.delete', '删除')}</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Divider,
|
||||
TextField,
|
||||
OutlinedInput,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { alpha, useTheme } from '@mui/material/styles';
|
||||
|
||||
export default function CreateBlindTestDialog({ open, onClose, projectId, onCreate }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
// 模型选择
|
||||
const [models, setModels] = useState([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelA, setModelA] = useState(null);
|
||||
const [modelB, setModelB] = useState(null);
|
||||
|
||||
// 题目选择
|
||||
const [questionTypes, setQuestionTypes] = useState(['short_answer', 'open_ended']);
|
||||
const [selectedTags, setSelectedTags] = useState([]);
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [questionCount, setQuestionCount] = useState(0);
|
||||
const [filteredCount, setFilteredCount] = useState(0);
|
||||
const [countLoading, setCountLoading] = useState(false);
|
||||
|
||||
// 提交状态
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 加载模型列表
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) return;
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
setModelsLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectId}/model-config`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.data) {
|
||||
setModels(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载模型失败:', err);
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchModels();
|
||||
}, [open, projectId]);
|
||||
|
||||
// 加载标签和题目数量
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) return;
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets?page=1&pageSize=1&includeStats=true`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.stats?.byTag) {
|
||||
setAvailableTags(Object.keys(result.stats.byTag).sort());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载统计失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [open, projectId]);
|
||||
|
||||
// 获取符合条件的题目数量
|
||||
const fetchFilteredCount = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
setCountLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// 只查询主观题
|
||||
questionTypes.forEach(t => params.append('questionTypes', t));
|
||||
selectedTags.forEach(t => params.append('tags', t));
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets/count?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
setFilteredCount(result.data?.total || 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取题目数量失败:', err);
|
||||
} finally {
|
||||
setCountLoading(false);
|
||||
}
|
||||
}, [projectId, questionTypes, selectedTags]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchFilteredCount();
|
||||
}
|
||||
}, [open, fetchFilteredCount]);
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
setModelA(null);
|
||||
setModelB(null);
|
||||
setQuestionTypes(['short_answer', 'open_ended']);
|
||||
setSelectedTags([]);
|
||||
setQuestionCount(0);
|
||||
setError('');
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
if (submitting) return;
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 提交创建
|
||||
const handleSubmit = async () => {
|
||||
// 验证
|
||||
if (!modelA) {
|
||||
setError(t('blindTest.errorSelectModelA', '请选择模型A'));
|
||||
return;
|
||||
}
|
||||
if (!modelB) {
|
||||
setError(t('blindTest.errorSelectModelB', '请选择模型B'));
|
||||
return;
|
||||
}
|
||||
if (modelA.id === modelB.id) {
|
||||
setError(t('blindTest.errorSameModel', '两个模型不能相同'));
|
||||
return;
|
||||
}
|
||||
if (filteredCount === 0) {
|
||||
setError(t('blindTest.errorNoQuestions', '没有符合条件的题目'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
// 获取题目ID列表
|
||||
const params = new URLSearchParams();
|
||||
questionTypes.forEach(t => params.append('questionTypes', t));
|
||||
selectedTags.forEach(t => params.append('tags', t));
|
||||
|
||||
const pageSize = questionCount > 0 ? questionCount : filteredCount;
|
||||
params.append('pageSize', pageSize.toString());
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.items || result.items.length === 0) {
|
||||
setError(t('blindTest.errorNoQuestions', '没有符合条件的题目'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 随机选择题目(如果指定了数量)
|
||||
let selectedIds = result.items.map(item => item.id);
|
||||
if (questionCount > 0 && questionCount < selectedIds.length) {
|
||||
// 随机抽取
|
||||
selectedIds = selectedIds.sort(() => Math.random() - 0.5).slice(0, questionCount);
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
const createResult = await onCreate({
|
||||
modelA: { modelId: modelA.modelId, providerId: modelA.providerId, id: modelA.id },
|
||||
modelB: { modelId: modelB.modelId, providerId: modelB.providerId, id: modelB.id },
|
||||
evalDatasetIds: selectedIds
|
||||
});
|
||||
|
||||
if (createResult.success) {
|
||||
handleClose();
|
||||
} else {
|
||||
setError(createResult.error || '创建失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建任务失败:', err);
|
||||
setError('创建任务失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const QUESTION_TYPES = [
|
||||
{ value: 'short_answer', labelKey: 'eval.questionTypes.short_answer' },
|
||||
{ value: 'open_ended', labelKey: 'eval.questionTypes.open_ended' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
backgroundImage: 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
pb: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1, borderRadius: 1.5, bgcolor: alpha(theme.palette.primary.main, 0.1), display: 'flex' }}>
|
||||
<CompareArrowsIcon color="primary" />
|
||||
</Box>
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{t('blindTest.createTitle', '创建盲测任务')}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ py: 3 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 模型选择 */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<SmartToyIcon fontSize="small" color="action" />
|
||||
{t('blindTest.selectModels', '选择对比模型')}
|
||||
</Typography>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 3, bgcolor: 'background.default' }}>
|
||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
||||
{/* 模型A */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Avatar sx={{ width: 24, height: 24, bgcolor: 'primary.main', fontSize: '0.75rem' }}>A</Avatar>
|
||||
<Typography variant="subtitle2" color="primary.main" fontWeight="bold">
|
||||
{t('blindTest.modelA', '模型 A')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={modelA?.id || ''}
|
||||
onChange={e => {
|
||||
const selected = models.find(m => m.id === e.target.value);
|
||||
setModelA(selected || null);
|
||||
}}
|
||||
displayEmpty
|
||||
disabled={modelsLoading}
|
||||
sx={{ bgcolor: 'background.paper' }}
|
||||
>
|
||||
<MenuItem value="" disabled>
|
||||
<Typography color="text.secondary">{t('common.select', '请选择')}</Typography>
|
||||
</MenuItem>
|
||||
{models.map(model => (
|
||||
<MenuItem key={model.id} value={model.id} disabled={model.id === modelB?.id}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{model.modelName || model.modelId}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{model.providerName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ alignSelf: 'center', pt: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'action.hover',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
VS
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 模型B */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Avatar sx={{ width: 24, height: 24, bgcolor: 'secondary.main', fontSize: '0.75rem' }}>B</Avatar>
|
||||
<Typography variant="subtitle2" color="secondary.main" fontWeight="bold">
|
||||
{t('blindTest.modelB', '模型 B')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={modelB?.id || ''}
|
||||
onChange={e => {
|
||||
const selected = models.find(m => m.id === e.target.value);
|
||||
setModelB(selected || null);
|
||||
}}
|
||||
displayEmpty
|
||||
disabled={modelsLoading}
|
||||
sx={{ bgcolor: 'background.paper' }}
|
||||
>
|
||||
<MenuItem value="" disabled>
|
||||
<Typography color="text.secondary">{t('common.select', '请选择')}</Typography>
|
||||
</MenuItem>
|
||||
{models.map(model => (
|
||||
<MenuItem key={model.id} value={model.id} disabled={model.id === modelA?.id}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{model.modelName || model.modelId}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{model.providerName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{models.length === 0 && !modelsLoading && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
{t('blindTest.noModelsAvailable', '暂无可用模型,请先在设置中配置模型')}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* 题目筛选 */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||
{t('blindTest.selectQuestions', '选择测试题目')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('blindTest.questionTypeHint', '盲测任务仅支持简答题和开放题')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{/* 题型筛选 */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('blindTest.questionType', '题型')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={questionTypes}
|
||||
onChange={e => setQuestionTypes(e.target.value)}
|
||||
input={<OutlinedInput label={t('blindTest.questionType', '题型')} />}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={t(`eval.questionTypes.${value}`)} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{QUESTION_TYPES.map(type => (
|
||||
<MenuItem key={type.value} value={type.value}>
|
||||
<Checkbox checked={questionTypes.includes(type.value)} />
|
||||
<ListItemText primary={t(type.labelKey)} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
{availableTags.length > 0 && (
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('blindTest.filterByTag', '按标签筛选')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={selectedTags}
|
||||
onChange={e => setSelectedTags(e.target.value)}
|
||||
input={<OutlinedInput label={t('blindTest.filterByTag', '按标签筛选')} />}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{availableTags.map(tag => (
|
||||
<MenuItem key={tag} value={tag}>
|
||||
<Checkbox checked={selectedTags.includes(tag)} />
|
||||
<ListItemText primary={tag} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 题目数量 */}
|
||||
<Box sx={{ p: 2, bgcolor: 'background.default', borderRadius: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label={t('blindTest.questionCount', '题目数量')}
|
||||
value={questionCount}
|
||||
onChange={e => setQuestionCount(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
inputProps={{ min: 0, max: filteredCount }}
|
||||
sx={{ width: 150, bgcolor: 'background.paper' }}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{countLoading ? (
|
||||
<CircularProgress size={14} />
|
||||
) : (
|
||||
t('blindTest.availableQuestions', '可用题目:{{count}} 道', { count: filteredCount })
|
||||
)}
|
||||
</Typography>
|
||||
{filteredCount > 0 && (
|
||||
<Chip
|
||||
label={t('common.ready', '就绪')}
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
sx={{ height: 20 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
{questionCount === 0
|
||||
? t('blindTest.useAllQuestions', '使用全部筛选结果')
|
||||
: t('blindTest.randomSample', '将随机抽取 {{count}} 道题目', { count: questionCount })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 3, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Button onClick={handleClose} disabled={submitting} color="inherit" size="large">
|
||||
{t('common.cancel', '取消')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="contained"
|
||||
disabled={submitting || !modelA || !modelB || filteredCount === 0}
|
||||
startIcon={submitting ? <CircularProgress size={16} color="inherit" /> : <CompareArrowsIcon />}
|
||||
size="large"
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
{submitting ? t('blindTest.creating', '创建中...') : t('blindTest.startBlindTest', '开始盲测')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { Box, Paper, Typography, Chip, Collapse, IconButton, Avatar, Divider, Grid } from '@mui/material';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle';
|
||||
import HelpIcon from '@mui/icons-material/Help';
|
||||
import { useState } from 'react';
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
|
||||
// 解析包含 <think> 标签的内容
|
||||
const parseAnswerContent = text => {
|
||||
if (!text) return { thinking: '', content: '' };
|
||||
|
||||
// 匹配 <think>...</think> 内容
|
||||
const thinkMatch = text.match(/<think>([\s\S]*?)<\/think>/);
|
||||
|
||||
if (thinkMatch) {
|
||||
return {
|
||||
thinking: thinkMatch[1].trim(),
|
||||
content: text.replace(/<think>[\s\S]*?<\/think>/, '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
return { thinking: '', content: text };
|
||||
};
|
||||
|
||||
function ResultAnswerSection({ title, rawContent, isWinner, modelLabel, t, theme }) {
|
||||
const { thinking, content } = parseAnswerContent(rawContent);
|
||||
const [showThinking, setShowThinking] = useState(false);
|
||||
|
||||
const isLeft = modelLabel.includes('A') || title.includes('左');
|
||||
const avatarColor = isLeft ? 'primary.main' : 'secondary.main';
|
||||
|
||||
return (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
bgcolor: avatarColor,
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
{modelLabel}
|
||||
</Avatar>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isWinner && (
|
||||
<Chip
|
||||
icon={<EmojiEventsIcon sx={{ fontSize: '1rem !important' }} />}
|
||||
label={t('blindTest.winner', '胜出')}
|
||||
size="small"
|
||||
color={isLeft ? 'primary' : 'secondary'}
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 3,
|
||||
flex: 1,
|
||||
bgcolor: isWinner
|
||||
? alpha(isLeft ? theme.palette.primary.main : theme.palette.secondary.main, 0.02)
|
||||
: 'background.paper',
|
||||
borderColor: isWinner
|
||||
? alpha(isLeft ? theme.palette.primary.main : theme.palette.secondary.main, 0.3)
|
||||
: 'divider',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 思维链展示 */}
|
||||
{thinking && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
mb: 1.5,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.05),
|
||||
'&:hover': { bgcolor: alpha(theme.palette.text.primary, 0.08) },
|
||||
width: 'fit-content'
|
||||
}}
|
||||
onClick={() => setShowThinking(!showThinking)}
|
||||
>
|
||||
<PsychologyIcon fontSize="small" color="action" />
|
||||
<Typography variant="caption" fontWeight="bold" color="text.secondary">
|
||||
{t('playground.reasoningProcess', '推理过程')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: 1 }}>
|
||||
{showThinking ? (
|
||||
<ExpandLessIcon fontSize="small" color="action" />
|
||||
) : (
|
||||
<ExpandMoreIcon fontSize="small" color="action" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Collapse in={showThinking}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.3)' : 'grey.50',
|
||||
borderRadius: 2,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
mb: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: 'text.secondary' }}>
|
||||
{thinking}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 正文内容 */}
|
||||
<div className="markdown-body" style={{ fontSize: '0.95rem', backgroundColor: 'transparent' }}>
|
||||
<ReactMarkdown>{content || '-'}</ReactMarkdown>
|
||||
</div>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultItem({ result, index, task, question }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Determine vote icon and color
|
||||
let VoteIcon = HelpIcon;
|
||||
let voteColor = 'default';
|
||||
let voteLabel = '';
|
||||
|
||||
switch (result.vote) {
|
||||
case 'left':
|
||||
VoteIcon = CheckCircleIcon;
|
||||
voteColor = 'primary';
|
||||
voteLabel = t('blindTest.leftBetter', '左边更好');
|
||||
break;
|
||||
case 'right':
|
||||
VoteIcon = CheckCircleIcon;
|
||||
voteColor = 'secondary';
|
||||
voteLabel = t('blindTest.rightBetter', '右边更好');
|
||||
break;
|
||||
case 'both_good':
|
||||
VoteIcon = CheckCircleIcon;
|
||||
voteColor = 'success';
|
||||
voteLabel = t('blindTest.bothGood', '都好');
|
||||
break;
|
||||
case 'both_bad':
|
||||
VoteIcon = CancelIcon;
|
||||
voteColor = 'error';
|
||||
voteLabel = t('blindTest.bothBad', '都不好');
|
||||
break;
|
||||
default:
|
||||
VoteIcon = RemoveCircleIcon;
|
||||
voteLabel = t('blindTest.ties', '平局');
|
||||
}
|
||||
|
||||
// Determine Model labels based on swap status
|
||||
const leftModelName = result.isSwapped ? task.modelInfo?.modelB?.modelName : task.modelInfo?.modelA?.modelName;
|
||||
const rightModelName = result.isSwapped ? task.modelInfo?.modelA?.modelName : task.modelInfo?.modelB?.modelName;
|
||||
const leftModelLabel = result.isSwapped ? 'B' : 'A';
|
||||
const rightModelLabel = result.isSwapped ? 'A' : 'B';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
mb: 2,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: expanded ? 'primary.main' : 'divider',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
boxShadow: `0 4px 12px ${alpha(theme.palette.primary.main, 0.08)}`
|
||||
}
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
{/* 头部摘要 */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
bgcolor: expanded ? alpha(theme.palette.primary.main, 0.02) : 'transparent'
|
||||
}}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, overflow: 'hidden', flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'action.hover',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
#{index + 1}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body1"
|
||||
noWrap
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
flex: 1,
|
||||
maxWidth: { xs: 200, md: 800 }
|
||||
}}
|
||||
>
|
||||
{question?.question || result.questionId}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<VoteIcon fontSize="small" />}
|
||||
label={voteLabel}
|
||||
color={voteColor === 'default' ? 'default' : voteColor}
|
||||
variant={result.vote === 'both_good' || result.vote === 'both_bad' ? 'outlined' : 'filled'}
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.3s'
|
||||
}}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 展开详情 */}
|
||||
<Collapse in={expanded}>
|
||||
<Divider />
|
||||
<Box sx={{ p: 4, bgcolor: 'background.default' }}>
|
||||
<Box
|
||||
sx={{
|
||||
mb: 4,
|
||||
p: 3,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
QUESTION
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.6 }}>
|
||||
{question?.question}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* 左侧详情 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<ResultAnswerSection
|
||||
title={`${leftModelName}`}
|
||||
modelLabel={leftModelLabel}
|
||||
rawContent={result.leftAnswer}
|
||||
isWinner={result.vote === 'left'}
|
||||
t={t}
|
||||
theme={theme}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 右侧详情 */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<ResultAnswerSection
|
||||
title={`${rightModelName}`}
|
||||
modelLabel={rightModelLabel}
|
||||
rawContent={result.rightAnswer}
|
||||
isWinner={result.vote === 'right'}
|
||||
t={t}
|
||||
theme={theme}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResultDetailList({ task }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3, fontWeight: 700 }}>
|
||||
{t('blindTest.detailResults', '详细结果')}
|
||||
</Typography>
|
||||
|
||||
{task.detail?.results?.map((result, index) => {
|
||||
const question = task.evalDatasets?.find(q => q.id === result.questionId);
|
||||
return <ResultItem key={index} result={result} index={index} task={task} question={question} />;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Box, Paper, Typography, Card, CardContent, Chip, Grid, Avatar } from '@mui/material';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { blindTestStyles } from '@/styles/blindTest';
|
||||
|
||||
export default function ResultSummary({ stats, modelInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const totalScore = stats.modelAScore + stats.modelBScore;
|
||||
const modelAPercent = totalScore > 0 ? (stats.modelAScore / totalScore) * 100 : 50;
|
||||
const modelBPercent = totalScore > 0 ? (stats.modelBScore / totalScore) * 100 : 50;
|
||||
|
||||
const winner = stats.modelAScore > stats.modelBScore ? 'A' : stats.modelBScore > stats.modelAScore ? 'B' : 'tie';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, 1)} 0%, ${alpha(theme.palette.background.default, 0.5)} 100%)`
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 4, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<EmojiEventsIcon color="warning" />
|
||||
{t('blindTest.resultSummary', '评测结果汇总')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={4} alignItems="center">
|
||||
{/* Model A */}
|
||||
<Grid item xs={12} md={5}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: winner === 'A' ? 'primary.main' : 'divider',
|
||||
bgcolor: winner === 'A' ? alpha(theme.palette.primary.main, 0.05) : 'background.paper',
|
||||
position: 'relative',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
>
|
||||
{winner === 'A' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
bgcolor: 'primary.main',
|
||||
color: 'white',
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderRadius: 10,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: 2
|
||||
}}
|
||||
>
|
||||
WINNER
|
||||
</Box>
|
||||
)}
|
||||
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
bgcolor: 'primary.main',
|
||||
mb: 2,
|
||||
mx: 'auto',
|
||||
fontSize: '1.25rem'
|
||||
}}
|
||||
>
|
||||
A
|
||||
</Avatar>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }} noWrap>
|
||||
{modelInfo?.modelA?.modelName || 'Model A'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={modelInfo?.modelA?.providerName}
|
||||
size="small"
|
||||
sx={{ mb: 2, bgcolor: 'background.default' }}
|
||||
/>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h3" color="primary.main" sx={{ fontWeight: 800 }}>
|
||||
{stats.modelAScore.toFixed(1)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('blindTest.wins', '胜出')}: {stats.modelAWins}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* VS / Progress */}
|
||||
<Grid item xs={12} md={2}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="text.disabled" sx={{ fontWeight: 900, opacity: 0.2, mb: 2 }}>
|
||||
VS
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', height: 8, borderRadius: 4, overflow: 'hidden', width: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${modelAPercent}%`,
|
||||
bgcolor: 'primary.main',
|
||||
transition: 'width 1s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${modelBPercent}%`,
|
||||
bgcolor: 'secondary.main',
|
||||
transition: 'width 1s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Model B */}
|
||||
<Grid item xs={12} md={5}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: winner === 'B' ? 'secondary.main' : 'divider',
|
||||
bgcolor: winner === 'B' ? alpha(theme.palette.secondary.main, 0.05) : 'background.paper',
|
||||
position: 'relative',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
>
|
||||
{winner === 'B' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
bgcolor: 'secondary.main',
|
||||
color: 'white',
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderRadius: 10,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: 2
|
||||
}}
|
||||
>
|
||||
WINNER
|
||||
</Box>
|
||||
)}
|
||||
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
bgcolor: 'secondary.main',
|
||||
mb: 2,
|
||||
mx: 'auto',
|
||||
fontSize: '1.25rem'
|
||||
}}
|
||||
>
|
||||
B
|
||||
</Avatar>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }} noWrap>
|
||||
{modelInfo?.modelB?.modelName || 'Model B'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={modelInfo?.modelB?.providerName}
|
||||
size="small"
|
||||
sx={{ mb: 2, bgcolor: 'background.default' }}
|
||||
/>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h3" color="secondary.main" sx={{ fontWeight: 800 }}>
|
||||
{stats.modelBScore.toFixed(1)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('blindTest.wins', '胜出')}: {stats.modelBWins}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* 底部统计条 */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 4,
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'background.default',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={2} justifyContent="center" alignItems="center">
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{stats.totalQuestions}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('blindTest.totalQuestions', '总题数')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5" color="success.main" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{stats.bothGood}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('blindTest.bothGood', '都好')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5" color="error.main" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{stats.bothBad}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('blindTest.bothBad', '都不好')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h5" color="text.secondary" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{stats.ties}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('blindTest.ties', '平局')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 盲测任务详情和盲测过程管理 Hook
|
||||
*/
|
||||
export default function useBlindTestDetail(projectId, taskId) {
|
||||
// 任务详情
|
||||
const [task, setTask] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 当前题目状态
|
||||
const [currentQuestion, setCurrentQuestion] = useState(null);
|
||||
const [leftAnswer, setLeftAnswer] = useState(null);
|
||||
const [rightAnswer, setRightAnswer] = useState(null);
|
||||
const [isSwapped, setIsSwapped] = useState(false);
|
||||
const [answersLoading, setAnswersLoading] = useState(false);
|
||||
|
||||
// 流式输出状态
|
||||
const [streamingA, setStreamingA] = useState(false);
|
||||
const [streamingB, setStreamingB] = useState(false);
|
||||
const abortControllerRef = useRef(null);
|
||||
const hasAutoLoadedRef = useRef(false);
|
||||
|
||||
// 投票状态
|
||||
const [voting, setVoting] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
// 加载任务详情
|
||||
const loadTask = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!projectId || !taskId) return;
|
||||
|
||||
try {
|
||||
if (!silent) setLoading(true);
|
||||
setError('');
|
||||
// 添加时间戳防止缓存
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}?t=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Pragma: 'no-cache',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
console.log('任务状态更新:', result.data.completedCount, '/', result.data.totalCount);
|
||||
setTask(result.data);
|
||||
// 检查任务是否已完成 (0=进行中, 1=已完成, 2=失败, 3=已中断)
|
||||
if (result.data.status !== 0) {
|
||||
setCompleted(true);
|
||||
}
|
||||
} else {
|
||||
if (!silent) setError(result.error || '加载任务详情失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载任务详情失败:', err);
|
||||
if (!silent) setError('加载任务详情失败');
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId, taskId]
|
||||
);
|
||||
|
||||
// 流式获取当前题目和模型回答
|
||||
const fetchCurrentQuestion = useCallback(async () => {
|
||||
if (!projectId || !taskId) return;
|
||||
|
||||
// 取消上一次的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
setAnswersLoading(true);
|
||||
setError('');
|
||||
setCurrentQuestion(null);
|
||||
setLeftAnswer({ fullContent: '', content: '', thinking: '', isThinking: false, duration: 0, error: null });
|
||||
setRightAnswer({ fullContent: '', content: '', thinking: '', isThinking: false, duration: 0, error: null });
|
||||
|
||||
// 1. 先获取题目信息
|
||||
const questionRes = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}/question`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!questionRes.ok) throw new Error('获取题目失败');
|
||||
|
||||
const questionData = await questionRes.json();
|
||||
|
||||
if (questionData.completed) {
|
||||
setCompleted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentQuestion({
|
||||
id: questionData.questionId,
|
||||
question: questionData.question,
|
||||
answer: questionData.answer,
|
||||
index: questionData.questionIndex,
|
||||
total: questionData.totalQuestions
|
||||
});
|
||||
setIsSwapped(questionData.isSwapped);
|
||||
setCompleted(false);
|
||||
|
||||
// 2. 并行调用两个模型的流式接口
|
||||
setStreamingA(true);
|
||||
setStreamingB(true);
|
||||
|
||||
const processStream = async (modelType, setAnswer, setStreaming) => {
|
||||
const modelStartTime = Date.now();
|
||||
try {
|
||||
const streamUrl = `/api/projects/${projectId}/blind-test-tasks/${taskId}/stream-model?model=${modelType}`;
|
||||
const response = await fetch(streamUrl, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`模型${modelType}调用失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let fullContent = '';
|
||||
let currentContent = '';
|
||||
let currentThinking = '';
|
||||
let isInThinking = false;
|
||||
let pendingBuffer = ''; // 用于处理跨 chunk 的标签识别
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
pendingBuffer += chunk;
|
||||
|
||||
// 处理缓冲区中的内容
|
||||
while (pendingBuffer.length > 0) {
|
||||
// 如果正在思考中,寻找结束标签
|
||||
if (isInThinking) {
|
||||
const endTagIndex = pendingBuffer.indexOf('</think>');
|
||||
if (endTagIndex !== -1) {
|
||||
const thinkingPart = pendingBuffer.substring(0, endTagIndex);
|
||||
currentThinking += thinkingPart;
|
||||
fullContent += thinkingPart + '</think>';
|
||||
isInThinking = false;
|
||||
pendingBuffer = pendingBuffer.substring(endTagIndex + 8);
|
||||
continue;
|
||||
} else {
|
||||
// 没有找到结束标签,但可能缓冲区末尾包含了部分结束标签
|
||||
// 保留最后 7 个字符("</think>" 长度为 8)以防被截断
|
||||
const safeLength = Math.max(0, pendingBuffer.length - 7);
|
||||
const processingPart = pendingBuffer.substring(0, safeLength);
|
||||
currentThinking += processingPart;
|
||||
fullContent += processingPart;
|
||||
pendingBuffer = pendingBuffer.substring(safeLength);
|
||||
break; // 等待下一个 chunk
|
||||
}
|
||||
} else {
|
||||
// 不在思考中,寻找开始标签
|
||||
const startTagIndex = pendingBuffer.indexOf('<think>');
|
||||
if (startTagIndex !== -1) {
|
||||
const contentPart = pendingBuffer.substring(0, startTagIndex);
|
||||
currentContent += contentPart;
|
||||
fullContent += contentPart + '<think>';
|
||||
isInThinking = true;
|
||||
pendingBuffer = pendingBuffer.substring(startTagIndex + 7);
|
||||
continue;
|
||||
} else {
|
||||
// 没有找到开始标签,保留最后 6 个字符以防开始标签被截断
|
||||
const safeLength = Math.max(0, pendingBuffer.length - 6);
|
||||
const processingPart = pendingBuffer.substring(0, safeLength);
|
||||
currentContent += processingPart;
|
||||
fullContent += processingPart;
|
||||
pendingBuffer = pendingBuffer.substring(safeLength);
|
||||
break; // 等待下一个 chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAnswer(prev => ({
|
||||
...prev,
|
||||
fullContent,
|
||||
content: currentContent,
|
||||
thinking: currentThinking,
|
||||
isThinking: isInThinking
|
||||
}));
|
||||
}
|
||||
|
||||
const modelDuration = Date.now() - modelStartTime;
|
||||
setAnswer(prev => ({ ...prev, duration: modelDuration }));
|
||||
setStreaming(false);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error(`模型${modelType}错误:`, err);
|
||||
const modelDuration = Date.now() - modelStartTime;
|
||||
setAnswer(prev => ({
|
||||
...prev,
|
||||
error: err.message,
|
||||
duration: modelDuration
|
||||
}));
|
||||
setStreaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 根据是否交换决定左右对应的模型
|
||||
const leftModel = questionData.isSwapped ? 'B' : 'A';
|
||||
const rightModel = questionData.isSwapped ? 'A' : 'B';
|
||||
|
||||
await Promise.all([
|
||||
processStream(leftModel, setLeftAnswer, setStreamingA),
|
||||
processStream(rightModel, setRightAnswer, setStreamingB)
|
||||
]);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('获取题目失败:', err);
|
||||
setError(err.message || '获取当前题目失败');
|
||||
setStreamingA(false);
|
||||
setStreamingB(false);
|
||||
} finally {
|
||||
// 只有当前请求未被取消时才重置loading
|
||||
if (abortControllerRef.current === controller) {
|
||||
setAnswersLoading(false);
|
||||
}
|
||||
}
|
||||
}, [projectId, taskId]);
|
||||
|
||||
// 提交投票
|
||||
const submitVote = useCallback(
|
||||
async vote => {
|
||||
if (!projectId || !taskId || !currentQuestion) return { success: false };
|
||||
|
||||
try {
|
||||
setVoting(true);
|
||||
setError('');
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
vote,
|
||||
questionId: currentQuestion.id,
|
||||
isSwapped,
|
||||
// 使用 fullContent 提交,包含思考过程
|
||||
leftAnswer: leftAnswer?.fullContent || leftAnswer?.content || '',
|
||||
rightAnswer: rightAnswer?.fullContent || rightAnswer?.content || ''
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
// 等待任务状态更新(进度条)
|
||||
await loadTask(true);
|
||||
|
||||
if (result.data.isCompleted) {
|
||||
setCompleted(true);
|
||||
} else {
|
||||
// 获取下一题
|
||||
await fetchCurrentQuestion();
|
||||
}
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
setError(result.error || '提交投票失败');
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('提交投票失败:', err);
|
||||
setError('提交投票失败');
|
||||
return { success: false, error: '提交投票失败' };
|
||||
} finally {
|
||||
setVoting(false);
|
||||
}
|
||||
},
|
||||
[projectId, taskId, currentQuestion, isSwapped, leftAnswer, rightAnswer, loadTask, fetchCurrentQuestion]
|
||||
);
|
||||
|
||||
// 中断任务
|
||||
const interruptTask = useCallback(async () => {
|
||||
if (!projectId || !taskId) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'interrupt' })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
setCompleted(true);
|
||||
loadTask();
|
||||
return true;
|
||||
} else {
|
||||
setError(result.error || '中断任务失败');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('中断任务失败:', err);
|
||||
setError('中断任务失败');
|
||||
return false;
|
||||
}
|
||||
}, [projectId, taskId, loadTask]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadTask();
|
||||
}, [loadTask]);
|
||||
|
||||
// 任务加载完成后,如果任务进行中,自动获取当前题目(只执行一次)
|
||||
useEffect(() => {
|
||||
if (task && task.status === 0 && !completed && !hasAutoLoadedRef.current && projectId && taskId) {
|
||||
hasAutoLoadedRef.current = true;
|
||||
fetchCurrentQuestion();
|
||||
}
|
||||
}, [task, completed, projectId, taskId, fetchCurrentQuestion]);
|
||||
|
||||
// 计算结果统计
|
||||
const getResultStats = useCallback(() => {
|
||||
if (!task?.detail?.results) return null;
|
||||
|
||||
const results = task.detail.results;
|
||||
const totalModelAScore = results.reduce((sum, r) => sum + (r.modelAScore || 0), 0);
|
||||
const totalModelBScore = results.reduce((sum, r) => sum + (r.modelBScore || 0), 0);
|
||||
|
||||
const leftWins = results.filter(r => r.vote === 'left').length;
|
||||
const rightWins = results.filter(r => r.vote === 'right').length;
|
||||
const bothGood = results.filter(r => r.vote === 'both_good').length;
|
||||
const bothBad = results.filter(r => r.vote === 'both_bad').length;
|
||||
|
||||
// 计算实际模型胜出次数(需要考虑 swap)
|
||||
const modelAWins = results.filter(r => {
|
||||
if (r.vote === 'left' && !r.isSwapped) return true;
|
||||
if (r.vote === 'right' && r.isSwapped) return true;
|
||||
return false;
|
||||
}).length;
|
||||
|
||||
const modelBWins = results.filter(r => {
|
||||
if (r.vote === 'left' && r.isSwapped) return true;
|
||||
if (r.vote === 'right' && !r.isSwapped) return true;
|
||||
return false;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
totalQuestions: results.length,
|
||||
modelAScore: totalModelAScore,
|
||||
modelBScore: totalModelBScore,
|
||||
modelAWins,
|
||||
modelBWins,
|
||||
ties: bothGood + bothBad,
|
||||
bothGood,
|
||||
bothBad,
|
||||
leftWins,
|
||||
rightWins
|
||||
};
|
||||
}, [task]);
|
||||
|
||||
// 组件卸载时取消请求
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 任务详情
|
||||
task,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
loadTask,
|
||||
|
||||
// 当前题目状态
|
||||
currentQuestion,
|
||||
leftAnswer,
|
||||
rightAnswer,
|
||||
answersLoading,
|
||||
|
||||
// 流式状态
|
||||
streamingA,
|
||||
streamingB,
|
||||
|
||||
// 投票状态
|
||||
voting,
|
||||
completed,
|
||||
|
||||
// 操作
|
||||
fetchCurrentQuestion,
|
||||
submitVote,
|
||||
interruptTask,
|
||||
|
||||
// 结果统计
|
||||
getResultStats
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 盲测任务列表管理 Hook
|
||||
*/
|
||||
export default function useBlindTestTasks(projectId) {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(6);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 加载任务列表
|
||||
const loadTasks = useCallback(
|
||||
async (isRefresh = false) => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
if (!isRefresh) setLoading(true);
|
||||
setError('');
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks?page=${page}&pageSize=${pageSize}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
setTasks(result.data.items || []);
|
||||
setTotal(result.data.total || 0);
|
||||
} else {
|
||||
setError(result.error || '加载失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载盲测任务失败:', err);
|
||||
setError('加载失败');
|
||||
} finally {
|
||||
if (!isRefresh) setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId, page, pageSize]
|
||||
);
|
||||
|
||||
// 初始加载和分页变化加载
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
// 删除任务
|
||||
const deleteTask = useCallback(
|
||||
async taskId => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
loadTasks();
|
||||
return true;
|
||||
} else {
|
||||
setError(result.error || '删除失败');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('删除任务失败:', err);
|
||||
setError('删除失败');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, loadTasks]
|
||||
);
|
||||
|
||||
// 中断任务
|
||||
const interruptTask = useCallback(
|
||||
async taskId => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'interrupt' })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
loadTasks();
|
||||
return true;
|
||||
} else {
|
||||
setError(result.error || '中断失败');
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('中断任务失败:', err);
|
||||
setError('中断失败');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, loadTasks]
|
||||
);
|
||||
|
||||
// 创建任务
|
||||
const createTask = useCallback(
|
||||
async taskData => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/blind-test-tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(taskData)
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
loadTasks();
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
return { success: false, error: result.error || '创建失败' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建任务失败:', err);
|
||||
return { success: false, error: '创建失败' };
|
||||
}
|
||||
},
|
||||
[projectId, loadTasks]
|
||||
);
|
||||
|
||||
return {
|
||||
tasks,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
loadTasks,
|
||||
deleteTask,
|
||||
interruptTask,
|
||||
createTask,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
total
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
TablePagination
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import useBlindTestTasks from './hooks/useBlindTestTasks';
|
||||
import BlindTestTaskCard from './components/BlindTestTaskCard';
|
||||
import CreateBlindTestDialog from './components/CreateBlindTestDialog';
|
||||
|
||||
export default function BlindTestTasksPage() {
|
||||
const { projectId } = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
tasks,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
deleteTask,
|
||||
interruptTask,
|
||||
createTask,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
total
|
||||
} = useBlindTestTasks(projectId);
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, task: null });
|
||||
const [interruptDialog, setInterruptDialog] = useState({ open: false, task: null });
|
||||
|
||||
const handleView = task => router.push(`/projects/${projectId}/blind-test-tasks/${task.id}`);
|
||||
const handleContinue = task => router.push(`/projects/${projectId}/blind-test-tasks/${task.id}`);
|
||||
const handleDelete = task => setDeleteDialog({ open: true, task });
|
||||
const handleInterrupt = task => setInterruptDialog({ open: true, task });
|
||||
|
||||
const handlePageChange = (event, newPage) => {
|
||||
setPage(newPage + 1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = event => {
|
||||
setPageSize(parseInt(event.target.value, 10));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteDialog.task) {
|
||||
await deleteTask(deleteDialog.task.id);
|
||||
}
|
||||
setDeleteDialog({ open: false, task: null });
|
||||
};
|
||||
|
||||
const confirmInterrupt = async () => {
|
||||
if (interruptDialog.task) {
|
||||
await interruptTask(interruptDialog.task.id);
|
||||
}
|
||||
setInterruptDialog({ open: false, task: null });
|
||||
};
|
||||
|
||||
const handleCreate = async taskData => {
|
||||
const result = await createTask(taskData);
|
||||
if (result.success) {
|
||||
// 创建成功后跳转到任务详情页开始盲测
|
||||
router.push(`/projects/${projectId}/blind-test-tasks/${result.data.id}`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
{/* 页面标题 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CompareArrowsIcon sx={{ fontSize: 28, color: 'primary.main' }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
{t('blindTest.title', '人工盲测任务')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateDialogOpen(true)}>
|
||||
{t('blindTest.createTask', '创建任务')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && tasks.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
py: 8,
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<CompareArrowsIcon sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
{t('blindTest.noTasks', '暂无盲测任务')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.disabled" sx={{ mb: 3 }}>
|
||||
{t('blindTest.noTasksHint', '创建盲测任务来对比两个模型的回答质量')}
|
||||
</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateDialogOpen(true)}>
|
||||
{t('blindTest.createTask', '创建任务')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 任务列表 */}
|
||||
{!loading && tasks.length > 0 && (
|
||||
<>
|
||||
<Grid container spacing={2}>
|
||||
{tasks.map(task => (
|
||||
<Grid item xs={12} key={task.id}>
|
||||
<BlindTestTaskCard
|
||||
task={task}
|
||||
onView={handleView}
|
||||
onDelete={handleDelete}
|
||||
onInterrupt={handleInterrupt}
|
||||
onContinue={handleContinue}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page - 1}
|
||||
onPageChange={handlePageChange}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={handlePageSizeChange}
|
||||
rowsPerPageOptions={[6, 12, 24, 48]}
|
||||
labelRowsPerPage={t('datasets.rowsPerPage', '每页行数')}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建对话框 */}
|
||||
<CreateBlindTestDialog
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
projectId={projectId}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialog.open} onClose={() => setDeleteDialog({ open: false, task: null })}>
|
||||
<DialogTitle>{t('blindTest.deleteConfirmTitle', '确认删除')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('blindTest.deleteConfirmMessage', '确定要删除这个盲测任务吗?此操作不可撤销。')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialog({ open: false, task: null })}>{t('common.cancel', '取消')}</Button>
|
||||
<Button color="error" variant="contained" onClick={confirmDelete}>
|
||||
{t('common.delete', '删除')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 中断确认对话框 */}
|
||||
<Dialog open={interruptDialog.open} onClose={() => setInterruptDialog({ open: false, task: null })}>
|
||||
<DialogTitle>{t('blindTest.interruptConfirmTitle', '确认中断')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('blindTest.interruptConfirmMessage', '确定要中断这个盲测任务吗?已完成的评判结果将保留。')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setInterruptDialog({ open: false, task: null })}>{t('common.cancel', '取消')}</Button>
|
||||
<Button color="warning" variant="contained" onClick={confirmInterrupt}>
|
||||
{t('blindTest.interrupt', '中断')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user