first-update
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
FormHelperText
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ModelSelector from './ModelSelector';
|
||||
import QuestionFilter from './QuestionFilter';
|
||||
import ScoreAnchorsForm from './ScoreAnchorsForm';
|
||||
import { useEvalTaskForm } from '../hooks/useEvalTaskForm';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function CreateEvalTaskDialog({ open, onClose, projectId, onSuccess }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
models,
|
||||
selectedModels,
|
||||
setSelectedModels,
|
||||
judgeModel,
|
||||
setJudgeModel,
|
||||
evalDatasets,
|
||||
availableTags,
|
||||
questionTypes,
|
||||
setQuestionTypes,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
searchKeyword,
|
||||
setSearchKeyword,
|
||||
questionCount,
|
||||
setQuestionCount,
|
||||
filteredTotal,
|
||||
sampledIds,
|
||||
hasSubjectiveQuestions,
|
||||
hasShortAnswer,
|
||||
hasOpenEnded,
|
||||
shortAnswerScoreAnchors,
|
||||
setShortAnswerScoreAnchors,
|
||||
openEndedScoreAnchors,
|
||||
setOpenEndedScoreAnchors,
|
||||
initScoreAnchors,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
setSampledIds,
|
||||
resetFilters,
|
||||
resetForm
|
||||
} = useEvalTaskForm(projectId, open);
|
||||
|
||||
// 当有主观题时,初始化评分规则
|
||||
useEffect(() => {
|
||||
if (hasSubjectiveQuestions && open) {
|
||||
initScoreAnchors(i18n.language === 'zh-CN' ? 'zh-CN' : 'en');
|
||||
}
|
||||
}, [hasSubjectiveQuestions, open, i18n.language]);
|
||||
|
||||
// 统计各题型数量
|
||||
const typeStats = {};
|
||||
evalDatasets.forEach(d => {
|
||||
typeStats[d.questionType] = (typeStats[d.questionType] || 0) + 1;
|
||||
});
|
||||
|
||||
const getModelKey = model => `${model.providerId}::${model.modelId}`;
|
||||
|
||||
const handleModelSelectionChange = newSelection => {
|
||||
setSelectedModels(newSelection);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 先清除之前的错误
|
||||
setError('');
|
||||
|
||||
// 验证
|
||||
if (selectedModels.length === 0) {
|
||||
setError(t('evalTasks.errorNoModels'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (filteredTotal === 0) {
|
||||
setError(t('evalTasks.errorNoQuestions'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSubjectiveQuestions && !judgeModel) {
|
||||
setError(t('evalTasks.errorNoJudgeModel'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证教师模型不在测试模型中
|
||||
if (judgeModel && selectedModels.includes(judgeModel)) {
|
||||
setError(t('evalTasks.errorJudgeSameAsTest'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
// 解析选中的模型
|
||||
const models = selectedModels.map(m => {
|
||||
const [providerId, modelId] = m.split('::');
|
||||
return { modelId, providerId }; // 注意顺序:modelId 在前
|
||||
});
|
||||
|
||||
// 解析教师模型
|
||||
let judgeModelId = null;
|
||||
let judgeProviderId = null;
|
||||
if (judgeModel) {
|
||||
const [pId, mId] = judgeModel.split('::');
|
||||
judgeProviderId = pId;
|
||||
judgeModelId = mId;
|
||||
}
|
||||
|
||||
// 调用后端采样接口获取题目 ID
|
||||
const sampleBody = {
|
||||
questionTypes: questionTypes,
|
||||
tags: selectedTags,
|
||||
keyword: searchKeyword.trim() || '',
|
||||
limit: questionCount > 0 ? questionCount : undefined
|
||||
};
|
||||
|
||||
const sampleResponse = await fetch(`/api/projects/${projectId}/eval-datasets/sample`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sampleBody)
|
||||
});
|
||||
|
||||
const sampleResult = await sampleResponse.json();
|
||||
if (!sampleResponse.ok || sampleResult.code !== 0) {
|
||||
setError(sampleResult.error || t('evalTasks.errorCreateFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = sampleResult?.data?.ids || [];
|
||||
if (ids.length === 0) {
|
||||
setError(t('evalTasks.errorNoQuestions'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSampledIds(ids);
|
||||
|
||||
// 构建自定义评分规则对象
|
||||
const customScoreAnchors = {};
|
||||
if (hasShortAnswer && shortAnswerScoreAnchors.length > 0) {
|
||||
customScoreAnchors.short_answer = shortAnswerScoreAnchors;
|
||||
}
|
||||
if (hasOpenEnded && openEndedScoreAnchors.length > 0) {
|
||||
customScoreAnchors.open_ended = openEndedScoreAnchors;
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
models, // 后端期望的字段名
|
||||
judgeModelId, // 分开传递
|
||||
judgeProviderId, // 分开传递
|
||||
evalDatasetIds: ids,
|
||||
language: i18n.language === 'zh-CN' ? 'zh-CN' : 'en',
|
||||
customScoreAnchors: Object.keys(customScoreAnchors).length > 0 ? customScoreAnchors : undefined
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
onSuccess && onSuccess(result.data);
|
||||
handleClose();
|
||||
} else {
|
||||
setError(result.error || t('evalTasks.errorCreateFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建评估任务失败:', err);
|
||||
setError(t('evalTasks.errorCreateFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleJudgeModelChange = event => {
|
||||
setJudgeModel(event.target.value);
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{t('evalTasks.createTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 选择测试模型 */}
|
||||
<ModelSelector
|
||||
models={models}
|
||||
selectedModels={selectedModels}
|
||||
onSelectionChange={handleModelSelectionChange}
|
||||
error={selectedModels.length === 0 && error}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 题目筛选 */}
|
||||
<QuestionFilter
|
||||
questionTypes={questionTypes}
|
||||
selectedTags={selectedTags}
|
||||
searchKeyword={searchKeyword}
|
||||
questionCount={questionCount}
|
||||
availableTags={availableTags}
|
||||
typeStats={typeStats}
|
||||
filteredCount={filteredTotal}
|
||||
onQuestionTypesChange={setQuestionTypes}
|
||||
onTagsChange={setSelectedTags}
|
||||
onSearchChange={setSearchKeyword}
|
||||
onQuestionCountChange={setQuestionCount}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
|
||||
{/* 最终题目统计 */}
|
||||
<Box sx={{ mb: 3, p: 2, bgcolor: 'action.hover', borderRadius: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalTasks.finalSelection')}
|
||||
<strong>{sampledIds.length || (questionCount > 0 ? questionCount : filteredTotal)}</strong>{' '}
|
||||
{t('evalTasks.questionsSuffix')}
|
||||
</Typography>
|
||||
{hasSubjectiveQuestions && (
|
||||
<Typography variant="body2" color="warning.main" sx={{ mt: 1 }}>
|
||||
{t('evalTasks.hasSubjectiveHint')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 选择教师模型(仅当有主观题时显示) */}
|
||||
{hasSubjectiveQuestions && (
|
||||
<>
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>{t('evalTasks.selectJudgeModel')} *</InputLabel>
|
||||
<Select
|
||||
value={judgeModel}
|
||||
onChange={handleJudgeModelChange}
|
||||
label={`${t('evalTasks.selectJudgeModel')} *`}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>{t('evalTasks.selectJudgeModelPlaceholder')}</em>
|
||||
</MenuItem>
|
||||
{models
|
||||
.filter(m => {
|
||||
const key = `${m.providerId}::${m.modelId}`;
|
||||
return !selectedModels.includes(key);
|
||||
})
|
||||
.map(model => {
|
||||
const key = `${model.providerId}::${model.modelId}`;
|
||||
return (
|
||||
<MenuItem key={key} value={key}>
|
||||
{model.providerName} / {model.modelName}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
<FormHelperText>{t('evalTasks.selectJudgeModelHint')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* 简答题评分规则 */}
|
||||
{hasShortAnswer && (
|
||||
<ScoreAnchorsForm
|
||||
questionType="short_answer"
|
||||
scoreAnchors={shortAnswerScoreAnchors}
|
||||
onChange={setShortAnswerScoreAnchors}
|
||||
language={i18n.language}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 开放题评分规则 */}
|
||||
{hasOpenEnded && (
|
||||
<ScoreAnchorsForm
|
||||
questionType="open_ended"
|
||||
scoreAnchors={openEndedScoreAnchors}
|
||||
onChange={setOpenEndedScoreAnchors}
|
||||
language={i18n.language}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={submitting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="contained"
|
||||
disabled={submitting || loading}
|
||||
startIcon={submitting && <CircularProgress size={16} />}
|
||||
>
|
||||
{submitting ? t('common.creating') : t('evalTasks.startEval')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
Avatar,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import PauseCircleIcon from '@mui/icons-material/PauseCircle';
|
||||
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import QuizIcon from '@mui/icons-material/Quiz';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getModelIcon } from '@/lib/util/modelIcon';
|
||||
import styles from '../styles';
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
0: { label: 'evalTasks.statusProcessing', color: 'info', icon: HourglassEmptyIcon },
|
||||
1: { label: 'evalTasks.statusCompleted', color: 'success', icon: CheckCircleIcon },
|
||||
2: { label: 'evalTasks.statusFailed', color: 'error', icon: ErrorIcon },
|
||||
3: { label: 'evalTasks.statusInterrupted', color: 'warning', icon: PauseCircleIcon }
|
||||
};
|
||||
|
||||
export default function EvalTaskCard({ task, onView, onDelete, onInterrupt }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const { modelInfo, detail, status, completedCount, totalCount, createAt } = task;
|
||||
const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG[0];
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
|
||||
const finalScore = detail?.finalScore;
|
||||
|
||||
const handleMenuClick = e => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => setAnchorEl(null);
|
||||
|
||||
const handleAction = action => () => {
|
||||
handleMenuClose();
|
||||
action?.(task);
|
||||
};
|
||||
|
||||
const getScoreColor = score => {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 60) return 'info';
|
||||
if (score >= 40) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={styles.taskCard(theme)} onClick={handleAction(onView)}>
|
||||
<CardContent sx={styles.taskCardContent}>
|
||||
{/* 头部 */}
|
||||
<Box sx={styles.taskCardHeader}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1, overflow: 'hidden' }}>
|
||||
<Avatar sx={{ bgcolor: 'transparent', width: 40, height: 40, border: '1px solid', borderColor: 'divider' }}>
|
||||
<img
|
||||
src={getModelIcon(modelInfo?.modelName || modelInfo?.modelId)}
|
||||
alt={modelInfo?.modelId || 'model'}
|
||||
style={{ width: 28, height: 28, objectFit: 'contain' }}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box sx={styles.taskCardModel}>
|
||||
<Typography sx={styles.taskCardModelName} noWrap>
|
||||
{modelInfo?.modelName || modelInfo?.modelId}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{modelInfo?.providerName || modelInfo?.providerId}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={handleMenuClick}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* 状态和得分 */}
|
||||
<Box sx={styles.taskCardStatus}>
|
||||
<Chip
|
||||
icon={<StatusIcon sx={{ fontSize: 14 }} />}
|
||||
label={t(statusConfig.label)}
|
||||
color={statusConfig.color}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 24, '& .MuiChip-label': { px: 1, fontSize: '0.7rem' } }}
|
||||
/>
|
||||
{finalScore !== undefined && status === 1 && (
|
||||
<Chip
|
||||
label={`${finalScore.toFixed(1)}%`}
|
||||
color={getScoreColor(finalScore)}
|
||||
size="small"
|
||||
sx={{ height: 24, fontWeight: 600, '& .MuiChip-label': { px: 1 } }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 进度条 */}
|
||||
{status === 0 && (
|
||||
<Box sx={styles.taskCardProgress}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('evalTasks.progress')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="primary" fontWeight={600}>
|
||||
{completedCount}/{totalCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={progress} sx={styles.progressBar} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 统计信息 */}
|
||||
<Box sx={styles.taskCardStats}>
|
||||
<Chip
|
||||
icon={<QuizIcon sx={{ fontSize: 14 }} />}
|
||||
label={`${totalCount} ${t('evalTasks.questions')}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 22, '& .MuiChip-label': { px: 0.75, fontSize: '0.7rem' } }}
|
||||
/>
|
||||
{detail?.hasSubjectiveQuestions && (
|
||||
<Chip
|
||||
label={t('evalTasks.hasSubjective')}
|
||||
size="small"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
sx={{ height: 22, '& .MuiChip-label': { px: 0.75, fontSize: '0.7rem' } }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 时间 */}
|
||||
<Typography sx={{ ...styles.taskCardTime, mt: 1.5 }} color="text.disabled">
|
||||
{new Date(createAt).toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
{/* 菜单 */}
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleMenuClose} onClick={e => e.stopPropagation()}>
|
||||
<MenuItem onClick={handleAction(onView)}>
|
||||
<ListItemIcon>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{t('datasets.viewDetails')}
|
||||
</MenuItem>
|
||||
{status === 0 && (
|
||||
<MenuItem onClick={handleAction(onInterrupt)}>
|
||||
<ListItemIcon>
|
||||
<StopIcon fontSize="small" color="warning" />
|
||||
</ListItemIcon>
|
||||
{t('evalTasks.interrupt')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleAction(onDelete)} sx={{ color: 'error.main' }}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon fontSize="small" color="error" />
|
||||
</ListItemIcon>
|
||||
{t('common.delete')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Checkbox,
|
||||
FormHelperText,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
ListItemText,
|
||||
OutlinedInput,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ModelSelector({ models, selectedModels, onSelectionChange, error }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getModelKey = model => `${model.providerId}::${model.modelId}`;
|
||||
|
||||
const handleChange = event => {
|
||||
const {
|
||||
target: { value }
|
||||
} = event;
|
||||
// On autofill we get a stringified value.
|
||||
onSelectionChange(typeof value === 'string' ? value.split(',') : value);
|
||||
};
|
||||
|
||||
const getModelLabel = modelKey => {
|
||||
const model = models.find(m => getModelKey(m) === modelKey);
|
||||
if (!model) return modelKey;
|
||||
return `${model.providerName || model.providerId} / ${model.modelName || model.modelId}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControl fullWidth error={!!error} size="small">
|
||||
<InputLabel id="model-selector-label">{t('evalTasks.selectModels')} *</InputLabel>
|
||||
<Select
|
||||
labelId="model-selector-label"
|
||||
multiple
|
||||
value={selectedModels}
|
||||
onChange={handleChange}
|
||||
input={<OutlinedInput label={`${t('evalTasks.selectModels')} *`} />}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={getModelLabel(value)} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: 300,
|
||||
width: 250
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{models.length === 0 ? (
|
||||
<MenuItem disabled value="">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalTasks.noModelsAvailable')}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
) : (
|
||||
models.map(model => {
|
||||
const modelKey = getModelKey(model);
|
||||
return (
|
||||
<MenuItem key={modelKey} value={modelKey}>
|
||||
<Checkbox checked={selectedModels.includes(modelKey)} />
|
||||
<ListItemText
|
||||
primary={`${model.providerName || model.providerId} / ${model.modelName || model.modelId}`}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Select>
|
||||
<FormHelperText>{error || t('evalTasks.selectModelsHint')}</FormHelperText>
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
OutlinedInput,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
Slider,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import FilterAltIcon from '@mui/icons-material/FilterAlt';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const QUESTION_TYPES = [
|
||||
{ value: 'true_false', labelKey: 'eval.questionTypes.true_false' },
|
||||
{ value: 'single_choice', labelKey: 'eval.questionTypes.single_choice' },
|
||||
{ value: 'multiple_choice', labelKey: 'eval.questionTypes.multiple_choice' },
|
||||
{ value: 'short_answer', labelKey: 'eval.questionTypes.short_answer' },
|
||||
{ value: 'open_ended', labelKey: 'eval.questionTypes.open_ended' }
|
||||
];
|
||||
|
||||
export default function QuestionFilter({
|
||||
questionTypes,
|
||||
selectedTags,
|
||||
searchKeyword,
|
||||
questionCount,
|
||||
availableTags,
|
||||
filteredCount,
|
||||
onQuestionTypesChange,
|
||||
onTagsChange,
|
||||
onSearchChange,
|
||||
onQuestionCountChange,
|
||||
onReset
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasFilters = questionTypes.length > 0 || selectedTags.length > 0 || searchKeyword || questionCount > 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<FilterAltIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>
|
||||
{t('evalTasks.filterTitle')}
|
||||
</Typography>
|
||||
{hasFilters && (
|
||||
<Button size="small" startIcon={<ClearIcon />} onClick={onReset}>
|
||||
{t('evalTasks.clearFilter')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* 关键字搜索 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('evalTasks.searchKeyword')}
|
||||
placeholder={t('evalTasks.searchPlaceholder')}
|
||||
value={searchKeyword}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* 题型和标签筛选 - 并排显示 */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{/* 题型筛选 */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t('evalTasks.filterByTypeLabel')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={questionTypes}
|
||||
onChange={e => onQuestionTypesChange(e.target.value)}
|
||||
input={<OutlinedInput label={t('evalTasks.filterByTypeLabel')} />}
|
||||
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('evalTasks.filterByTagLabel')}</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={selectedTags}
|
||||
onChange={e => onTagsChange(e.target.value)}
|
||||
input={<OutlinedInput label={t('evalTasks.filterByTagLabel')} />}
|
||||
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>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>
|
||||
{t('evalTasks.questionCountLabel')}
|
||||
{questionCount === 0 ? t('common.all') : questionCount} / {filteredCount}
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={questionCount}
|
||||
onChange={e => onQuestionCountChange(parseInt(e.target.value) || 0)}
|
||||
inputProps={{ min: 0, max: filteredCount }}
|
||||
sx={{ width: 100 }}
|
||||
/>
|
||||
</Box>
|
||||
<Slider
|
||||
value={questionCount}
|
||||
onChange={(e, value) => onQuestionCountChange(value)}
|
||||
min={0}
|
||||
max={filteredCount}
|
||||
step={1}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{questionCount === 0
|
||||
? t('evalTasks.useAllQuestions')
|
||||
: t('evalTasks.randomSampleHint', { filteredCount, questionCount })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getDefaultScoreAnchors } from '@/lib/llm/prompts/llmJudge';
|
||||
|
||||
/**
|
||||
* 评分规则表单组件
|
||||
* 用于自定义简答题和开放题的评分规则
|
||||
*/
|
||||
export default function ScoreAnchorsForm({
|
||||
questionType, // 'short_answer' 或 'open_ended'
|
||||
scoreAnchors,
|
||||
onChange,
|
||||
language = 'zh-CN'
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// 获取当前语言
|
||||
const currentLanguage = i18n.language === 'zh-CN' ? 'zh-CN' : 'en';
|
||||
|
||||
// 初始化评分规则(如果为空)
|
||||
useEffect(() => {
|
||||
if (!scoreAnchors || scoreAnchors.length === 0) {
|
||||
onChange(getDefaultScoreAnchors(questionType, currentLanguage));
|
||||
}
|
||||
}, [questionType, currentLanguage]);
|
||||
|
||||
// 处理单个规则的描述更改
|
||||
const handleDescriptionChange = (index, newDescription) => {
|
||||
const newAnchors = [...scoreAnchors];
|
||||
newAnchors[index] = { ...newAnchors[index], description: newDescription };
|
||||
onChange(newAnchors);
|
||||
};
|
||||
|
||||
// 恢复默认值
|
||||
const handleRestore = () => {
|
||||
onChange(getDefaultScoreAnchors(questionType, currentLanguage));
|
||||
};
|
||||
|
||||
// 获取题型显示名称
|
||||
const getQuestionTypeName = () => {
|
||||
if (questionType === 'short_answer') {
|
||||
return t('evalTasks.shortAnswer', '简答题');
|
||||
}
|
||||
return t('evalTasks.openEnded', '开放题');
|
||||
};
|
||||
|
||||
// 获取分数区间的颜色
|
||||
const getScoreColor = range => {
|
||||
if (range === '1.0') return 'success';
|
||||
if (range.includes('0.8') || range.includes('0.9')) return 'info';
|
||||
if (range.includes('0.6') || range.includes('0.7')) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
if (!scoreAnchors || scoreAnchors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
expanded={expanded}
|
||||
onChange={(e, isExpanded) => setExpanded(isExpanded)}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'&:before': { display: 'none' },
|
||||
boxShadow: 1
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
bgcolor: 'action.hover',
|
||||
'&:hover': { bgcolor: 'action.selected' }
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
{t('evalTasks.scoreAnchorsTitle', '{{type}}评分规则', { type: getQuestionTypeName() })}
|
||||
</Typography>
|
||||
<Chip label={t('evalTasks.customizable', '可自定义')} size="small" color="primary" variant="outlined" />
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('evalTasks.scoreAnchorsHint', '自定义评分标准,用于指导LLM评估模型的回答质量')}
|
||||
</Typography>
|
||||
<Tooltip title={t('evalTasks.restoreDefault', '恢复默认')}>
|
||||
<IconButton size="small" onClick={handleRestore} color="primary">
|
||||
<RestoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{scoreAnchors.map((anchor, index) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Chip
|
||||
label={anchor.range}
|
||||
size="small"
|
||||
color={getScoreColor(anchor.range)}
|
||||
sx={{ minWidth: 70, fontWeight: 600 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('evalTasks.scoreRange', '分数区间')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
multiline
|
||||
rows={2}
|
||||
value={anchor.description}
|
||||
onChange={e => handleDescriptionChange(index, e.target.value)}
|
||||
placeholder={t('evalTasks.scoreDescriptionPlaceholder', '请输入该分数区间的评分标准描述...')}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user