first-update
This commit is contained in:
226
easy-dataset-main/components/ExportDatasetDialog.js
Normal file
226
easy-dataset-main/components/ExportDatasetDialog.js
Normal file
@@ -0,0 +1,226 @@
|
||||
// ExportDatasetDialog.js 组件
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Tabs, Tab } from '@mui/material';
|
||||
|
||||
// 导入拆分后的组件
|
||||
import LocalExportTab from './export/LocalExportTab';
|
||||
import LlamaFactoryTab from './export/LlamaFactoryTab';
|
||||
import HuggingFaceTab from './export/HuggingFaceTab';
|
||||
|
||||
const ExportDatasetDialog = ({ open, onClose, onExport, projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
const [formatType, setFormatType] = useState('alpaca');
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [reasoningLanguage, setReasoningLanguage] = useState('');
|
||||
const [confirmedOnly, setConfirmedOnly] = useState(false);
|
||||
const [fileFormat, setFileFormat] = useState('json');
|
||||
const [includeCOT, setIncludeCOT] = useState(true);
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
// alpaca 格式特有的设置
|
||||
const [alpacaFieldType, setAlpacaFieldType] = useState('instruction'); // 'instruction' 或 'input'
|
||||
const [customInstruction, setCustomInstruction] = useState(''); // 当选择 input 时使用的自定义 instruction
|
||||
const [customFields, setCustomFields] = useState({
|
||||
questionField: 'instruction',
|
||||
answerField: 'output',
|
||||
cotField: 'complexCOT', // 添加思维链字段名
|
||||
includeLabels: false,
|
||||
includeChunk: false, // 添加是否包含chunk字段
|
||||
questionOnly: false // 添加仅导出问题选项
|
||||
});
|
||||
|
||||
const handleFileFormatChange = event => {
|
||||
setFileFormat(event.target.value);
|
||||
};
|
||||
|
||||
const handleFormatChange = event => {
|
||||
setFormatType(event.target.value);
|
||||
// 根据格式类型设置默认字段名
|
||||
if (event.target.value === 'alpaca') {
|
||||
setCustomFields({
|
||||
...customFields,
|
||||
questionField: 'instruction',
|
||||
answerField: 'output'
|
||||
});
|
||||
} else if (event.target.value === 'sharegpt') {
|
||||
setCustomFields({
|
||||
...customFields,
|
||||
questionField: 'content',
|
||||
answerField: 'content'
|
||||
});
|
||||
} else if (event.target.value === 'multilingual-thinking') {
|
||||
setCustomFields({
|
||||
...customFields,
|
||||
questionField: 'content',
|
||||
answerField: 'content'
|
||||
});
|
||||
} else if (event.target.value === 'custom') {
|
||||
// 自定义格式保持当前值
|
||||
}
|
||||
};
|
||||
|
||||
const handleSystemPromptChange = event => {
|
||||
setSystemPrompt(event.target.value);
|
||||
};
|
||||
|
||||
const handleReasoningLanguageChange = event => {
|
||||
setReasoningLanguage(event.target.value);
|
||||
};
|
||||
const handleConfirmedOnlyChange = event => {
|
||||
setConfirmedOnly(event.target.checked);
|
||||
};
|
||||
|
||||
// 新增处理函数
|
||||
const handleIncludeCOTChange = event => {
|
||||
setIncludeCOT(event.target.checked);
|
||||
};
|
||||
|
||||
const handleCustomFieldChange = field => event => {
|
||||
setCustomFields({
|
||||
...customFields,
|
||||
[field]: event.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleIncludeLabelsChange = event => {
|
||||
setCustomFields({
|
||||
...customFields,
|
||||
includeLabels: event.target.checked
|
||||
});
|
||||
};
|
||||
|
||||
const handleIncludeChunkChange = event => {
|
||||
setCustomFields({
|
||||
...customFields,
|
||||
includeChunk: event.target.checked
|
||||
});
|
||||
};
|
||||
|
||||
const handleQuestionOnlyChange = event => {
|
||||
setCustomFields({
|
||||
...customFields,
|
||||
questionOnly: event.target.checked
|
||||
});
|
||||
};
|
||||
|
||||
// 处理 Alpaca 字段类型变更
|
||||
const handleAlpacaFieldTypeChange = event => {
|
||||
setAlpacaFieldType(event.target.value);
|
||||
};
|
||||
|
||||
// 处理自定义 instruction 变更
|
||||
const handleCustomInstructionChange = event => {
|
||||
setCustomInstruction(event.target.value);
|
||||
};
|
||||
|
||||
const handleExport = options => {
|
||||
// 如果 LocalExportTab 传入了完整的导出配置(例如平衡导出),直接使用该配置
|
||||
if (options && typeof options === 'object' && options.balanceMode) {
|
||||
onExport(options);
|
||||
return;
|
||||
}
|
||||
|
||||
// 否则使用当前对话框内的状态组装导出配置
|
||||
onExport({
|
||||
formatType,
|
||||
systemPrompt,
|
||||
reasoningLanguage,
|
||||
confirmedOnly,
|
||||
fileFormat,
|
||||
includeCOT,
|
||||
alpacaFieldType, // 添加 alpaca 字段类型
|
||||
customInstruction, // 添加自定义 instruction
|
||||
customFields: formatType === 'custom' ? customFields : undefined
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{t('export.title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tabs value={currentTab} onChange={(e, newValue) => setCurrentTab(newValue)} aria-label="export tabs">
|
||||
<Tab label={t('export.localTab')} />
|
||||
<Tab label={t('export.llamaFactoryTab')} />
|
||||
<Tab label={t('export.huggingFaceTab')} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* 第一个标签页:本地导出 */}
|
||||
{currentTab === 0 && (
|
||||
<LocalExportTab
|
||||
fileFormat={fileFormat}
|
||||
formatType={formatType}
|
||||
systemPrompt={systemPrompt}
|
||||
reasoningLanguage={reasoningLanguage}
|
||||
confirmedOnly={confirmedOnly}
|
||||
includeCOT={includeCOT}
|
||||
customFields={customFields}
|
||||
alpacaFieldType={alpacaFieldType}
|
||||
customInstruction={customInstruction}
|
||||
handleFileFormatChange={handleFileFormatChange}
|
||||
handleFormatChange={handleFormatChange}
|
||||
handleSystemPromptChange={handleSystemPromptChange}
|
||||
handleReasoningLanguageChange={handleReasoningLanguageChange}
|
||||
handleConfirmedOnlyChange={handleConfirmedOnlyChange}
|
||||
handleIncludeCOTChange={handleIncludeCOTChange}
|
||||
handleCustomFieldChange={handleCustomFieldChange}
|
||||
handleIncludeLabelsChange={handleIncludeLabelsChange}
|
||||
handleIncludeChunkChange={handleIncludeChunkChange}
|
||||
handleQuestionOnlyChange={handleQuestionOnlyChange}
|
||||
handleAlpacaFieldTypeChange={handleAlpacaFieldTypeChange}
|
||||
handleCustomInstructionChange={handleCustomInstructionChange}
|
||||
handleExport={handleExport}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 第二个标签页:Llama Factory */}
|
||||
{currentTab === 1 && (
|
||||
<LlamaFactoryTab
|
||||
projectId={projectId}
|
||||
systemPrompt={systemPrompt}
|
||||
reasoningLanguage={reasoningLanguage}
|
||||
confirmedOnly={confirmedOnly}
|
||||
includeCOT={includeCOT}
|
||||
formatType={formatType}
|
||||
handleSystemPromptChange={handleSystemPromptChange}
|
||||
handleReasoningLanguageChange={handleReasoningLanguageChange}
|
||||
handleConfirmedOnlyChange={handleConfirmedOnlyChange}
|
||||
handleIncludeCOTChange={handleIncludeCOTChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 第三个标签页:HuggingFace */}
|
||||
{currentTab === 2 && (
|
||||
<HuggingFaceTab
|
||||
projectId={projectId}
|
||||
systemPrompt={systemPrompt}
|
||||
reasoningLanguage={reasoningLanguage}
|
||||
confirmedOnly={confirmedOnly}
|
||||
includeCOT={includeCOT}
|
||||
formatType={formatType}
|
||||
fileFormat={fileFormat}
|
||||
customFields={customFields}
|
||||
handleSystemPromptChange={handleSystemPromptChange}
|
||||
handleReasoningLanguageChange={handleReasoningLanguageChange}
|
||||
handleConfirmedOnlyChange={handleConfirmedOnlyChange}
|
||||
handleIncludeCOTChange={handleIncludeCOTChange}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportDatasetDialog;
|
||||
104
easy-dataset-main/components/ExportProgressDialog.js
Normal file
104
easy-dataset-main/components/ExportProgressDialog.js
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Dialog, DialogTitle, DialogContent, Box, LinearProgress, Typography, CircularProgress } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ExportProgressDialog = ({ open, progress }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { processed, total, hasMore } = progress;
|
||||
|
||||
// 计算进度百分比
|
||||
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
disableEscapeKeyDown
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
minHeight: 200
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>{t('datasets.exportProgress')}</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
py: 2
|
||||
}}
|
||||
>
|
||||
{/* 圆形进度指示器 */}
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={percentage}
|
||||
size={80}
|
||||
thickness={4}
|
||||
sx={{
|
||||
color: 'primary.main'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="div" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
{`${percentage}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 进度详情 */}
|
||||
<Box sx={{ textAlign: 'center', width: '100%' }}>
|
||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
||||
{t('datasets.exportingData')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('datasets.processedCount', { processed, total })}
|
||||
</Typography>
|
||||
|
||||
{/* 线性进度条 */}
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={percentage}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'grey.200',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 状态提示 */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
{hasMore ? t('datasets.exportInProgress') : t('datasets.exportFinalizing')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportProgressDialog;
|
||||
16
easy-dataset-main/components/I18nProvider.js
Normal file
16
easy-dataset-main/components/I18nProvider.js
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
|
||||
export default function I18nProvider({ children }) {
|
||||
useEffect(() => {
|
||||
// 确保i18n只在客户端初始化
|
||||
if (typeof window !== 'undefined') {
|
||||
// 这里可以添加任何客户端特定的i18n初始化逻辑
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
||||
}
|
||||
88
easy-dataset-main/components/LanguageSwitcher.js
Normal file
88
easy-dataset-main/components/LanguageSwitcher.js
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconButton, Menu, MenuItem, Tooltip, useTheme, Typography } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import TranslateIcon from '@mui/icons-material/Translate';
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const { i18n, t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', label: t('language.english', 'English'), short: 'EN' },
|
||||
{ code: 'zh-CN', label: t('language.chineseSimplified', '简体中文'), short: '中文' },
|
||||
{ code: 'tr', label: t('language.turkish', 'Türkçe'), short: 'TR' },
|
||||
{ code: 'pt-BR', label: t('language.portugues', 'Portugues'), short: 'pt-BR' }
|
||||
];
|
||||
|
||||
const normalizedCurrentLanguage =
|
||||
i18n.language && String(i18n.language).toLowerCase().startsWith('zh') ? 'zh-CN' : i18n.language;
|
||||
const currentLanguage = languages.find(lang => lang.code === normalizedCurrentLanguage) || languages[0];
|
||||
|
||||
const handleClick = event => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleLanguageChange = langCode => {
|
||||
i18n.changeLanguage(langCode);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t('language.switcherTitle', 'Change Language / 切换语言 / Dil Değiştir')}>
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(255, 255, 255, 0.15)',
|
||||
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
|
||||
p: 1,
|
||||
borderRadius: 1.5,
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.25)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" fontWeight="medium" sx={{ mr: 0.5 }}>
|
||||
{currentLanguage.short}
|
||||
</Typography>
|
||||
<TranslateIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
>
|
||||
{languages.map(lang => (
|
||||
<MenuItem
|
||||
key={lang.code}
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
selected={normalizedCurrentLanguage === lang.code}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mr: 1, minWidth: 28 }}>
|
||||
{lang.short}
|
||||
</Typography>
|
||||
{lang.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
346
easy-dataset-main/components/ModelSelect.js
Normal file
346
easy-dataset-main/components/ModelSelect.js
Normal file
@@ -0,0 +1,346 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { FormControl, Select, MenuItem, useTheme, ListSubheader, Box, IconButton, Tooltip } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtom, useAtomValue } from 'jotai/index';
|
||||
import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store';
|
||||
import axios from 'axios';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import { getModelIcon } from '@/lib/util/modelIcon';
|
||||
|
||||
export default function ModelSelect({
|
||||
size = 'small',
|
||||
minWidth = 50,
|
||||
projectId,
|
||||
minHeight = 36,
|
||||
required = false,
|
||||
onError
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const models = useAtomValue(modelConfigListAtom);
|
||||
const [selectedModelInfo, setSelectedModelInfo] = useAtom(selectedModelInfoAtom);
|
||||
const [selectedModel, setSelectedModel] = useState(() => {
|
||||
if (selectedModelInfo && selectedModelInfo.id) {
|
||||
return selectedModelInfo.id;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const [error, setError] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleModelChange = event => {
|
||||
if (!event || !event.target) return;
|
||||
const newModelId = event.target.value;
|
||||
|
||||
if (error) {
|
||||
setError(false);
|
||||
if (onError) onError(false);
|
||||
}
|
||||
|
||||
if (!newModelId) {
|
||||
setSelectedModel('');
|
||||
setSelectedModelInfo(null);
|
||||
updateDefaultModel(null);
|
||||
} else {
|
||||
const selectedModelObj = models.find(model => model.id === newModelId);
|
||||
if (selectedModelObj) {
|
||||
setSelectedModel(newModelId);
|
||||
setSelectedModelInfo(selectedModelObj);
|
||||
updateDefaultModel(newModelId);
|
||||
} else {
|
||||
setSelectedModel(newModelId);
|
||||
setSelectedModelInfo({ id: newModelId });
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
setIsOpen(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const updateDefaultModel = async id => {
|
||||
const res = await axios.put(`/api/projects/${projectId}`, { projectId, defaultModelConfigId: id });
|
||||
if (res.status === 200) {
|
||||
console.log('更新成功');
|
||||
}
|
||||
};
|
||||
|
||||
const validateModel = () => {
|
||||
if (required && (!selectedModel || selectedModel === '')) {
|
||||
setError(true);
|
||||
if (onError) onError(true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedModelInfo && selectedModelInfo.id) {
|
||||
setSelectedModel(selectedModelInfo.id);
|
||||
} else {
|
||||
setSelectedModel('');
|
||||
}
|
||||
}, [selectedModelInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (required) {
|
||||
validateModel();
|
||||
}
|
||||
}, [required]);
|
||||
|
||||
const renderSelectedValue = value => {
|
||||
if (!value) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SmartToyIcon fontSize="small" />
|
||||
{t('models.unselectedModel', t('playground.selectModelFirst'))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedModelObj = models.find(model => model.id === value);
|
||||
if (!selectedModelObj) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={getModelIcon(selectedModelObj.modelName || selectedModelObj.modelId)}
|
||||
alt={selectedModelObj.modelName}
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
objectFit: 'contain',
|
||||
flexShrink: 0,
|
||||
background: '#ffffffc9',
|
||||
borderRadius: '50%',
|
||||
marginBottom: '-2px'
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.src = '/imgs/models/default.svg';
|
||||
}}
|
||||
/>
|
||||
{selectedModelObj.modelName}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const currentModelIcon = useMemo(() => {
|
||||
const selectedModelObj = models.find(model => model.id === selectedModel);
|
||||
return selectedModelObj ? getModelIcon(selectedModelObj.modelName, selectedModelObj.modelId) : null;
|
||||
}, [selectedModel, models]);
|
||||
|
||||
const shouldShowFullSelect = isHovered || isOpen;
|
||||
|
||||
return (
|
||||
<Box
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
setIsHovered(false);
|
||||
if (!isOpen) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{!shouldShowFullSelect && (
|
||||
<Tooltip
|
||||
title={
|
||||
selectedModel
|
||||
? models.find(m => m.id === selectedModel)?.modelName
|
||||
: t('playground.selectModelFirst', '请先选择模型')
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<IconButton
|
||||
size="medium"
|
||||
sx={{
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.69)',
|
||||
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
|
||||
borderRadius: 1.5,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(255, 255, 255, 0.35)'
|
||||
},
|
||||
...(error && {
|
||||
animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'@keyframes pulse': {
|
||||
'0%, 100%': {
|
||||
opacity: 1
|
||||
},
|
||||
'50%': {
|
||||
opacity: 0.5
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{currentModelIcon ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={currentModelIcon}
|
||||
alt="model icon"
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.src = '/imgs/models/default.svg';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SmartToyIcon
|
||||
fontSize="small"
|
||||
color="red"
|
||||
sx={{
|
||||
color: error ? 'red' : 'red'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<FormControl
|
||||
size={size}
|
||||
sx={{
|
||||
minWidth: shouldShowFullSelect ? 200 : 0,
|
||||
minHeight,
|
||||
opacity: shouldShowFullSelect ? 1 : 0,
|
||||
width: shouldShowFullSelect ? 'auto' : 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: shouldShowFullSelect ? 'relative' : 'absolute',
|
||||
pointerEvents: shouldShowFullSelect ? 'auto' : 'none'
|
||||
}}
|
||||
error={error}
|
||||
>
|
||||
<Select
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
displayEmpty
|
||||
variant="outlined"
|
||||
onBlur={validateModel}
|
||||
renderValue={renderSelectedValue}
|
||||
onOpen={() => setIsOpen(true)}
|
||||
onClose={() => setIsOpen(false)}
|
||||
sx={{
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.2)',
|
||||
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
|
||||
borderRadius: 1.5,
|
||||
'& .MuiSelect-select': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '6px 32px 6px 12px'
|
||||
},
|
||||
'& .MuiSelect-icon': {
|
||||
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
|
||||
right: '8px'
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'transparent'
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'transparent'
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.mode === 'dark' ? 'primary.main' : 'rgba(255, 255, 255, 0.5)'
|
||||
},
|
||||
minHeight: '36px'
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
elevation: 2,
|
||||
sx: {
|
||||
mt: 1,
|
||||
borderRadius: 2,
|
||||
'& .MuiMenuItem-root': {
|
||||
minHeight: '30px'
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
{error ? t('models.pleaseSelectModel') : t('models.unselectedModel', t('playground.selectModelFirst'))}
|
||||
</MenuItem>
|
||||
{(() => {
|
||||
const filteredModels = models.filter(m => {
|
||||
if (m.providerId?.toLowerCase() === 'ollama') {
|
||||
return m.modelName && m.endpoint;
|
||||
} else {
|
||||
return m.modelName && m.endpoint && m.apiKey;
|
||||
}
|
||||
});
|
||||
|
||||
const providers = [...new Set(filteredModels.map(m => m.providerName || 'Other'))];
|
||||
|
||||
return providers.map(provider => {
|
||||
const providerModels = filteredModels.filter(m => (m.providerName || 'Other') === provider);
|
||||
return [
|
||||
<ListSubheader
|
||||
key={`header-${provider}`}
|
||||
sx={{
|
||||
pl: 2,
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 500,
|
||||
mt: 1,
|
||||
mb: 0.5
|
||||
}}
|
||||
>
|
||||
{provider || 'Other'}
|
||||
</ListSubheader>,
|
||||
...providerModels.map(model => (
|
||||
<MenuItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
sx={{
|
||||
pl: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
minHeight: '30px',
|
||||
'&.Mui-selected': {
|
||||
bgcolor: theme.palette.action.selected,
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.action.selected
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={getModelIcon(model.modelName || model.modelId)}
|
||||
alt={model.modelName}
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
objectFit: 'contain',
|
||||
flexShrink: 0
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.src = '/imgs/models/default.svg';
|
||||
}}
|
||||
/>
|
||||
<Box component="span" sx={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{model.modelName}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))
|
||||
];
|
||||
});
|
||||
})()}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
112
easy-dataset-main/components/Navbar/ActionButtons.js
Normal file
112
easy-dataset-main/components/Navbar/ActionButtons.js
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box, IconButton, Tooltip } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';
|
||||
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
import LanguageSwitcher from '../LanguageSwitcher';
|
||||
import UpdateChecker from '../UpdateChecker';
|
||||
import TaskIcon from '../TaskIcon';
|
||||
import ModelSelect from '../ModelSelect';
|
||||
import * as styles from './styles';
|
||||
|
||||
/**
|
||||
* ActionButtons 组件
|
||||
* 右侧操作区按钮:语言切换、主题切换、文档、GitHub、更新检查
|
||||
*/
|
||||
export default function ActionButtons({
|
||||
theme,
|
||||
resolvedTheme,
|
||||
toggleTheme,
|
||||
isProjectDetail,
|
||||
currentProject,
|
||||
onActionAreaEnter
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isZhLanguage = String(i18n.language || '')
|
||||
.toLowerCase()
|
||||
.startsWith('zh');
|
||||
|
||||
return (
|
||||
<Box sx={styles.actionAreaStyles} onMouseEnter={onActionAreaEnter}>
|
||||
{isProjectDetail && <ModelSelect projectId={currentProject} />}
|
||||
{isProjectDetail && <TaskIcon theme={theme} projectId={currentProject} />}
|
||||
|
||||
{/* Monitoring Dashboard - Only visible on Home page */}
|
||||
{!isProjectDetail && (
|
||||
<Tooltip title={t('monitoring.title', 'Resource Monitoring')}>
|
||||
<IconButton component="a" href="/monitoring" size="medium" sx={styles.getIconButtonStyles(theme)}>
|
||||
<BarChartIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Language Switcher - Always visible */}
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Theme Toggle - Always visible */}
|
||||
<Tooltip
|
||||
title={
|
||||
resolvedTheme === 'dark'
|
||||
? t('theme.switchToLight', 'Switch to light mode')
|
||||
: t('theme.switchToDark', 'Switch to dark mode')
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
onClick={toggleTheme}
|
||||
size="medium"
|
||||
aria-label={
|
||||
resolvedTheme === 'dark'
|
||||
? t('theme.switchToLight', 'Switch to light mode')
|
||||
: t('theme.switchToDark', 'Switch to dark mode')
|
||||
}
|
||||
sx={styles.getIconButtonStyles(theme)}
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
<LightModeOutlinedIcon fontSize="small" />
|
||||
) : (
|
||||
<DarkModeOutlinedIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Documentation - Hide below xl */}
|
||||
<Tooltip title={t('documentation')}>
|
||||
<IconButton
|
||||
component="a"
|
||||
href={isZhLanguage ? 'https://docs.easy-dataset.com/' : 'https://docs.easy-dataset.com/ed/en'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="medium"
|
||||
sx={styles.getIconButtonStyles(theme)}
|
||||
>
|
||||
<HelpOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* GitHub - Hide at larger tablet screens and below */}
|
||||
<Tooltip title={t('common.visitGitHub', 'View on GitHub')}>
|
||||
<IconButton
|
||||
component="a"
|
||||
href="https://github.com/ConardLi/easy-dataset"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="medium"
|
||||
aria-label={t('common.visitGitHub', 'Open GitHub repository')}
|
||||
sx={styles.getIconButtonStyles(theme)}
|
||||
>
|
||||
<GitHubIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Update Checker - Hide below xl */}
|
||||
<Box sx={{ display: { xs: 'none', xl: 'flex' } }}>
|
||||
<UpdateChecker />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
175
easy-dataset-main/components/Navbar/ContextBar.js
Normal file
175
easy-dataset-main/components/Navbar/ContextBar.js
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Typography,
|
||||
useTheme,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
// Icons
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
|
||||
// 样式
|
||||
import * as styles from './contextBarStyles';
|
||||
|
||||
export default function ContextBar({ projects = [], currentProjectId, onMouseLeave }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
// State
|
||||
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null);
|
||||
|
||||
// Jotai atoms
|
||||
const setConfigList = useSetAtom(modelConfigListAtom);
|
||||
const setSelectedModelInfo = useSetAtom(selectedModelInfoAtom);
|
||||
|
||||
// Get current project
|
||||
const currentProject = projects.find(p => p.id === currentProjectId);
|
||||
|
||||
// Handlers
|
||||
const handleProjectMenuOpen = event => {
|
||||
event.preventDefault();
|
||||
setProjectMenuAnchor(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleProjectMenuClose = () => {
|
||||
setProjectMenuAnchor(null);
|
||||
// 菜单关闭时,如果提供了 onMouseLeave 回调,则调用它
|
||||
if (onMouseLeave) {
|
||||
onMouseLeave();
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectChange = async newProjectId => {
|
||||
handleProjectMenuClose();
|
||||
|
||||
try {
|
||||
// Fetch model config for new project
|
||||
const response = await axios.get(`/api/projects/${newProjectId}/model-config`);
|
||||
setConfigList(response.data.data);
|
||||
|
||||
if (response.data.defaultModelConfigId) {
|
||||
const defaultModel = response.data.data.find(item => item.id === response.data.defaultModelConfigId);
|
||||
setSelectedModelInfo(defaultModel || null);
|
||||
} else {
|
||||
setSelectedModelInfo(null);
|
||||
}
|
||||
|
||||
// Navigate to the new project's text-split page
|
||||
router.push(`/projects/${newProjectId}/text-split`);
|
||||
} catch (error) {
|
||||
console.error('Error switching project:', error);
|
||||
toast.error(t('common.error', 'Error switching project'));
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentProjectId || !currentProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
component="nav"
|
||||
aria-label={t('common.contextNavigation', 'Context navigation')}
|
||||
sx={styles.getContextBarPaperStyles(theme)}
|
||||
>
|
||||
<Box sx={styles.contextBarContainerStyles}>
|
||||
{/* Project Selector */}
|
||||
<Box sx={styles.selectorContainerStyles}>
|
||||
<Typography variant="caption" sx={styles.labelTypographyStyles}>
|
||||
{t('common.project', 'Project')}:
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={currentProject?.name || t('projects.selectProject', 'Select Project')}
|
||||
placement="bottom-start"
|
||||
arrow
|
||||
>
|
||||
<Chip
|
||||
icon={<FolderIcon fontSize="small" />}
|
||||
label={
|
||||
<Box sx={styles.chipLabelBoxStyles}>
|
||||
<Typography variant="body2" noWrap sx={styles.chipTextStyles}>
|
||||
{currentProject?.name || t('projects.selectProject', 'Select Project')}
|
||||
</Typography>
|
||||
<ArrowDropDownIcon fontSize="small" sx={styles.chipArrowStyles} />
|
||||
</Box>
|
||||
}
|
||||
onClick={handleProjectMenuOpen}
|
||||
clickable
|
||||
variant="outlined"
|
||||
size="medium"
|
||||
sx={styles.getProjectChipStyles(theme)}
|
||||
aria-label={t('projects.selectProject', 'Select project')}
|
||||
aria-controls={projectMenuAnchor ? 'project-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={Boolean(projectMenuAnchor)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Project Menu */}
|
||||
<Menu
|
||||
id="project-menu"
|
||||
anchorEl={projectMenuAnchor}
|
||||
open={Boolean(projectMenuAnchor)}
|
||||
onClose={handleProjectMenuClose}
|
||||
role="menu"
|
||||
aria-label={t('projects.projectMenu', 'Project menu')}
|
||||
PaperProps={{
|
||||
elevation: 8,
|
||||
sx: styles.getMenuPaperStyles(theme)
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'left', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'project-selector',
|
||||
...styles.menuListPropsStyles
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={styles.menuHeaderTypographyStyles}>
|
||||
{t('projects.allProjects', 'All Projects')}
|
||||
</Typography>
|
||||
{projects.map((project, index) => (
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
onClick={() => handleProjectChange(project.id)}
|
||||
selected={project.id === currentProjectId}
|
||||
role="menuitem"
|
||||
sx={styles.getMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.menuItemIconStyles}>
|
||||
{project.id === currentProjectId ? (
|
||||
<CheckIcon fontSize="small" color="primary" />
|
||||
) : (
|
||||
<FolderIcon fontSize="small" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={project.name}
|
||||
primaryTypographyProps={styles.getMenuItemTextPrimaryProps(project.id === currentProjectId)}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
315
easy-dataset-main/components/Navbar/DesktopMenus.js
Normal file
315
easy-dataset-main/components/Navbar/DesktopMenus.js
Normal file
@@ -0,0 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Menu, MenuItem, ListItemIcon, ListItemText, Divider } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Link from 'next/link';
|
||||
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import DatasetOutlinedIcon from '@mui/icons-material/DatasetOutlined';
|
||||
import ChatIcon from '@mui/icons-material/Chat';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
import ScienceOutlinedIcon from '@mui/icons-material/ScienceOutlined';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined';
|
||||
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import * as styles from './styles';
|
||||
|
||||
/**
|
||||
* DesktopMenus 缂備礁瀚▎?
|
||||
* 婵℃鐭傚鎵博椤栨稑浜鹃柛瀣矎瑜板秹宕¢弴顏嗙闁告牕鎳庨幆鍫ュ极閻楀牆绁︽繝褎鍔戦埀顑跨劍閺嗙喖骞戦鈧▔锔剧不閿涘嫭鍊為柕鍡曠劍濞叉寧寰勫顐ょ憦濞戞搩浜hぐ宥夊础?
|
||||
*/
|
||||
export default function DesktopMenus({
|
||||
theme,
|
||||
menuState,
|
||||
isMenuOpen,
|
||||
handleMenuClose,
|
||||
currentProject,
|
||||
onNavigateStart
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 闁轰胶澧楀畵浣糕攦閹邦垰缍呴柛?*/}
|
||||
<Menu
|
||||
anchorEl={menuState.anchorEl}
|
||||
open={isMenuOpen('source')}
|
||||
onClose={handleMenuClose}
|
||||
hideBackdrop
|
||||
disableScrollLock
|
||||
sx={{ pointerEvents: 'none' }}
|
||||
aria-label={t('common.dataSource', 'Data source menu')}
|
||||
PaperProps={{
|
||||
elevation: 8,
|
||||
sx: {
|
||||
...styles.getMenuPaperStyles(theme),
|
||||
pointerEvents: 'auto'
|
||||
},
|
||||
onMouseLeave: handleMenuClose
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'center', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
|
||||
MenuListProps={{
|
||||
dense: false,
|
||||
onMouseLeave: handleMenuClose,
|
||||
sx: styles.menuListStyles,
|
||||
role: 'menu'
|
||||
}}
|
||||
transitionDuration={200}
|
||||
>
|
||||
<MenuItem
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/text-split`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
role="menuitem"
|
||||
sx={styles.getMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.listItemIconStyles}>
|
||||
<DescriptionOutlinedIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('textSplit.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</MenuItem>
|
||||
<Divider sx={{ my: 0.75, mx: 1.5 }} />
|
||||
<MenuItem
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/images`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
role="menuitem"
|
||||
sx={styles.getMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.listItemIconStyles}>
|
||||
<ImageIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('images.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* 闁轰胶澧楀畵渚€姊块崱娆樺悁闁荤偛妫滆ぐ宥夊础?*/}
|
||||
<Menu
|
||||
anchorEl={menuState.anchorEl}
|
||||
open={isMenuOpen('dataset')}
|
||||
onClose={handleMenuClose}
|
||||
hideBackdrop
|
||||
disableScrollLock
|
||||
sx={{ pointerEvents: 'none' }}
|
||||
PaperProps={{
|
||||
elevation: 8,
|
||||
sx: {
|
||||
...styles.getSimpleMenuPaperStyles(theme),
|
||||
pointerEvents: 'auto'
|
||||
},
|
||||
onMouseLeave: handleMenuClose
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'center', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
|
||||
MenuListProps={{
|
||||
dense: true,
|
||||
onMouseLeave: handleMenuClose,
|
||||
sx: styles.simpleMenuListStyles
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/datasets`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
sx={styles.getSimpleMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<DatasetOutlinedIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('datasets.singleTurn', '单轮问答数据集')}
|
||||
primaryTypographyProps={styles.smallListItemTextStyles}
|
||||
/>
|
||||
</MenuItem>
|
||||
<Divider sx={{ my: 0.5, mx: 1 }} />
|
||||
<MenuItem
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/multi-turn`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
sx={styles.getSimpleMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<ChatIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('datasets.multiTurn', '多轮对话数据集')}
|
||||
primaryTypographyProps={styles.smallListItemTextStyles}
|
||||
/>
|
||||
</MenuItem>
|
||||
<Divider sx={{ my: 0.5, mx: 1 }} />
|
||||
<MenuItem
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/image-datasets`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
sx={styles.getSimpleMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<ImageIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('datasets.imageQA', '图片问答数据集')}
|
||||
primaryTypographyProps={styles.smallListItemTextStyles}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* 閻犲洤瀚崣濠囨嚕濠婂啫绀?*/}
|
||||
<Menu
|
||||
anchorEl={menuState.anchorEl}
|
||||
open={isMenuOpen('eval')}
|
||||
onClose={handleMenuClose}
|
||||
hideBackdrop
|
||||
disableScrollLock
|
||||
sx={{ pointerEvents: 'none' }}
|
||||
PaperProps={{
|
||||
elevation: 8,
|
||||
sx: {
|
||||
...styles.getSimpleMenuPaperStyles(theme),
|
||||
pointerEvents: 'auto'
|
||||
},
|
||||
onMouseLeave: handleMenuClose
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'center', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
|
||||
MenuListProps={{
|
||||
dense: true,
|
||||
onMouseLeave: handleMenuClose,
|
||||
sx: styles.simpleMenuListStyles
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/eval-datasets`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
sx={styles.getSimpleMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<AssessmentOutlinedIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('eval.datasets')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</MenuItem>
|
||||
<Divider sx={{ my: 0.5, mx: 1 }} />
|
||||
<MenuItem
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/eval-tasks`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
sx={styles.getSimpleMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<PlaylistPlayIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('eval.tasks')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</MenuItem>
|
||||
<Divider sx={{ my: 0.5, mx: 1 }} />
|
||||
<MenuItem
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/blind-test-tasks`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
sx={styles.getSimpleMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<VisibilityIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('blindTest.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* 闁哄洦娼欓ˇ鍧楁嚕濠婂啫绀?*/}
|
||||
<Menu
|
||||
anchorEl={menuState.anchorEl}
|
||||
open={isMenuOpen('more')}
|
||||
onClose={handleMenuClose}
|
||||
hideBackdrop
|
||||
disableScrollLock
|
||||
sx={{ pointerEvents: 'none' }}
|
||||
PaperProps={{
|
||||
elevation: 8,
|
||||
sx: {
|
||||
...styles.getSimpleMenuPaperStyles(theme),
|
||||
pointerEvents: 'auto'
|
||||
},
|
||||
onMouseLeave: handleMenuClose
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'center', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
|
||||
MenuListProps={{
|
||||
dense: true,
|
||||
onMouseLeave: handleMenuClose,
|
||||
sx: styles.simpleMenuListStyles
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/settings`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
sx={styles.getSimpleMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<SettingsOutlinedIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('settings.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</MenuItem>
|
||||
<Divider sx={{ my: 0.5, mx: 1 }} />
|
||||
<MenuItem
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/playground`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
sx={styles.getSimpleMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<ScienceOutlinedIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('playground.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</MenuItem>
|
||||
<Divider sx={{ my: 0.5, mx: 1 }} />
|
||||
<MenuItem
|
||||
component={Link}
|
||||
href="/dataset-square"
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
sx={styles.getSimpleMenuItemStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<StorageIcon fontSize="small" sx={styles.getPrimaryIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('datasetSquare.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
easy-dataset-main/components/Navbar/Logo.js
Normal file
42
easy-dataset-main/components/Navbar/Logo.js
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Typography, Tooltip } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as styles from './styles';
|
||||
|
||||
/**
|
||||
* Logo 组件
|
||||
* 显示应用 Logo 和标题,支持点击跳转到首页
|
||||
*/
|
||||
export default function Logo({ theme }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip title={t('common.goHome', 'Go to Home')} placement="bottom">
|
||||
<Box
|
||||
component="a"
|
||||
href="/"
|
||||
role="link"
|
||||
aria-label={t('common.goToHomePage', 'Go to home page')}
|
||||
tabIndex={0}
|
||||
sx={styles.getLogoLinkStyles(theme)}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
window.location.href = '/';
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
window.location.href = '/';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box component="img" src="/imgs/logo.svg" alt="Easy Dataset Logo" sx={styles.logoImageStyles} />
|
||||
<Typography variant="h6" sx={styles.getLogoTextStyles(theme)}>
|
||||
Easy DataSet
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
405
easy-dataset-main/components/Navbar/MobileDrawer.js
Normal file
405
easy-dataset-main/components/Navbar/MobileDrawer.js
Normal file
@@ -0,0 +1,405 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Drawer,
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Collapse
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Link from 'next/link';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
||||
import TokenOutlinedIcon from '@mui/icons-material/TokenOutlined';
|
||||
import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined';
|
||||
import DatasetOutlinedIcon from '@mui/icons-material/DatasetOutlined';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
import ScienceOutlinedIcon from '@mui/icons-material/ScienceOutlined';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import ChatIcon from '@mui/icons-material/Chat';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined';
|
||||
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import UpdateChecker from '../UpdateChecker';
|
||||
import * as styles from './styles';
|
||||
|
||||
/**
|
||||
* MobileDrawer 组件
|
||||
* 移动端抽屉菜单,包含所有导航项
|
||||
*/
|
||||
export default function MobileDrawer({
|
||||
theme,
|
||||
drawerOpen,
|
||||
toggleDrawer,
|
||||
expandedMenu,
|
||||
toggleMobileSubmenu,
|
||||
currentProject,
|
||||
onNavigateStart
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const handleNavigateStart = () => {
|
||||
onNavigateStart?.();
|
||||
toggleDrawer();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
id="mobile-navigation-drawer"
|
||||
anchor="left"
|
||||
open={drawerOpen}
|
||||
onClose={toggleDrawer}
|
||||
PaperProps={{
|
||||
role: 'navigation',
|
||||
'aria-label': t('common.mobileNavigation', 'Mobile navigation menu'),
|
||||
sx: styles.getDrawerPaperStyles(theme)
|
||||
}}
|
||||
ModalProps={{
|
||||
keepMounted: true // Better mobile performance
|
||||
}}
|
||||
transitionDuration={300}
|
||||
SlideProps={{
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}}
|
||||
>
|
||||
{/* Drawer Header */}
|
||||
<Box sx={styles.getDrawerHeaderStyles(theme)}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box component="img" src="/imgs/logo.svg" alt="Easy Dataset Logo" sx={{ width: 32, height: 32 }} />
|
||||
<Typography variant="h6" component="h2" sx={{ fontWeight: 700, fontSize: '1.15rem' }}>
|
||||
{t('common.navigation', 'Navigation')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title={t('common.closeMenu', 'Close menu')}>
|
||||
<IconButton
|
||||
onClick={toggleDrawer}
|
||||
aria-label={t('common.closeMenu', 'Close menu')}
|
||||
size="medium"
|
||||
sx={styles.getDrawerCloseButtonStyles(theme)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Drawer Menu List */}
|
||||
<List sx={styles.drawerListStyles} role="menu">
|
||||
{/* 数据源菜单 */}
|
||||
<ListItem disablePadding sx={{ mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
onClick={() => toggleMobileSubmenu('source')}
|
||||
aria-expanded={expandedMenu === 'source'}
|
||||
aria-controls="source-submenu"
|
||||
role="menuitem"
|
||||
sx={styles.getDrawerListItemButtonStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.listItemIconStyles}>
|
||||
<DescriptionOutlinedIcon sx={styles.getIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('common.dataSource')} primaryTypographyProps={styles.listItemTextStyles} />
|
||||
{expandedMenu === 'source' ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<Collapse id="source-submenu" in={expandedMenu === 'source'} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding role="menu" sx={styles.getDrawerSubmenuContainerStyles(theme)}>
|
||||
<ListItemButton
|
||||
role="menuitem"
|
||||
sx={styles.getDrawerSubmenuItemStyles(theme)}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/text-split`}
|
||||
onClick={toggleDrawer}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<DescriptionOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('textSplit.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
role="menuitem"
|
||||
sx={styles.getDrawerSubmenuItemStyles(theme)}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/images`}
|
||||
onClick={toggleDrawer}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<ImageIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('images.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</Collapse>
|
||||
|
||||
{/* 数据蒸馏 */}
|
||||
<ListItem disablePadding sx={{ mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/distill`}
|
||||
onClick={toggleDrawer}
|
||||
role="menuitem"
|
||||
sx={styles.getDrawerListItemButtonStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.listItemIconStyles}>
|
||||
<TokenOutlinedIcon sx={styles.getIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('distill.title')} primaryTypographyProps={styles.listItemTextStyles} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{/* 问题管理 */}
|
||||
<ListItem disablePadding sx={{ mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/questions`}
|
||||
onClick={toggleDrawer}
|
||||
role="menuitem"
|
||||
sx={styles.getDrawerListItemButtonStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.listItemIconStyles}>
|
||||
<QuestionAnswerOutlinedIcon sx={styles.getIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('questions.title')} primaryTypographyProps={styles.listItemTextStyles} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{/* 数据集管理 */}
|
||||
<ListItem disablePadding sx={{ mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
onClick={() => toggleMobileSubmenu('dataset')}
|
||||
role="menuitem"
|
||||
aria-expanded={expandedMenu === 'dataset'}
|
||||
aria-controls="dataset-submenu"
|
||||
sx={styles.getDrawerListItemButtonStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.listItemIconStyles}>
|
||||
<DatasetOutlinedIcon sx={styles.getIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('datasets.management')} primaryTypographyProps={styles.listItemTextStyles} />
|
||||
{expandedMenu === 'dataset' ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<Collapse in={expandedMenu === 'dataset'} timeout="auto" unmountOnExit id="dataset-submenu">
|
||||
<List component="div" disablePadding sx={styles.getDrawerSubmenuContainerStyles(theme)}>
|
||||
<ListItemButton
|
||||
role="menuitem"
|
||||
sx={styles.getDrawerSubmenuItemStyles(theme)}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/datasets`}
|
||||
onClick={toggleDrawer}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<DatasetOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('datasets.singleTurn', '单轮问答数据集')}
|
||||
primaryTypographyProps={styles.smallListItemTextStyles}
|
||||
/>
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
sx={styles.getDrawerSubmenuItemStyles(theme)}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/multi-turn`}
|
||||
onClick={handleNavigateStart}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<ChatIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('datasets.multiTurn', '多轮对话数据集')}
|
||||
primaryTypographyProps={styles.smallListItemTextStyles}
|
||||
/>
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
sx={styles.getDrawerSubmenuItemStyles(theme)}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/image-datasets`}
|
||||
onClick={toggleDrawer}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<ImageIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('datasets.imageQA', '图片问答数据集')}
|
||||
primaryTypographyProps={styles.smallListItemTextStyles}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</Collapse>
|
||||
|
||||
{/* 评估菜单 */}
|
||||
<ListItem disablePadding sx={{ mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
onClick={() => toggleMobileSubmenu('eval')}
|
||||
role="menuitem"
|
||||
aria-expanded={expandedMenu === 'eval'}
|
||||
aria-controls="eval-submenu"
|
||||
sx={styles.getDrawerListItemButtonStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.listItemIconStyles}>
|
||||
<AssessmentOutlinedIcon sx={styles.getIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('eval.title')} primaryTypographyProps={styles.listItemTextStyles} />
|
||||
{expandedMenu === 'eval' ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<Collapse in={expandedMenu === 'eval'} timeout="auto" unmountOnExit id="eval-submenu">
|
||||
<List component="div" disablePadding sx={styles.getDrawerSubmenuContainerStyles(theme)}>
|
||||
<ListItemButton
|
||||
role="menuitem"
|
||||
sx={styles.getDrawerSubmenuItemStyles(theme)}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/eval-datasets`}
|
||||
onClick={handleNavigateStart}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<AssessmentOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('eval.datasets')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
role="menuitem"
|
||||
sx={styles.getDrawerSubmenuItemStyles(theme)}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/eval-tasks`}
|
||||
onClick={handleNavigateStart}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<PlaylistPlayIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('eval.tasks')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
role="menuitem"
|
||||
sx={styles.getDrawerSubmenuItemStyles(theme)}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/blind-test-tasks`}
|
||||
onClick={toggleDrawer}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('blindTest.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</Collapse>
|
||||
|
||||
{/* 更多菜单 */}
|
||||
<ListItem disablePadding sx={{ mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
onClick={() => toggleMobileSubmenu('more')}
|
||||
role="menuitem"
|
||||
aria-expanded={expandedMenu === 'more'}
|
||||
aria-controls="more-submenu"
|
||||
sx={styles.getDrawerListItemButtonStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.listItemIconStyles}>
|
||||
<MoreVertIcon sx={styles.getIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('common.more')} primaryTypographyProps={styles.listItemTextStyles} />
|
||||
{expandedMenu === 'more' ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<Collapse in={expandedMenu === 'more'} timeout="auto" unmountOnExit id="more-submenu">
|
||||
<List component="div" disablePadding sx={styles.getDrawerSubmenuContainerStyles(theme)}>
|
||||
<ListItemButton
|
||||
sx={styles.getDrawerSubmenuItemStyles(theme)}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/settings`}
|
||||
onClick={toggleDrawer}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('settings.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
sx={styles.getDrawerSubmenuItemStyles(theme)}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/playground`}
|
||||
onClick={toggleDrawer}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<ScienceOutlinedIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('playground.title')} primaryTypographyProps={styles.smallListItemTextStyles} />
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
sx={styles.getDrawerSubmenuItemStyles(theme)}
|
||||
component={Link}
|
||||
href="/dataset-square"
|
||||
onClick={toggleDrawer}
|
||||
>
|
||||
<ListItemIcon sx={styles.smallListItemIconStyles}>
|
||||
<StorageIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('datasetSquare.title')}
|
||||
primaryTypographyProps={styles.smallListItemTextStyles}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</Collapse>
|
||||
|
||||
{/* Utilities Section */}
|
||||
<Box sx={styles.getDrawerUtilitiesStyles(theme)}>
|
||||
<ListItem disablePadding sx={{ mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
component="a"
|
||||
href={
|
||||
i18n.language === 'zh-CN' ? 'https://docs.easy-dataset.com/' : 'https://docs.easy-dataset.com/ed/en'
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={toggleDrawer}
|
||||
sx={styles.getDrawerListItemButtonStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.listItemIconStyles}>
|
||||
<HelpOutlineIcon sx={styles.getIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('common.documentation', 'Documentation')}
|
||||
primaryTypographyProps={styles.listItemTextStyles}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItem disablePadding sx={{ mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
window.open('https://github.com/ConardLi/easy-dataset', '_blank');
|
||||
toggleDrawer();
|
||||
}}
|
||||
sx={styles.getDrawerListItemButtonStyles(theme)}
|
||||
>
|
||||
<ListItemIcon sx={styles.listItemIconStyles}>
|
||||
<GitHubIcon sx={styles.getIconColorStyles(theme)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('common.viewOnGitHub', 'View on GitHub')}
|
||||
primaryTypographyProps={styles.listItemTextStyles}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
<ListItem disablePadding sx={{ mb: 1 }}>
|
||||
<Box sx={{ px: 1, width: '100%' }}>
|
||||
<UpdateChecker />
|
||||
</Box>
|
||||
</ListItem>
|
||||
</Box>
|
||||
</List>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
139
easy-dataset-main/components/Navbar/NavigationTabs.js
Normal file
139
easy-dataset-main/components/Navbar/NavigationTabs.js
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Tabs, Tab } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Link from 'next/link';
|
||||
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
||||
import TokenOutlinedIcon from '@mui/icons-material/TokenOutlined';
|
||||
import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined';
|
||||
import DatasetOutlinedIcon from '@mui/icons-material/DatasetOutlined';
|
||||
import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import * as styles from './styles';
|
||||
|
||||
/**
|
||||
* NavigationTabs 组件
|
||||
* 桌面端导航 Tabs,包含数据源、数据蒸馏、问题管理、数据集管理、更多等 Tab
|
||||
*/
|
||||
export default function NavigationTabs({
|
||||
theme,
|
||||
pathname,
|
||||
currentProject,
|
||||
handleMenuOpen,
|
||||
handleMenuClose,
|
||||
onNavigateStart
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 计算当前 Tab 值
|
||||
const getCurrentTabValue = () => {
|
||||
if (pathname.includes('/settings') || pathname.includes('/playground') || pathname.includes('/datasets-sq')) {
|
||||
return 'more';
|
||||
}
|
||||
if (pathname.includes('/eval-datasets') || pathname.includes('/eval-tasks')) {
|
||||
return 'eval';
|
||||
}
|
||||
if (pathname.includes('/datasets') || pathname.includes('/multi-turn') || pathname.includes('/image-datasets')) {
|
||||
return 'datasets';
|
||||
}
|
||||
if (pathname.includes('/text-split') || pathname.includes('/images')) {
|
||||
return 'source';
|
||||
}
|
||||
return pathname;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={styles.navContainerStyles}>
|
||||
<Tabs
|
||||
value={getCurrentTabValue()}
|
||||
textColor="inherit"
|
||||
indicatorColor="secondary"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
sx={styles.getTabsStyles(theme)}
|
||||
>
|
||||
<Tab
|
||||
icon={<DescriptionOutlinedIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
{t('common.dataSource')}
|
||||
<ArrowDropDownIcon fontSize="small" sx={{ ml: 0.25 }} />
|
||||
</Box>
|
||||
}
|
||||
value="source"
|
||||
onMouseEnter={e => handleMenuOpen(e, 'source')}
|
||||
sx={styles.tabIconWrapperStyles}
|
||||
/>
|
||||
<Tab
|
||||
icon={<TokenOutlinedIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label={t('distill.title')}
|
||||
value={`/projects/${currentProject}/distill`}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/distill`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
sx={styles.tabIconWrapperStyles}
|
||||
/>
|
||||
<Tab
|
||||
icon={<QuestionAnswerOutlinedIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label={t('questions.title')}
|
||||
value={`/projects/${currentProject}/questions`}
|
||||
component={Link}
|
||||
href={`/projects/${currentProject}/questions`}
|
||||
onClick={() => {
|
||||
onNavigateStart?.();
|
||||
handleMenuClose();
|
||||
}}
|
||||
sx={styles.tabIconWrapperStyles}
|
||||
/>
|
||||
<Tab
|
||||
icon={<DatasetOutlinedIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
{t('datasets.management')}
|
||||
<ArrowDropDownIcon fontSize="small" sx={{ ml: 0.25 }} />
|
||||
</Box>
|
||||
}
|
||||
value="datasets"
|
||||
onMouseEnter={e => handleMenuOpen(e, 'dataset')}
|
||||
sx={styles.tabIconWrapperStyles}
|
||||
/>
|
||||
<Tab
|
||||
icon={<AssessmentOutlinedIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
{t('eval.title')}
|
||||
<ArrowDropDownIcon fontSize="small" sx={{ ml: 0.25 }} />
|
||||
</Box>
|
||||
}
|
||||
value="eval"
|
||||
onMouseEnter={e => handleMenuOpen(e, 'eval')}
|
||||
sx={styles.tabIconWrapperStyles}
|
||||
/>
|
||||
<Tab
|
||||
icon={<MoreVertIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
{t('common.more')}
|
||||
<ArrowDropDownIcon fontSize="small" sx={{ ml: 0.25 }} />
|
||||
</Box>
|
||||
}
|
||||
onMouseEnter={e => handleMenuOpen(e, 'more')}
|
||||
value="more"
|
||||
sx={styles.tabIconWrapperStyles}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
247
easy-dataset-main/components/Navbar/contextBarStyles.js
Normal file
247
easy-dataset-main/components/Navbar/contextBarStyles.js
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* ContextBar 组件样式
|
||||
* 包含项目选择器和模型选择器的所有样式
|
||||
*/
|
||||
|
||||
import { alpha } from '@mui/material';
|
||||
|
||||
// ===== 主容器样式 =====
|
||||
export const getContextBarPaperStyles = theme => ({
|
||||
position: 'absolute',
|
||||
top: 64, // Below navbar
|
||||
left: 0,
|
||||
zIndex: 1100,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor:
|
||||
theme.palette.mode === 'dark'
|
||||
? alpha(theme.palette.background.paper, 0.9)
|
||||
: alpha(theme.palette.background.paper, 0.95),
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
px: { xs: 2, sm: 3, md: 4 },
|
||||
py: { xs: 1.25, sm: 1.5 },
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: theme.palette.mode === 'dark' ? '0 1px 3px rgba(0, 0, 0, 0.2)' : '0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
width: 'auto'
|
||||
});
|
||||
|
||||
export const contextBarContainerStyles = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { xs: 1, sm: 1.5, md: 2 },
|
||||
flexWrap: 'nowrap',
|
||||
width: 'auto'
|
||||
};
|
||||
|
||||
// ===== 选择器容器样式 =====
|
||||
export const selectorContainerStyles = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
};
|
||||
|
||||
// ===== 标签样式 =====
|
||||
export const labelTypographyStyles = {
|
||||
color: 'text.secondary',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.7rem',
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
};
|
||||
|
||||
// ===== Chip 内部文本样式 =====
|
||||
export const chipLabelBoxStyles = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5
|
||||
};
|
||||
|
||||
export const chipTextStyles = {
|
||||
fontWeight: 600,
|
||||
fontSize: { xs: '0.8rem', sm: '0.875rem' },
|
||||
maxWidth: { xs: '80px', sm: '120px', md: '150px' },
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
};
|
||||
|
||||
export const chipArrowStyles = {
|
||||
ml: -0.25,
|
||||
flexShrink: 0
|
||||
};
|
||||
|
||||
// ===== 项目选择器 Chip 样式 =====
|
||||
export const getProjectChipStyles = theme => ({
|
||||
minWidth: 'auto',
|
||||
maxWidth: { xs: '120px', sm: '150px', md: '180px' },
|
||||
height: { xs: 32, sm: 36 },
|
||||
minWidth: { xs: 120, sm: 150, md: 180 },
|
||||
maxWidth: { xs: '120px', sm: '150px', md: '180px' },
|
||||
borderRadius: 1.5,
|
||||
borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.23)' : 'rgba(0, 0, 0, 0.23)',
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.02)',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(25, 118, 210, 0.04)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow:
|
||||
theme.palette.mode === 'dark' ? '0 4px 12px rgba(144, 202, 249, 0.15)' : '0 4px 12px rgba(25, 118, 210, 0.15)'
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0)'
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.primary.main}`,
|
||||
outlineOffset: 2
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
color: 'text.primary',
|
||||
fontSize: '1.1rem',
|
||||
ml: 0.5,
|
||||
flexShrink: 0
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
px: 1,
|
||||
overflow: 'hidden'
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 模型选择器 Chip 样式 =====
|
||||
export const getModelChipStyles = theme => ({
|
||||
minWidth: { xs: 140, sm: 160, md: 180 },
|
||||
maxWidth: { xs: 200, sm: 280, md: 360 },
|
||||
height: { xs: 36, sm: 40 },
|
||||
borderRadius: 2,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(25, 118, 210, 0.04)',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.15)' : 'rgba(25, 118, 210, 0.08)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow:
|
||||
theme.palette.mode === 'dark' ? '0 4px 12px rgba(144, 202, 249, 0.25)' : '0 4px 12px rgba(25, 118, 210, 0.25)'
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0)'
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.primary.main}`,
|
||||
outlineOffset: 2
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
color: 'primary.main',
|
||||
fontSize: '1.1rem',
|
||||
ml: 0.5,
|
||||
flexShrink: 0
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
px: 1,
|
||||
overflow: 'hidden'
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 菜单样式 =====
|
||||
export const getMenuPaperStyles = theme => ({
|
||||
mt: 1,
|
||||
minWidth: 240,
|
||||
maxWidth: 400,
|
||||
maxHeight: 400,
|
||||
borderRadius: 2,
|
||||
overflow: 'visible',
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'background.paper' : 'background.paper',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow:
|
||||
theme.palette.mode === 'dark'
|
||||
? '0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1)'
|
||||
: '0 12px 40px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
left: 24,
|
||||
width: 12,
|
||||
height: 12,
|
||||
bgcolor: 'background.paper',
|
||||
transform: 'translateY(-50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
borderLeft: `1px solid ${theme.palette.divider}`,
|
||||
borderTop: `1px solid ${theme.palette.divider}`
|
||||
}
|
||||
});
|
||||
|
||||
export const menuListPropsStyles = {
|
||||
dense: false,
|
||||
sx: { py: 1 }
|
||||
};
|
||||
|
||||
// ===== 菜单标题样式 =====
|
||||
export const menuHeaderTypographyStyles = {
|
||||
px: 2,
|
||||
py: 1,
|
||||
display: 'block',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.7rem'
|
||||
};
|
||||
|
||||
// ===== 菜单项样式 =====
|
||||
export const getMenuItemStyles = theme => ({
|
||||
mx: 1,
|
||||
borderRadius: 1.5,
|
||||
minHeight: 44,
|
||||
py: 1.25,
|
||||
px: 1.5,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(25, 118, 210, 0.04)',
|
||||
transform: 'translateX(4px)'
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.16)' : 'rgba(25, 118, 210, 0.08)',
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.24)' : 'rgba(25, 118, 210, 0.12)'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const menuItemIconStyles = {
|
||||
minWidth: 36
|
||||
};
|
||||
|
||||
export const getMenuItemTextPrimaryProps = isSelected => ({
|
||||
variant: 'body2',
|
||||
fontWeight: isSelected ? 600 : 400
|
||||
});
|
||||
|
||||
export const menuItemTextSecondaryProps = {
|
||||
variant: 'caption',
|
||||
sx: { fontSize: '0.7rem' }
|
||||
};
|
||||
|
||||
// ===== 模型图标样式 =====
|
||||
export const modelIconStyles = {
|
||||
width: 20,
|
||||
height: 20,
|
||||
objectFit: 'contain',
|
||||
flexShrink: 0,
|
||||
borderRadius: '50%',
|
||||
mr: 1
|
||||
};
|
||||
|
||||
// ===== 分组标题样式 =====
|
||||
export const getProviderHeaderStyles = theme => ({
|
||||
pl: 2,
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
mt: 1,
|
||||
mb: 0.5
|
||||
});
|
||||
257
easy-dataset-main/components/Navbar/index.js
Normal file
257
easy-dataset-main/components/Navbar/index.js
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Box,
|
||||
IconButton,
|
||||
useTheme as useMuiTheme,
|
||||
Tooltip,
|
||||
useMediaQuery,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
|
||||
// 样式
|
||||
import * as styles from './styles';
|
||||
|
||||
// 子组件
|
||||
import Logo from './Logo';
|
||||
import ActionButtons from './ActionButtons';
|
||||
import NavigationTabs from './NavigationTabs';
|
||||
import MobileDrawer from './MobileDrawer';
|
||||
import DesktopMenus from './DesktopMenus';
|
||||
import ContextBar from './ContextBar';
|
||||
|
||||
export default function Navbar({ projects = [], currentProject }) {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const theme = useMuiTheme();
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const isProjectDetail = pathname.includes('/projects/') && pathname.split('/').length > 3;
|
||||
|
||||
// 检测移动设备
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
|
||||
// 移动端抽屉状态
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [expandedMenu, setExpandedMenu] = useState(null);
|
||||
|
||||
// 桌面端菜单状态
|
||||
const [menuState, setMenuState] = useState({ anchorEl: null, menuType: null });
|
||||
const [navLoading, setNavLoading] = useState(false);
|
||||
const navLoadingTimeoutRef = useRef(null);
|
||||
|
||||
// ContextBar 悬浮状态
|
||||
const [contextBarHovered, setContextBarHovered] = useState(false);
|
||||
const contextTriggerRef = useRef(null);
|
||||
const contextBarRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextBarHovered) return;
|
||||
|
||||
const handleOutsideClick = event => {
|
||||
if (contextBarRef.current?.contains(event.target)) return;
|
||||
if (contextTriggerRef.current?.contains(event.target)) return;
|
||||
const projectMenuEl = document.getElementById('project-menu');
|
||||
if (projectMenuEl?.contains(event.target)) return;
|
||||
setContextBarHovered(false);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handleOutsideClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handleOutsideClick, true);
|
||||
};
|
||||
}, [contextBarHovered]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuState.menuType) return;
|
||||
|
||||
const handleOutsideMenuClick = event => {
|
||||
if (menuState.anchorEl?.contains(event.target)) return;
|
||||
if (event.target?.closest?.('.MuiMenu-root')) return;
|
||||
setMenuState({ anchorEl: null, menuType: null });
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handleOutsideMenuClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handleOutsideMenuClick, true);
|
||||
};
|
||||
}, [menuState.anchorEl, menuState.menuType]);
|
||||
|
||||
useEffect(() => {
|
||||
setNavLoading(false);
|
||||
if (navLoadingTimeoutRef.current) {
|
||||
clearTimeout(navLoadingTimeoutRef.current);
|
||||
navLoadingTimeoutRef.current = null;
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isProjectDetail || !currentProject) return;
|
||||
const prefetchRoutes = [
|
||||
`/projects/${currentProject}/multi-turn`,
|
||||
`/projects/${currentProject}/eval-datasets`,
|
||||
`/projects/${currentProject}/eval-tasks`
|
||||
];
|
||||
prefetchRoutes.forEach(route => router.prefetch(route));
|
||||
}, [router, currentProject, isProjectDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (navLoadingTimeoutRef.current) {
|
||||
clearTimeout(navLoadingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNavigateStart = () => {
|
||||
setNavLoading(true);
|
||||
if (navLoadingTimeoutRef.current) {
|
||||
clearTimeout(navLoadingTimeoutRef.current);
|
||||
}
|
||||
navLoadingTimeoutRef.current = setTimeout(() => {
|
||||
setNavLoading(false);
|
||||
navLoadingTimeoutRef.current = null;
|
||||
}, 12000);
|
||||
};
|
||||
|
||||
const handleMenuOpen = (event, menuType) => {
|
||||
setMenuState({ anchorEl: event.currentTarget, menuType });
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setMenuState({ anchorEl: null, menuType: null });
|
||||
};
|
||||
|
||||
const isMenuOpen = menuType => menuState.menuType === menuType;
|
||||
|
||||
const toggleDrawer = () => {
|
||||
setDrawerOpen(!drawerOpen);
|
||||
setExpandedMenu(null);
|
||||
};
|
||||
|
||||
const toggleMobileSubmenu = menuType => {
|
||||
setExpandedMenu(expandedMenu === menuType ? null : menuType);
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar
|
||||
component="nav"
|
||||
position="sticky"
|
||||
elevation={0}
|
||||
color={theme.palette.mode === 'dark' ? 'transparent' : 'primary'}
|
||||
sx={styles.getAppBarStyles(theme)}
|
||||
style={{ borderRadius: 0, zIndex: 1200 }}
|
||||
role="navigation"
|
||||
aria-label={t('common.mainNavigation', 'Main navigation')}
|
||||
>
|
||||
<Toolbar sx={styles.toolbarStyles}>
|
||||
{/* 左侧: 汉堡菜单(移动端) + Logo */}
|
||||
<Box
|
||||
ref={contextTriggerRef}
|
||||
sx={styles.logoContainerStyles}
|
||||
onMouseEnter={() => isProjectDetail && setContextBarHovered(true)}
|
||||
>
|
||||
{/* 汉堡菜单按钮 */}
|
||||
{isProjectDetail && isMobile && (
|
||||
<Tooltip title={t('common.menu', 'Menu')} placement="bottom">
|
||||
<IconButton
|
||||
onClick={toggleDrawer}
|
||||
size="medium"
|
||||
aria-label={t('common.openMenu', 'Open navigation menu')}
|
||||
aria-expanded={drawerOpen}
|
||||
aria-controls="mobile-navigation-drawer"
|
||||
sx={styles.getHamburgerButtonStyles(theme)}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Logo 组件 */}
|
||||
<Logo theme={theme} />
|
||||
</Box>
|
||||
|
||||
{/* 中间导航 - 仅桌面端 */}
|
||||
{isProjectDetail && !isMobile && (
|
||||
<NavigationTabs
|
||||
theme={theme}
|
||||
pathname={pathname}
|
||||
currentProject={currentProject}
|
||||
handleMenuOpen={handleMenuOpen}
|
||||
handleMenuClose={handleMenuClose}
|
||||
onNavigateStart={handleNavigateStart}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧操作区 */}
|
||||
<ActionButtons
|
||||
theme={theme}
|
||||
resolvedTheme={resolvedTheme}
|
||||
toggleTheme={toggleTheme}
|
||||
isProjectDetail={isProjectDetail}
|
||||
currentProject={currentProject}
|
||||
onActionAreaEnter={!isMobile ? handleMenuClose : undefined}
|
||||
/>
|
||||
</Toolbar>
|
||||
{isProjectDetail && (
|
||||
<LinearProgress
|
||||
color="secondary"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 2,
|
||||
opacity: navLoading ? 1 : 0,
|
||||
transition: 'opacity 180ms ease'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AppBar>
|
||||
|
||||
{/* ContextBar - 在 Logo 或 ContextBar 悬浮时展示 */}
|
||||
{isProjectDetail && contextBarHovered && (
|
||||
<Box ref={contextBarRef} onMouseLeave={() => setContextBarHovered(false)}>
|
||||
<ContextBar
|
||||
projects={projects}
|
||||
currentProjectId={currentProject}
|
||||
onMouseLeave={() => setContextBarHovered(false)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 移动端抽屉组件 */}
|
||||
<MobileDrawer
|
||||
theme={theme}
|
||||
drawerOpen={drawerOpen}
|
||||
toggleDrawer={toggleDrawer}
|
||||
expandedMenu={expandedMenu}
|
||||
toggleMobileSubmenu={toggleMobileSubmenu}
|
||||
currentProject={currentProject}
|
||||
onNavigateStart={handleNavigateStart}
|
||||
/>
|
||||
|
||||
{/* 桌面端菜单组件 */}
|
||||
<DesktopMenus
|
||||
theme={theme}
|
||||
menuState={menuState}
|
||||
isMenuOpen={isMenuOpen}
|
||||
handleMenuClose={handleMenuClose}
|
||||
currentProject={currentProject}
|
||||
onNavigateStart={handleNavigateStart}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
374
easy-dataset-main/components/Navbar/styles.js
Normal file
374
easy-dataset-main/components/Navbar/styles.js
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Navbar 组件样式配置
|
||||
*/
|
||||
|
||||
// AppBar 样式
|
||||
export const getAppBarStyles = theme => ({
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'background.paper' : 'primary.main',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: theme.palette.mode === 'dark' ? '0 1px 3px rgba(0, 0, 0, 0.3)' : '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
});
|
||||
|
||||
// Toolbar 样式
|
||||
export const toolbarStyles = {
|
||||
height: '64px',
|
||||
minHeight: '64px !important',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: { xs: 2, sm: 2, md: 3 },
|
||||
gap: 2
|
||||
};
|
||||
|
||||
// Logo 容器样式
|
||||
export const logoContainerStyles = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
flexShrink: 0
|
||||
};
|
||||
|
||||
// 汉堡菜单按钮样式
|
||||
export const getHamburgerButtonStyles = theme => ({
|
||||
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.15)'
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)'
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white'}`,
|
||||
outlineOffset: 2
|
||||
}
|
||||
});
|
||||
|
||||
// Logo 链接样式
|
||||
export const getLogoLinkStyles = theme => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
borderRadius: 1.5,
|
||||
px: 0.5,
|
||||
'&:hover': {
|
||||
opacity: 0.85,
|
||||
transform: 'translateY(-1px)'
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0)'
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white'}`,
|
||||
outlineOffset: 2
|
||||
}
|
||||
});
|
||||
|
||||
// Logo 图片样式
|
||||
export const logoImageStyles = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
mr: 1.5,
|
||||
transition: 'transform 0.2s ease'
|
||||
};
|
||||
|
||||
// Logo 文字样式
|
||||
export const getLogoTextStyles = theme => ({
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.5px',
|
||||
fontSize: '1.125rem',
|
||||
display: { xs: 'none', md: 'block' },
|
||||
color: 'white',
|
||||
...(theme.palette.mode === 'dark' && {
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
})
|
||||
});
|
||||
|
||||
// 中间导航容器样式
|
||||
export const navContainerStyles = {
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mx: { lg: 1, xl: 3 },
|
||||
overflow: 'hidden'
|
||||
};
|
||||
|
||||
// Tabs 样式
|
||||
export const getTabsStyles = theme => ({
|
||||
minHeight: '64px',
|
||||
'& .MuiTab-root': {
|
||||
minWidth: 100,
|
||||
maxWidth: 180,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
color: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.7)' : 'rgba(255, 255, 255, 1)',
|
||||
px: 2,
|
||||
minHeight: '64px',
|
||||
textTransform: 'none',
|
||||
letterSpacing: '0.3px',
|
||||
'&:hover': {
|
||||
color: 'white',
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.15)'
|
||||
}
|
||||
},
|
||||
'& .Mui-selected': {
|
||||
color: 'white !important',
|
||||
fontWeight: 600,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.2)'
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
height: 3,
|
||||
borderRadius: '3px 3px 0 0',
|
||||
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white',
|
||||
boxShadow: theme.palette.mode === 'dark' ? '0 0 8px rgba(103, 126, 234, 0.5)' : '0 0 8px rgba(255, 255, 255, 0.5)'
|
||||
}
|
||||
});
|
||||
|
||||
// Tab 图标包装器样式
|
||||
export const tabIconWrapperStyles = {
|
||||
'& .MuiTab-iconWrapper': { mr: 1 }
|
||||
};
|
||||
|
||||
// 右侧操作区容器样式
|
||||
export const actionAreaStyles = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexShrink: 0
|
||||
};
|
||||
|
||||
// 文档/GitHub 按钮样式
|
||||
export const getIconButtonStyles = theme => ({
|
||||
display: { xs: 'none', xl: 'flex' },
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.2)',
|
||||
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
|
||||
borderRadius: 1.5,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(255, 255, 255, 0.35)'
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white'}`,
|
||||
outlineOffset: 2
|
||||
}
|
||||
});
|
||||
|
||||
// Drawer Paper 样式
|
||||
export const getDrawerPaperStyles = theme => ({
|
||||
width: { xs: '85vw', sm: 320 },
|
||||
maxWidth: 380,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'background.paper' : 'background.default',
|
||||
backgroundImage:
|
||||
theme.palette.mode === 'dark' ? 'linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05))' : 'none',
|
||||
boxShadow: theme.palette.mode === 'dark' ? '0 8px 32px rgba(0, 0, 0, 0.6)' : '0 8px 32px rgba(0, 0, 0, 0.15)'
|
||||
});
|
||||
|
||||
// Drawer 头部样式
|
||||
export const getDrawerHeaderStyles = theme => ({
|
||||
p: 2.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
minHeight: 64
|
||||
});
|
||||
|
||||
// Drawer 关闭按钮样式
|
||||
export const getDrawerCloseButtonStyles = theme => ({
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'rotate(90deg)',
|
||||
bgcolor: 'action.hover'
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.primary.main}`,
|
||||
outlineOffset: 2
|
||||
}
|
||||
});
|
||||
|
||||
// Drawer 列表样式
|
||||
export const drawerListStyles = {
|
||||
pt: 1,
|
||||
px: 1
|
||||
};
|
||||
|
||||
// Drawer 列表项按钮样式
|
||||
export const getDrawerListItemButtonStyles = theme => ({
|
||||
borderRadius: '8px',
|
||||
minHeight: 48,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.12)' : 'rgba(103, 126, 234, 0.08)'
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.primary.main}`,
|
||||
outlineOffset: -2
|
||||
}
|
||||
});
|
||||
|
||||
// Drawer 子菜单容器样式
|
||||
export const getDrawerSubmenuContainerStyles = theme => ({
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.02)',
|
||||
borderRadius: '8px',
|
||||
my: 0.5
|
||||
});
|
||||
|
||||
// Drawer 子菜单项样式
|
||||
export const getDrawerSubmenuItemStyles = theme => ({
|
||||
pl: 4,
|
||||
mx: 1,
|
||||
borderRadius: '8px',
|
||||
minHeight: 44,
|
||||
py: 1.5,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.08)' : 'rgba(103, 126, 234, 0.05)'
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.primary.main}`,
|
||||
outlineOffset: -2
|
||||
}
|
||||
});
|
||||
|
||||
// Drawer 工具区域样式
|
||||
export const getDrawerUtilitiesStyles = theme => ({
|
||||
mt: 'auto',
|
||||
pt: 2,
|
||||
borderTop: `1px solid ${theme.palette.divider}`
|
||||
});
|
||||
|
||||
// Menu Paper 样式
|
||||
export const getMenuPaperStyles = theme => ({
|
||||
mt: 1.5,
|
||||
borderRadius: '12px',
|
||||
minWidth: 220,
|
||||
overflow: 'visible',
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow:
|
||||
theme.palette.mode === 'dark'
|
||||
? '0 12px 40px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.1)'
|
||||
: '0 12px 40px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.05)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: '50%',
|
||||
width: 12,
|
||||
height: 12,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)',
|
||||
transform: 'translateY(-50%) translateX(50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
boxShadow: theme.palette.mode === 'dark' ? '-2px -2px 4px rgba(0, 0, 0, 0.3)' : '-2px -2px 4px rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
});
|
||||
|
||||
// Menu 列表样式
|
||||
export const menuListStyles = {
|
||||
py: 1.5
|
||||
};
|
||||
|
||||
// Menu 项样式
|
||||
export const getMenuItemStyles = theme => ({
|
||||
mx: 1,
|
||||
borderRadius: '8px',
|
||||
py: 1.25,
|
||||
minHeight: 44,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.15)' : 'rgba(103, 126, 234, 0.1)',
|
||||
transform: 'translateX(4px)'
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.primary.main}`,
|
||||
outlineOffset: -2
|
||||
}
|
||||
});
|
||||
|
||||
// Dataset/More Menu Paper 样式(简化版)
|
||||
export const getSimpleMenuPaperStyles = theme => ({
|
||||
mt: 1.5,
|
||||
borderRadius: '12px',
|
||||
minWidth: 220,
|
||||
overflow: 'visible',
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
boxShadow:
|
||||
theme.palette.mode === 'dark'
|
||||
? '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1)'
|
||||
: '0 8px 32px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: '50%',
|
||||
width: 12,
|
||||
height: 12,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)',
|
||||
transform: 'translateY(-50%) translateX(50%) rotate(45deg)',
|
||||
zIndex: 0
|
||||
}
|
||||
});
|
||||
|
||||
// 简化 Menu 列表样式
|
||||
export const simpleMenuListStyles = {
|
||||
py: 1
|
||||
};
|
||||
|
||||
// 简化 Menu 项样式
|
||||
export const getSimpleMenuItemStyles = theme => ({
|
||||
mx: 0.75,
|
||||
borderRadius: '8px',
|
||||
py: 1,
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.15)' : 'rgba(103, 126, 234, 0.1)',
|
||||
transform: 'translateX(4px)'
|
||||
}
|
||||
});
|
||||
|
||||
// ListItemIcon 样式
|
||||
export const listItemIconStyles = {
|
||||
minWidth: 40
|
||||
};
|
||||
|
||||
export const smallListItemIconStyles = {
|
||||
minWidth: 36
|
||||
};
|
||||
|
||||
// ListItemText 样式
|
||||
export const listItemTextStyles = {
|
||||
fontWeight: 600,
|
||||
fontSize: '0.95rem'
|
||||
};
|
||||
|
||||
export const smallListItemTextStyles = {
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500
|
||||
};
|
||||
|
||||
// 图标颜色样式
|
||||
export const getIconColorStyles = theme => ({
|
||||
color: theme.palette.mode === 'dark' ? 'primary.light' : 'primary.main'
|
||||
});
|
||||
|
||||
export const getPrimaryIconColorStyles = theme => ({
|
||||
color: theme.palette.primary.main
|
||||
});
|
||||
223
easy-dataset-main/components/TaskIcon.js
Normal file
223
easy-dataset-main/components/TaskIcon.js
Normal file
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Badge, IconButton, Tooltip, CircularProgress, Menu, MenuItem, Divider, ListItemIcon } from '@mui/material';
|
||||
import TaskAltIcon from '@mui/icons-material/TaskAlt';
|
||||
import ListAltIcon from '@mui/icons-material/ListAlt';
|
||||
import QuizIcon from '@mui/icons-material/Quiz';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import useFileProcessingStatus from '@/hooks/useFileProcessingStatus';
|
||||
import { useAtomValue } from 'jotai/index';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function TaskIcon({ projectId, theme }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
|
||||
const isMenuOpen = Boolean(menuAnchorEl);
|
||||
const selectedModel = useAtomValue(selectedModelInfoAtom);
|
||||
const { setTaskFileProcessing, setTask } = useFileProcessingStatus();
|
||||
|
||||
const fetchPendingTasks = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/projects/${projectId}/tasks/list?status=0`);
|
||||
if (response.data?.code === 0) {
|
||||
const pendingTasks = response.data.data || [];
|
||||
setTasks(pendingTasks);
|
||||
|
||||
const hasActiveFileTask = pendingTasks.some(
|
||||
task => task.projectId === projectId && task.taskType === 'file-processing'
|
||||
);
|
||||
setTaskFileProcessing(hasActiveFileTask);
|
||||
|
||||
if (hasActiveFileTask) {
|
||||
const activeTask = pendingTasks.find(
|
||||
task => task.projectId === projectId && task.taskType === 'file-processing'
|
||||
);
|
||||
try {
|
||||
const detailInfo = JSON.parse(activeTask?.detail || '{}');
|
||||
setTask(detailInfo);
|
||||
} catch {
|
||||
setTask(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task list:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
fetchPendingTasks();
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
fetchPendingTasks();
|
||||
}, 10000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setMenuAnchorEl(null);
|
||||
}, [pathname]);
|
||||
|
||||
const handleOpenTaskList = () => {
|
||||
setMenuAnchorEl(null);
|
||||
router.push(`/projects/${projectId}/tasks`);
|
||||
};
|
||||
|
||||
const handleMenuOpen = event => {
|
||||
if (isMenuOpen) {
|
||||
setMenuAnchorEl(null);
|
||||
return;
|
||||
}
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchorEl(null);
|
||||
};
|
||||
|
||||
const createBatchTask = async (taskType, detail) => {
|
||||
if (!projectId || !selectedModel?.id) {
|
||||
toast.error(t('textSplit.selectModelFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
|
||||
taskType,
|
||||
modelInfo: selectedModel,
|
||||
language: i18n.language,
|
||||
detail
|
||||
});
|
||||
|
||||
if (response.data?.code === 0) {
|
||||
toast.success(t('tasks.createSuccess'));
|
||||
await fetchPendingTasks();
|
||||
} else {
|
||||
toast.error(`${t('tasks.createFailed')}: ${response.data?.message || ''}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Create batch task failed:', error);
|
||||
toast.error(`${t('tasks.createFailed')}: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAutoQuestionTask = async () => {
|
||||
await createBatchTask('question-generation', '批量生成问题任务');
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleCreateAutoEvalTask = async () => {
|
||||
await createBatchTask('eval-generation', '批量生成评估集任务');
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleCreateAutoCleaningTask = async () => {
|
||||
await createBatchTask('data-cleaning', '批量数据清洗任务');
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const renderTaskIcon = () => {
|
||||
const pendingTasks = tasks.filter(task => task.status === 0);
|
||||
|
||||
if (pendingTasks.length > 0) {
|
||||
return (
|
||||
<Badge badgeContent={pendingTasks.length} color="error">
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return <TaskAltIcon fontSize="small" />;
|
||||
};
|
||||
|
||||
const getTooltipText = () => {
|
||||
const pendingTasks = tasks.filter(task => task.status === 0);
|
||||
|
||||
if (pendingTasks.length > 0) {
|
||||
return t('tasks.pending', { count: pendingTasks.length });
|
||||
}
|
||||
|
||||
return t('tasks.completed');
|
||||
};
|
||||
|
||||
if (!projectId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={getTooltipText()}>
|
||||
<IconButton
|
||||
onClick={handleMenuOpen}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(255, 255, 255, 0.15)',
|
||||
color: theme.palette.mode === 'dark' ? 'inherit' : 'white',
|
||||
p: 1,
|
||||
borderRadius: 1.5,
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.25)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderTaskIcon()}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Menu
|
||||
anchorEl={menuAnchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
hideBackdrop
|
||||
disableScrollLock
|
||||
keepMounted
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<MenuItem onClick={handleOpenTaskList}>
|
||||
<ListItemIcon>
|
||||
<ListAltIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{t('tasks.title')}
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuItem onClick={handleCreateAutoQuestionTask} disabled={!selectedModel?.id}>
|
||||
<ListItemIcon>
|
||||
<QuizIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{t('textSplit.autoGenerateQuestions', { defaultValue: '自动提取问题' })}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={handleCreateAutoEvalTask} disabled={!selectedModel?.id}>
|
||||
<ListItemIcon>
|
||||
<AssessmentIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{t('textSplit.autoEvalGeneration', { defaultValue: '自动生成评估集' })}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={handleCreateAutoCleaningTask} disabled={!selectedModel?.id}>
|
||||
<ListItemIcon>
|
||||
<CleaningServicesIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{t('textSplit.autoDataCleaning', { defaultValue: '自动数据清洗' })}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
342
easy-dataset-main/components/ThemeRegistry.js
Normal file
342
easy-dataset-main/components/ThemeRegistry.js
Normal file
@@ -0,0 +1,342 @@
|
||||
'use client';
|
||||
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { ThemeProvider as NextThemeProvider, useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// 导入字体
|
||||
import '@fontsource/inter/300.css';
|
||||
import '@fontsource/inter/400.css';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/600.css';
|
||||
import '@fontsource/inter/700.css';
|
||||
import '@fontsource/jetbrains-mono/400.css';
|
||||
import '@fontsource/jetbrains-mono/500.css';
|
||||
|
||||
// 创建主题配置
|
||||
const getTheme = mode => {
|
||||
// 主色调
|
||||
const mainBlue = '#2A5CAA';
|
||||
const darkGray = '#2D2D2D';
|
||||
|
||||
// 辅助色 - 数据可视化色谱
|
||||
const dataVizColors = [
|
||||
'#6366F1', // 紫蓝色
|
||||
'#10B981', // 绿色
|
||||
'#F59E0B', // 琥珀色
|
||||
'#EC4899', // 粉色
|
||||
'#8B5CF6', // 紫色
|
||||
'#3B82F6' // 蓝色
|
||||
];
|
||||
|
||||
// 状态色
|
||||
const successColor = '#10B981'; // 翡翠绿
|
||||
const warningColor = '#F59E0B'; // 琥珀色
|
||||
const errorColor = '#EF4444'; // 珊瑚红
|
||||
|
||||
// 渐变色
|
||||
const gradientPrimary = 'linear-gradient(90deg, #2A5CAA 0%, #8B5CF6 100%)';
|
||||
|
||||
// 根据模式调整颜色
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
primary: {
|
||||
main: mainBlue,
|
||||
dark: '#1E4785',
|
||||
light: '#4878C6',
|
||||
contrastText: '#FFFFFF'
|
||||
},
|
||||
secondary: {
|
||||
main: '#8B5CF6',
|
||||
dark: '#7039F2',
|
||||
light: '#A78BFA',
|
||||
contrastText: '#FFFFFF'
|
||||
},
|
||||
error: {
|
||||
main: errorColor,
|
||||
dark: '#DC2626',
|
||||
light: '#F87171'
|
||||
},
|
||||
warning: {
|
||||
main: warningColor,
|
||||
dark: '#D97706',
|
||||
light: '#FBBF24'
|
||||
},
|
||||
success: {
|
||||
main: successColor,
|
||||
dark: '#059669',
|
||||
light: '#34D399'
|
||||
},
|
||||
background: {
|
||||
default: mode === 'dark' ? '#121212' : '#F8F9FA',
|
||||
paper: mode === 'dark' ? '#1E1E1E' : '#FFFFFF',
|
||||
subtle: mode === 'dark' ? '#2A2A2A' : '#F3F4F6'
|
||||
},
|
||||
text: {
|
||||
primary: mode === 'dark' ? '#F3F4F6' : darkGray,
|
||||
secondary: mode === 'dark' ? '#9CA3AF' : '#6B7280',
|
||||
disabled: mode === 'dark' ? '#4B5563' : '#9CA3AF'
|
||||
},
|
||||
divider: mode === 'dark' ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)',
|
||||
dataViz: dataVizColors,
|
||||
gradient: {
|
||||
primary: gradientPrimary
|
||||
}
|
||||
},
|
||||
typography: {
|
||||
fontFamily:
|
||||
'"Inter", "HarmonyOS Sans", "PingFang SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
fontSize: 14,
|
||||
fontWeightLight: 300,
|
||||
fontWeightRegular: 400,
|
||||
fontWeightMedium: 500,
|
||||
fontWeightBold: 600,
|
||||
h1: {
|
||||
fontSize: '2rem', // 32px
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: '-0.01em'
|
||||
},
|
||||
h2: {
|
||||
fontSize: '1.5rem', // 24px
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: '-0.005em'
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.25rem', // 20px
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.125rem', // 18px
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1rem', // 16px
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5
|
||||
},
|
||||
h6: {
|
||||
fontSize: '0.875rem', // 14px
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem', // 16px
|
||||
lineHeight: 1.5
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem', // 14px
|
||||
lineHeight: 1.5
|
||||
},
|
||||
caption: {
|
||||
fontSize: '0.75rem', // 12px
|
||||
lineHeight: 1.5
|
||||
},
|
||||
code: {
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8
|
||||
},
|
||||
spacing: 8, // 基础间距单位为8px
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: mode === 'dark' ? '#4B5563 transparent' : '#9CA3AF transparent',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
height: '8px'
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: 'transparent'
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: mode === 'dark' ? '#4B5563' : '#9CA3AF',
|
||||
borderRadius: '4px'
|
||||
}
|
||||
},
|
||||
// 确保代码块使用 JetBrains Mono 字体
|
||||
'code, pre': {
|
||||
fontFamily: '"JetBrains Mono", monospace'
|
||||
},
|
||||
// 自定义渐变文本的通用样式
|
||||
'.gradient-text': {
|
||||
background: gradientPrimary,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
textFillColor: 'transparent'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
borderRadius: '8px',
|
||||
padding: '6px 16px'
|
||||
},
|
||||
contained: {
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
},
|
||||
containedPrimary: {
|
||||
background: mainBlue,
|
||||
'&:hover': {
|
||||
backgroundColor: '#1E4785'
|
||||
}
|
||||
},
|
||||
containedSecondary: {
|
||||
background: '#8B5CF6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#7039F2'
|
||||
}
|
||||
},
|
||||
outlined: {
|
||||
borderWidth: '1.5px',
|
||||
'&:hover': {
|
||||
borderWidth: '1.5px'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: 'none',
|
||||
background: mode === 'dark' ? '#1A1A1A' : mainBlue
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '12px',
|
||||
boxShadow: mode === 'dark' ? '0px 4px 8px rgba(0, 0, 0, 0.4)' : '0px 4px 8px rgba(0, 0, 0, 0.05)'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '12px'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '6px',
|
||||
fontWeight: 500
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTableHead: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiTableCell-head': {
|
||||
fontWeight: 600,
|
||||
backgroundColor: mode === 'dark' ? '#2A2A2A' : '#F3F4F6'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTabs: {
|
||||
styleOverrides: {
|
||||
indicator: {
|
||||
height: '3px',
|
||||
borderRadius: '3px 3px 0 0'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
'&.Mui-selected': {
|
||||
fontWeight: 600
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiModal: {
|
||||
defaultProps: {
|
||||
disableScrollLock: true
|
||||
}
|
||||
},
|
||||
MuiDialog: {
|
||||
defaultProps: {
|
||||
disableScrollLock: true
|
||||
}
|
||||
},
|
||||
MuiPopover: {
|
||||
defaultProps: {
|
||||
disableScrollLock: true
|
||||
}
|
||||
},
|
||||
MuiMenu: {
|
||||
defaultProps: {
|
||||
disableScrollLock: true
|
||||
}
|
||||
},
|
||||
MuiDialogTitle: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default function ThemeRegistry({ children }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NextThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<InnerThemeRegistry>{children}</InnerThemeRegistry>
|
||||
</NextThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerThemeRegistry({ children }) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const theme = getTheme(resolvedTheme === 'dark' ? 'dark' : 'light');
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
235
easy-dataset-main/components/UpdateChecker.js
Normal file
235
easy-dataset-main/components/UpdateChecker.js
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Button, Snackbar, Alert, Typography, Link, CircularProgress, LinearProgress } from '@mui/material';
|
||||
import UpdateIcon from '@mui/icons-material/Update';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const UpdateChecker = () => {
|
||||
const { t } = useTranslation();
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [updateDownloaded, setUpdateDownloaded] = useState(false);
|
||||
const [updateError, setUpdateError] = useState(null);
|
||||
|
||||
// 检查更新
|
||||
const checkForUpdates = async () => {
|
||||
if (!window.electron?.updater) {
|
||||
console.warn('Update feature is not available, possibly running in browser environment');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setChecking(true);
|
||||
setUpdateError(null);
|
||||
|
||||
const result = await window.electron.updater.checkForUpdates();
|
||||
console.log('Update check result:', result);
|
||||
|
||||
// 返回当前版本信息
|
||||
if (result) {
|
||||
setUpdateInfo(prev => ({
|
||||
...prev,
|
||||
currentVersion: result.currentVersion
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
// setUpdateError(error.message || 'Failed to check for updates');
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载更新
|
||||
const downloadUpdate = async () => {
|
||||
if (!window.electron?.updater) return;
|
||||
|
||||
try {
|
||||
setDownloading(true);
|
||||
setUpdateError(null);
|
||||
await window.electron.updater.downloadUpdate();
|
||||
} catch (error) {
|
||||
console.error('下载更新失败:', error);
|
||||
setUpdateError(error.message || '下载更新失败');
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 安装更新
|
||||
const installUpdate = async () => {
|
||||
if (!window.electron?.updater) return;
|
||||
|
||||
try {
|
||||
await window.electron.updater.installUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to install update:', error);
|
||||
// setUpdateError(error.message || 'Failed to install update');
|
||||
}
|
||||
};
|
||||
|
||||
// 设置更新事件监听
|
||||
useEffect(() => {
|
||||
if (!window.electron?.updater) return;
|
||||
|
||||
// 有可用更新
|
||||
const removeUpdateAvailable = window.electron.updater.onUpdateAvailable(info => {
|
||||
console.log('发现新版本:', info);
|
||||
setUpdateAvailable(true);
|
||||
setUpdateInfo(prev => ({
|
||||
...prev,
|
||||
...info,
|
||||
releaseUrl: `https://github.com/ConardLi/easy-dataset/releases`
|
||||
}));
|
||||
setOpen(true);
|
||||
});
|
||||
|
||||
// 没有可用更新
|
||||
const removeUpdateNotAvailable = window.electron.updater.onUpdateNotAvailable(() => {
|
||||
console.log('没有可用更新');
|
||||
setUpdateAvailable(false);
|
||||
});
|
||||
|
||||
// 更新错误
|
||||
const removeUpdateError = window.electron.updater.onUpdateError(error => {
|
||||
console.error('更新错误:', error);
|
||||
// setUpdateError(error);
|
||||
});
|
||||
|
||||
// 下载进度
|
||||
const removeDownloadProgress = window.electron.updater.onDownloadProgress(progress => {
|
||||
console.log('下载进度:', progress);
|
||||
setDownloadProgress(progress.percent || 0);
|
||||
});
|
||||
|
||||
// 更新下载完成
|
||||
const removeUpdateDownloaded = window.electron.updater.onUpdateDownloaded(info => {
|
||||
console.log('更新下载完成:', info);
|
||||
setDownloading(false);
|
||||
setUpdateDownloaded(true);
|
||||
});
|
||||
|
||||
// 组件挂载时检查更新
|
||||
const timer = setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 5000);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
removeUpdateAvailable();
|
||||
removeUpdateNotAvailable();
|
||||
removeUpdateError();
|
||||
removeDownloadProgress();
|
||||
removeUpdateDownloaded();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 定期检查更新(每小时一次)
|
||||
useEffect(() => {
|
||||
if (!window.electron?.updater) return;
|
||||
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
checkForUpdates();
|
||||
},
|
||||
60 * 60 * 1000
|
||||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// 如果没有更新或者不在 Electron 环境中,不显示任何内容
|
||||
if (!updateAvailable && !open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{updateAvailable && (
|
||||
<Button color="primary" startIcon={<UpdateIcon />} onClick={() => setOpen(true)} sx={{ ml: 1 }}>
|
||||
{t('update.newVersion')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={null}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert onClose={handleClose} severity="info" sx={{ width: '100%', maxWidth: 400 }}>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="h6">{t('update.newVersionAvailable')}</Typography>
|
||||
|
||||
{updateInfo && (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
{t('update.currentVersion')}: {updateInfo.currentVersion}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{t('update.latestVersion')}: {updateInfo.version}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{checking && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
|
||||
<CircularProgress size={16} sx={{ mr: 1 }} />
|
||||
<Typography variant="body2">{t('update.checking')}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{updateError && (
|
||||
<Typography variant="body2" color="error.main" sx={{ mt: 1 }}>
|
||||
{updateError}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{downloading && (
|
||||
<Box sx={{ mt: 2, width: '100%' }}>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
{t('update.downloading')}: {Math.round(downloadProgress)}%
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={downloadProgress} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
|
||||
{/* {!downloading && !updateDownloaded ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={checking || downloading}
|
||||
onClick={downloadUpdate}
|
||||
>
|
||||
{t('update.downloadNow')}
|
||||
</Button>
|
||||
) : updateDownloaded ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={installUpdate}
|
||||
>
|
||||
{t('update.installNow')}
|
||||
</Button>
|
||||
) : null} */}
|
||||
|
||||
{updateInfo?.releaseUrl && (
|
||||
<Link href={updateInfo.releaseUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outlined">{t('update.viewRelease')}</Button>
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateChecker;
|
||||
23
easy-dataset-main/components/common/MessageAlert.js
Normal file
23
easy-dataset-main/components/common/MessageAlert.js
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { Snackbar, Alert } from '@mui/material';
|
||||
|
||||
export default function MessageAlert({ message, onClose }) {
|
||||
if (!message) return null;
|
||||
|
||||
const severity = message.severity || 'error';
|
||||
const text = typeof message === 'string' ? message : message.message;
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={Boolean(message)}
|
||||
autoHideDuration={2000}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={onClose} severity={severity} sx={{ width: '100%' }}>
|
||||
{text}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Card, CardContent, Chip, TextField } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 多轮对话内容展示和编辑组件
|
||||
*/
|
||||
export default function ConversationContent({ messages, editMode, onMessageChange, conversation }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 获取角色显示信息
|
||||
const getRoleDisplay = role => {
|
||||
switch (role) {
|
||||
case 'system':
|
||||
return { name: t('datasets.system'), color: 'default' };
|
||||
case 'user':
|
||||
return { name: conversation?.roleA || t('datasets.user'), color: 'primary' };
|
||||
case 'assistant':
|
||||
return { name: conversation?.roleB || t('datasets.assistant'), color: 'secondary' };
|
||||
default:
|
||||
return { name: role, color: 'default' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('datasets.conversationContent')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ maxHeight: editMode ? 'none' : '70vh', overflowY: 'auto' }}>
|
||||
{messages.map((message, index) => {
|
||||
const roleInfo = getRoleDisplay(message.role);
|
||||
return (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Chip label={roleInfo.name} color={roleInfo.color} size="small" sx={{ fontSize: '0.75rem' }} />
|
||||
{message.role !== 'system' && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{t('datasets.round', { round: Math.floor((index + 1) / 2) + 1 })}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Card variant="outlined" sx={{ mb: 1 }}>
|
||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||
{editMode ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={10}
|
||||
value={message.content}
|
||||
onChange={e => onMessageChange && onMessageChange(index, e.target.value)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiInputBase-input': {
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.5
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="pre"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 1.6,
|
||||
margin: 0
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Button, Divider, Typography, IconButton, CircularProgress, Paper, Tooltip } from '@mui/material';
|
||||
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* 多轮对话详情页面的头部导航组件
|
||||
*/
|
||||
export default function ConversationHeader({
|
||||
projectId,
|
||||
conversationId,
|
||||
conversation,
|
||||
editMode,
|
||||
saving,
|
||||
onEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onNavigate
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button startIcon={<NavigateBeforeIcon />} onClick={() => router.push(`/projects/${projectId}/multi-turn`)}>
|
||||
{t('common.backToList')}
|
||||
</Button>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="h6">{t('datasets.conversationDetail')}</Typography>
|
||||
{conversation && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{conversation.scenario && (
|
||||
<>
|
||||
{conversation.scenario} • {conversation.turnCount}/{conversation.maxTurns} 轮
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
{/* 翻页按钮 */}
|
||||
<IconButton onClick={() => onNavigate && onNavigate('prev')}>
|
||||
<NavigateBeforeIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => onNavigate && onNavigate('next')}>
|
||||
<NavigateNextIcon />
|
||||
</IconButton>
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
|
||||
|
||||
{/* 编辑/保存按钮 */}
|
||||
{editMode ? (
|
||||
<>
|
||||
<Button onClick={onCancel}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={saving ? <CircularProgress size={16} /> : <SaveIcon />}
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? t('datasets.saving') : t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outlined" startIcon={<EditIcon />} onClick={onEdit}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button variant="outlined" color="error" startIcon={<DeleteIcon />} onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Chip, Tooltip, alpha, Paper } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
/**
|
||||
* 多轮对话元数据展示组件
|
||||
*/
|
||||
export default function ConversationMetadata({ conversation }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!conversation) return null;
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('datasets.metadata')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip label={`${t('datasets.modelUsed')}: ${conversation.model}`} variant="outlined" size="small" />
|
||||
|
||||
{conversation.scenario && (
|
||||
<Chip
|
||||
label={`${t('datasets.conversationScenario')}: ${conversation.scenario}`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Chip
|
||||
label={`${t('datasets.conversationRounds')}: ${conversation.turnCount}/${conversation.maxTurns}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{conversation.roleA && (
|
||||
<Chip
|
||||
label={`${t('settings.multiTurnRoleA')}: ${conversation.roleA}`}
|
||||
variant="outlined"
|
||||
color="info"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
{conversation.roleB && (
|
||||
<Chip
|
||||
label={`${t('settings.multiTurnRoleB')}: ${conversation.roleB}`}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Chip
|
||||
label={`${t('datasets.createdAt')}: ${new Date(conversation.createAt).toLocaleDateString()}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{conversation.confirmed && (
|
||||
<Chip
|
||||
label={t('datasets.confirmed')}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: alpha(theme.palette.success.main, 0.1),
|
||||
color: theme.palette.success.dark,
|
||||
fontWeight: 'medium'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Divider, Paper, TextField } from '@mui/material';
|
||||
import { toast } from 'sonner';
|
||||
import StarRating from '@/components/datasets/StarRating';
|
||||
import TagSelector from '@/components/datasets/TagSelector';
|
||||
import NoteInput from '@/components/datasets/NoteInput';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 多轮对话评分、标签、备注综合组件
|
||||
*/
|
||||
export default function ConversationRatingSection({ conversation, projectId, onUpdate }) {
|
||||
const { t } = useTranslation();
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 解析对话中的标签
|
||||
const parseConversationTags = tagsString => {
|
||||
try {
|
||||
if (typeof tagsString === 'string' && tagsString.trim()) {
|
||||
return tagsString.split(/\s+/).filter(tag => tag.length > 0);
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 本地状态管理
|
||||
const [localScore, setLocalScore] = useState(conversation.score || 0);
|
||||
const [localTags, setLocalTags] = useState(() => parseConversationTags(conversation.tags));
|
||||
const [localNote, setLocalNote] = useState(conversation.note || '');
|
||||
|
||||
// 获取项目中已使用的标签
|
||||
useEffect(() => {
|
||||
const fetchAvailableTags = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/tags`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAvailableTags(data.tags || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取可用标签失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
fetchAvailableTags();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 同步props中的conversation到本地状态
|
||||
useEffect(() => {
|
||||
setLocalScore(conversation.score || 0);
|
||||
setLocalTags(parseConversationTags(conversation.tags));
|
||||
setLocalNote(conversation.note || '');
|
||||
}, [conversation]);
|
||||
|
||||
// 更新对话元数据
|
||||
const updateMetadata = async updates => {
|
||||
if (loading) return;
|
||||
|
||||
// 立即更新本地状态
|
||||
if (updates.score !== undefined) {
|
||||
setLocalScore(updates.score);
|
||||
}
|
||||
if (updates.tagsArray !== undefined) {
|
||||
setLocalTags(updates.tagsArray);
|
||||
}
|
||||
if (updates.note !== undefined) {
|
||||
setLocalNote(updates.note);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversation.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
score: updates.score,
|
||||
tags: updates.tags,
|
||||
note: updates.note
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('datasets.saveFailed'));
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
toast.success(t('datasets.saveSuccess'));
|
||||
|
||||
// 如果有父组件的更新回调,调用它
|
||||
if (onUpdate) {
|
||||
onUpdate(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新对话元数据失败:', error);
|
||||
toast.error(error.message || t('datasets.saveFailed'));
|
||||
|
||||
// 出错时恢复本地状态
|
||||
if (updates.score !== undefined) {
|
||||
setLocalScore(conversation.score || 0);
|
||||
}
|
||||
if (updates.tagsArray !== undefined) {
|
||||
setLocalTags(parseConversationTags(conversation.tags));
|
||||
}
|
||||
if (updates.note !== undefined) {
|
||||
setLocalNote(conversation.note || '');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理评分变更
|
||||
const handleScoreChange = newScore => {
|
||||
updateMetadata({ score: newScore });
|
||||
};
|
||||
|
||||
// 处理标签变更
|
||||
const handleTagsChange = newTags => {
|
||||
const tagsString = Array.isArray(newTags) ? newTags.join(' ') : '';
|
||||
updateMetadata({ tags: tagsString, tagsArray: newTags });
|
||||
};
|
||||
|
||||
// 处理备注变更
|
||||
const handleNoteChange = newNote => {
|
||||
updateMetadata({ note: newNote });
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
{/* 评分区域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('datasets.rating')}
|
||||
</Typography>
|
||||
<StarRating value={localScore} onChange={handleScoreChange} readOnly={loading} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 标签区域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('datasets.customTags')}
|
||||
</Typography>
|
||||
<TagSelector
|
||||
value={localTags}
|
||||
onChange={handleTagsChange}
|
||||
availableTags={availableTags}
|
||||
readOnly={loading}
|
||||
placeholder={t('datasets.addCustomTag', '添加自定义标签...')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 备注区域 */}
|
||||
<NoteInput
|
||||
value={localNote}
|
||||
onChange={handleNoteChange}
|
||||
readOnly={loading}
|
||||
placeholder={t('datasets.addNote', '添加备注...')}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 确认状态 */}
|
||||
{/* <Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
{t('datasets.confirmationStatus')}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{conversation.confirmed ? t('datasets.confirmed') : t('datasets.unconfirmed')}
|
||||
</Typography>
|
||||
</Box> */}
|
||||
|
||||
{/* AI评估 */}
|
||||
{conversation.aiEvaluation && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
{t('datasets.aiEvaluation')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{conversation.aiEvaluation}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
264
easy-dataset-main/components/dataset-square/DatasetSearchBar.js
Normal file
264
easy-dataset-main/components/dataset-square/DatasetSearchBar.js
Normal file
@@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Typography,
|
||||
ClickAwayListener,
|
||||
Fade,
|
||||
Avatar,
|
||||
useTheme,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import LaunchIcon from '@mui/icons-material/Launch';
|
||||
import TravelExploreIcon from '@mui/icons-material/TravelExplore';
|
||||
import sites from '@/constant/sites.json';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function DatasetSearchBar() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [recentSearches, setRecentSearches] = useState([]);
|
||||
const searchRef = useRef(null);
|
||||
const suggestionsRef = useRef(null);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 从 localStorage 加载最近搜索
|
||||
useEffect(() => {
|
||||
const savedSearches = localStorage.getItem('recentDatasetSearches');
|
||||
if (savedSearches) {
|
||||
try {
|
||||
const searches = JSON.parse(savedSearches);
|
||||
setRecentSearches(searches);
|
||||
} catch (e) {
|
||||
console.error('解析最近搜索失败', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 处理搜索输入变化
|
||||
const handleSearchChange = event => {
|
||||
setSearchQuery(event.target.value);
|
||||
if (event.target.value) {
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理回车搜索
|
||||
const handleSearchSubmit = event => {
|
||||
if (event.key === 'Enter' && searchQuery.trim()) {
|
||||
// 默认使用第一个搜索引擎
|
||||
if (sites.length > 0) {
|
||||
handleSuggestionClick(sites[0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 保存最近搜索
|
||||
const saveRecentSearch = query => {
|
||||
if (!query.trim()) return;
|
||||
|
||||
// 添加到最近搜索并去重
|
||||
const updatedSearches = [query, ...recentSearches.filter(s => s !== query)].slice(0, 5);
|
||||
setRecentSearches(updatedSearches);
|
||||
|
||||
// 保存到 localStorage
|
||||
try {
|
||||
localStorage.setItem('recentDatasetSearches', JSON.stringify(updatedSearches));
|
||||
} catch (e) {
|
||||
console.error('保存最近搜索失败', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理点击搜索建议
|
||||
const handleSuggestionClick = site => {
|
||||
if (searchQuery.trim()) {
|
||||
// 根据不同网站处理搜索参数
|
||||
let searchUrl = site.link;
|
||||
|
||||
// 如果链接中不包含问号,则添加搜索参数
|
||||
if (site.link.includes('huggingface.co')) {
|
||||
searchUrl = `${site.link}?sort=trending&search=${encodeURIComponent(searchQuery)}`;
|
||||
} else if (site.link.includes('kaggle.com')) {
|
||||
searchUrl = `${site.link}?search=${encodeURIComponent(searchQuery)}`;
|
||||
} else if (site.link.includes('datasetsearch.research.google.com')) {
|
||||
searchUrl = `${site.link}/search?query=${encodeURIComponent(searchQuery)}&src=0`;
|
||||
} else if (site.link.includes('paperswithcode.com')) {
|
||||
searchUrl = `${site.link}?q=${encodeURIComponent(searchQuery)}`;
|
||||
} else if (site.link.includes('modelscope.cn')) {
|
||||
searchUrl = `${site.link}?query=${encodeURIComponent(searchQuery)}`;
|
||||
} else if (site.link.includes('opendatalab.com')) {
|
||||
searchUrl = `${site.link}?keywords=${encodeURIComponent(searchQuery)}`;
|
||||
} else if (site.link.includes('tianchi.aliyun.com')) {
|
||||
searchUrl = `${site.link}?q=${encodeURIComponent(searchQuery)}`;
|
||||
} else {
|
||||
// 默认处理方式,在URL后添加搜索参数
|
||||
searchUrl = `${site.link}${site.link.includes('?') ? '&' : '?'}search=${encodeURIComponent(searchQuery)}`;
|
||||
}
|
||||
|
||||
// 保存最近搜索
|
||||
saveRecentSearch(searchQuery);
|
||||
|
||||
window.open(searchUrl, '_blank');
|
||||
}
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
// 处理点击外部关闭建议
|
||||
const handleClickAway = event => {
|
||||
// 确保点击的不是建议框本身
|
||||
if (suggestionsRef.current && !suggestionsRef.current.contains(event.target)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ClickAwayListener onClickAway={handleClickAway}>
|
||||
<Box sx={{ position: 'relative', width: '100%', zIndex: 1300 }} ref={searchRef}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder={t('datasetSquare.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleSearchSubmit}
|
||||
onClick={() => searchQuery && setShowSuggestions(true)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon color="primary" />
|
||||
</InputAdornment>
|
||||
),
|
||||
sx: {
|
||||
height: 56,
|
||||
borderRadius: 3,
|
||||
backgroundColor:
|
||||
theme.palette.mode === 'dark'
|
||||
? alpha(theme.palette.background.default, 0.6)
|
||||
: alpha(theme.palette.background.default, 0.8),
|
||||
backdropFilter: 'blur(8px)',
|
||||
px: 2,
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: `0 0 0 1px ${alpha(theme.palette.primary.main, 0.15)}`,
|
||||
'&.MuiOutlinedInput-root': {
|
||||
'& fieldset': {
|
||||
borderColor: 'transparent'
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'transparent'
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.3)}`,
|
||||
backgroundColor:
|
||||
theme.palette.mode === 'dark'
|
||||
? alpha(theme.palette.background.paper, 0.8)
|
||||
: alpha(theme.palette.common.white, 0.95)
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: 'transparent'
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
mb: 1,
|
||||
'& .MuiInputBase-input': {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.primary
|
||||
},
|
||||
'& .MuiInputBase-input::placeholder': {
|
||||
color: alpha(theme.palette.text.primary, 0.6),
|
||||
opacity: 0.7
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 搜索建议下拉框 - 使用绝对定位确保不被裁剪 */}
|
||||
{showSuggestions && searchQuery && (
|
||||
<Box
|
||||
ref={suggestionsRef}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
zIndex: 9999,
|
||||
top: 'calc(100% + 8px)',
|
||||
left: 0,
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'auto' // 确保可以点击
|
||||
}}
|
||||
>
|
||||
<Fade in={showSuggestions}>
|
||||
<Paper
|
||||
elevation={6}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxHeight: 350,
|
||||
overflow: 'auto',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<List>
|
||||
{sites.slice(0, 5).map((site, index) => (
|
||||
<ListItem key={index} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => handleSuggestionClick(site)}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
'&:hover': {
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.05)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
mr: 1.5,
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.1),
|
||||
color: theme.palette.primary.main
|
||||
}}
|
||||
>
|
||||
<TravelExploreIcon fontSize="small" />
|
||||
</Avatar>
|
||||
<Typography>
|
||||
{t('datasetSquare.searchVia')} <strong>{site.name}</strong> Search
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mr: 1 }}>
|
||||
"{searchQuery}"
|
||||
</Typography>
|
||||
<LaunchIcon fontSize="small" color="action" />
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</Fade>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
}
|
||||
197
easy-dataset-main/components/dataset-square/DatasetSiteCard.js
Normal file
197
easy-dataset-main/components/dataset-square/DatasetSiteCard.js
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardActionArea, CardContent, CardMedia, Typography, Box, Chip, useTheme, alpha } from '@mui/material';
|
||||
import LaunchIcon from '@mui/icons-material/Launch';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function DatasetSiteCard({ site }) {
|
||||
const { name, link, description, image, labels } = site;
|
||||
const theme = useTheme();
|
||||
|
||||
// 处理图片路径,如果没有图片则使用默认图片
|
||||
const imageUrl = image || `/imgs/default-dataset.png`;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 处理卡片点击
|
||||
const handleCardClick = () => {
|
||||
window.open(link, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'all 0.3s ease',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
boxShadow: theme.palette.mode === 'dark' ? '0 4px 20px rgba(0,0,0,0.3)' : '0 4px 20px rgba(0,0,0,0.1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-6px)',
|
||||
boxShadow: theme.palette.mode === 'dark' ? '0 8px 30px rgba(0,0,0,0.4)' : '0 8px 30px rgba(0,0,0,0.15)',
|
||||
'& .MuiCardMedia-root': {
|
||||
transform: 'scale(1.05)'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
onClick={handleCardClick}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
height: '100%',
|
||||
'&:hover': {
|
||||
'& .card-content': {
|
||||
background:
|
||||
theme.palette.mode === 'dark'
|
||||
? alpha(theme.palette.primary.dark, 0.1)
|
||||
: alpha(theme.palette.primary.light, 0.1)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 网站截图 */}
|
||||
<Box sx={{ position: 'relative', width: '100%', height: 160, overflow: 'hidden' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="160"
|
||||
image={imageUrl}
|
||||
alt={name}
|
||||
sx={{
|
||||
objectFit: 'cover',
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100',
|
||||
transition: 'transform 0.5s ease'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: `linear-gradient(to bottom, transparent 70%, ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.5)'})`,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
icon={<StorageIcon fontSize="small" />}
|
||||
label={t('datasetSquare.dataset')}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
zIndex: 2,
|
||||
backgroundColor: alpha(theme.palette.background.paper, 0.8),
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`,
|
||||
'& .MuiChip-icon': {
|
||||
color: theme.palette.primary.main
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 网站信息 */}
|
||||
<CardContent
|
||||
className="card-content"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'background 0.3s ease',
|
||||
p: 2.5
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="div"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
mb: 0.5,
|
||||
pr: 2, // 留出空间给图标
|
||||
color: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.dark
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
<LaunchIcon
|
||||
fontSize="small"
|
||||
sx={{
|
||||
color: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.main,
|
||||
opacity: 0.8,
|
||||
mt: 0.5
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: 1.6,
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 'auto', pt: 1.5 }}>
|
||||
{/* 标签显示 */}
|
||||
{labels && labels.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1.5 }}>
|
||||
{labels.map((label, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={label}
|
||||
size="small"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
color: theme.palette.primary.main,
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.2)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Chip
|
||||
label={t('datasetSquare.viewDataset')}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
height: 24,
|
||||
fontSize: '0.75rem',
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
211
easy-dataset-main/components/dataset-square/DatasetSiteList.js
Normal file
211
easy-dataset-main/components/dataset-square/DatasetSiteList.js
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Grid, Box, Typography, Skeleton, Divider, Tabs, Tab, Fade, Chip, useTheme, alpha, Paper } from '@mui/material';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import CategoryIcon from '@mui/icons-material/Category';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import { DatasetSiteCard } from './DatasetSiteCard';
|
||||
import sites from '@/constant/sites.json';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function DatasetSiteList() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 定义类别
|
||||
const CATEGORIES = {
|
||||
ALL: t('datasetSquare.categories.all'),
|
||||
POPULAR: t('datasetSquare.categories.popular'),
|
||||
CHINESE: t('datasetSquare.categories.chinese'),
|
||||
ENGLISH: t('datasetSquare.categories.english'),
|
||||
RESEARCH: t('datasetSquare.categories.research'),
|
||||
MULTIMODAL: t('datasetSquare.categories.multimodal')
|
||||
};
|
||||
const [activeCategory, setActiveCategory] = useState(CATEGORIES.ALL);
|
||||
|
||||
// 模拟加载效果
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 800);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// 处理类别切换
|
||||
const handleCategoryChange = (event, newValue) => {
|
||||
setActiveCategory(newValue);
|
||||
};
|
||||
|
||||
// 根据当前选中的类别过滤网站
|
||||
const getFilteredSites = () => {
|
||||
if (activeCategory === CATEGORIES.ALL) {
|
||||
return sites;
|
||||
} else if (activeCategory === CATEGORIES.POPULAR) {
|
||||
return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.popular')));
|
||||
} else if (activeCategory === CATEGORIES.CHINESE) {
|
||||
return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.chinese')));
|
||||
} else if (activeCategory === CATEGORIES.ENGLISH) {
|
||||
return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.english')));
|
||||
} else if (activeCategory === CATEGORIES.RESEARCH) {
|
||||
return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.research')));
|
||||
} else if (activeCategory === CATEGORIES.MULTIMODAL) {
|
||||
return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.multimodal')));
|
||||
}
|
||||
return sites;
|
||||
};
|
||||
|
||||
const filteredSites = getFilteredSites();
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
{/* 类别选择器 */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<CategoryIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="h5" component="h2" fontWeight={600}>
|
||||
{t('datasetSquare.categoryTitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
p: 0.5,
|
||||
bgcolor:
|
||||
theme.palette.mode === 'dark'
|
||||
? alpha(theme.palette.primary.dark, 0.1)
|
||||
: alpha(theme.palette.primary.light, 0.1),
|
||||
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeCategory}
|
||||
onChange={handleCategoryChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
'& .MuiTabs-indicator': {
|
||||
display: 'none'
|
||||
},
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 40,
|
||||
borderRadius: 2,
|
||||
mx: 0.5,
|
||||
transition: 'all 0.2s',
|
||||
'&.Mui-selected': {
|
||||
bgcolor:
|
||||
theme.palette.mode === 'dark' ? alpha(theme.palette.primary.main, 0.2) : theme.palette.primary.main,
|
||||
color: theme.palette.mode === 'dark' ? theme.palette.primary.light : 'white',
|
||||
fontWeight: 600
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
value={CATEGORIES.ALL}
|
||||
label={CATEGORIES.ALL}
|
||||
icon={<StorageIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab
|
||||
value={CATEGORIES.POPULAR}
|
||||
label={CATEGORIES.POPULAR}
|
||||
icon={<StarIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab value={CATEGORIES.CHINESE} label={CATEGORIES.CHINESE} />
|
||||
<Tab value={CATEGORIES.ENGLISH} label={CATEGORIES.ENGLISH} />
|
||||
<Tab value={CATEGORIES.RESEARCH} label={CATEGORIES.RESEARCH} />
|
||||
<Tab value={CATEGORIES.MULTIMODAL} label={CATEGORIES.MULTIMODAL} />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* 数据集网站列表 */}
|
||||
<Box sx={{ position: 'relative', minHeight: 300 }}>
|
||||
{loading ? (
|
||||
// 加载骨架屏
|
||||
<Grid container spacing={3}>
|
||||
{Array.from(new Array(8)).map((_, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
|
||||
<Box sx={{ width: '100%', height: '100%' }}>
|
||||
<Skeleton variant="rectangular" height={160} sx={{ borderRadius: 2 }} />
|
||||
<Box sx={{ pt: 1.5, px: 0.5 }}>
|
||||
<Skeleton width="80%" height={28} />
|
||||
<Skeleton width="100%" />
|
||||
<Skeleton width="100%" />
|
||||
<Skeleton width="60%" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Fade in={!loading} timeout={500}>
|
||||
<Box>
|
||||
{/* 结果数量提示 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{t('datasetSquare.foundResources', { count: filteredSites.length })}{' '}
|
||||
<Chip
|
||||
label={filteredSites.length}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ mx: 0.5, height: 20, fontSize: '0.75rem' }}
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
{activeCategory !== CATEGORIES.ALL && (
|
||||
<Chip
|
||||
label={t('datasetSquare.currentFilter', { category: activeCategory })}
|
||||
size="small"
|
||||
onDelete={() => setActiveCategory(CATEGORIES.ALL)}
|
||||
sx={{ borderRadius: 1.5 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{filteredSites.length > 0 ? (
|
||||
<Grid container spacing={3}>
|
||||
{filteredSites.map((site, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
|
||||
<DatasetSiteCard site={site} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
py: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
bgcolor:
|
||||
theme.palette.mode === 'dark'
|
||||
? alpha(theme.palette.background.paper, 0.2)
|
||||
: alpha(theme.palette.grey[100], 0.5),
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<StorageIcon sx={{ fontSize: 60, color: 'text.disabled', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
{t('datasetSquare.noDatasets')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
{t('datasetSquare.tryOtherCategories')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
97
easy-dataset-main/components/datasets/DatasetHeader.js
Normal file
97
easy-dataset-main/components/datasets/DatasetHeader.js
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Button, Divider, Typography, IconButton, CircularProgress, Paper, Tooltip } from '@mui/material';
|
||||
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import UndoIcon from '@mui/icons-material/Undo';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* 数据集详情页面的头部导航组件
|
||||
*/
|
||||
export default function DatasetHeader({
|
||||
projectId,
|
||||
datasetsAllCount,
|
||||
datasetsConfirmCount,
|
||||
confirming,
|
||||
unconfirming,
|
||||
currentDataset,
|
||||
shortcutsEnabled,
|
||||
setShortcutsEnabled,
|
||||
onNavigate,
|
||||
onConfirm,
|
||||
onUnconfirm,
|
||||
onDelete
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button startIcon={<NavigateBeforeIcon />} onClick={() => router.push(`/projects/${projectId}/datasets`)}>
|
||||
{t('common.backToList')}
|
||||
</Button>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="h6">{t('datasets.datasetDetail')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('datasets.stats', {
|
||||
total: datasetsAllCount,
|
||||
confirmed: datasetsConfirmCount,
|
||||
percentage: ((datasetsConfirmCount / datasetsAllCount) * 100).toFixed(2)
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* 快捷键启用选项 - 已注释掉,保持原代码结构 */}
|
||||
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body1">{t('datasets.enableShortcuts')}</Typography>
|
||||
<Tooltip title={t('datasets.shortcutsHelp')}>
|
||||
<IconButton size="small" color="info">
|
||||
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>?</Typography>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant={shortcutsEnabled ? 'contained' : 'outlined'}
|
||||
onClick={() => setShortcutsEnabled((prev) => !prev)}
|
||||
>
|
||||
{shortcutsEnabled ? t('common.enabled') : t('common.disabled')}
|
||||
</Button>
|
||||
</Box> */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton onClick={() => onNavigate('prev')}>
|
||||
<NavigateBeforeIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => onNavigate('next')}>
|
||||
<NavigateNextIcon />
|
||||
</IconButton>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
{/* 确认/取消确认按钮 */}
|
||||
{currentDataset.confirmed ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={unconfirming}
|
||||
onClick={onUnconfirm}
|
||||
startIcon={unconfirming ? <CircularProgress size={16} /> : <UndoIcon />}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{unconfirming ? t('datasets.unconfirming') : t('datasets.unconfirm')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" color="primary" disabled={confirming} onClick={onConfirm} sx={{ mr: 1 }}>
|
||||
{confirming ? <CircularProgress size={24} /> : t('datasets.confirmSave')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outlined" color="error" startIcon={<DeleteIcon />} onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
77
easy-dataset-main/components/datasets/DatasetMetadata.js
Normal file
77
easy-dataset-main/components/datasets/DatasetMetadata.js
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Chip, Tooltip, alpha, CircularProgress } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* 数据集元数据展示组件
|
||||
*/
|
||||
export default function DatasetMetadata({ currentDataset, onViewChunk }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('datasets.metadata')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Chip label={`${t('datasets.model')}: ${currentDataset.model}`} variant="outlined" />
|
||||
{currentDataset.questionLabel && (
|
||||
<Chip label={`${t('common.label')}: ${currentDataset.questionLabel}`} color="primary" variant="outlined" />
|
||||
)}
|
||||
<Chip
|
||||
label={`${t('datasets.createdAt')}: ${new Date(currentDataset.createAt).toLocaleString('zh-CN')}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Tooltip title={t('textSplit.viewChunk')}>
|
||||
<Chip
|
||||
label={`${t('datasets.chunkId')}: ${currentDataset.chunkName}`}
|
||||
variant="outlined"
|
||||
color="info"
|
||||
onClick={async () => {
|
||||
try {
|
||||
// 使用新API接口获取文本块内容
|
||||
const response = await fetch(
|
||||
`/api/projects/${currentDataset.projectId}/chunks/name?chunkName=${encodeURIComponent(currentDataset.chunkName)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取文本块失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const chunkData = await response.json();
|
||||
|
||||
// 调用父组件的方法显示文本块
|
||||
onViewChunk({
|
||||
name: currentDataset.chunkName,
|
||||
content: chunkData.content
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取文本块内容失败:', error);
|
||||
// 即使API请求失败,也尝试调用查看方法
|
||||
onViewChunk({
|
||||
name: currentDataset.chunkName,
|
||||
content: '内容加载失败,请重试'
|
||||
});
|
||||
}
|
||||
}}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{currentDataset.confirmed && (
|
||||
<Chip
|
||||
label={t('datasets.confirmed')}
|
||||
sx={{
|
||||
backgroundColor: alpha(theme.palette.success.main, 0.1),
|
||||
color: theme.palette.success.dark,
|
||||
fontWeight: 'medium'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
330
easy-dataset-main/components/datasets/DatasetRatingSection.js
Normal file
330
easy-dataset-main/components/datasets/DatasetRatingSection.js
Normal file
@@ -0,0 +1,330 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Divider, Paper, Button, Stack } from '@mui/material';
|
||||
import { toast } from 'sonner';
|
||||
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import StarRating from './StarRating';
|
||||
import TagSelector from './TagSelector';
|
||||
import NoteInput from './NoteInput';
|
||||
import EvalVariantDialog from './EvalVariantDialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
|
||||
/**
|
||||
* 数据集评分、标签、备注综合组件
|
||||
*/
|
||||
export default function DatasetRatingSection({ dataset, projectId, onUpdate, currentDataset }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [addingToEval, setAddingToEval] = useState(false);
|
||||
const [generatingVariant, setGeneratingVariant] = useState(false);
|
||||
const [variantDialog, setVariantDialog] = useState({
|
||||
open: false,
|
||||
data: null
|
||||
});
|
||||
|
||||
const selectedModel = useAtomValue(selectedModelInfoAtom);
|
||||
|
||||
// 解析数据集中的标签
|
||||
const parseDatasetTags = tagsString => {
|
||||
try {
|
||||
return JSON.parse(tagsString || '[]');
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 本地状态管理,从 props 初始化
|
||||
const [localScore, setLocalScore] = useState(dataset.score || 0);
|
||||
const [localTags, setLocalTags] = useState(() => parseDatasetTags(dataset.tags));
|
||||
const [localNote, setLocalNote] = useState(dataset.note || '');
|
||||
|
||||
// 获取项目中已使用的标签
|
||||
useEffect(() => {
|
||||
const fetchAvailableTags = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/tags`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAvailableTags(data.tags || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取可用标签失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
fetchAvailableTags();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 同步props中的dataset到本地状态
|
||||
useEffect(() => {
|
||||
setLocalScore(dataset.score || 0);
|
||||
setLocalTags(parseDatasetTags(dataset.tags));
|
||||
setLocalNote(dataset.note || '');
|
||||
}, [dataset]);
|
||||
|
||||
// 更新数据集元数据
|
||||
const updateMetadata = async updates => {
|
||||
if (loading) return;
|
||||
|
||||
// 立即更新本地状态,提升响应速度
|
||||
if (updates.score !== undefined) {
|
||||
setLocalScore(updates.score);
|
||||
}
|
||||
if (updates.tags !== undefined) {
|
||||
setLocalTags(updates.tags);
|
||||
}
|
||||
if (updates.note !== undefined) {
|
||||
setLocalNote(updates.note);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/${dataset.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('更新失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 显示成功提示
|
||||
toast.success(t('datasets.updateSuccess', '更新成功'));
|
||||
|
||||
// 如果有父组件的更新回调,调用它
|
||||
if (onUpdate) {
|
||||
onUpdate(result.dataset);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新数据集元数据失败:', error);
|
||||
// 显示错误提示
|
||||
toast.error(t('datasets.updateFailed', '更新失败'));
|
||||
|
||||
// 出错时恢复本地状态
|
||||
if (updates.score !== undefined) {
|
||||
setLocalScore(dataset.score || 0);
|
||||
}
|
||||
if (updates.tags !== undefined) {
|
||||
setLocalTags(parseDatasetTags(dataset.tags));
|
||||
}
|
||||
if (updates.note !== undefined) {
|
||||
setLocalNote(dataset.note || '');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理评分变更
|
||||
const handleScoreChange = newScore => {
|
||||
updateMetadata({ score: newScore });
|
||||
};
|
||||
|
||||
// 处理标签变更
|
||||
const handleTagsChange = newTags => {
|
||||
updateMetadata({ tags: newTags });
|
||||
};
|
||||
|
||||
// 处理备注变更
|
||||
const handleNoteChange = newNote => {
|
||||
updateMetadata({ note: newNote });
|
||||
};
|
||||
|
||||
// 添加到评估数据集
|
||||
const handleAddToEval = async () => {
|
||||
if (addingToEval) return;
|
||||
|
||||
setAddingToEval(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/${dataset.id}/copy-to-eval`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add to eval dataset');
|
||||
}
|
||||
|
||||
toast.success(t('datasets.addToEvalSuccess', '成功添加到评估数据集'));
|
||||
|
||||
// 更新本地标签显示
|
||||
const currentTags = localTags || [];
|
||||
if (!currentTags.includes('Eval')) {
|
||||
setLocalTags([...currentTags, 'Eval']);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加评估数据集失败:', error);
|
||||
toast.error(t('datasets.addToEvalFailed', '添加失败'));
|
||||
} finally {
|
||||
setAddingToEval(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成评估集变体
|
||||
const handleGenerateEvalVariant = async config => {
|
||||
if (!selectedModel) {
|
||||
toast.error(t('datasets.selectModelFirst', '请先选择模型'));
|
||||
throw new Error('No model selected');
|
||||
}
|
||||
|
||||
try {
|
||||
const language = i18n.language === 'zh-CN' ? 'zh-CN' : 'en';
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/generate-eval-variant`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
datasetId: dataset.id,
|
||||
model: selectedModel,
|
||||
language,
|
||||
questionType: config.questionType,
|
||||
count: config.count
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate variant');
|
||||
}
|
||||
|
||||
const { data } = await response.json();
|
||||
|
||||
// 为每个生成的项添加题型信息,以便保存时使用
|
||||
return Array.isArray(data) ? data.map(item => ({ ...item, questionType: config.questionType })) : [];
|
||||
} catch (error) {
|
||||
console.error('生成变体失败:', error);
|
||||
toast.error(t('datasets.generateVariantFailed', '生成变体失败'));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 保存评估集变体
|
||||
const handleSaveEvalVariant = async variantItems => {
|
||||
try {
|
||||
// 过滤掉 'Eval' 标签,并确保转为逗号分隔的字符串
|
||||
const tagsToSync = (localTags || []).filter(tag => tag !== 'Eval').join(',');
|
||||
|
||||
const itemsToSave = variantItems.map(item => ({
|
||||
question: item.question,
|
||||
correctAnswer: item.correctAnswer,
|
||||
questionType: item.questionType || 'open_ended',
|
||||
options: item.options,
|
||||
tags: tagsToSync,
|
||||
note: dataset.note,
|
||||
chunkId: null // 变体暂时不关联原始文本块
|
||||
}));
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/eval-datasets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ items: itemsToSave })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save eval dataset');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
toast.success(t('datasets.saveVariantSuccess', '已保存到评估数据集'));
|
||||
|
||||
// 关闭对话框
|
||||
setVariantDialog({ open: false, data: null });
|
||||
} catch (error) {
|
||||
console.error('保存变体失败:', error);
|
||||
toast.error(t('datasets.saveVariantFailed', '保存失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
{/* 评分区域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('datasets.rating', '评分')}
|
||||
</Typography>
|
||||
<StarRating value={localScore} onChange={handleScoreChange} readOnly={loading} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 标签区域 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('datasets.customTags', '自定义标签')}
|
||||
</Typography>
|
||||
<TagSelector
|
||||
value={localTags}
|
||||
onChange={handleTagsChange}
|
||||
availableTags={availableTags}
|
||||
readOnly={loading}
|
||||
placeholder={t('datasets.addCustomTag', '添加自定义标签...')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 备注区域 */}
|
||||
<NoteInput
|
||||
value={localNote}
|
||||
onChange={handleNoteChange}
|
||||
readOnly={loading}
|
||||
placeholder={t('datasets.addNote', '添加备注...')}
|
||||
/>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<PlaylistAddIcon />}
|
||||
onClick={handleAddToEval}
|
||||
disabled={addingToEval}
|
||||
sx={{ py: 1, flex: 1 }}
|
||||
>
|
||||
{addingToEval ? t('common.processing') : t('datasets.addToEval')}
|
||||
</Button>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon={<AutoFixHighIcon />}
|
||||
onClick={() => setVariantDialog({ open: true, data: null })}
|
||||
disabled={loading}
|
||||
sx={{ py: 1, flex: 1 }}
|
||||
>
|
||||
{t('datasets.generateEvalVariant')}
|
||||
</Button>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{currentDataset.aiEvaluation && (
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom color="primary">
|
||||
{t('datasets.aiEvaluation')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{currentDataset.aiEvaluation}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<EvalVariantDialog
|
||||
open={variantDialog.open}
|
||||
onClose={() => setVariantDialog({ open: false, data: null })}
|
||||
onGenerate={handleGenerateEvalVariant}
|
||||
onSave={handleSaveEvalVariant}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
286
easy-dataset-main/components/datasets/EditableField.js
Normal file
286
easy-dataset-main/components/datasets/EditableField.js
Normal file
@@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
IconButton,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
CircularProgress,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
|
||||
function getValue(value, answerType, useMarkdown, t, onOptimize) {
|
||||
if (value) {
|
||||
if (answerType === 'custom_format' && onOptimize) {
|
||||
try {
|
||||
const data = JSON.parse(value);
|
||||
value = JSON.stringify(data, null, 2);
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'grey.50',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 2,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
<Typography component="pre" variant="body2" sx={{ m: 0 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
if (answerType === 'label' && onOptimize) {
|
||||
try {
|
||||
const labels = JSON.parse(value);
|
||||
if (Array.isArray(labels)) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{labels.map((label, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={String(label)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
sx={{ height: 22, '& .MuiChip-label': { px: 1 } }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return <Typography variant="body1">{value}</Typography>;
|
||||
}
|
||||
}
|
||||
return useMarkdown ? (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown>{value}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Typography variant="body1">{value}</Typography>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('common.noData')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可编辑字段组件,支持 Markdown 和原始文本两种展示方式
|
||||
*/
|
||||
export default function EditableField({
|
||||
label,
|
||||
value,
|
||||
multiline = true,
|
||||
editing,
|
||||
onEdit,
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
onOptimize,
|
||||
tokenCount,
|
||||
optimizing = false,
|
||||
dataset
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { answerType } = dataset;
|
||||
const custom = answerType === 'custom_format' || answerType === 'label';
|
||||
|
||||
// 从 localStorage 读取 Markdown 展示设置,默认为 false
|
||||
const [useMarkdown, setUseMarkdown] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('dataset-use-markdown');
|
||||
return saved ? JSON.parse(saved) : false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// 当 useMarkdown 状态改变时,保存到 localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('dataset-use-markdown', JSON.stringify(useMarkdown));
|
||||
}
|
||||
}, [useMarkdown]);
|
||||
|
||||
const toggleMarkdown = () => {
|
||||
setUseMarkdown(!useMarkdown);
|
||||
};
|
||||
|
||||
const getAnswerTypeLabel = type => {
|
||||
switch (type) {
|
||||
case 'label':
|
||||
return t('imageDatasets.typeLabel', '标签');
|
||||
case 'custom_format':
|
||||
return t('imageDatasets.typeCustom', '自定义');
|
||||
default:
|
||||
return t('imageDatasets.typeText', '文本');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle1" color="text.secondary" sx={{ mr: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{!editing && value && (
|
||||
<>
|
||||
{onOptimize && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '12px',
|
||||
bgcolor: 'info.50',
|
||||
color: 'info.main',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
border: '1px solid',
|
||||
borderColor: 'info.100',
|
||||
mr: 1
|
||||
}}
|
||||
>
|
||||
{getAnswerTypeLabel(answerType)}
|
||||
</Box>
|
||||
)}
|
||||
{/* 字符数标签 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '12px',
|
||||
bgcolor: 'info.50',
|
||||
color: 'info.main',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
border: '1px solid',
|
||||
borderColor: 'info.100',
|
||||
mr: 1
|
||||
}}
|
||||
>
|
||||
{value.length} Characters
|
||||
</Box>
|
||||
|
||||
{/* Token 标签 */}
|
||||
{tokenCount > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '12px',
|
||||
bgcolor: 'primary.50',
|
||||
color: 'primary.main',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.100',
|
||||
mr: 1
|
||||
}}
|
||||
>
|
||||
{tokenCount} Tokens
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!editing && (
|
||||
<>
|
||||
<IconButton size="small" onClick={onEdit} disabled={optimizing}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{onOptimize && !custom && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onOptimize}
|
||||
disabled={optimizing}
|
||||
sx={{ ml: 0.5, position: 'relative' }}
|
||||
title={`optimizing=${optimizing}`}
|
||||
>
|
||||
{optimizing ? <CircularProgress size={20} /> : <AutoFixHighIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
)}
|
||||
{!custom && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={useMarkdown}
|
||||
onChange={toggleMarkdown}
|
||||
sx={{ ml: 1 }}
|
||||
disabled={optimizing}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="caption">{useMarkdown ? 'Markdown' : 'Text'}</Typography>}
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{editing ? (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline={multiline}
|
||||
rows={10}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mb: 2,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)'
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button variant="outlined" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)',
|
||||
'& img': {
|
||||
maxWidth: '100%'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getValue(value, answerType, useMarkdown, t, onOptimize)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
238
easy-dataset-main/components/datasets/EvalVariantDialog.js
Normal file
238
easy-dataset-main/components/datasets/EvalVariantDialog.js
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Slider,
|
||||
Card,
|
||||
CardContent,
|
||||
IconButton,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 评估集变体编辑对话框
|
||||
*/
|
||||
export default function EvalVariantDialog({ open, onClose, onGenerate, onSave }) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState('config'); // 'config' | 'preview'
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [config, setConfig] = useState({
|
||||
questionType: 'open_ended',
|
||||
count: 1
|
||||
});
|
||||
const [items, setItems] = useState([]);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('config');
|
||||
setConfig({ questionType: 'open_ended', count: 1 });
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await onGenerate(config);
|
||||
// Ensure data is an array
|
||||
const newItems = Array.isArray(data) ? data : [data];
|
||||
setItems(newItems);
|
||||
setStep('preview');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(items);
|
||||
};
|
||||
|
||||
const handleItemChange = (index, field, value) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
const handleDeleteItem = index => {
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
setItems(newItems);
|
||||
if (newItems.length === 0) {
|
||||
setStep('config');
|
||||
}
|
||||
};
|
||||
|
||||
const renderConfigStep = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mt: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('datasets.evalVariantConfigHint', '请选择生成的题目类型和数量,AI 将基于当前问答对进行改写。')}
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('datasets.questionType', '题目类型')}</InputLabel>
|
||||
<Select
|
||||
value={config.questionType}
|
||||
label={t('datasets.questionType', '题目类型')}
|
||||
onChange={e => setConfig({ ...config, questionType: e.target.value })}
|
||||
>
|
||||
<MenuItem value="open_ended">{t('datasets.typeOpenEnded', '开放式问答')}</MenuItem>
|
||||
<MenuItem value="single_choice">{t('datasets.typeSingleChoice', '单选题')}</MenuItem>
|
||||
<MenuItem value="multiple_choice">{t('datasets.typeMultipleChoice', '多选题')}</MenuItem>
|
||||
<MenuItem value="true_false">{t('datasets.typeTrueFalse', '判断题')}</MenuItem>
|
||||
<MenuItem value="short_answer">{t('datasets.typeShortAnswer', '简答题')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box>
|
||||
<Typography gutterBottom>
|
||||
{t('datasets.generateCount', '生成数量')}: {config.count}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={config.count}
|
||||
onChange={(_, value) => setConfig({ ...config, count: value })}
|
||||
step={1}
|
||||
marks
|
||||
min={1}
|
||||
max={5}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderPreviewStep = () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('datasets.evalVariantPreviewHint', '您可以编辑生成的题目,确认无误后保存到评估集。')}
|
||||
</Typography>
|
||||
|
||||
{items.map((item, index) => (
|
||||
<Card key={index} variant="outlined">
|
||||
<CardContent sx={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDeleteItem(index)}
|
||||
sx={{ position: 'absolute', right: 8, top: 8 }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="subtitle2" color="primary">
|
||||
{t('datasets.questionIndex', '题目 {{index}}', { index: index + 1 })}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('datasets.question', '问题')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={item.question || ''}
|
||||
onChange={e => handleItemChange(index, 'question', e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* Render Options for choice questions */}
|
||||
{(item.options || config.questionType.includes('choice')) && (
|
||||
<TextField
|
||||
label={t('datasets.options', '选项 (JSON数组)')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={Array.isArray(item.options) ? JSON.stringify(item.options) : item.options || ''}
|
||||
onChange={e => {
|
||||
let val = e.target.value;
|
||||
try {
|
||||
// Try to parse if user inputs valid JSON, otherwise keep string
|
||||
const parsed = JSON.parse(val);
|
||||
if (Array.isArray(parsed)) val = parsed;
|
||||
} catch (e) {}
|
||||
handleItemChange(index, 'options', val);
|
||||
}}
|
||||
helperText={t('datasets.optionsHint', '例如: ["选项A", "选项B"]')}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label={t('datasets.answer', '答案')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={Array.isArray(item.correctAnswer) ? JSON.stringify(item.correctAnswer) : item.correctAnswer || ''}
|
||||
onChange={e => {
|
||||
let val = e.target.value;
|
||||
// For multiple choice, answer might be array
|
||||
if (config.questionType === 'multiple_choice') {
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
if (Array.isArray(parsed)) val = parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
handleItemChange(index, 'correctAnswer', val);
|
||||
}}
|
||||
helperText={
|
||||
config.questionType === 'multiple_choice'
|
||||
? t('datasets.answerArrayHint', '多选题答案请输入数组,如 ["A", "C"]')
|
||||
: config.questionType === 'true_false'
|
||||
? t('datasets.answerBoolHint', '判断题答案请输入 ✅ 或 ❌')
|
||||
: ''
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
{step === 'config'
|
||||
? t('datasets.evalVariantTitle', '生成评估集变体')
|
||||
: t('datasets.evalVariantPreviewTitle', '确认生成的题目')}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>{step === 'config' ? renderConfigStep() : renderPreviewStep()}</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={loading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
|
||||
{step === 'config' ? (
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
startIcon={loading && <CircularProgress size={20} color="inherit" />}
|
||||
>
|
||||
{loading ? t('common.generating', '生成中...') : t('datasets.generate', '生成')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSave} variant="contained" color="primary" disabled={items.length === 0}>
|
||||
{t('datasets.saveToEval', '保存到评估集')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
169
easy-dataset-main/components/datasets/ImportDatasetDialog.js
Normal file
169
easy-dataset-main/components/datasets/ImportDatasetDialog.js
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileUploadStep from './import/FileUploadStep';
|
||||
// import DatasetSourceStep from './import/DatasetSourceStep'; // 不再需要
|
||||
import FieldMappingStep from './import/FieldMappingStep';
|
||||
import ImportProgressStep from './import/ImportProgressStep';
|
||||
|
||||
/**
|
||||
* 数据集导入对话框
|
||||
*/
|
||||
export default function ImportDatasetDialog({ open, onClose, projectId, onImportSuccess }) {
|
||||
const { t } = useTranslation();
|
||||
const [importType, setImportType] = useState('file'); // 只支持文件上传
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [importData, setImportData] = useState({
|
||||
rawData: null,
|
||||
previewData: null,
|
||||
fieldMapping: {},
|
||||
sourceInfo: null
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const steps = [
|
||||
t('import.fileUpload', '文件上传'),
|
||||
t('import.mapFields', '字段映射'),
|
||||
t('import.importing', '导入中')
|
||||
];
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentStep(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep(prev => prev - 1);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(0);
|
||||
setImportData({
|
||||
rawData: null,
|
||||
previewData: null,
|
||||
fieldMapping: {},
|
||||
sourceInfo: null
|
||||
});
|
||||
setError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDataLoaded = (data, preview, source) => {
|
||||
setImportData({
|
||||
...importData,
|
||||
rawData: data,
|
||||
previewData: preview,
|
||||
sourceInfo: source
|
||||
});
|
||||
setError('');
|
||||
handleNext();
|
||||
};
|
||||
|
||||
const handleFieldMappingComplete = mapping => {
|
||||
setImportData({
|
||||
...importData,
|
||||
fieldMapping: mapping
|
||||
});
|
||||
handleNext();
|
||||
};
|
||||
|
||||
const handleImportComplete = () => {
|
||||
handleClose();
|
||||
if (onImportSuccess) {
|
||||
onImportSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return <FileUploadStep onDataLoaded={handleDataLoaded} onError={setError} />;
|
||||
case 1:
|
||||
return (
|
||||
<FieldMappingStep
|
||||
previewData={importData.previewData}
|
||||
onMappingComplete={handleFieldMappingComplete}
|
||||
onError={setError}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<ImportProgressStep
|
||||
projectId={projectId}
|
||||
rawData={importData.rawData}
|
||||
fieldMapping={importData.fieldMapping}
|
||||
sourceInfo={importData.sourceInfo}
|
||||
onComplete={handleImportComplete}
|
||||
onError={setError}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: { minHeight: 600 }
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{t('import.title', '导入数据集')}</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{/* 导入类型选择 - 只保留文件上传 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('import.fileUpload', '文件上传')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.fileUploadDescription', '上传本地文件导入数据集')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 步骤指示器 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Stepper activeStep={currentStep} alternativeLabel>
|
||||
{steps.map(label => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Box>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 步骤内容 */}
|
||||
<Box sx={{ minHeight: 300 }}>{renderStepContent()}</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>{t('common.cancel', '取消')}</Button>
|
||||
{currentStep > 0 && currentStep < 2 && <Button onClick={handleBack}>{t('common.back', '上一步')}</Button>}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
199
easy-dataset-main/components/datasets/NoteInput.js
Normal file
199
easy-dataset-main/components/datasets/NoteInput.js
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, TextField, Typography, IconButton, Tooltip, Collapse } from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import NotesIcon from '@mui/icons-material/Notes';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 备注输入组件
|
||||
*/
|
||||
export default function NoteInput({
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
maxLength = 500,
|
||||
minRows = 3,
|
||||
maxRows = 6
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [noteValue, setNoteValue] = useState(value);
|
||||
const [tempValue, setTempValue] = useState(value);
|
||||
|
||||
// 同步外部value变化
|
||||
useEffect(() => {
|
||||
setNoteValue(value);
|
||||
setTempValue(value);
|
||||
}, [value]);
|
||||
|
||||
// 开始编辑
|
||||
const handleStartEdit = () => {
|
||||
setIsEditing(true);
|
||||
setTempValue(noteValue);
|
||||
};
|
||||
|
||||
// 保存备注
|
||||
const handleSave = () => {
|
||||
setNoteValue(tempValue);
|
||||
setIsEditing(false);
|
||||
if (onChange) {
|
||||
onChange(tempValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const handleCancel = () => {
|
||||
setTempValue(noteValue);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
// 处理键盘快捷键
|
||||
const handleKeyDown = event => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<NotesIcon fontSize="small" color="action" />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t('datasets.note', '备注')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{noteValue ? (
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', pl: 3 }}>
|
||||
{noteValue}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled" sx={{ pl: 3 }}>
|
||||
{t('datasets.noNote', '暂无备注')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 标题和操作按钮 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<NotesIcon fontSize="small" color="action" />
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t('datasets.note', '备注')}
|
||||
</Typography>
|
||||
{noteValue && !isEditing && (
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
({noteValue.length} / {maxLength})
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!isEditing && (
|
||||
<Tooltip title={t('common.edit', '编辑')}>
|
||||
<IconButton size="small" onClick={handleStartEdit}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 显示模式 */}
|
||||
<Collapse in={!isEditing}>
|
||||
<Box sx={{ pl: 3, mb: 2 }}>
|
||||
{noteValue ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover'
|
||||
},
|
||||
p: 1,
|
||||
borderRadius: 1
|
||||
}}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
{noteValue}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.disabled"
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover'
|
||||
},
|
||||
p: 1,
|
||||
borderRadius: 1
|
||||
}}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
{placeholder || t('datasets.clickToAddNote', '点击添加备注...')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{/* 编辑模式 */}
|
||||
<Collapse in={isEditing}>
|
||||
<Box sx={{ pl: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={minRows}
|
||||
maxRows={maxRows}
|
||||
value={tempValue}
|
||||
onChange={e => setTempValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder || t('datasets.enterNote', '请输入备注...')}
|
||||
inputProps={{ maxLength }}
|
||||
helperText={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('datasets.noteShortcuts', 'Ctrl+Enter 保存,Esc 取消')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={tempValue.length > maxLength * 0.9 ? 'warning.main' : 'text.secondary'}
|
||||
>
|
||||
{tempValue.length} / {maxLength}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Tooltip title={t('common.cancel', '取消')}>
|
||||
<IconButton size="small" onClick={handleCancel}>
|
||||
<CancelIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.save', '保存')}>
|
||||
<IconButton size="small" onClick={handleSave} color="primary" disabled={tempValue.length > maxLength}>
|
||||
<SaveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
50
easy-dataset-main/components/datasets/OptimizeDialog.js
Normal file
50
easy-dataset-main/components/datasets/OptimizeDialog.js
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* AI优化对话框组件
|
||||
*/
|
||||
export default function OptimizeDialog({ open, onClose, onConfirm }) {
|
||||
const [advice, setAdvice] = useState('');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(advice);
|
||||
setAdvice('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setAdvice('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('datasets.optimizeTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label={t('datasets.optimizeAdvice')}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
multiline
|
||||
rows={4}
|
||||
value={advice}
|
||||
onChange={e => setAdvice(e.target.value)}
|
||||
placeholder={t('datasets.optimizePlaceholder')}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleConfirm} variant="contained" color="primary" disabled={!advice.trim()}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
69
easy-dataset-main/components/datasets/StarRating.js
Normal file
69
easy-dataset-main/components/datasets/StarRating.js
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Rating, Typography } from '@mui/material';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 五星评分组件
|
||||
*/
|
||||
export default function StarRating({ value = 0, onChange, readOnly = false, size = 'medium', showLabel = true }) {
|
||||
const { t } = useTranslation();
|
||||
const [hover, setHover] = useState(-1);
|
||||
|
||||
const labels = {
|
||||
0.5: t('rating.veryPoor', '很差'),
|
||||
1: t('rating.poor', '差'),
|
||||
1.5: t('rating.belowAverage', '偏差'),
|
||||
2: t('rating.fair', '一般'),
|
||||
2.5: t('rating.average', '中等'),
|
||||
3: t('rating.good', '良好'),
|
||||
3.5: t('rating.veryGood', '很好'),
|
||||
4: t('rating.excellent', '优秀'),
|
||||
4.5: t('rating.outstanding', '杰出'),
|
||||
5: t('rating.perfect', '完美')
|
||||
};
|
||||
|
||||
const getLabelText = value => {
|
||||
return `${value} Star${value !== 1 ? 's' : ''}, ${labels[value]}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Rating
|
||||
name="dataset-rating"
|
||||
value={value}
|
||||
precision={0.5}
|
||||
getLabelText={getLabelText}
|
||||
onChange={(event, newValue) => {
|
||||
if (!readOnly && onChange) {
|
||||
onChange(newValue || 0);
|
||||
}
|
||||
}}
|
||||
onChangeActive={(event, newHover) => {
|
||||
if (!readOnly) {
|
||||
setHover(newHover);
|
||||
}
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
size={size}
|
||||
icon={<StarIcon fontSize="inherit" />}
|
||||
emptyIcon={<StarIcon fontSize="inherit" />}
|
||||
sx={{
|
||||
'& .MuiRating-iconFilled': {
|
||||
color: '#ffc107'
|
||||
},
|
||||
'& .MuiRating-iconHover': {
|
||||
color: '#ffb300'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{showLabel && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 60 }}>
|
||||
{labels[hover !== -1 ? hover : value] || (value === 0 ? t('rating.unrated', '未评分') : '')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
185
easy-dataset-main/components/datasets/TagSelector.js
Normal file
185
easy-dataset-main/components/datasets/TagSelector.js
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Chip, TextField, Autocomplete, Typography, IconButton, Tooltip } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 标签选择器组件
|
||||
* 支持从已有标签选择和自定义添加新标签
|
||||
*/
|
||||
export default function TagSelector({
|
||||
value = [],
|
||||
onChange,
|
||||
availableTags = [],
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
maxTags = 10
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
// 确保 value 始终是数组
|
||||
const normalizeValue = val => {
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
if (typeof val === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const [selectedTags, setSelectedTags] = useState(() => normalizeValue(value));
|
||||
|
||||
// 同步外部value变化
|
||||
useEffect(() => {
|
||||
setSelectedTags(normalizeValue(value));
|
||||
}, [value]);
|
||||
|
||||
// 处理标签变更
|
||||
const handleTagsChange = newTags => {
|
||||
setSelectedTags(newTags);
|
||||
if (onChange) {
|
||||
onChange(newTags);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加新标签
|
||||
const handleAddTag = newTag => {
|
||||
if (!newTag || newTag.trim() === '') return;
|
||||
|
||||
const trimmedTag = newTag.trim();
|
||||
if (selectedTags.includes(trimmedTag)) return;
|
||||
|
||||
if (selectedTags.length >= maxTags) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTags = [...selectedTags, trimmedTag];
|
||||
handleTagsChange(updatedTags);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
// 删除标签
|
||||
const handleDeleteTag = tagToDelete => {
|
||||
const updatedTags = selectedTags.filter(tag => tag !== tagToDelete);
|
||||
handleTagsChange(updatedTags);
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyPress = event => {
|
||||
if (event.key === 'Enter' && inputValue.trim()) {
|
||||
event.preventDefault();
|
||||
handleAddTag(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取可选的标签选项(排除已选择的)
|
||||
const getAvailableOptions = () => {
|
||||
return availableTags.filter(tag => !selectedTags.includes(tag));
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{selectedTags.length > 0 ? (
|
||||
selectedTags.map((tag, index) => (
|
||||
<Chip key={index} label={tag} size="small" variant="outlined" color="primary" />
|
||||
))
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('tags.noTags', '暂无标签')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 已选择的标签 */}
|
||||
{selectedTags.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||
{selectedTags.map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={tag}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onDelete={() => handleDeleteTag(tag)}
|
||||
deleteIcon={<CloseIcon />}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 标签输入区域 */}
|
||||
{selectedTags.length < maxTags && (
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={getAvailableOptions()}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(event, newInputValue) => {
|
||||
setInputValue(newInputValue);
|
||||
}}
|
||||
onChange={(event, newValue) => {
|
||||
if (newValue) {
|
||||
handleAddTag(newValue);
|
||||
}
|
||||
}}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
size="small"
|
||||
placeholder={placeholder || t('tags.addTag', '添加标签...')}
|
||||
onKeyPress={handleKeyPress}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, option) => (
|
||||
<Box component="li" {...props}>
|
||||
<Typography variant="body2">{option}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
|
||||
<Tooltip title={t('tags.addCustomTag', '添加自定义标签')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleAddTag(inputValue)}
|
||||
disabled={!inputValue.trim()}
|
||||
color="primary"
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 标签数量提示 */}
|
||||
{selectedTags.length >= maxTags && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{t('tags.maxTagsReached', `最多可添加 ${maxTags} 个标签`)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 可用标签提示 */}
|
||||
{availableTags.length > 0 && selectedTags.length < maxTags && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{t('tags.availableTagsHint', '可从已有标签中选择,或输入新标签')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
314
easy-dataset-main/components/datasets/import/FieldMappingStep.js
Normal file
314
easy-dataset-main/components/datasets/import/FieldMappingStep.js
Normal file
@@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Alert,
|
||||
Button,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 字段映射步骤组件
|
||||
*/
|
||||
export default function FieldMappingStep({ previewData, onMappingComplete, onError }) {
|
||||
const { t } = useTranslation();
|
||||
const [fieldMapping, setFieldMapping] = useState({
|
||||
question: '',
|
||||
answer: '',
|
||||
cot: '',
|
||||
tags: ''
|
||||
});
|
||||
const [availableFields, setAvailableFields] = useState([]);
|
||||
const [mappingValid, setMappingValid] = useState(false);
|
||||
|
||||
// 智能字段识别(支持 Alpaca: instruction + input -> question,output -> answer)
|
||||
const smartFieldMapping = fields => {
|
||||
const mapping = {
|
||||
question: '',
|
||||
answer: '',
|
||||
cot: '',
|
||||
tags: ''
|
||||
};
|
||||
|
||||
const lower = fields.map(f => f.toLowerCase());
|
||||
const instructionIdx = lower.findIndex(f => f.includes('instruction'));
|
||||
const inputIdx = lower.findIndex(f => f.includes('input'));
|
||||
const outputIdx = lower.findIndex(f => f.includes('output'));
|
||||
|
||||
// Alpaca 格式的优先识别
|
||||
if (instructionIdx !== -1 && inputIdx !== -1) {
|
||||
// 如果同时有instruction和input字段,将它们组合为question
|
||||
mapping.question = [fields[instructionIdx], fields[inputIdx]];
|
||||
} else if (instructionIdx !== -1) {
|
||||
// 如果只有instruction字段(比如从ShareGPT转换而来),直接映射为question
|
||||
mapping.question = fields[instructionIdx];
|
||||
}
|
||||
|
||||
if (outputIdx !== -1) {
|
||||
mapping.answer = fields[outputIdx];
|
||||
}
|
||||
|
||||
const questionKeywords = ['question', 'input', 'query', 'prompt', 'instruction', '问题', '输入', '指令'];
|
||||
const answerKeywords = ['answer', 'output', 'response', 'completion', 'target', '答案', '输出', '回答'];
|
||||
const cotKeywords = ['cot', 'reasoning', 'explanation', 'thinking', 'rationale', '思维链', '推理', '解释'];
|
||||
const tagKeywords = ['tag', 'tags', 'label', 'labels', 'category', 'categories', '标签', '类别'];
|
||||
|
||||
fields.forEach(field => {
|
||||
const fieldLower = field.toLowerCase();
|
||||
|
||||
if (!mapping.question || (typeof mapping.question === 'string' && !mapping.question)) {
|
||||
if (questionKeywords.some(keyword => fieldLower.includes(keyword))) {
|
||||
mapping.question = field;
|
||||
}
|
||||
} else if (!mapping.answer) {
|
||||
if (answerKeywords.some(keyword => fieldLower.includes(keyword))) {
|
||||
mapping.answer = field;
|
||||
}
|
||||
} else if (!mapping.cot) {
|
||||
if (cotKeywords.some(keyword => fieldLower.includes(keyword))) {
|
||||
mapping.cot = field;
|
||||
}
|
||||
} else if (!mapping.tags) {
|
||||
if (tagKeywords.some(keyword => fieldLower.includes(keyword))) {
|
||||
mapping.tags = field;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return mapping;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (previewData && previewData.length > 0) {
|
||||
const fields = Object.keys(previewData[0]);
|
||||
setAvailableFields(fields);
|
||||
|
||||
// 智能识别字段映射
|
||||
const smartMapping = smartFieldMapping(fields);
|
||||
setFieldMapping(smartMapping);
|
||||
}
|
||||
}, [previewData]);
|
||||
|
||||
useEffect(() => {
|
||||
// 验证映射是否有效(问题和答案字段必须选择)
|
||||
const hasQuestion = Array.isArray(fieldMapping.question)
|
||||
? fieldMapping.question.length > 0
|
||||
: !!fieldMapping.question;
|
||||
const hasAnswer = !!fieldMapping.answer;
|
||||
const isValid = hasQuestion && hasAnswer;
|
||||
setMappingValid(isValid);
|
||||
}, [fieldMapping]);
|
||||
|
||||
const handleFieldChange = (targetField, sourceField) => {
|
||||
setFieldMapping(prev => ({
|
||||
...prev,
|
||||
[targetField]:
|
||||
targetField === 'question'
|
||||
? Array.isArray(sourceField)
|
||||
? sourceField.filter(Boolean)
|
||||
: sourceField
|
||||
: sourceField
|
||||
}));
|
||||
};
|
||||
|
||||
const handleConfirmMapping = () => {
|
||||
if (!mappingValid) {
|
||||
onError(t('import.mappingRequired', '问题和答案字段为必选项'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有重复映射(兼容数组)
|
||||
const flatFields = Object.values(fieldMapping)
|
||||
.filter(Boolean)
|
||||
.flatMap(f => (Array.isArray(f) ? f.filter(Boolean) : [f]));
|
||||
const uniqueFields = [...new Set(flatFields)];
|
||||
if (flatFields.length !== uniqueFields.length) {
|
||||
onError(t('import.duplicateMapping', '不能将多个目标字段映射到同一个源字段'));
|
||||
return;
|
||||
}
|
||||
|
||||
onMappingComplete(fieldMapping);
|
||||
};
|
||||
|
||||
const getFieldDescription = field => {
|
||||
switch (field) {
|
||||
case 'question':
|
||||
return t('import.questionDesc', '用户的问题或输入内容(必选,可多选)');
|
||||
case 'answer':
|
||||
return t('import.answerDesc', 'AI的回答或输出内容(必选)');
|
||||
case 'cot':
|
||||
return t('import.cotDesc', '思维链或推理过程(可选)');
|
||||
case 'tags':
|
||||
return t('import.tagsDesc', '标签数组,多个标签用逗号分隔(可选)');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isFieldRequired = field => {
|
||||
return field === 'question' || field === 'answer';
|
||||
};
|
||||
|
||||
if (!previewData || previewData.length === 0) {
|
||||
return <Alert severity="error">{t('import.noPreviewData', '没有可预览的数据')}</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('import.fieldMapping', '字段映射')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t(
|
||||
'import.mappingDescription',
|
||||
'请将源数据的字段映射到目标字段。系统已自动识别可能的映射关系,您可以根据需要调整。'
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{/* 字段映射选择 */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('import.selectMapping', '选择字段映射')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))' }}>
|
||||
{Object.keys(fieldMapping).map(targetField => (
|
||||
<FormControl key={targetField} fullWidth>
|
||||
<InputLabel>
|
||||
{t(`import.${targetField}Field`, targetField)}
|
||||
{isFieldRequired(targetField) && <span style={{ color: 'red' }}>*</span>}
|
||||
</InputLabel>
|
||||
{targetField === 'question' ? (
|
||||
<Select
|
||||
multiple
|
||||
value={
|
||||
Array.isArray(fieldMapping.question)
|
||||
? fieldMapping.question
|
||||
: fieldMapping.question
|
||||
? [fieldMapping.question]
|
||||
: []
|
||||
}
|
||||
label={t(`import.${targetField}Field`, targetField)}
|
||||
onChange={e => handleFieldChange(targetField, e.target.value)}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(value => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{availableFields.map(field => (
|
||||
<MenuItem key={field} value={field}>
|
||||
{field}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Select
|
||||
value={fieldMapping[targetField]}
|
||||
label={t(`import.${targetField}Field`, targetField)}
|
||||
onChange={e => handleFieldChange(targetField, e.target.value)}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>{t('import.selectField', '选择字段')}</em>
|
||||
</MenuItem>
|
||||
{availableFields.map(field => (
|
||||
<MenuItem key={field} value={field}>
|
||||
{field}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{getFieldDescription(targetField)}
|
||||
</Typography>
|
||||
</FormControl>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* 数据预览 */}
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="subtitle1">{t('import.dataPreview', '数据预览')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.previewNote', '显示前3条记录,每个字段值最多显示100个字符')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TableContainer sx={{ maxHeight: 400 }}>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{availableFields.map(field => (
|
||||
<TableCell key={field} sx={{ minWidth: 150 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2">{field}</Typography>
|
||||
{Object.entries(fieldMapping).map(([targetField, sourceField]) => {
|
||||
const match = Array.isArray(sourceField) ? sourceField.includes(field) : sourceField === field;
|
||||
if (match) {
|
||||
return (
|
||||
<Chip
|
||||
key={targetField}
|
||||
label={t(`import.${targetField}Field`, targetField)}
|
||||
size="small"
|
||||
color={isFieldRequired(targetField) ? 'primary' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Box>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{previewData.map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
{availableFields.map(field => (
|
||||
<TableCell key={field}>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-word' }}>
|
||||
{row[field] || '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="contained" onClick={handleConfirmMapping} disabled={!mappingValid}>
|
||||
{t('import.confirmMapping', '确认映射')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{!mappingValid && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
{t('import.requiredFields', '请至少选择问题和答案字段的映射')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
344
easy-dataset-main/components/datasets/import/FileUploadStep.js
Normal file
344
easy-dataset-main/components/datasets/import/FileUploadStep.js
Normal file
@@ -0,0 +1,344 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
LinearProgress,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { CloudUpload as UploadIcon, Description as FileIcon, CheckCircle as CheckIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
// import { useDropzone } from 'react-dropzone';
|
||||
|
||||
/**
|
||||
* 文件上传步骤组件
|
||||
*/
|
||||
export default function FileUploadStep({ onDataLoaded, onError }) {
|
||||
const { t } = useTranslation();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||
|
||||
// 健壮的CSV解析函数,支持多行字段和引号转义
|
||||
const parseCSV = text => {
|
||||
const result = [];
|
||||
const lines = [];
|
||||
let currentLine = '';
|
||||
let inQuotes = false;
|
||||
|
||||
// 逐字符解析,正确处理引号内的换行符
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const nextChar = text[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
// 转义的引号
|
||||
currentLine += '"';
|
||||
i++; // 跳过下一个引号
|
||||
} else {
|
||||
// 切换引号状态
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === '\n' && !inQuotes) {
|
||||
// 行结束(不在引号内)
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
currentLine = '';
|
||||
} else {
|
||||
currentLine += char;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最后一行
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error('CSV文件格式不正确,至少需要标题行和一行数据');
|
||||
}
|
||||
|
||||
// 解析标题行
|
||||
const headers = parseCSVLine(lines[0]);
|
||||
|
||||
// 解析数据行
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = parseCSVLine(lines[i]);
|
||||
if (values.length > 0) {
|
||||
const obj = {};
|
||||
headers.forEach((header, index) => {
|
||||
obj[header] = values[index] || '';
|
||||
});
|
||||
result.push(obj);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 解析单行CSV,处理逗号分隔和引号转义
|
||||
const parseCSVLine = line => {
|
||||
const result = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
const nextChar = line[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
// 转义的引号
|
||||
current += '"';
|
||||
i++; // 跳过下一个引号
|
||||
} else {
|
||||
// 切换引号状态
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
// 字段分隔符(不在引号内)
|
||||
result.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最后一个字段
|
||||
result.push(current.trim());
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 检测并转换ShareGPT格式为Alpaca格式
|
||||
const convertShareGPTToAlpaca = item => {
|
||||
// 检查是否包含conversations字段且格式正确
|
||||
if (item.conversations && Array.isArray(item.conversations)) {
|
||||
const conversations = item.conversations;
|
||||
|
||||
// 查找system、human、gpt消息
|
||||
let systemMessage = '';
|
||||
let instruction = '';
|
||||
let output = '';
|
||||
|
||||
for (const conv of conversations) {
|
||||
if (conv.from === 'system' && conv.value) {
|
||||
systemMessage = conv.value;
|
||||
} else if (conv.from === 'human' && conv.value) {
|
||||
instruction = conv.value;
|
||||
} else if (conv.from === 'gpt' && conv.value) {
|
||||
output = conv.value;
|
||||
break; // 只取第一轮对话
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有system消息,将其作为instruction的前缀
|
||||
if (systemMessage && instruction) {
|
||||
instruction = `${systemMessage}\n\n${instruction}`;
|
||||
} else if (systemMessage && !instruction) {
|
||||
instruction = systemMessage;
|
||||
}
|
||||
|
||||
// 转换为Alpaca格式
|
||||
return {
|
||||
instruction: instruction || '',
|
||||
input: '', // ShareGPT格式通常没有单独的input字段
|
||||
output: output || '',
|
||||
// 保留其他字段
|
||||
...Object.fromEntries(Object.entries(item).filter(([key]) => key !== 'conversations'))
|
||||
};
|
||||
}
|
||||
|
||||
return item; // 如果不是ShareGPT格式,返回原始数据
|
||||
};
|
||||
|
||||
const parseFileContent = async file => {
|
||||
const text = await file.text();
|
||||
const extension = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
try {
|
||||
let data = [];
|
||||
|
||||
if (extension === 'json') {
|
||||
const parsed = JSON.parse(text);
|
||||
data = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} else if (extension === 'jsonl') {
|
||||
data = text
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => JSON.parse(line));
|
||||
} else if (extension === 'csv') {
|
||||
// 更健壮的CSV解析,支持多行字段和引号转义
|
||||
data = parseCSV(text);
|
||||
if (data.length === 0) {
|
||||
throw new Error('CSV文件格式不正确或没有数据');
|
||||
}
|
||||
} else {
|
||||
throw new Error('不支持的文件格式');
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new Error('文件中没有找到有效数据');
|
||||
}
|
||||
|
||||
// 检测并转换ShareGPT格式为Alpaca格式
|
||||
data = data.map(convertShareGPTToAlpaca);
|
||||
|
||||
// 生成预览数据(取前3条记录,每个字段值截取前100字符)
|
||||
const previewData = data.slice(0, 3).map(item => {
|
||||
const preview = {};
|
||||
Object.keys(item).forEach(key => {
|
||||
const value = String(item[key] || '');
|
||||
preview[key] = value.length > 100 ? value.substring(0, 100) + '...' : value;
|
||||
});
|
||||
return preview;
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
preview: previewData,
|
||||
source: {
|
||||
type: 'file',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
totalRecords: data.length
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`解析文件失败: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async event => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const result = await parseFileContent(file);
|
||||
setUploadedFiles([
|
||||
{
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: 'success'
|
||||
}
|
||||
]);
|
||||
|
||||
onDataLoaded(result.data, result.preview, result.source);
|
||||
} catch (error) {
|
||||
setUploadedFiles([
|
||||
{
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: 'error',
|
||||
error: error.message
|
||||
}
|
||||
]);
|
||||
onError(error.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = bytes => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('import.uploadFile', '上传文件')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t('import.supportedFormats', '支持 JSON、JSONL、CSV 格式文件')}
|
||||
</Typography>
|
||||
|
||||
{/* 文件上传区域 */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
border: '2px dashed',
|
||||
borderColor: 'divider',
|
||||
backgroundColor: 'background.paper',
|
||||
transition: 'all 0.2s ease',
|
||||
mb: 3,
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
backgroundColor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
onClick={() => document.getElementById('file-upload-input').click()}
|
||||
>
|
||||
<input
|
||||
id="file-upload-input"
|
||||
type="file"
|
||||
accept=".json,.jsonl,.csv"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<UploadIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('import.dragDropFile', '拖拽文件到此处或点击选择文件')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.maxFileSize', '最大文件大小: 50MB')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploading && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{t('import.processingFile', '正在处理文件...')}
|
||||
</Typography>
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 已上传文件列表 */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('import.uploadedFiles', '已上传文件')}
|
||||
</Typography>
|
||||
<List>
|
||||
{uploadedFiles.map((file, index) => (
|
||||
<ListItem key={index} sx={{ px: 0 }}>
|
||||
<ListItemIcon>
|
||||
{file.status === 'success' ? <CheckIcon color="success" /> : <FileIcon color="error" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={file.name}
|
||||
secondary={file.status === 'success' ? `${formatFileSize(file.size)}` : file.error}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{uploadedFiles.some(f => f.status === 'error') && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{t('import.uploadError', '文件上传失败,请检查文件格式是否正确')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import { CheckCircle as CheckIcon, Error as ErrorIcon, Info as InfoIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 导入进度步骤组件
|
||||
*/
|
||||
export default function ImportProgressStep({ projectId, rawData, fieldMapping, sourceInfo, onComplete, onError }) {
|
||||
const { t } = useTranslation();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentStep, setCurrentStep] = useState('');
|
||||
const [importStats, setImportStats] = useState({
|
||||
total: 0,
|
||||
processed: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: []
|
||||
});
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const startedRef = useRef(false); // 防止在开发模式下因严格模式导致重复执行
|
||||
|
||||
useEffect(() => {
|
||||
if (!startedRef.current && rawData && fieldMapping && projectId) {
|
||||
startedRef.current = true;
|
||||
startImport();
|
||||
}
|
||||
}, [rawData, fieldMapping, projectId]);
|
||||
|
||||
const startImport = async () => {
|
||||
try {
|
||||
setCurrentStep(t('import.preparingData', '准备数据...'));
|
||||
setImportStats(prev => ({ ...prev, total: rawData.length }));
|
||||
|
||||
// 转换数据格式
|
||||
const convertedData = rawData.map(item => {
|
||||
// 支持 question 映射多个字段,拼接为一个字符串
|
||||
const qFields = fieldMapping.question;
|
||||
const question = Array.isArray(qFields)
|
||||
? qFields
|
||||
.map(f => item[f] || '')
|
||||
.filter(v => v && String(v).trim())
|
||||
.join('\n')
|
||||
: item[qFields] || '';
|
||||
|
||||
const converted = {
|
||||
question,
|
||||
answer: item[fieldMapping.answer] || '',
|
||||
cot: fieldMapping.cot ? item[fieldMapping.cot] || '' : '',
|
||||
questionLabel: '', // 默认标签,后续可以通过AI生成
|
||||
chunkName: sourceInfo?.datasetName || sourceInfo?.fileName || 'Imported Data',
|
||||
chunkContent: `Imported from ${sourceInfo?.type || 'file'}`,
|
||||
model: 'imported',
|
||||
confirmed: false,
|
||||
score: 0,
|
||||
tags: fieldMapping.tags ? JSON.stringify(parseTagsField(item[fieldMapping.tags])) : '[]',
|
||||
note: '',
|
||||
other: JSON.stringify(getOtherFields(item, fieldMapping))
|
||||
};
|
||||
|
||||
// 不在前端抛错,由后端负责校验并统计 skipped
|
||||
return converted;
|
||||
});
|
||||
|
||||
setProgress(25);
|
||||
setCurrentStep(t('import.uploadingData', '上传数据...'));
|
||||
|
||||
// 分批上传数据
|
||||
const batchSize = 500;
|
||||
let processed = 0;
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < convertedData.length; i += batchSize) {
|
||||
const batch = convertedData.slice(i, i + batchSize);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/datasets/import`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
datasets: batch,
|
||||
sourceInfo
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`批次上传失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
success += result.success || 0;
|
||||
failed += typeof result.failed === 'number' ? result.failed : result.errors?.length || 0;
|
||||
skipped += result.skipped || 0;
|
||||
processed += batch.length;
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
errors.push(...result.errors);
|
||||
}
|
||||
} catch (error) {
|
||||
failed += batch.length;
|
||||
processed += batch.length;
|
||||
errors.push(`批次 ${Math.floor(i / batchSize) + 1}: ${error.message}`);
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
const progressPercent = 25 + (processed / convertedData.length) * 70;
|
||||
setProgress(progressPercent);
|
||||
setImportStats({
|
||||
total: convertedData.length,
|
||||
processed,
|
||||
success,
|
||||
failed,
|
||||
skipped,
|
||||
errors
|
||||
});
|
||||
|
||||
setCurrentStep(
|
||||
t('import.processing', '处理中... {{processed}}/{{total}}', {
|
||||
processed,
|
||||
total: convertedData.length
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setProgress(100);
|
||||
setCurrentStep(t('import.completed', '导入完成'));
|
||||
setCompleted(true);
|
||||
|
||||
// 延迟一下再调用完成回调,让用户看到完成状态
|
||||
setTimeout(() => {
|
||||
onComplete();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
onError(error.message);
|
||||
setImportStats(prev => ({
|
||||
...prev,
|
||||
errors: [...prev.errors, error.message]
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 解析标签字段
|
||||
const parseTagsField = tagsValue => {
|
||||
if (!tagsValue) return [];
|
||||
|
||||
if (Array.isArray(tagsValue)) {
|
||||
return tagsValue;
|
||||
}
|
||||
|
||||
if (typeof tagsValue === 'string') {
|
||||
return tagsValue
|
||||
.split(',')
|
||||
.map(tag => tag.trim())
|
||||
.filter(tag => tag);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// 获取其他字段(兼容数组映射)
|
||||
const getOtherFields = (item, mapping) => {
|
||||
const used = [];
|
||||
Object.values(mapping).forEach(field => {
|
||||
if (!field) return;
|
||||
if (Array.isArray(field)) used.push(...field);
|
||||
else used.push(field);
|
||||
});
|
||||
const mappedFields = new Set(used);
|
||||
const otherFields = {};
|
||||
|
||||
Object.keys(item).forEach(key => {
|
||||
if (!mappedFields.has(key)) {
|
||||
otherFields[key] = item[key];
|
||||
}
|
||||
});
|
||||
|
||||
return otherFields;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('import.importing', '正在导入数据集')}
|
||||
</Typography>
|
||||
|
||||
{/* 进度条 */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
{currentStep}
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={progress} sx={{ height: 8, borderRadius: 4, mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(progress)}% {t('import.complete', '完成')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* 导入统计 */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('import.importStats', '导入统计')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Chip
|
||||
icon={<InfoIcon />}
|
||||
label={t('import.total', '总计: {{count}}', { count: importStats.total })}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
icon={<CheckIcon />}
|
||||
label={t('import.success', '成功: {{count}}', { count: importStats.success })}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
/>
|
||||
{importStats.skipped > 0 && (
|
||||
<Chip
|
||||
icon={<InfoIcon />}
|
||||
label={t('import.skipped', '跳过: {{count}}', { count: importStats.skipped })}
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{importStats.failed > 0 && (
|
||||
<Chip
|
||||
icon={<ErrorIcon />}
|
||||
label={t('import.failed', '失败: {{count}}', { count: importStats.failed })}
|
||||
color="error"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{sourceInfo && (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.source', '数据源')}:{' '}
|
||||
{sourceInfo.type === 'file' ? sourceInfo.fileName : sourceInfo.datasetName}
|
||||
</Typography>
|
||||
{sourceInfo.description && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.description', '描述')}: {sourceInfo.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* 错误列表 */}
|
||||
{importStats.errors.length > 0 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom color="error">
|
||||
{t('import.errors', '错误信息')}
|
||||
</Typography>
|
||||
<List dense>
|
||||
{importStats.errors.slice(0, 10).map((error, index) => (
|
||||
<ListItem key={index} sx={{ px: 0 }}>
|
||||
<ListItemIcon>
|
||||
<ErrorIcon color="error" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={error} primaryTypographyProps={{ variant: 'body2' }} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{importStats.errors.length > 10 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('import.moreErrors', '还有 {{count}} 个错误未显示...', {
|
||||
count: importStats.errors.length - 10
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* 完成提示 */}
|
||||
{completed && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
{t('import.importSuccess', '数据集导入完成!成功导入 {{success}} 条记录。', {
|
||||
success: importStats.success
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
135
easy-dataset-main/components/datasets/utils/ratingUtils.js
Normal file
135
easy-dataset-main/components/datasets/utils/ratingUtils.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 评分相关的工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 根据评分获取对应的颜色和标签(不包含国际化)
|
||||
* @param {number} score - 评分 (0-5)
|
||||
* @returns {object} - 包含颜色、背景色和标签的对象
|
||||
*/
|
||||
export const getRatingConfig = score => {
|
||||
if (score >= 4.5) {
|
||||
return {
|
||||
color: '#2e7d32', // 深绿色
|
||||
backgroundColor: '#e8f5e8',
|
||||
label: '优秀',
|
||||
variant: 'excellent'
|
||||
};
|
||||
} else if (score >= 3.5) {
|
||||
return {
|
||||
color: '#388e3c', // 绿色
|
||||
backgroundColor: '#f1f8e9',
|
||||
label: '良好',
|
||||
variant: 'good'
|
||||
};
|
||||
} else if (score >= 2.5) {
|
||||
return {
|
||||
color: '#f57c00', // 橙色
|
||||
backgroundColor: '#fff3e0',
|
||||
label: '一般',
|
||||
variant: 'average'
|
||||
};
|
||||
} else if (score >= 1.5) {
|
||||
return {
|
||||
color: '#f44336', // 红色
|
||||
backgroundColor: '#ffebee',
|
||||
label: '较差',
|
||||
variant: 'poor'
|
||||
};
|
||||
} else if (score > 0) {
|
||||
return {
|
||||
color: '#d32f2f', // 深红色
|
||||
backgroundColor: '#ffebee',
|
||||
label: '很差',
|
||||
variant: 'very-poor'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
color: '#757575', // 灰色
|
||||
backgroundColor: '#f5f5f5',
|
||||
label: '未评分',
|
||||
variant: 'unrated'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据评分获取对应的颜色和国际化标签
|
||||
* @param {number} score - 评分 (0-5)
|
||||
* @param {function} t - 国际化翻译函数
|
||||
* @returns {object} - 包含颜色、背景色和国际化标签的对象
|
||||
*/
|
||||
export const getRatingConfigI18n = (score, t) => {
|
||||
const baseConfig = getRatingConfig(score);
|
||||
|
||||
// 根据variant获取对应的翻译键
|
||||
let translationKey;
|
||||
let fallbackText;
|
||||
|
||||
switch (baseConfig.variant) {
|
||||
case 'excellent':
|
||||
translationKey = 'datasets.ratingExcellent';
|
||||
fallbackText = '优秀';
|
||||
break;
|
||||
case 'good':
|
||||
translationKey = 'datasets.ratingGood';
|
||||
fallbackText = '良好';
|
||||
break;
|
||||
case 'average':
|
||||
translationKey = 'datasets.ratingAverage';
|
||||
fallbackText = '一般';
|
||||
break;
|
||||
case 'poor':
|
||||
translationKey = 'datasets.ratingPoor';
|
||||
fallbackText = '较差';
|
||||
break;
|
||||
case 'very-poor':
|
||||
translationKey = 'datasets.ratingVeryPoor';
|
||||
fallbackText = '很差';
|
||||
break;
|
||||
case 'unrated':
|
||||
translationKey = 'datasets.ratingUnrated';
|
||||
fallbackText = '未评分';
|
||||
break;
|
||||
default:
|
||||
translationKey = 'datasets.ratingUnrated';
|
||||
fallbackText = '未评分';
|
||||
}
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
label: t(translationKey, fallbackText)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化评分显示
|
||||
* @param {number} score - 评分
|
||||
* @returns {string} - 格式化后的评分字符串
|
||||
*/
|
||||
export const formatScore = score => {
|
||||
if (score === 0) return '';
|
||||
return score.toFixed(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取评分范围的描述
|
||||
* @param {number} score - 评分
|
||||
* @returns {string} - 评分范围描述
|
||||
*/
|
||||
export const getScoreDescription = score => {
|
||||
const config = getRatingConfig(score);
|
||||
return `${formatScore(score)} - ${config.label}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 评分范围常量
|
||||
*/
|
||||
export const SCORE_RANGES = {
|
||||
EXCELLENT: { min: 4.5, max: 5.0, label: '优秀' },
|
||||
GOOD: { min: 3.5, max: 4.4, label: '良好' },
|
||||
AVERAGE: { min: 2.5, max: 3.4, label: '一般' },
|
||||
POOR: { min: 1.5, max: 2.4, label: '较差' },
|
||||
VERY_POOR: { min: 0.1, max: 1.4, label: '很差' },
|
||||
UNRATED: { min: 0, max: 0, label: '未评分' }
|
||||
};
|
||||
325
easy-dataset-main/components/distill/AutoDistillDialog.js
Normal file
325
easy-dataset-main/components/distill/AutoDistillDialog.js
Normal file
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Alert,
|
||||
Paper,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio
|
||||
} from '@mui/material';
|
||||
|
||||
/**
|
||||
* 全自动蒸馏数据集配置弹框
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 对话框是否打开
|
||||
* @param {Function} props.onClose - 关闭对话框的回调
|
||||
* @param {Function} props.onStart - 开始蒸馏任务的回调
|
||||
* @param {Function} props.onStartBackground - 开始后台蒸馏任务的回调
|
||||
* @param {string} props.projectId - 项目ID
|
||||
* @param {Object} props.project - 项目信息
|
||||
* @param {Object} props.stats - 当前统计信息
|
||||
*/
|
||||
export default function AutoDistillDialog({
|
||||
open,
|
||||
onClose,
|
||||
onStart,
|
||||
onStartBackground,
|
||||
projectId,
|
||||
project,
|
||||
stats = {}
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 表单状态
|
||||
const [topic, setTopic] = useState('');
|
||||
const [levels, setLevels] = useState(2);
|
||||
const [tagsPerLevel, setTagsPerLevel] = useState(10);
|
||||
const [questionsPerTag, setQuestionsPerTag] = useState(10);
|
||||
const [datasetType, setDatasetType] = useState('single-turn'); // 'single-turn' | 'multi-turn' | 'both'
|
||||
|
||||
// 计算信息
|
||||
const [estimatedTags, setEstimatedTags] = useState(0); // 所有标签总数(包括根节点和中间节点)
|
||||
const [leafTags, setLeafTags] = useState(0); // 叶子节点数量(即最后一层标签数)
|
||||
const [estimatedQuestions, setEstimatedQuestions] = useState(0);
|
||||
const [newTags, setNewTags] = useState(0);
|
||||
const [newQuestions, setNewQuestions] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 初始化默认主题
|
||||
useEffect(() => {
|
||||
if (project && project.name) {
|
||||
setTopic(project.name);
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
// 计算预估标签和问题数量
|
||||
useEffect(() => {
|
||||
/*
|
||||
* 根据公式:总问题数 = \left( \prod_{i=1}^{n} L_i \right) \times Q
|
||||
* 当每层标签数量相同(L)时:总问题数 = L^n \times Q
|
||||
*/
|
||||
|
||||
const leafTags = Math.pow(tagsPerLevel, levels);
|
||||
|
||||
// 总问题数 = 叶子节点数 * 每个节点的问题数
|
||||
const totalQuestions = leafTags * questionsPerTag;
|
||||
|
||||
let totalTags;
|
||||
if (tagsPerLevel === 1) {
|
||||
// 如果每层只有1个标签,总数就是 levels+1
|
||||
totalTags = levels + 1;
|
||||
} else {
|
||||
// 使用等比数列求和公式
|
||||
totalTags = (1 - Math.pow(tagsPerLevel, levels + 1)) / (1 - tagsPerLevel);
|
||||
}
|
||||
|
||||
setLeafTags(leafTags);
|
||||
setEstimatedTags(leafTags); // 改为只显示叶子节点数量,而非所有节点数量
|
||||
setEstimatedQuestions(totalQuestions);
|
||||
|
||||
// 计算新增标签和问题数量
|
||||
const currentTags = stats.tagsCount || 0;
|
||||
const currentQuestions = stats.questionsCount || 0;
|
||||
|
||||
// 只考虑最后一层的标签数量
|
||||
setNewTags(Math.max(0, leafTags - currentTags));
|
||||
setNewQuestions(Math.max(0, totalQuestions - currentQuestions));
|
||||
|
||||
// 验证是否可以执行任务
|
||||
if (leafTags <= currentTags && totalQuestions <= currentQuestions) {
|
||||
setError(t('distill.autoDistillInsufficientError'));
|
||||
} else {
|
||||
setError('');
|
||||
}
|
||||
}, [levels, tagsPerLevel, questionsPerTag, stats, t]);
|
||||
|
||||
// 处理开始任务
|
||||
const handleStart = () => {
|
||||
if (error) return;
|
||||
|
||||
onStart({
|
||||
topic,
|
||||
levels,
|
||||
tagsPerLevel,
|
||||
questionsPerTag,
|
||||
estimatedTags,
|
||||
estimatedQuestions,
|
||||
datasetType
|
||||
});
|
||||
};
|
||||
|
||||
// 处理开始后台任务
|
||||
const handleStartBackground = () => {
|
||||
if (error) return;
|
||||
|
||||
onStartBackground({
|
||||
topic,
|
||||
levels,
|
||||
tagsPerLevel,
|
||||
questionsPerTag,
|
||||
estimatedTags,
|
||||
estimatedQuestions,
|
||||
datasetType
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{t('distill.autoDistillTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ py: 2, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, gap: 3 }}>
|
||||
{/* 左侧:输入区域 */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
label={t('distill.distillTopic')}
|
||||
value={topic}
|
||||
onChange={e => setTopic(e.target.value)}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
required
|
||||
disabled
|
||||
helperText={t('distill.rootTopicHelperText')}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 3, mb: 2 }}>
|
||||
<Typography gutterBottom>{t('distill.tagLevels')}</Typography>
|
||||
<TextField
|
||||
type="number"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
inputProps: { min: 1, max: 5 }
|
||||
}}
|
||||
value={levels}
|
||||
onChange={e => {
|
||||
const value = Math.min(5, Math.max(1, Number(e.target.value)));
|
||||
setLevels(value);
|
||||
}}
|
||||
helperText={t('distill.tagLevelsHelper', { max: 5 })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3, mb: 2 }}>
|
||||
<Typography gutterBottom>{t('distill.tagsPerLevel')}</Typography>
|
||||
<TextField
|
||||
type="number"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
inputProps: { min: 1, max: 50 }
|
||||
}}
|
||||
value={tagsPerLevel}
|
||||
onChange={e => {
|
||||
const value = Math.min(50, Math.max(1, Number(e.target.value)));
|
||||
setTagsPerLevel(value);
|
||||
}}
|
||||
helperText={t('distill.tagsPerLevelHelper', { max: 50 })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3, mb: 2 }}>
|
||||
<Typography gutterBottom>{t('distill.questionsPerTag')}</Typography>
|
||||
<TextField
|
||||
type="number"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
inputProps: { min: 1, max: 50 }
|
||||
}}
|
||||
value={questionsPerTag}
|
||||
onChange={e => {
|
||||
const value = Math.min(50, Math.max(1, Number(e.target.value)));
|
||||
setQuestionsPerTag(value);
|
||||
}}
|
||||
helperText={t('distill.questionsPerTagHelper', { max: 50 })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3, mb: 2 }}>
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel component="legend" sx={{ mb: 2, fontWeight: 'medium' }}>
|
||||
{t('distill.datasetType', { defaultValue: '数据集类型' })}
|
||||
</FormLabel>
|
||||
<RadioGroup value={datasetType} onChange={e => setDatasetType(e.target.value)}>
|
||||
<FormControlLabel
|
||||
value="single-turn"
|
||||
control={<Radio />}
|
||||
label={t('distill.singleTurnDataset', { defaultValue: '单轮对话数据集' })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="multi-turn"
|
||||
control={<Radio />}
|
||||
label={t('distill.multiTurnDataset', { defaultValue: '多轮对话数据集' })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="both"
|
||||
control={<Radio />}
|
||||
label={t('distill.bothDatasetTypes', { defaultValue: '两种数据集都生成' })}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:预估信息区域 */}
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 3,
|
||||
mt: 1,
|
||||
borderRadius: 2,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
||||
{t('distill.estimationInfo')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, mt: 2 }}>
|
||||
<Typography variant="subtitle2">{t('distill.estimatedTags')}:</Typography>
|
||||
<Typography variant="subtitle1" fontWeight="medium">
|
||||
{estimatedTags}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle2">{t('distill.estimatedQuestions')}:</Typography>
|
||||
<Typography variant="subtitle1" fontWeight="medium">
|
||||
{estimatedQuestions}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle2">{t('distill.currentTags')}:</Typography>
|
||||
<Typography variant="subtitle1" fontWeight="medium">
|
||||
{stats.tagsCount || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle2">{t('distill.currentQuestions')}:</Typography>
|
||||
<Typography variant="subtitle1" fontWeight="medium">
|
||||
{stats.questionsCount || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle1" color="primary">
|
||||
{t('distill.newTags')}:
|
||||
</Typography>
|
||||
<Typography variant="h6" fontWeight="bold" color="primary.main">
|
||||
{newTags}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="subtitle1" color="primary">
|
||||
{t('distill.newQuestions')}:
|
||||
</Typography>
|
||||
<Typography variant="h6" fontWeight="bold" color="primary.main">
|
||||
{newQuestions}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleStartBackground} color="secondary" variant="outlined" disabled={!!error || !topic}>
|
||||
{t('distill.startAutoDistillBackground', { defaultValue: '开始自动蒸馏(后台运行)' })}
|
||||
</Button>
|
||||
<Button onClick={handleStart} color="primary" variant="contained" disabled={!!error || !topic}>
|
||||
{t('distill.startAutoDistill')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
212
easy-dataset-main/components/distill/AutoDistillProgress.js
Normal file
212
easy-dataset-main/components/distill/AutoDistillProgress.js
Normal file
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
Divider,
|
||||
IconButton,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
/**
|
||||
* 全自动蒸馏进度组件
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 对话框是否打开
|
||||
* @param {Function} props.onClose - 关闭对话框的回调
|
||||
* @param {Object} props.progress - 进度信息
|
||||
*/
|
||||
export default function AutoDistillProgress({ open, onClose, progress = {} }) {
|
||||
const { t } = useTranslation();
|
||||
const logContainerRef = useRef(null);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [progress.logs]);
|
||||
|
||||
const getStageText = () => {
|
||||
const { stage } = progress;
|
||||
switch (stage) {
|
||||
case 'level1':
|
||||
return t('distill.stageBuildingLevel1');
|
||||
case 'level2':
|
||||
return t('distill.stageBuildingLevel2');
|
||||
case 'level3':
|
||||
return t('distill.stageBuildingLevel3');
|
||||
case 'level4':
|
||||
return t('distill.stageBuildingLevel4');
|
||||
case 'level5':
|
||||
return t('distill.stageBuildingLevel5');
|
||||
case 'questions':
|
||||
return t('distill.stageBuildingQuestions');
|
||||
case 'datasets':
|
||||
return t('distill.stageBuildingDatasets');
|
||||
case 'multi-turn-datasets':
|
||||
return t('distill.stageBuildingMultiTurnDatasets', { defaultValue: '生成多轮对话数据集中...' });
|
||||
case 'completed':
|
||||
return t('distill.stageCompleted');
|
||||
default:
|
||||
return t('distill.stageInitializing');
|
||||
}
|
||||
};
|
||||
|
||||
const getOverallProgress = () => {
|
||||
const { tagsBuilt, tagsTotal, questionsBuilt, questionsTotal, datasetsBuilt, datasetsTotal } = progress;
|
||||
|
||||
// 整体进度按比例计算:标签构建占30%,问题生成占35%,数据集生成占35%
|
||||
let tagProgress = tagsTotal ? (tagsBuilt / tagsTotal) * 30 : 0;
|
||||
let questionProgress = questionsTotal ? (questionsBuilt / questionsTotal) * 35 : 0;
|
||||
let datasetProgress = datasetsTotal ? (datasetsBuilt / datasetsTotal) * 35 : 0;
|
||||
|
||||
return Math.min(100, Math.round(tagProgress + questionProgress + datasetProgress));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={progress.stage === 'completed' || !progress.stage ? onClose : null}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{t('distill.autoDistillProgress')}
|
||||
{(progress.stage === 'completed' || !progress.stage) && (
|
||||
<IconButton onClick={onClose} aria-label="close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ py: 1 }}>
|
||||
{/* 整体进度 */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('distill.overallProgress')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress variant="determinate" value={getOverallProgress()} sx={{ height: 10, borderRadius: 5 }} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.5 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{getOverallProgress()}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: progress.multiTurnDatasetsTotal > 0 ? 'repeat(4, 1fr)' : 'repeat(3, 1fr)',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('distill.tagsProgress')}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{progress.tagsBuilt || 0} / {progress.tagsTotal || 0}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('distill.questionsProgress')}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{progress.questionsBuilt || 0} / {progress.questionsTotal || 0}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('distill.datasetsProgress')}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{progress.datasetsBuilt || 0} / {progress.datasetsTotal || 0}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{progress.multiTurnDatasetsTotal > 0 && (
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('distill.multiTurnDatasetsProgress', { defaultValue: '多轮对话进度' })}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{progress.multiTurnDatasetsBuilt || 0} / {progress.multiTurnDatasetsTotal || 0}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 当前阶段 */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('distill.currentStage')}
|
||||
</Typography>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'primary.light', color: 'primary.contrastText' }}>
|
||||
<Typography variant="h6">{getStageText()}</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* 实时日志 */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('distill.realTimeLogs')}
|
||||
</Typography>
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
maxHeight: 250,
|
||||
overflow: 'auto',
|
||||
bgcolor: 'grey.900',
|
||||
color: 'grey.100',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
ref={logContainerRef}
|
||||
>
|
||||
{progress.logs?.length > 0 ? (
|
||||
progress.logs.map((log, index) => {
|
||||
// 检测成功日志,显示为绿色 Successfully
|
||||
let color = 'inherit';
|
||||
if (log.includes('成功') || log.includes('完成') || log.includes('Successfully')) {
|
||||
color = '#4caf50';
|
||||
}
|
||||
if (log.includes('失败') || log.toLowerCase().includes('error')) {
|
||||
color = '#f44336';
|
||||
}
|
||||
return (
|
||||
<Box key={index} sx={{ mb: 0.5, color: color }}>
|
||||
{log}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Typography variant="body2" color="grey.500">
|
||||
{t('distill.waitingForLogs')}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
37
easy-dataset-main/components/distill/ConfirmDialog.js
Normal file
37
easy-dataset-main/components/distill/ConfirmDialog.js
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog, DialogActions, DialogTitle, Button } from '@mui/material';
|
||||
|
||||
/**
|
||||
* 通用确认对话框组件
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 对话框是否打开
|
||||
* @param {Function} props.onClose - 关闭对话框的回调
|
||||
* @param {Function} props.onConfirm - 确认操作的回调
|
||||
* @param {string} props.title - 对话框标题
|
||||
* @param {string} props.cancelText - 取消按钮文本
|
||||
* @param {string} props.confirmText - 确认按钮文本
|
||||
*/
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
cancelText = '取消',
|
||||
confirmText = '确认',
|
||||
confirmColor = 'error'
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} aria-labelledby="confirm-dialog-title">
|
||||
<DialogTitle id="confirm-dialog-title">{title}</DialogTitle>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color="primary">
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} color={confirmColor} autoFocus>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
515
easy-dataset-main/components/distill/DistillTreeView.js
Normal file
515
easy-dataset-main/components/distill/DistillTreeView.js
Normal file
@@ -0,0 +1,515 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Typography, List } from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
import { useGenerateDataset } from '@/hooks/useGenerateDataset';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// 导入子组件
|
||||
import TagTreeItem from './TagTreeItem';
|
||||
import TagMenu from './TagMenu';
|
||||
import TagEditDialog from './TagEditDialog';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import { sortTagsByNumber } from './utils';
|
||||
|
||||
/**
|
||||
* 蒸馏树形视图组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.projectId - 项目ID
|
||||
* @param {Array} props.tags - 标签列表
|
||||
* @param {Function} props.onGenerateSubTags - 生成子标签的回调函数
|
||||
* @param {Function} props.onGenerateQuestions - 生成问题的回调函数
|
||||
* @param {Function} props.onTagsUpdate - 标签更新的回调函数
|
||||
*/
|
||||
const DistillTreeView = forwardRef(function DistillTreeView(
|
||||
{ projectId, tags = [], onGenerateSubTags, onGenerateQuestions, onTagsUpdate },
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const selectedModel = useAtomValue(selectedModelInfoAtom);
|
||||
const [expandedTags, setExpandedTags] = useState({});
|
||||
const [tagQuestions, setTagQuestions] = useState({});
|
||||
const [loadingTags, setLoadingTags] = useState({});
|
||||
const [loadingQuestions, setLoadingQuestions] = useState({});
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
|
||||
const [selectedTagForMenu, setSelectedTagForMenu] = useState(null);
|
||||
const [allQuestions, setAllQuestions] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [processingQuestions, setProcessingQuestions] = useState({});
|
||||
const [processingMultiTurnQuestions, setProcessingMultiTurnQuestions] = useState({});
|
||||
const [deleteQuestionConfirmOpen, setDeleteQuestionConfirmOpen] = useState(false);
|
||||
const [questionToDelete, setQuestionToDelete] = useState(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [tagToDelete, setTagToDelete] = useState(null);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [tagToEdit, setTagToEdit] = useState(null);
|
||||
const [project, setProject] = useState(null);
|
||||
const [projectName, setProjectName] = useState('');
|
||||
|
||||
// 使用生成数据集的hook
|
||||
const { generateSingleDataset } = useGenerateDataset();
|
||||
|
||||
// 获取问题统计信息
|
||||
const fetchQuestionsStats = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`/api/projects/${projectId}/questions/tree?isDistill=true`);
|
||||
setAllQuestions(response.data);
|
||||
console.log('获取问题统计信息成功:', { totalQuestions: response.data.length });
|
||||
} catch (error) {
|
||||
console.error('获取问题统计信息失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
fetchQuestionsStats
|
||||
}));
|
||||
|
||||
// 获取标签下的问题
|
||||
const fetchQuestionsByTag = useCallback(
|
||||
async tagId => {
|
||||
try {
|
||||
setLoadingQuestions(prev => ({ ...prev, [tagId]: true }));
|
||||
const response = await axios.get(`/api/projects/${projectId}/distill/questions/by-tag?tagId=${tagId}`);
|
||||
setTagQuestions(prev => ({
|
||||
...prev,
|
||||
[tagId]: response.data
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('获取标签问题失败:', error);
|
||||
} finally {
|
||||
setLoadingQuestions(prev => ({ ...prev, [tagId]: false }));
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
// 获取项目信息,获取项目名称
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
axios
|
||||
.get(`/api/projects/${projectId}`)
|
||||
.then(response => {
|
||||
setProject(response.data);
|
||||
setProjectName(response.data.name || '');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取项目信息失败:', error);
|
||||
});
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 初始化时获取问题统计信息
|
||||
useEffect(() => {
|
||||
fetchQuestionsStats();
|
||||
}, [fetchQuestionsStats]);
|
||||
|
||||
// 构建标签树
|
||||
const tagTree = useMemo(() => {
|
||||
const rootTags = [];
|
||||
const tagMap = {};
|
||||
|
||||
// 创建标签映射
|
||||
tags.forEach(tag => {
|
||||
tagMap[tag.id] = { ...tag, children: [] };
|
||||
});
|
||||
|
||||
// 构建树结构
|
||||
tags.forEach(tag => {
|
||||
if (tag.parentId && tagMap[tag.parentId]) {
|
||||
tagMap[tag.parentId].children.push(tagMap[tag.id]);
|
||||
} else {
|
||||
rootTags.push(tagMap[tag.id]);
|
||||
}
|
||||
});
|
||||
|
||||
return rootTags;
|
||||
}, [tags]);
|
||||
|
||||
// 切换标签展开/折叠状态
|
||||
const toggleTag = useCallback(
|
||||
tagId => {
|
||||
setExpandedTags(prev => ({
|
||||
...prev,
|
||||
[tagId]: !prev[tagId]
|
||||
}));
|
||||
|
||||
// 如果展开且还没有加载过问题,则加载问题
|
||||
if (!expandedTags[tagId] && !tagQuestions[tagId]) {
|
||||
fetchQuestionsByTag(tagId);
|
||||
}
|
||||
},
|
||||
[expandedTags, tagQuestions, fetchQuestionsByTag]
|
||||
);
|
||||
|
||||
// 处理菜单打开
|
||||
const handleMenuOpen = (event, tag) => {
|
||||
event.stopPropagation();
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
setSelectedTagForMenu(tag);
|
||||
};
|
||||
|
||||
// 处理菜单关闭
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchorEl(null);
|
||||
setSelectedTagForMenu(null);
|
||||
};
|
||||
|
||||
// 打开编辑标签对话框
|
||||
const openEditDialog = () => {
|
||||
setTagToEdit(selectedTagForMenu);
|
||||
setEditDialogOpen(true);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
// 关闭编辑标签对话框
|
||||
const closeEditDialog = () => {
|
||||
setEditDialogOpen(false);
|
||||
setTagToEdit(null);
|
||||
};
|
||||
|
||||
// 处理编辑标签成功
|
||||
const handleEditTagSuccess = updatedTag => {
|
||||
// 更新标签数据,不刷新页面
|
||||
const updateTagInTree = tagList => {
|
||||
return tagList.map(tag => {
|
||||
if (tag.id === updatedTag.id) {
|
||||
return { ...tag, label: updatedTag.label };
|
||||
}
|
||||
if (tag.children && tag.children.length > 0) {
|
||||
return { ...tag, children: updateTagInTree(tag.children) };
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
};
|
||||
|
||||
// 调用父组件的回调更新标签列表
|
||||
const updatedTags = updateTagInTree(tags);
|
||||
onTagsUpdate?.(updatedTags);
|
||||
};
|
||||
|
||||
// 打开删除确认对话框
|
||||
const openDeleteConfirm = () => {
|
||||
console.log('打开删除确认对话框', selectedTagForMenu);
|
||||
// 保存要删除的标签
|
||||
setTagToDelete(selectedTagForMenu);
|
||||
setDeleteConfirmOpen(true);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
// 关闭删除确认对话框
|
||||
const closeDeleteConfirm = () => {
|
||||
setDeleteConfirmOpen(false);
|
||||
};
|
||||
|
||||
// 处理删除标签
|
||||
const handleDeleteTag = () => {
|
||||
if (!tagToDelete) {
|
||||
console.log('没有要删除的标签信息');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('开始删除标签:', tagToDelete.id, tagToDelete.label);
|
||||
|
||||
// 先关闭确认对话框
|
||||
closeDeleteConfirm();
|
||||
|
||||
// 执行删除操作
|
||||
const deleteTagAction = async () => {
|
||||
try {
|
||||
console.log('发送删除请求:', `/api/projects/${projectId}/tags?id=${tagToDelete.id}`);
|
||||
|
||||
// 发送删除请求
|
||||
const response = await axios.delete(`/api/projects/${projectId}/tags?id=${tagToDelete.id}`);
|
||||
|
||||
console.log('删除标签成功:', response.data);
|
||||
|
||||
// 刷新页面
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('删除标签失败:', error);
|
||||
console.error('错误详情:', error.response ? error.response.data : '无响应数据');
|
||||
alert(`删除标签失败: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 立即执行删除操作
|
||||
deleteTagAction();
|
||||
};
|
||||
|
||||
// 打开删除问题确认对话框
|
||||
const openDeleteQuestionConfirm = (questionId, event) => {
|
||||
event.stopPropagation();
|
||||
setQuestionToDelete(questionId);
|
||||
setDeleteQuestionConfirmOpen(true);
|
||||
};
|
||||
|
||||
// 关闭删除问题确认对话框
|
||||
const closeDeleteQuestionConfirm = () => {
|
||||
setDeleteQuestionConfirmOpen(false);
|
||||
setQuestionToDelete(null);
|
||||
};
|
||||
|
||||
// 处理删除问题
|
||||
const handleDeleteQuestion = async () => {
|
||||
if (!questionToDelete) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/projects/${projectId}/questions/${questionToDelete}`);
|
||||
// 更新问题列表
|
||||
setTagQuestions(prev => {
|
||||
const newQuestions = { ...prev };
|
||||
Object.keys(newQuestions).forEach(tagId => {
|
||||
newQuestions[tagId] = newQuestions[tagId].filter(q => q.id !== questionToDelete);
|
||||
});
|
||||
return newQuestions;
|
||||
});
|
||||
// 关闭确认对话框
|
||||
closeDeleteQuestionConfirm();
|
||||
} catch (error) {
|
||||
console.error('删除问题失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理生成数据集
|
||||
const handleGenerateDataset = async (questionId, questionInfo, event) => {
|
||||
event.stopPropagation();
|
||||
// 设置处理状态
|
||||
setProcessingQuestions(prev => ({
|
||||
...prev,
|
||||
[questionId]: true
|
||||
}));
|
||||
await generateSingleDataset({ projectId, questionId, questionInfo });
|
||||
// 重置处理状态
|
||||
setProcessingQuestions(prev => ({
|
||||
...prev,
|
||||
[questionId]: false
|
||||
}));
|
||||
};
|
||||
|
||||
// 处理生成多轮对话数据集
|
||||
const handleGenerateMultiTurnDataset = async (questionId, questionInfo, event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
try {
|
||||
// 设置处理状态
|
||||
setProcessingMultiTurnQuestions(prev => ({
|
||||
...prev,
|
||||
[questionId]: true
|
||||
}));
|
||||
|
||||
// 首先检查项目是否配置了多轮对话设置
|
||||
const configResponse = await axios.get(`/api/projects/${projectId}/tasks`);
|
||||
if (configResponse.status !== 200) {
|
||||
throw new Error('获取项目配置失败');
|
||||
}
|
||||
|
||||
const config = configResponse.data;
|
||||
const multiTurnConfig = {
|
||||
systemPrompt: config.multiTurnSystemPrompt,
|
||||
scenario: config.multiTurnScenario,
|
||||
rounds: config.multiTurnRounds,
|
||||
roleA: config.multiTurnRoleA,
|
||||
roleB: config.multiTurnRoleB
|
||||
};
|
||||
|
||||
// 检查是否已配置必要的多轮对话设置
|
||||
if (
|
||||
!multiTurnConfig.scenario ||
|
||||
!multiTurnConfig.roleA ||
|
||||
!multiTurnConfig.roleB ||
|
||||
!multiTurnConfig.rounds ||
|
||||
multiTurnConfig.rounds < 1
|
||||
) {
|
||||
throw new Error('请先在项目设置中配置多轮对话相关参数');
|
||||
}
|
||||
|
||||
// 检查是否选择了模型
|
||||
if (!selectedModel || Object.keys(selectedModel).length === 0) {
|
||||
throw new Error('请先选择一个模型');
|
||||
}
|
||||
|
||||
// 调用多轮对话生成API
|
||||
const response = await axios.post(`/api/projects/${projectId}/dataset-conversations`, {
|
||||
questionId,
|
||||
...multiTurnConfig,
|
||||
model: selectedModel,
|
||||
language: 'zh-CN'
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
// 成功后刷新问题统计
|
||||
fetchQuestionsStats();
|
||||
toast.success(t('datasets.multiTurnGenerateSuccess', { defaultValue: '多轮对话数据集生成成功!' }));
|
||||
|
||||
// 通知父组件刷新统计信息
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('refreshDistillStats'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成多轮对话数据集失败:', error);
|
||||
toast.error(error.message || t('datasets.multiTurnGenerateError', { defaultValue: '生成多轮对话数据集失败' }));
|
||||
} finally {
|
||||
// 重置处理状态
|
||||
setProcessingMultiTurnQuestions(prev => ({
|
||||
...prev,
|
||||
[questionId]: false
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 获取标签路径
|
||||
const getTagPath = useCallback(
|
||||
tag => {
|
||||
if (!tag) return '';
|
||||
|
||||
const findPath = (currentTag, path = []) => {
|
||||
const newPath = [currentTag.label, ...path];
|
||||
|
||||
if (!currentTag.parentId) {
|
||||
// 如果是顶级标签,确保路径以项目名称开始
|
||||
if (projectName && !newPath.includes(projectName)) {
|
||||
return [projectName, ...newPath];
|
||||
}
|
||||
return newPath;
|
||||
}
|
||||
|
||||
const parentTag = tags.find(t => t.id === currentTag.parentId);
|
||||
if (!parentTag) {
|
||||
// 如果没有找到父标签,确保路径以项目名称开始
|
||||
if (projectName && !newPath.includes(projectName)) {
|
||||
return [projectName, ...newPath];
|
||||
}
|
||||
return newPath;
|
||||
}
|
||||
|
||||
return findPath(parentTag, newPath);
|
||||
};
|
||||
|
||||
const path = findPath(tag);
|
||||
|
||||
// 最终检查,确保路径以项目名称开始
|
||||
if (projectName && path.length > 0 && path[0] !== projectName) {
|
||||
path.unshift(projectName);
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
},
|
||||
[tags, projectName]
|
||||
);
|
||||
|
||||
// 渲染标签树
|
||||
const renderTagTree = (tagList, level = 0) => {
|
||||
// 对同级标签进行排序
|
||||
const sortedTagList = sortTagsByNumber(tagList);
|
||||
|
||||
return (
|
||||
<List disablePadding sx={{ px: 2 }}>
|
||||
{sortedTagList.map(tag => (
|
||||
<TagTreeItem
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
level={level}
|
||||
expanded={expandedTags[tag.id]}
|
||||
onToggle={toggleTag}
|
||||
onMenuOpen={handleMenuOpen}
|
||||
onGenerateQuestions={tag => {
|
||||
// 包装函数,处理问题生成后的刷新
|
||||
const handleGenerateQuestionsWithRefresh = async () => {
|
||||
// 调用父组件传入的函数生成问题
|
||||
await onGenerateQuestions(tag, getTagPath(tag));
|
||||
|
||||
// 生成问题后刷新数据
|
||||
await fetchQuestionsStats();
|
||||
|
||||
// 如果标签已展开,刷新该标签的问题详情
|
||||
if (expandedTags[tag.id]) {
|
||||
await fetchQuestionsByTag(tag.id);
|
||||
}
|
||||
};
|
||||
|
||||
handleGenerateQuestionsWithRefresh();
|
||||
}}
|
||||
onGenerateSubTags={tag => onGenerateSubTags(tag, getTagPath(tag))}
|
||||
questions={tagQuestions[tag.id] || []}
|
||||
loadingQuestions={loadingQuestions[tag.id]}
|
||||
processingQuestions={processingQuestions}
|
||||
processingMultiTurnQuestions={processingMultiTurnQuestions}
|
||||
onDeleteQuestion={openDeleteQuestionConfirm}
|
||||
onGenerateDataset={handleGenerateDataset}
|
||||
onGenerateMultiTurnDataset={handleGenerateMultiTurnDataset}
|
||||
allQuestions={allQuestions}
|
||||
tagQuestions={tagQuestions}
|
||||
>
|
||||
{/* 递归渲染子标签 */}
|
||||
{tag.children && tag.children.length > 0 && expandedTags[tag.id] && renderTagTree(tag.children, level + 1)}
|
||||
</TagTreeItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{tagTree.length > 0 ? (
|
||||
renderTagTree(tagTree)
|
||||
) : (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{t('distill.noTags')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 标签操作菜单 */}
|
||||
<TagMenu
|
||||
anchorEl={menuAnchorEl}
|
||||
open={Boolean(menuAnchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
onEdit={openEditDialog}
|
||||
onDelete={openDeleteConfirm}
|
||||
/>
|
||||
|
||||
{/* 编辑标签对话框 */}
|
||||
<TagEditDialog
|
||||
open={editDialogOpen}
|
||||
tag={tagToEdit}
|
||||
projectId={projectId}
|
||||
onClose={closeEditDialog}
|
||||
onSuccess={handleEditTagSuccess}
|
||||
/>
|
||||
|
||||
{/* 删除标签确认对话框 */}
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
onClose={closeDeleteConfirm}
|
||||
onConfirm={handleDeleteTag}
|
||||
title={t('distill.deleteTagConfirmTitle')}
|
||||
cancelText={t('common.cancel')}
|
||||
confirmText={t('common.delete')}
|
||||
confirmColor="error"
|
||||
/>
|
||||
|
||||
{/* 删除问题确认对话框 */}
|
||||
<ConfirmDialog
|
||||
open={deleteQuestionConfirmOpen}
|
||||
onClose={closeDeleteQuestionConfirm}
|
||||
onConfirm={handleDeleteQuestion}
|
||||
title={t('questions.deleteConfirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
confirmText={t('common.delete')}
|
||||
confirmColor="error"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
export default DistillTreeView;
|
||||
194
easy-dataset-main/components/distill/QuestionGenerationDialog.js
Normal file
194
easy-dataset-main/components/distill/QuestionGenerationDialog.js
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Paper,
|
||||
IconButton,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import axios from 'axios';
|
||||
import i18n from '@/lib/i18n';
|
||||
|
||||
/**
|
||||
* 问题生成对话框组件
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 对话框是否打开
|
||||
* @param {Function} props.onClose - 关闭对话框的回调函数
|
||||
* @param {Function} props.onGenerated - 问题生成完成的回调函数
|
||||
* @param {string} props.projectId - 项目ID
|
||||
* @param {Object} props.tag - 标签对象
|
||||
* @param {string} props.tagPath - 标签路径
|
||||
* @param {Object} props.model - 选择的模型配置
|
||||
*/
|
||||
export default function QuestionGenerationDialog({ open, onClose, onGenerated, projectId, tag, tagPath, model }) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [count, setCount] = useState(5);
|
||||
const [generatedQuestions, setGeneratedQuestions] = useState([]);
|
||||
|
||||
// 处理生成问题
|
||||
const handleGenerateQuestions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const response = await axios.post(`/api/projects/${projectId}/distill/questions`, {
|
||||
tagPath,
|
||||
currentTag: tag.label,
|
||||
tagId: tag.id,
|
||||
count,
|
||||
model,
|
||||
language: i18n.language
|
||||
});
|
||||
|
||||
setGeneratedQuestions(response.data);
|
||||
} catch (error) {
|
||||
console.error('生成问题失败:', error);
|
||||
setError(error.response?.data?.error || t('distill.generateQuestionsError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理生成完成
|
||||
const handleGenerateComplete = async () => {
|
||||
if (onGenerated) {
|
||||
onGenerated(generatedQuestions);
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// 处理关闭对话框
|
||||
const handleClose = () => {
|
||||
setGeneratedQuestions([]);
|
||||
setError('');
|
||||
setCount(5);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理数量变化
|
||||
const handleCountChange = event => {
|
||||
const value = parseInt(event.target.value);
|
||||
if (!isNaN(value) && value >= 1 && value <= 100) {
|
||||
setCount(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: { borderRadius: 2 }
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" component="div">
|
||||
{t('distill.generateQuestionsTitle', { tag: tag?.label || t('distill.unknownTag') })}
|
||||
</Typography>
|
||||
<IconButton edge="end" color="inherit" onClick={handleClose} aria-label="close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('distill.tagPath')}:
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 1, backgroundColor: 'background.paper' }}>
|
||||
<Typography variant="body1">{tagPath || tag?.label || t('distill.unknownTag')}</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('distill.questionCount')}:
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
type="number"
|
||||
value={count}
|
||||
onChange={handleCountChange}
|
||||
inputProps={{ min: 1, max: 100 }}
|
||||
disabled={loading}
|
||||
helperText={t('distill.questionCountHelp')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{generatedQuestions.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('distill.generatedQuestions')}:
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 0, borderRadius: 1, backgroundColor: 'background.paper' }}>
|
||||
<List disablePadding>
|
||||
{generatedQuestions.map((question, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && <Divider />}
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={question.question}
|
||||
primaryTypographyProps={{
|
||||
style: { whiteSpace: 'normal', wordBreak: 'break-word' }
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={handleClose} color="inherit">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{generatedQuestions.length > 0 ? (
|
||||
<Button onClick={handleGenerateComplete} color="primary" variant="contained">
|
||||
{t('common.complete')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleGenerateQuestions}
|
||||
disabled={loading}
|
||||
startIcon={loading && <CircularProgress size={20} color="inherit" />}
|
||||
>
|
||||
{loading ? t('common.generating') : t('distill.generateQuestions')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
121
easy-dataset-main/components/distill/QuestionListItem.js
Normal file
121
easy-dataset-main/components/distill/QuestionListItem.js
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import ChatIcon from '@mui/icons-material/Chat';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 问题列表项组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.question - 问题对象
|
||||
* @param {number} props.level - 缩进级别
|
||||
* @param {Function} props.onDelete - 删除问题的回调
|
||||
* @param {Function} props.onGenerateDataset - 生成数据集的回调
|
||||
* @param {Function} props.onGenerateMultiTurnDataset - 生成多轮对话数据集的回调
|
||||
* @param {boolean} props.processing - 是否正在处理
|
||||
* @param {boolean} props.processingMultiTurn - 是否正在生成多轮对话
|
||||
*/
|
||||
export default function QuestionListItem({
|
||||
question,
|
||||
level,
|
||||
onDelete,
|
||||
onGenerateDataset,
|
||||
onGenerateMultiTurnDataset,
|
||||
processing = false,
|
||||
processingMultiTurn = false
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
sx={{
|
||||
pl: (level + 1) * 2,
|
||||
py: 0.75,
|
||||
borderLeft: '1px dashed rgba(0, 0, 0, 0.1)',
|
||||
ml: 2,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
secondaryAction={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Tooltip title={t('datasets.generateDataset')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={e => onGenerateDataset(e)}
|
||||
disabled={processing || processingMultiTurn}
|
||||
>
|
||||
{processing ? <CircularProgress size={16} /> : <AutoFixHighIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('questions.generateMultiTurnDataset', { defaultValue: '生成多轮对话数据集' })}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={e => onGenerateMultiTurnDataset && onGenerateMultiTurnDataset(e)}
|
||||
disabled={processing || processingMultiTurn || !onGenerateMultiTurnDataset}
|
||||
>
|
||||
{processingMultiTurn ? <CircularProgress size={16} /> : <ChatIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={e => onDelete(e)}
|
||||
disabled={processing || processingMultiTurn}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32, color: 'secondary.main' }}>
|
||||
<HelpOutlineIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
paddingRight: '28px' // 留出删除按钮的空间
|
||||
}}
|
||||
>
|
||||
{question.question}
|
||||
</Typography>
|
||||
{question.answered && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={t('datasets.answered')}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
115
easy-dataset-main/components/distill/TagEditDialog.js
Normal file
115
easy-dataset-main/components/distill/TagEditDialog.js
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* 标签编辑对话框组件
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 对话框是否打开
|
||||
* @param {Object} props.tag - 要编辑的标签对象
|
||||
* @param {string} props.projectId - 项目ID
|
||||
* @param {Function} props.onClose - 关闭对话框的回调
|
||||
* @param {Function} props.onSuccess - 编辑成功的回调
|
||||
*/
|
||||
export default function TagEditDialog({ open, tag, projectId, onClose, onSuccess }) {
|
||||
const { t } = useTranslation();
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open && tag) {
|
||||
setNewLabel(tag.label);
|
||||
setError('');
|
||||
}
|
||||
}, [open, tag]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!newLabel.trim()) {
|
||||
setError(t('distill.labelRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newLabel === tag.label) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const response = await axios.put(`/api/projects/${projectId}/distill/tags/${tag.id}`, { label: newLabel.trim() });
|
||||
|
||||
if (response.status === 200) {
|
||||
toast.success(t('distill.tagUpdateSuccess'));
|
||||
onSuccess?.(response.data);
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('更新标签失败:', err);
|
||||
setError(err.response?.data?.error || t('distill.tagUpdateFailed'));
|
||||
toast.error(err.response?.data?.error || t('distill.tagUpdateFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!loading) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('distill.editTagTitle')}</DialogTitle>
|
||||
<DialogContent sx={{ pt: 2 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('distill.tagName')}
|
||||
value={newLabel}
|
||||
onChange={e => setNewLabel(e.target.value)}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
onKeyPress={e => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={loading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant="contained"
|
||||
disabled={loading || !newLabel.trim()}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
230
easy-dataset-main/components/distill/TagGenerationDialog.js
Normal file
230
easy-dataset-main/components/distill/TagGenerationDialog.js
Normal file
@@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
Paper,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import axios from 'axios';
|
||||
import i18n from '@/lib/i18n';
|
||||
|
||||
/**
|
||||
* 标签生成对话框组件
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 对话框是否打开
|
||||
* @param {Function} props.onClose - 关闭对话框的回调函数
|
||||
* @param {Function} props.onGenerated - 标签生成完成的回调函数
|
||||
* @param {string} props.projectId - 项目ID
|
||||
* @param {Object} props.parentTag - 父标签对象,为null时表示生成根标签
|
||||
* @param {string} props.tagPath - 标签链路
|
||||
* @param {Object} props.model - 选择的模型配置
|
||||
*/
|
||||
export default function TagGenerationDialog({ open, onClose, onGenerated, projectId, parentTag, tagPath, model }) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [count, setCount] = useState(5);
|
||||
const [generatedTags, setGeneratedTags] = useState([]);
|
||||
const [parentTagName, setParentTagName] = useState('');
|
||||
const [project, setProject] = useState(null);
|
||||
|
||||
// 获取项目信息,如果是顶级标签,默认填写项目名称
|
||||
useEffect(() => {
|
||||
if (projectId && !parentTag) {
|
||||
axios
|
||||
.get(`/api/projects/${projectId}`)
|
||||
.then(response => {
|
||||
setProject(response.data);
|
||||
setParentTagName(response.data.name || '');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取项目信息失败:', error);
|
||||
});
|
||||
} else if (parentTag) {
|
||||
setParentTagName(parentTag.label || '');
|
||||
}
|
||||
}, [projectId, parentTag]);
|
||||
|
||||
// 处理生成标签
|
||||
const handleGenerateTags = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const response = await axios.post(`/api/projects/${projectId}/distill/tags`, {
|
||||
parentTag: parentTagName,
|
||||
parentTagId: parentTag ? parentTag.id : null,
|
||||
tagPath: tagPath || parentTagName,
|
||||
count,
|
||||
model,
|
||||
language: i18n.language
|
||||
});
|
||||
|
||||
setGeneratedTags(response.data);
|
||||
} catch (error) {
|
||||
console.error('生成标签失败:', error);
|
||||
setError(error.response?.data?.error || t('distill.generateTagsError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理生成完成
|
||||
const handleGenerateComplete = async () => {
|
||||
if (onGenerated) {
|
||||
onGenerated(generatedTags);
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// 处理关闭对话框
|
||||
const handleClose = () => {
|
||||
setGeneratedTags([]);
|
||||
setError('');
|
||||
setCount(5);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理数量变化
|
||||
const handleCountChange = event => {
|
||||
const value = parseInt(event.target.value);
|
||||
if (!isNaN(value) && value >= 1 && value <= 100) {
|
||||
setCount(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: { borderRadius: 2 }
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" component="div">
|
||||
{parentTag
|
||||
? t('distill.generateSubTagsTitle', { parentTag: parentTag.label })
|
||||
: t('distill.generateRootTagsTitle')}
|
||||
</Typography>
|
||||
<IconButton edge="end" color="inherit" onClick={handleClose} aria-label="close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 标签路径显示 */}
|
||||
{parentTag && tagPath && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('distill.tagPath')}:
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 1, backgroundColor: 'background.paper' }}>
|
||||
<Typography variant="body1">{tagPath || parentTag.label}</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('distill.parentTag')}:
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={parentTagName}
|
||||
onChange={e => setParentTagName(e.target.value)}
|
||||
placeholder={t('distill.parentTagPlaceholder')}
|
||||
disabled={loading || !parentTag}
|
||||
// 如果是顶级标签,设置为只读
|
||||
InputProps={{
|
||||
readOnly: !parentTag
|
||||
}}
|
||||
// 显示适当的帮助文本
|
||||
helperText={
|
||||
!parentTag
|
||||
? t('distill.rootTopicHelperText', { defaultValue: '使用项目名称作为顶级主题' })
|
||||
: t('distill.parentTagHelp')
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('distill.tagCount')}:
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
type="number"
|
||||
value={count}
|
||||
onChange={handleCountChange}
|
||||
inputProps={{ min: 1, max: 100 }}
|
||||
disabled={loading}
|
||||
helperText={t('distill.tagCountHelp')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{generatedTags.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('distill.generatedTags')}:
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 1, backgroundColor: 'background.paper' }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{generatedTags.map((tag, index) => (
|
||||
<Chip key={index} label={tag.label} color="primary" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={handleClose} color="inherit">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{generatedTags.length > 0 ? (
|
||||
<Button onClick={handleGenerateComplete} color="primary" variant="contained">
|
||||
{t('common.complete')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleGenerateTags}
|
||||
disabled={loading || !parentTagName}
|
||||
startIcon={loading && <CircularProgress size={20} color="inherit" />}
|
||||
>
|
||||
{loading ? t('common.generating') : t('distill.generateTags')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
46
easy-dataset-main/components/distill/TagMenu.js
Normal file
46
easy-dataset-main/components/distill/TagMenu.js
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 标签操作菜单组件
|
||||
* @param {Object} props
|
||||
* @param {HTMLElement} props.anchorEl - 菜单锚点元素
|
||||
* @param {boolean} props.open - 菜单是否打开
|
||||
* @param {Function} props.onClose - 关闭菜单的回调
|
||||
* @param {Function} props.onEdit - 编辑操作的回调
|
||||
* @param {Function} props.onDelete - 删除操作的回调
|
||||
*/
|
||||
export default function TagMenu({ anchorEl, open, onClose, onEdit, onDelete }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
onDelete?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={onClose}>
|
||||
<MenuItem onClick={handleEdit}>
|
||||
<ListItemIcon>
|
||||
<EditIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('common.edit')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleDelete}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('common.delete')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
240
easy-dataset-main/components/distill/TagTreeItem.js
Normal file
240
easy-dataset-main/components/distill/TagTreeItem.js
Normal file
@@ -0,0 +1,240 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Chip,
|
||||
Tooltip,
|
||||
List,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import QuestionListItem from './QuestionListItem';
|
||||
|
||||
/**
|
||||
* 标签树项组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.tag - 标签对象
|
||||
* @param {number} props.level - 缩进级别
|
||||
* @param {boolean} props.expanded - 是否展开
|
||||
* @param {Function} props.onToggle - 切换展开/折叠的回调
|
||||
* @param {Function} props.onMenuOpen - 打开菜单的回调
|
||||
* @param {Function} props.onGenerateQuestions - 生成问题的回调
|
||||
* @param {Function} props.onGenerateSubTags - 生成子标签的回调
|
||||
* @param {Array} props.questions - 标签下的问题列表
|
||||
* @param {boolean} props.loadingQuestions - 是否正在加载问题
|
||||
* @param {Object} props.processingQuestions - 正在处理的问题ID映射
|
||||
* @param {Function} props.onDeleteQuestion - 删除问题的回调
|
||||
* @param {Function} props.onGenerateDataset - 生成数据集的回调
|
||||
* @param {Function} props.onGenerateMultiTurnDataset - 生成多轮对话数据集的回调
|
||||
* @param {Object} props.processingMultiTurnQuestions - 正在生成多轮对话的问题ID映射
|
||||
* @param {Array} props.allQuestions - 所有问题列表(用于计算问题数量)
|
||||
* @param {Object} props.tagQuestions - 标签问题映射
|
||||
* @param {React.ReactNode} props.children - 子标签内容
|
||||
*/
|
||||
export default function TagTreeItem({
|
||||
tag,
|
||||
level = 0,
|
||||
expanded = false,
|
||||
onToggle,
|
||||
onMenuOpen,
|
||||
onGenerateQuestions,
|
||||
onGenerateSubTags,
|
||||
questions = [],
|
||||
loadingQuestions = false,
|
||||
processingQuestions = {},
|
||||
onDeleteQuestion,
|
||||
onGenerateDataset,
|
||||
onGenerateMultiTurnDataset,
|
||||
processingMultiTurnQuestions = {},
|
||||
allQuestions = [],
|
||||
tagQuestions = {},
|
||||
children
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 递归计算所有层级的子标签数量
|
||||
const getTotalSubTagsCount = childrenTags => {
|
||||
let count = childrenTags.length;
|
||||
childrenTags.forEach(childTag => {
|
||||
if (childTag.children && childTag.children.length > 0) {
|
||||
count += getTotalSubTagsCount(childTag.children);
|
||||
}
|
||||
});
|
||||
return count;
|
||||
};
|
||||
|
||||
// 递归获取所有子标签的问题数量
|
||||
const getChildrenQuestionsCount = childrenTags => {
|
||||
let count = 0;
|
||||
childrenTags.forEach(childTag => {
|
||||
// 子标签的问题
|
||||
if (tagQuestions[childTag.id] && tagQuestions[childTag.id].length > 0) {
|
||||
count += tagQuestions[childTag.id].length;
|
||||
} else {
|
||||
count += allQuestions.filter(q => q.label === childTag.label).length;
|
||||
}
|
||||
|
||||
// 子标签的子标签的问题
|
||||
if (childTag.children && childTag.children.length > 0) {
|
||||
count += getChildrenQuestionsCount(childTag.children);
|
||||
}
|
||||
});
|
||||
return count;
|
||||
};
|
||||
|
||||
// 计算当前标签的问题数量
|
||||
const getCurrentTagQuestionsCount = () => {
|
||||
let currentTagQuestions = 0;
|
||||
if (tagQuestions[tag.id] && tagQuestions[tag.id].length > 0) {
|
||||
currentTagQuestions = tagQuestions[tag.id].length;
|
||||
} else {
|
||||
currentTagQuestions = allQuestions.filter(q => q.label === tag.label).length;
|
||||
}
|
||||
return currentTagQuestions;
|
||||
};
|
||||
|
||||
// 总问题数量 = 当前标签的问题 + 所有子标签的问题
|
||||
const totalQuestions =
|
||||
getCurrentTagQuestionsCount() + (tag.children ? getChildrenQuestionsCount(tag.children || []) : 0);
|
||||
|
||||
return (
|
||||
<Box key={tag.id} sx={{ my: 0.5 }}>
|
||||
<ListItem
|
||||
disablePadding
|
||||
sx={{
|
||||
pl: level * 2,
|
||||
borderLeft: level > 0 ? '1px dashed rgba(0, 0, 0, 0.1)' : 'none',
|
||||
ml: level > 0 ? 2 : 0
|
||||
}}
|
||||
>
|
||||
<ListItemButton onClick={() => onToggle(tag.id)} sx={{ borderRadius: 1, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<FolderIcon color="primary" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontWeight: 'medium' }}>{tag.label}</Typography>
|
||||
{tag.children && tag.children.length > 0 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${getTotalSubTagsCount(tag.children)} ${t('distill.subTags')}`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
{totalQuestions > 0 && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${totalQuestions} ${t('distill.questions')}`}
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
primaryTypographyProps={{ component: 'div' }}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Tooltip title={t('distill.generateQuestions')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onGenerateQuestions(tag);
|
||||
}}
|
||||
>
|
||||
<QuestionMarkIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('distill.addChildTag')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onGenerateSubTags(tag);
|
||||
}}
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<IconButton size="small" onClick={e => onMenuOpen(e, tag)}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
{tag.children && tag.children.length > 0 ? (
|
||||
expanded ? (
|
||||
<ExpandLessIcon fontSize="small" />
|
||||
) : (
|
||||
<ExpandMoreIcon fontSize="small" />
|
||||
)
|
||||
) : null}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{/* 子标签 */}
|
||||
{tag.children && tag.children.length > 0 && (
|
||||
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||
{children}
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
{/* 标签下的问题 */}
|
||||
{expanded && (
|
||||
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||
<List disablePadding sx={{ mt: 0.5, mb: 1 }}>
|
||||
{loadingQuestions ? (
|
||||
<ListItem sx={{ pl: (level + 1) * 2, py: 0.75 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" sx={{ ml: 2 }}>
|
||||
{t('common.loading')}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
) : questions && questions.length > 0 ? (
|
||||
questions.map(question => (
|
||||
<QuestionListItem
|
||||
key={question.id}
|
||||
question={question}
|
||||
level={level}
|
||||
processing={processingQuestions[question.id]}
|
||||
processingMultiTurn={processingMultiTurnQuestions[question.id]}
|
||||
onDelete={e => onDeleteQuestion(question.id, e)}
|
||||
onGenerateDataset={e => onGenerateDataset(question.id, question.question, e)}
|
||||
onGenerateMultiTurnDataset={
|
||||
onGenerateMultiTurnDataset ? e => onGenerateMultiTurnDataset(question.id, question, e) : undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<ListItem sx={{ pl: (level + 1) * 2, py: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('distill.noQuestions')}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
72
easy-dataset-main/components/distill/utils.js
Normal file
72
easy-dataset-main/components/distill/utils.js
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 按照标签前面的序号对标签进行排序
|
||||
* @param {Array} tags - 标签数组
|
||||
* @returns {Array} 排序后的标签数组
|
||||
*/
|
||||
export const sortTagsByNumber = tags => {
|
||||
return [...tags].sort((a, b) => {
|
||||
// 提取标签前面的序号
|
||||
const getNumberPrefix = label => {
|
||||
// 匹配形如 1, 1.1, 1.1.2 的序号
|
||||
const match = label.match(/^([\d.]+)\s/);
|
||||
if (match) {
|
||||
return match[1]; // 返回完整的序号字符串,如 "1.10"
|
||||
}
|
||||
return null; // 没有序号
|
||||
};
|
||||
|
||||
const aPrefix = getNumberPrefix(a.label);
|
||||
const bPrefix = getNumberPrefix(b.label);
|
||||
|
||||
// 如果两个标签都有序号,按序号比较
|
||||
if (aPrefix && bPrefix) {
|
||||
// 将序号分解为数组,然后按数值比较
|
||||
const aParts = aPrefix.split('.').map(num => parseInt(num, 10));
|
||||
const bParts = bPrefix.split('.').map(num => parseInt(num, 10));
|
||||
|
||||
// 比较序号数组
|
||||
for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
|
||||
if (aParts[i] !== bParts[i]) {
|
||||
return aParts[i] - bParts[i]; // 数值比较,确保 1.2 排在 1.10 前面
|
||||
}
|
||||
}
|
||||
// 如果前面的数字都相同,则较短的序号在前
|
||||
return aParts.length - bParts.length;
|
||||
}
|
||||
// 如果只有一个标签有序号,则有序号的在前
|
||||
else if (aPrefix) {
|
||||
return -1;
|
||||
} else if (bPrefix) {
|
||||
return 1;
|
||||
}
|
||||
// 如果都没有序号,则按原来的字母序排序
|
||||
else {
|
||||
return a.label.localeCompare(b.label, 'zh-CN');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取标签的完整路径
|
||||
* @param {Object} tag - 标签对象
|
||||
* @param {Array} allTags - 所有标签数组
|
||||
* @returns {string} 标签路径,如 "标签1 > 标签2 > 标签3"
|
||||
*/
|
||||
export const getTagPath = (tag, allTags) => {
|
||||
if (!tag) return '';
|
||||
|
||||
const findPath = (currentTag, path = []) => {
|
||||
const newPath = [currentTag.label, ...path];
|
||||
|
||||
if (!currentTag.parentId) return newPath;
|
||||
|
||||
const parentTag = allTags.find(t => t.id === currentTag.parentId);
|
||||
if (!parentTag) return newPath;
|
||||
|
||||
return findPath(parentTag, newPath);
|
||||
};
|
||||
|
||||
return findPath(tag).join(' > ');
|
||||
};
|
||||
245
easy-dataset-main/components/export/HuggingFaceTab.js
Normal file
245
easy-dataset-main/components/export/HuggingFaceTab.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// HuggingFaceTab.js 组件
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Paper,
|
||||
Grid,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Link
|
||||
} from '@mui/material';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
|
||||
const HuggingFaceTab = ({
|
||||
projectId,
|
||||
systemPrompt,
|
||||
reasoningLanguage,
|
||||
confirmedOnly,
|
||||
includeCOT,
|
||||
formatType,
|
||||
fileFormat,
|
||||
customFields,
|
||||
handleSystemPromptChange,
|
||||
handleReasoningLanguageChange,
|
||||
handleConfirmedOnlyChange,
|
||||
handleIncludeCOTChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [token, setToken] = useState('');
|
||||
const [datasetName, setDatasetName] = useState('');
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [datasetUrl, setDatasetUrl] = useState('');
|
||||
const [hasToken, setHasToken] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 从配置中获取 huggingfaceToken
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
setLoading(true);
|
||||
fetch(`/api/projects/${projectId}/config`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.huggingfaceToken) {
|
||||
setToken(data.huggingfaceToken);
|
||||
setHasToken(true);
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('获取 HuggingFace Token 失败:', err);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// 处理上传数据集到 HuggingFace
|
||||
const handleUpload = async () => {
|
||||
if (!hasToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!datasetName) {
|
||||
setError('请输入数据集名称');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/huggingface/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
datasetName,
|
||||
isPrivate,
|
||||
formatType,
|
||||
systemPrompt,
|
||||
reasoningLanguage,
|
||||
confirmedOnly,
|
||||
includeCOT,
|
||||
fileFormat,
|
||||
customFields: formatType === 'custom' ? customFields : undefined
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '上传失败');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setDatasetUrl(data.url);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 2 }} icon={<CheckCircleOutlineIcon fontSize="inherit" />}>
|
||||
{t('export.uploadSuccess')}
|
||||
{datasetUrl && (
|
||||
<Box mt={1}>
|
||||
<Link href={datasetUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t('export.viewOnHuggingFace')}
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!hasToken ? (
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
{t('export.noTokenWarning')}
|
||||
<Box mt={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => (window.location.href = `/projects/${projectId}/settings`)}
|
||||
>
|
||||
{t('export.goToSettings')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('export.datasetSettings')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('export.datasetName')}
|
||||
placeholder="username/dataset-name"
|
||||
value={datasetName}
|
||||
onChange={e => setDatasetName(e.target.value)}
|
||||
helperText={t('export.datasetNameHelp')}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={isPrivate} onChange={e => setIsPrivate(e.target.checked)} />}
|
||||
label={t('export.privateDataset')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('export.exportOptions')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('export.systemPrompt')}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={systemPrompt}
|
||||
onChange={handleSystemPromptChange}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
{/* Reasoning language – only for multilingual‑thinking */}
|
||||
{formatType === 'multilingualthinking' && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('export.reasoningLanguage')}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
rows={3}
|
||||
multiline
|
||||
variant="outlined"
|
||||
placeholder={t('export.reasoningLanguage')}
|
||||
value={reasoningLanguage}
|
||||
onChange={handleReasoningLanguageChange}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 4 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={confirmedOnly} onChange={handleConfirmedOnlyChange} />}
|
||||
label={t('export.onlyConfirmed')}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={includeCOT} onChange={handleIncludeCOTChange} />}
|
||||
label={t('export.includeCOT')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || !hasToken || !datasetName}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{uploading ? <CircularProgress size={24} /> : t('export.uploadToHuggingFace')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HuggingFaceTab;
|
||||
184
easy-dataset-main/components/export/LlamaFactoryTab.js
Normal file
184
easy-dataset-main/components/export/LlamaFactoryTab.js
Normal file
@@ -0,0 +1,184 @@
|
||||
// LlamaFactoryTab.js 组件
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
|
||||
const LlamaFactoryTab = ({
|
||||
projectId,
|
||||
systemPrompt,
|
||||
reasoningLanguage,
|
||||
confirmedOnly,
|
||||
includeCOT,
|
||||
formatType,
|
||||
handleSystemPromptChange,
|
||||
handleReasoningLanguageChange,
|
||||
handleConfirmedOnlyChange,
|
||||
handleIncludeCOTChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [configExists, setConfigExists] = useState(false);
|
||||
const [configPath, setConfigPath] = useState('');
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// 检查配置文件是否存在
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetch(`/api/projects/${projectId}/llamaFactory/checkConfig`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setConfigExists(data.exists);
|
||||
if (data.exists) {
|
||||
setConfigPath(data.configPath);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
});
|
||||
}
|
||||
}, [projectId, configExists]);
|
||||
|
||||
// 复制路径到剪贴板
|
||||
const handleCopyPath = () => {
|
||||
const path = configPath.replace('dataset_info.json', '');
|
||||
navigator.clipboard.writeText(path).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
// 处理生成 Llama Factory 配置
|
||||
const handleGenerateConfig = async () => {
|
||||
try {
|
||||
setGenerating(true);
|
||||
setError('');
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/llamaFactory/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
formatType,
|
||||
systemPrompt,
|
||||
reasoningLanguage,
|
||||
confirmedOnly,
|
||||
includeCOT
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
setConfigExists(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('export.systemPrompt')}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={systemPrompt}
|
||||
onChange={handleSystemPromptChange}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
{/* Reasoning language – only for multilingual‑thinking */}
|
||||
{formatType === 'multilingualthinking' && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('export.reasoningLanguage')}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
rows={3}
|
||||
multiline
|
||||
variant="outlined"
|
||||
placeholder={t('export.reasoningLanguage')}
|
||||
value={reasoningLanguage}
|
||||
onChange={handleReasoningLanguageChange}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'row', gap: 4 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={confirmedOnly} onChange={handleConfirmedOnlyChange} />}
|
||||
label={t('export.onlyConfirmed')}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={includeCOT} onChange={handleIncludeCOTChange} />}
|
||||
label={t('export.includeCOT')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{configExists ? (
|
||||
<>
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{t('export.configExists')}
|
||||
</Alert>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('export.configPath')}: {configPath.replace('dataset_info.json', '')}
|
||||
</Typography>
|
||||
<Tooltip title={copied ? t('common.copied') : t('common.copy')}>
|
||||
<IconButton size="small" onClick={handleCopyPath} sx={{ ml: 1 }}>
|
||||
{copied ? <CheckIcon fontSize="small" color="success" /> : <ContentCopyIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{t('export.noConfig')}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
||||
<Button onClick={handleGenerateConfig} variant="contained" disabled={generating} sx={{ borderRadius: 2 }}>
|
||||
{generating ? (
|
||||
<CircularProgress size={24} />
|
||||
) : configExists ? (
|
||||
t('export.updateConfig')
|
||||
) : (
|
||||
t('export.generateConfig')
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LlamaFactoryTab;
|
||||
777
easy-dataset-main/components/export/LocalExportTab.js
Normal file
777
easy-dataset-main/components/export/LocalExportTab.js
Normal file
@@ -0,0 +1,777 @@
|
||||
// LocalExportTab.js 组件
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
TextField,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Box,
|
||||
Paper,
|
||||
useTheme,
|
||||
Grid,
|
||||
Table,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Chip,
|
||||
Alert,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
|
||||
const LocalExportTab = ({
|
||||
fileFormat,
|
||||
formatType,
|
||||
systemPrompt,
|
||||
confirmedOnly,
|
||||
includeCOT,
|
||||
customFields,
|
||||
alpacaFieldType,
|
||||
customInstruction,
|
||||
reasoningLanguage,
|
||||
handleFileFormatChange,
|
||||
handleFormatChange,
|
||||
handleSystemPromptChange,
|
||||
handleReasoningLanguageChange,
|
||||
handleConfirmedOnlyChange,
|
||||
handleIncludeCOTChange,
|
||||
handleCustomFieldChange,
|
||||
handleIncludeLabelsChange,
|
||||
handleIncludeChunkChange,
|
||||
handleQuestionOnlyChange,
|
||||
handleAlpacaFieldTypeChange,
|
||||
handleCustomInstructionChange,
|
||||
handleExport,
|
||||
projectId
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Balance export related state
|
||||
const [balanceDialogOpen, setBalanceDialogOpen] = useState(false);
|
||||
const [tagStats, setTagStats] = useState([]);
|
||||
const [balanceConfig, setBalanceConfig] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// Get label statistics (changed to GET + query parameters)
|
||||
const fetchTagStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = `/api/projects/${projectId}/datasets/export?confirmed=${confirmedOnly ? 'true' : 'false'}`;
|
||||
const response = await fetch(url, { method: 'GET' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('errors.getTagStatsFailed'));
|
||||
}
|
||||
|
||||
const stats = await response.json();
|
||||
setTagStats(stats);
|
||||
|
||||
// 初始化平衡配置
|
||||
const initialConfig = stats.map(stat => ({
|
||||
tagLabel: stat.tagLabel,
|
||||
maxCount: Math.min(stat.datasetCount, 100), // 默认最多100条
|
||||
availableCount: stat.datasetCount
|
||||
}));
|
||||
|
||||
setBalanceConfig(initialConfig);
|
||||
|
||||
// 计算总数
|
||||
const total = initialConfig.reduce((sum, config) => sum + config.maxCount, 0);
|
||||
setTotalCount(total);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开平衡导出对话框
|
||||
const handleOpenBalanceDialog = () => {
|
||||
setBalanceDialogOpen(true);
|
||||
fetchTagStats();
|
||||
};
|
||||
|
||||
// 更新单个标签的数量配置
|
||||
const updateBalanceConfig = (tagLabel, newCount) => {
|
||||
const newConfig = balanceConfig.map(config => {
|
||||
if (config.tagLabel === tagLabel) {
|
||||
const count = Math.min(Math.max(0, parseInt(newCount) || 0), config.availableCount);
|
||||
return { ...config, maxCount: count };
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
setBalanceConfig(newConfig);
|
||||
|
||||
// 重新计算总数
|
||||
const total = newConfig.reduce((sum, config) => sum + config.maxCount, 0);
|
||||
setTotalCount(total);
|
||||
};
|
||||
|
||||
// 一键设置所有标签为相同数量
|
||||
const setAllToSameCount = count => {
|
||||
const newConfig = balanceConfig.map(config => ({
|
||||
...config,
|
||||
maxCount: Math.min(Math.max(0, parseInt(count) || 0), config.availableCount)
|
||||
}));
|
||||
|
||||
setBalanceConfig(newConfig);
|
||||
|
||||
const total = newConfig.reduce((sum, config) => sum + config.maxCount, 0);
|
||||
setTotalCount(total);
|
||||
};
|
||||
|
||||
// 处理平衡导出
|
||||
const handleBalancedExport = () => {
|
||||
// 过滤出数量大于0的配置
|
||||
const validConfig = balanceConfig.filter(config => config.maxCount > 0);
|
||||
|
||||
if (validConfig.length === 0) {
|
||||
setError(t('export.balancedExport.atLeastOneTag', '请至少为一个标签设置大于0的数量'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用原有的导出函数,但传递平衡配置
|
||||
handleExport({
|
||||
balanceMode: true,
|
||||
balanceConfig: validConfig,
|
||||
formatType,
|
||||
systemPrompt,
|
||||
reasoningLanguage,
|
||||
confirmedOnly,
|
||||
fileFormat,
|
||||
includeCOT,
|
||||
alpacaFieldType,
|
||||
customInstruction,
|
||||
customFields: formatType === 'custom' ? customFields : undefined
|
||||
});
|
||||
|
||||
setBalanceDialogOpen(false);
|
||||
};
|
||||
|
||||
// 自定义格式的示例
|
||||
const getCustomFormatExample = () => {
|
||||
const { questionField, answerField, cotField, includeLabels, includeChunk } = customFields;
|
||||
const example = {
|
||||
[questionField]: t('sampleData.questionContent'),
|
||||
[answerField]: t('sampleData.answerContent')
|
||||
};
|
||||
|
||||
// 如果包含思维链字段,添加到示例中
|
||||
if (includeCOT) {
|
||||
example[cotField] = t('sampleData.cotContent');
|
||||
}
|
||||
|
||||
if (includeLabels) {
|
||||
example.labels = [t('sampleData.domainLabel')];
|
||||
}
|
||||
|
||||
if (includeChunk) {
|
||||
example.chunk = t('sampleData.textChunk');
|
||||
}
|
||||
|
||||
return fileFormat === 'json' ? JSON.stringify([example], null, 2) : JSON.stringify(example);
|
||||
};
|
||||
|
||||
// CSV 自定义格式化示例
|
||||
const getPreviewData = () => {
|
||||
if (formatType === 'alpaca') {
|
||||
// 根据选择的字段类型生成不同的示例
|
||||
if (alpacaFieldType === 'instruction') {
|
||||
return {
|
||||
headers: ['instruction', 'input', 'output', 'system'],
|
||||
rows: [
|
||||
{
|
||||
instruction: t('export.sampleInstruction', '人类指令(必填)'),
|
||||
input: '',
|
||||
output: t('export.sampleOutput', '模型回答(必填)'),
|
||||
system: t('export.sampleSystem', '系统提示词(选填)')
|
||||
},
|
||||
{
|
||||
instruction: t('export.sampleInstruction2', '第二个指令'),
|
||||
input: '',
|
||||
output: t('export.sampleOutput2', '第二个回答'),
|
||||
system: t('export.sampleSystemShort', '系统提示词')
|
||||
}
|
||||
]
|
||||
};
|
||||
} else {
|
||||
// input
|
||||
return {
|
||||
headers: ['instruction', 'input', 'output', 'system'],
|
||||
rows: [
|
||||
{
|
||||
instruction: customInstruction || t('export.fixedInstruction', '固定的指令内容'),
|
||||
input: t('export.sampleInput', '人类问题(必填)'),
|
||||
output: t('export.sampleOutput', '模型回答(必填)'),
|
||||
system: t('export.sampleSystem', '系统提示词(选填)')
|
||||
},
|
||||
{
|
||||
instruction: customInstruction || t('export.fixedInstruction', '固定的指令内容'),
|
||||
input: t('export.sampleInput2', '第二个问题'),
|
||||
output: t('export.sampleOutput2', '第二个回答'),
|
||||
system: t('export.sampleSystemShort', '系统提示词')
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
} else if (formatType === 'sharegpt') {
|
||||
return {
|
||||
headers: ['messages'],
|
||||
rows: [
|
||||
{
|
||||
messages: JSON.stringify(
|
||||
[
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: t('export.sampleSystem', '系统提示词(选填)')
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: t('export.sampleUserMessage', '人类指令') // 映射到 question 字段
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: t('export.sampleAssistantMessage', '模型回答') // 映射到 cot+answer 字段
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
} else if (formatType === 'multilingualthinking') {
|
||||
return {
|
||||
headers: 'messages',
|
||||
rows: {
|
||||
messages: JSON.stringify(
|
||||
{
|
||||
reasoning_language: 'English',
|
||||
developer: t('export.sampleSystem', '系统提示词(选填)'),
|
||||
user: t('export.sampleUserMessage', '人类指令'), // 映射到 question 字段
|
||||
analysis: t('export.sampleAnalysis', '模型的思维链内容'), // 映射到 cot 字段
|
||||
final: t('export.sampleFinal', '模型回答'), // 映射到 answer 字段
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '系统提示词(选填)',
|
||||
thinking: 'null'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: '人类指令', // 映射到 question 字段
|
||||
thinking: 'null'
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '模型回答', // 映射到 answer 字段
|
||||
thinking: '模型的思维链内容' // 映射到 cot 字段
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
};
|
||||
} else if (formatType === 'custom') {
|
||||
// 如果选择仅导出问题,只包含问题字段
|
||||
if (customFields.questionOnly) {
|
||||
const headers = [customFields.questionField];
|
||||
if (customFields.includeLabels) headers.push('labels');
|
||||
if (customFields.includeChunk) headers.push('chunk');
|
||||
|
||||
const row = {
|
||||
[customFields.questionField]: t('sampleData.questionContent')
|
||||
};
|
||||
if (customFields.includeLabels) row.labels = t('sampleData.domainLabel');
|
||||
if (customFields.includeChunk) row.chunk = t('sampleData.textChunk');
|
||||
return {
|
||||
headers,
|
||||
rows: [row]
|
||||
};
|
||||
} else {
|
||||
// 正常的自定义格式
|
||||
const headers = [customFields.questionField, customFields.answerField];
|
||||
if (includeCOT) headers.push(customFields.cotField);
|
||||
if (customFields.includeLabels) headers.push('labels');
|
||||
if (customFields.includeChunk) headers.push('chunk');
|
||||
|
||||
const row = {
|
||||
[customFields.questionField]: t('sampleData.questionContent'),
|
||||
[customFields.answerField]: t('sampleData.answerContent')
|
||||
};
|
||||
if (includeCOT) row[customFields.cotField] = t('sampleData.cotContent');
|
||||
if (customFields.includeLabels) row.labels = t('sampleData.domainLabel');
|
||||
if (customFields.includeChunk) row.chunk = t('sampleData.textChunk');
|
||||
return {
|
||||
headers,
|
||||
rows: [row]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('export.fileFormat')}
|
||||
</Typography>
|
||||
<FormControl component="fieldset">
|
||||
<RadioGroup
|
||||
aria-label="fileFormat"
|
||||
name="fileFormat"
|
||||
value={fileFormat}
|
||||
onChange={handleFileFormatChange}
|
||||
row
|
||||
>
|
||||
<FormControlLabel value="json" control={<Radio />} label="JSON" />
|
||||
<FormControlLabel value="jsonl" control={<Radio />} label="JSONL" />
|
||||
{/* <FormControlLabel value="csv" control={<Radio />} label="CSV" /> */}
|
||||
<FormControlLabel
|
||||
value="csv"
|
||||
control={<Radio disabled={formatType === 'multilingualthinking'} />}
|
||||
label="CSV"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* 数据集风格 */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('export.format')}
|
||||
</Typography>
|
||||
<FormControl component="fieldset">
|
||||
<RadioGroup aria-label="format" name="format" value={formatType} onChange={handleFormatChange} row>
|
||||
<FormControlLabel value="alpaca" control={<Radio />} label="Alpaca" />
|
||||
<FormControlLabel value="sharegpt" control={<Radio />} label="ShareGPT" />
|
||||
{/* NEW: Multilingual‑Thinking format */}
|
||||
<FormControlLabel
|
||||
value="multilingualthinking"
|
||||
control={<Radio disabled={fileFormat === 'csv'} />}
|
||||
label={t('export.multilingualThinkingFormat') || 'Multilingual‑Thinking'}
|
||||
/>
|
||||
<FormControlLabel value="custom" control={<Radio />} label={t('export.customFormat')} />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* Alpaca 格式特有的设置 */}
|
||||
{formatType === 'alpaca' && (
|
||||
<Box sx={{ mb: 3, pl: 2, borderLeft: `1px solid ${theme.palette.divider}` }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('export.alpacaSettings', 'Alpaca 格式设置')}
|
||||
</Typography>
|
||||
<FormControl component="fieldset">
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{t('export.questionFieldType', '问题字段类型')}
|
||||
</Typography>
|
||||
<RadioGroup
|
||||
aria-label="alpacaFieldType"
|
||||
name="alpacaFieldType"
|
||||
value={alpacaFieldType}
|
||||
onChange={handleAlpacaFieldTypeChange}
|
||||
row
|
||||
>
|
||||
<FormControlLabel
|
||||
value="instruction"
|
||||
control={<Radio />}
|
||||
label={t('export.useInstruction', '使用 instruction 字段')}
|
||||
/>
|
||||
<FormControlLabel value="input" control={<Radio />} label={t('export.useInput', '使用 input 字段')} />
|
||||
</RadioGroup>
|
||||
|
||||
{alpacaFieldType === 'input' && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('export.customInstruction', '自定义 instruction 字段内容')}
|
||||
value={customInstruction}
|
||||
onChange={handleCustomInstructionChange}
|
||||
margin="normal"
|
||||
placeholder={t('export.instructionPlaceholder', '请输入固定的指令内容')}
|
||||
helperText={t(
|
||||
'export.instructionHelperText',
|
||||
'当使用 input 字段时,可以在这里指定固定的 instruction 内容'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 自定义格式选项 */}
|
||||
{formatType === 'custom' && (
|
||||
<Box sx={{ mb: 3, pl: 2, borderLeft: `1px solid ${theme.palette.divider}` }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('export.customFormatSettings')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('export.questionFieldName')}
|
||||
value={customFields.questionField}
|
||||
onChange={handleCustomFieldChange('questionField')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('export.answerFieldName')}
|
||||
value={customFields.answerField}
|
||||
onChange={handleCustomFieldChange('answerField')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
{/* 添加思维链字段名输入框 */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('export.cotFieldName')}
|
||||
value={customFields.cotField}
|
||||
onChange={handleCustomFieldChange('cotField')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox checked={customFields.includeLabels} onChange={handleIncludeLabelsChange} size="small" />
|
||||
}
|
||||
label={t('export.includeLabels')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={customFields.includeChunk} onChange={handleIncludeChunkChange} size="small" />}
|
||||
label={t('export.includeChunk')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={customFields.questionOnly} onChange={handleQuestionOnlyChange} size="small" />}
|
||||
label={t('export.questionOnly')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('export.example')}
|
||||
</Typography>
|
||||
|
||||
{fileFormat === 'csv' ? (
|
||||
<TableContainer component={Paper} sx={{ mb: 2 }}>
|
||||
{(() => {
|
||||
const { headers, rows } = getPreviewData();
|
||||
const tableKey = `${formatType}-${fileFormat}-${JSON.stringify(customFields)}`;
|
||||
return (
|
||||
<Table size="small" key={tableKey}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headers.map(header => (
|
||||
<TableCell key={header}>{header}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
{headers.map(header => (
|
||||
<TableCell key={header}>
|
||||
{Array.isArray(row[header]) ? row[header].join(', ') : row[header] || ''}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
})()}
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[900] : theme.palette.grey[100],
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
>
|
||||
<pre style={{ margin: 0 }}>
|
||||
{formatType === 'custom'
|
||||
? getCustomFormatExample()
|
||||
: formatType === 'multilingualthinking'
|
||||
? fileFormat === 'json'
|
||||
? JSON.stringify(
|
||||
{
|
||||
reasoning_language: 'English',
|
||||
developer: '系统提示词(选填)',
|
||||
user: '人类指令', // 映射到 question 字段
|
||||
analysis: '模型的思维链内容', // 映射到 cot 字段
|
||||
final: '模型回答', // 映射到 answer 字段
|
||||
messages: [
|
||||
{
|
||||
content: t('export.sampleSystem', '系统提示词(选填)'),
|
||||
role: 'system',
|
||||
thinking: null
|
||||
},
|
||||
{
|
||||
content: t('export.sampleUserMessage', '人类指令'),
|
||||
role: 'user',
|
||||
thinking: null
|
||||
},
|
||||
{
|
||||
content: t('export.sampleAssistantMessage', '模型回答'),
|
||||
role: 'assistant',
|
||||
thinking: t('export.sampleThinking', '模型的思维链内容')
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
: '{"reasoning_language": "English","developer": "系统提示词(选填)", "user": "人类指令", "analysis": "模型的思维链内容", "final": "模型回答", "messages": [{"role": "user", "content": "人类指令", "thinking": "null"}, {"role": "assistant", "content": "模型回答", "thinking": "模型的思维链内容"}]}'
|
||||
: formatType === 'alpaca'
|
||||
? fileFormat === 'json'
|
||||
? JSON.stringify(
|
||||
[
|
||||
{
|
||||
instruction: t('export.sampleInstruction', '人类指令(必填)'), // 映射到 question 字段
|
||||
input: t('export.sampleInputOptional', '人类输入(选填)'),
|
||||
output: t('export.sampleOutput', '模型回答(必填)'), // 映射到 cot+answer 字段
|
||||
system: t('export.sampleSystem', '系统提示词(选填)')
|
||||
}
|
||||
],
|
||||
null,
|
||||
2
|
||||
)
|
||||
: '{"instruction": "人类指令(必填)", "input": "人类输入(选填)", "output": "模型回答(必填)", "system": "系统提示词(选填)"}\n{"instruction": "第二个指令", "input": "", "output": "第二个回答", "system": "系统提示词"}'
|
||||
: fileFormat === 'json'
|
||||
? JSON.stringify(
|
||||
[
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: t('export.sampleSystem', '系统提示词(选填)')
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: t('export.sampleUserMessage', '人类指令') // 映射到 question 字段
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: t('export.sampleAssistantMessage', '模型回答') // 映射到 cot+answer 字段
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
null,
|
||||
2
|
||||
)
|
||||
: '{"messages": [{"role": "system", "content": "系统提示词(选填)"}, {"role": "user", "content": "人类指令"}, {"role": "assistant", "content": "模型回答"}]}\n{"messages": [{"role": "user", "content": "第二个问题"}, {"role": "assistant", "content": "第二个回答"}]}'}
|
||||
</pre>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('export.systemPrompt')}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
variant="outlined"
|
||||
placeholder={t('export.systemPromptPlaceholder')}
|
||||
value={systemPrompt}
|
||||
onChange={handleSystemPromptChange}
|
||||
/>
|
||||
</Box>
|
||||
{/* Reasoning language – only for multilingual‑thinking */}
|
||||
{formatType === 'multilingualthinking' && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
{t('export.Reasoninglanguage')}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
rows={3}
|
||||
multiline
|
||||
variant="outlined"
|
||||
placeholder={t('export.ReasoninglanguagePlaceholder')}
|
||||
value={reasoningLanguage}
|
||||
onChange={handleReasoningLanguageChange}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'row', gap: 4 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={confirmedOnly} onChange={handleConfirmedOnlyChange} />}
|
||||
label={t('export.onlyConfirmed')}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={includeCOT} onChange={handleIncludeCOTChange} />}
|
||||
label={t('export.includeCOT')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 2 }}>
|
||||
<Button onClick={handleOpenBalanceDialog} variant="outlined" sx={{ borderRadius: 2 }}>
|
||||
{t('exportDialog.balancedExport')}
|
||||
</Button>
|
||||
<Button onClick={handleExport} variant="contained" sx={{ borderRadius: 2 }}>
|
||||
{t('export.confirmExport')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 平衡导出对话框 */}
|
||||
<Dialog
|
||||
open={balanceDialogOpen}
|
||||
onClose={() => setBalanceDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{t('exportDialog.balancedExportTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom sx={{ mb: 3 }}>
|
||||
{t('exportDialog.balancedExportDescription')}
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{/* 批量设置 */}
|
||||
<Box sx={{ mb: 3, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('exportDialog.quickSettings')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Button size="small" onClick={() => setAllToSameCount(50)}>
|
||||
{t('exportDialog.setAllTo50')}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setAllToSameCount(100)}>
|
||||
{t('exportDialog.setAllTo100')}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setAllToSameCount(200)}>
|
||||
{t('exportDialog.setAllTo200')}
|
||||
</Button>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
placeholder={t('exportDialog.customAmount')}
|
||||
sx={{ width: 120 }}
|
||||
onKeyPress={e => {
|
||||
if (e.key === 'Enter') {
|
||||
setAllToSameCount(e.target.value);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 标签配置表格 */}
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('exportDialog.tagName')}</TableCell>
|
||||
<TableCell align="right">{t('exportDialog.availableCount')}</TableCell>
|
||||
<TableCell align="right">{t('exportDialog.exportCount')}</TableCell>
|
||||
<TableCell align="right">{t('exportDialog.settings')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{balanceConfig.map(config => (
|
||||
<TableRow key={config.tagLabel}>
|
||||
<TableCell>
|
||||
<Chip label={config.tagLabel} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell align="right">{config.availableCount}</TableCell>
|
||||
<TableCell align="right">
|
||||
<strong>{config.maxCount}</strong>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={config.maxCount}
|
||||
onChange={e => updateBalanceConfig(config.tagLabel, e.target.value)}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
max: config.availableCount,
|
||||
style: { textAlign: 'right' }
|
||||
}}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'primary.50', borderRadius: 1 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>
|
||||
{t('exportDialog.totalExportCount')}: {totalCount}
|
||||
</strong>{' '}
|
||||
| {t('exportDialog.tagCount')}: {balanceConfig.filter(c => c.maxCount > 0).length} /{' '}
|
||||
{balanceConfig.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setBalanceDialogOpen(false)}>{t('common.cancel', '取消')}</Button>
|
||||
<Button variant="contained" onClick={handleBalancedExport} disabled={loading || totalCount === 0}>
|
||||
{t('exportDialog.export')} ({totalCount})
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocalExportTab;
|
||||
173
easy-dataset-main/components/home/CreateProjectDialog.js
Normal file
173
easy-dataset-main/components/home/CreateProjectDialog.js
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
useTheme,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function CreateProjectDialog({ open, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
reuseConfigFrom: ''
|
||||
});
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 获取项目列表
|
||||
useEffect(() => {
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/projects');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProjects(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const handleChange = e => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('projects.createFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
router.push(`/projects/${data.id}/settings?tab=model`);
|
||||
} catch (err) {
|
||||
console.error(t('projects.createError'), err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: '16px',
|
||||
background: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.9)' : 'rgba(255, 255, 255, 0.9)',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{t('projects.createNew')}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogContent>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
name="name"
|
||||
label={t('projects.name')}
|
||||
fullWidth
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
name="description"
|
||||
label={t('projects.description')}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel id="reuse-config-label">{t('projects.reuseConfig')}</InputLabel>
|
||||
<Select
|
||||
labelId="reuse-config-label"
|
||||
name="reuseConfigFrom"
|
||||
value={formData.reuseConfigFrom}
|
||||
onChange={handleChange}
|
||||
label={t('projects.reuseConfig')}
|
||||
>
|
||||
<MenuItem value="">{t('projects.noReuse')}</MenuItem>
|
||||
{projects.map(project => (
|
||||
<MenuItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
{error && (
|
||||
<Typography color="error" variant="body2" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={loading || !formData.name}
|
||||
sx={{
|
||||
background: theme.palette.gradient.primary,
|
||||
'&:hover': {
|
||||
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : t('home.createProject')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
135
easy-dataset-main/components/home/HeroSection.js
Normal file
135
easy-dataset-main/components/home/HeroSection.js
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Container, Typography, Button, useMediaQuery } from '@mui/material';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { styles } from '@/styles/home';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import ParticleBackground from './ParticleBackground';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function HeroSection({ onCreateProject }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
return (
|
||||
<Box sx={{ ...styles.heroSection, ...styles.heroBackground(theme) }}>
|
||||
{/* 添加粒子背景 */}
|
||||
<ParticleBackground />
|
||||
|
||||
<Box sx={styles.decorativeCircle} />
|
||||
<Box sx={styles.decorativeCircleSecond} />
|
||||
|
||||
<Container maxWidth="lg" sx={{ position: 'relative', zIndex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
maxWidth: '800px',
|
||||
mx: 'auto',
|
||||
py: { xs: 5, md: 8 }
|
||||
}}
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<Typography
|
||||
variant={isMobile ? 'h3' : 'h1'}
|
||||
component="h1"
|
||||
fontWeight="bold"
|
||||
sx={{
|
||||
...styles.gradientTitle(theme),
|
||||
letterSpacing: '-1px',
|
||||
mb: 3,
|
||||
textShadow: theme.palette.mode === 'dark' ? '0 0 30px rgba(139, 92, 246, 0.3)' : 'none'
|
||||
}}
|
||||
>
|
||||
{t('home.title')}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant={isMobile ? 'body1' : 'h5'}
|
||||
component={motion.p}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3, duration: 0.8 }}
|
||||
color="text.secondary"
|
||||
paragraph
|
||||
sx={{
|
||||
maxWidth: '650px',
|
||||
mx: 'auto',
|
||||
lineHeight: 1.8,
|
||||
opacity: 0.9,
|
||||
fontSize: { xs: '1rem', md: '1.2rem' },
|
||||
fontWeight: 400,
|
||||
mb: 4
|
||||
}}
|
||||
>
|
||||
{t('home.subtitle')}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
sx={{
|
||||
mt: 6,
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
justifyContent: 'center',
|
||||
gap: { xs: 2, sm: 3 }
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={onCreateProject}
|
||||
startIcon={<AddCircleOutlineIcon />}
|
||||
sx={{
|
||||
...styles.createButton(theme),
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.3s ease',
|
||||
transform: 'translateY(0)',
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
borderRadius: '12px',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-3px)',
|
||||
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('home.createProject')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
window.location.href = '/dataset-square';
|
||||
}}
|
||||
startIcon={<SearchIcon />}
|
||||
sx={{
|
||||
...styles.createButton(theme),
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.3s ease',
|
||||
transform: 'translateY(0)',
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
borderRadius: '12px',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-3px)',
|
||||
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('home.searchDataset')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
300
easy-dataset-main/components/home/MigrationDialog.js
Normal file
300
easy-dataset-main/components/home/MigrationDialog.js
Normal file
@@ -0,0 +1,300 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Alert,
|
||||
Paper,
|
||||
useTheme,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 项目迁移对话框组件
|
||||
* @param {Object} props - 组件属性
|
||||
* @param {boolean} props.open - 对话框是否打开
|
||||
* @param {Function} props.onClose - 关闭对话框的回调函数
|
||||
* @param {Array<string>} props.projectIds - 需要迁移的项目ID列表
|
||||
*/
|
||||
export default function MigrationDialog({ open, onClose, projectIds = [] }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [migratedCount, setMigratedCount] = useState(0);
|
||||
const [taskId, setTaskId] = useState(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [statusText, setStatusText] = useState('');
|
||||
const [processingIds, setProcessingIds] = useState([]);
|
||||
|
||||
// 打开项目目录
|
||||
const handleOpenDirectory = async projectId => {
|
||||
try {
|
||||
setProcessingIds(prev => [...prev, projectId]);
|
||||
|
||||
const response = await fetch('/api/projects/open-directory', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ projectId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('migration.openDirectoryFailed'));
|
||||
}
|
||||
|
||||
// 成功打开目录,不需要特别处理
|
||||
} catch (err) {
|
||||
console.error('打开目录错误:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setProcessingIds(prev => prev.filter(id => id !== projectId));
|
||||
}
|
||||
};
|
||||
|
||||
// 删除项目目录
|
||||
const handleDeleteDirectory = async projectId => {
|
||||
try {
|
||||
if (!window.confirm(t('migration.confirmDelete'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessingIds(prev => [...prev, projectId]);
|
||||
|
||||
const response = await fetch('/api/projects/delete-directory', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ projectId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('migration.deleteDirectoryFailed'));
|
||||
}
|
||||
|
||||
// 从列表中移除已删除的项目
|
||||
const updatedProjectIds = projectIds.filter(id => id !== projectId);
|
||||
// 这里我们不能直接修改 projectIds,因为它是从父组件传入的
|
||||
// 但我们可以通知用户界面刷新
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
console.error('删除目录错误:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setProcessingIds(prev => prev.filter(id => id !== projectId));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理迁移操作
|
||||
const handleMigration = async () => {
|
||||
try {
|
||||
setMigrating(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
setProgress(0);
|
||||
setStatusText(t('migration.starting'));
|
||||
|
||||
// 调用异步迁移接口启动迁移任务
|
||||
const response = await fetch('/api/projects/migrate', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('migration.failed'));
|
||||
}
|
||||
|
||||
const { success, taskId: newTaskId } = await response.json();
|
||||
|
||||
if (!success || !newTaskId) {
|
||||
throw new Error(t('migration.startFailed'));
|
||||
}
|
||||
|
||||
// 保存任务ID
|
||||
setTaskId(newTaskId);
|
||||
setStatusText(t('migration.processing'));
|
||||
|
||||
// 开始轮询任务状态
|
||||
await pollMigrationStatus(newTaskId);
|
||||
} catch (err) {
|
||||
console.error('迁移错误:', err);
|
||||
setError(err.message);
|
||||
setMigrating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 轮询迁移任务状态
|
||||
const pollMigrationStatus = async id => {
|
||||
try {
|
||||
// 定义轮询间隔(毫秒)
|
||||
const pollInterval = 1000;
|
||||
|
||||
// 发送请求获取任务状态
|
||||
const response = await fetch(`/api/projects/migrate?taskId=${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('migration.statusFailed'));
|
||||
}
|
||||
|
||||
const { success, task } = await response.json();
|
||||
|
||||
if (!success || !task) {
|
||||
throw new Error(t('migration.taskNotFound'));
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
setProgress(task.progress || 0);
|
||||
|
||||
// 根据任务状态更新UI
|
||||
if (task.status === 'completed') {
|
||||
// 任务完成
|
||||
setMigratedCount(task.completed);
|
||||
setSuccess(true);
|
||||
setMigrating(false);
|
||||
setStatusText(t('migration.completed'));
|
||||
|
||||
// 迁移成功后,延迟关闭对话框并刷新页面
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else if (task.status === 'failed') {
|
||||
// 任务失败
|
||||
throw new Error(task.error || t('migration.failed'));
|
||||
} else {
|
||||
// 任务仍在进行中,继续轮询
|
||||
setTimeout(() => pollMigrationStatus(id), pollInterval);
|
||||
|
||||
// 更新状态文本
|
||||
if (task.total > 0) {
|
||||
setStatusText(
|
||||
t('migration.progressStatus', {
|
||||
completed: task.completed || 0,
|
||||
total: task.total
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取迁移状态错误:', err);
|
||||
setError(err.message);
|
||||
setMigrating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={migrating ? undefined : onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5
|
||||
}}
|
||||
>
|
||||
<WarningAmberIcon color="warning" />
|
||||
<Typography variant="h6">{t('migration.title')}</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{success ? (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{t('migration.success', { count: migratedCount })}
|
||||
</Alert>
|
||||
) : error ? (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{t('migration.description')}
|
||||
</Typography>
|
||||
|
||||
{projectIds.length > 0 && (
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
||||
{t('migration.projectsList')}:
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ maxHeight: 180, overflow: 'auto' }}>
|
||||
<List dense>
|
||||
{projectIds.map(id => (
|
||||
<ListItem key={id}>
|
||||
<ListItemText primary={id} />
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title={t('migration.openDirectory')}>
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="open"
|
||||
onClick={() => handleOpenDirectory(id)}
|
||||
disabled={processingIds.includes(id)}
|
||||
size="small"
|
||||
>
|
||||
<FolderOpenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('migration.deleteDirectory')}>
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete"
|
||||
onClick={() => handleDeleteDirectory(id)}
|
||||
disabled={processingIds.includes(id)}
|
||||
size="small"
|
||||
sx={{ ml: 1, color: 'error.main' }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{migrating && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', my: 3, gap: 1.5 }}>
|
||||
<CircularProgress variant={progress > 0 ? 'determinate' : 'indeterminate'} value={progress} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{statusText || t('migration.migrating')}
|
||||
</Typography>
|
||||
{progress > 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{progress}%
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={migrating}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleMigration} variant="contained" color="primary" disabled={migrating || success}>
|
||||
{migrating ? t('migration.migrating') : t('migration.migrate')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
251
easy-dataset-main/components/home/ParticleBackground.js
Normal file
251
easy-dataset-main/components/home/ParticleBackground.js
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTheme } from '@mui/material';
|
||||
|
||||
export default function ParticleBackground() {
|
||||
const canvasRef = useRef(null);
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
let animationFrameId;
|
||||
let particles = [];
|
||||
let mousePosition = { x: 0, y: 0 };
|
||||
let hoverRadius = 150; // 增加鼠标影响范围
|
||||
let mouseSpeed = { x: 0, y: 0 }; // 跟踪鼠标速度
|
||||
let lastMousePosition = { x: 0, y: 0 }; // 上一帧鼠标位置
|
||||
|
||||
// 设置画布大小为窗口大小
|
||||
const handleResize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
initParticles();
|
||||
};
|
||||
|
||||
// 跟踪鼠标位置和速度
|
||||
const handleMouseMove = event => {
|
||||
// 计算鼠标速度
|
||||
mouseSpeed.x = event.clientX - mousePosition.x;
|
||||
mouseSpeed.y = event.clientY - mousePosition.y;
|
||||
|
||||
// 更新鼠标位置
|
||||
lastMousePosition.x = mousePosition.x;
|
||||
lastMousePosition.y = mousePosition.y;
|
||||
mousePosition.x = event.clientX;
|
||||
mousePosition.y = event.clientY;
|
||||
};
|
||||
|
||||
// 触摸设备支持
|
||||
const handleTouchMove = event => {
|
||||
if (event.touches.length > 0) {
|
||||
// 计算触摸速度
|
||||
mouseSpeed.x = event.touches[0].clientX - mousePosition.x;
|
||||
mouseSpeed.y = event.touches[0].clientY - mousePosition.y;
|
||||
|
||||
// 更新触摸位置
|
||||
lastMousePosition.x = mousePosition.x;
|
||||
lastMousePosition.y = mousePosition.y;
|
||||
mousePosition.x = event.touches[0].clientX;
|
||||
mousePosition.y = event.touches[0].clientY;
|
||||
}
|
||||
};
|
||||
|
||||
// 生成随机颜色
|
||||
const getRandomColor = () => {
|
||||
// 主题色调
|
||||
const colors =
|
||||
theme.palette.mode === 'dark'
|
||||
? [
|
||||
'rgba(255, 255, 255, 0.5)', // 白色
|
||||
'rgba(100, 181, 246, 0.5)', // 蓝色
|
||||
'rgba(156, 39, 176, 0.4)', // 紫色
|
||||
'rgba(121, 134, 203, 0.5)' // 靛蓝色
|
||||
]
|
||||
: [
|
||||
'rgba(42, 92, 170, 0.5)', // 主蓝色
|
||||
'rgba(66, 165, 245, 0.4)', // 浅蓝色
|
||||
'rgba(94, 53, 177, 0.3)', // 深紫色
|
||||
'rgba(3, 169, 244, 0.4)' // 天蓝色
|
||||
];
|
||||
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
};
|
||||
|
||||
// 初始化粒子
|
||||
const initParticles = () => {
|
||||
particles = [];
|
||||
// 增加粒子数量,但保持性能平衡
|
||||
const particleCount = Math.min(Math.floor(window.innerWidth / 8), 150);
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
// 创建不同大小和速度的粒子
|
||||
const size = Math.random();
|
||||
const speedFactor = Math.max(0.1, size); // 较大的粒子移动较慢
|
||||
|
||||
particles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
// 粒子大小更加多样化
|
||||
radius: size * 3 + 0.5,
|
||||
// 使用随机颜色
|
||||
color: getRandomColor(),
|
||||
// 添加发光效果
|
||||
glow: Math.random() * 10 + 5,
|
||||
// 调整速度范围,使运动更加自然
|
||||
speedX: (Math.random() * 0.6 - 0.3) * speedFactor,
|
||||
speedY: (Math.random() * 0.6 - 0.3) * speedFactor,
|
||||
originalSpeedX: (Math.random() * 0.6 - 0.3) * speedFactor,
|
||||
originalSpeedY: (Math.random() * 0.6 - 0.3) * speedFactor,
|
||||
// 添加脉动效果
|
||||
pulseSpeed: Math.random() * 0.02 + 0.01,
|
||||
pulseDirection: Math.random() > 0.5 ? 1 : -1,
|
||||
pulseAmount: 0,
|
||||
// 粒子透明度
|
||||
opacity: Math.random() * 0.5 + 0.5
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 绘制粒子
|
||||
const drawParticles = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 计算鼠标速度衰减
|
||||
mouseSpeed.x *= 0.95;
|
||||
mouseSpeed.y *= 0.95;
|
||||
|
||||
// 绘制粒子之间的连线
|
||||
drawLines();
|
||||
|
||||
particles.forEach(particle => {
|
||||
// 计算粒子与鼠标的距离
|
||||
const dx = mousePosition.x - particle.x;
|
||||
const dy = mousePosition.y - particle.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// 脉动效果
|
||||
particle.pulseAmount += particle.pulseSpeed * particle.pulseDirection;
|
||||
if (Math.abs(particle.pulseAmount) > 0.5) {
|
||||
particle.pulseDirection *= -1;
|
||||
}
|
||||
|
||||
// 如果粒子在鼠标影响范围内,调整其速度
|
||||
if (distance < hoverRadius) {
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const force = (hoverRadius - distance) / hoverRadius;
|
||||
const mouseFactor = 3; // 增强鼠标影响力度
|
||||
|
||||
// 粒子远离鼠标,并受鼠标速度影响
|
||||
particle.speedX = -Math.cos(angle) * force * mouseFactor + particle.originalSpeedX + mouseSpeed.x * 0.05;
|
||||
particle.speedY = -Math.sin(angle) * force * mouseFactor + particle.originalSpeedY + mouseSpeed.y * 0.05;
|
||||
} else {
|
||||
// 逐渐恢复原始速度
|
||||
particle.speedX = particle.speedX * 0.95 + particle.originalSpeedX * 0.05;
|
||||
particle.speedY = particle.speedY * 0.95 + particle.originalSpeedY * 0.05;
|
||||
}
|
||||
|
||||
// 更新粒子位置
|
||||
particle.x += particle.speedX;
|
||||
particle.y += particle.speedY;
|
||||
|
||||
// 边界检查
|
||||
if (particle.x < 0) particle.x = canvas.width;
|
||||
if (particle.x > canvas.width) particle.x = 0;
|
||||
if (particle.y < 0) particle.y = canvas.height;
|
||||
if (particle.y > canvas.height) particle.y = 0;
|
||||
|
||||
// 应用脉动效果到粒子大小
|
||||
const currentRadius = particle.radius * (1 + particle.pulseAmount * 0.2);
|
||||
|
||||
// 绘制发光效果
|
||||
const gradient = ctx.createRadialGradient(particle.x, particle.y, 0, particle.x, particle.y, particle.glow);
|
||||
gradient.addColorStop(0, particle.color);
|
||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
// 绘制粒子
|
||||
ctx.beginPath();
|
||||
ctx.arc(particle.x, particle.y, currentRadius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.fill();
|
||||
|
||||
// 添加发光效果
|
||||
ctx.beginPath();
|
||||
ctx.arc(particle.x, particle.y, particle.glow, 0, Math.PI * 2);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.globalAlpha = 0.3 * particle.opacity;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1.0;
|
||||
});
|
||||
|
||||
animationFrameId = requestAnimationFrame(drawParticles);
|
||||
};
|
||||
|
||||
// 绘制粒子之间的连线
|
||||
const drawLines = () => {
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const dx = particles[i].x - particles[j].x;
|
||||
const dy = particles[i].y - particles[j].y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// 增加连线的最大距离
|
||||
const maxDistance = 120;
|
||||
|
||||
if (distance < maxDistance) {
|
||||
// 只在粒子距离小于maxDistance时绘制连线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particles[i].x, particles[i].y);
|
||||
ctx.lineTo(particles[j].x, particles[j].y);
|
||||
|
||||
// 根据距离设置线条透明度
|
||||
const opacity = 1 - distance / maxDistance;
|
||||
|
||||
// 根据主题设置线条颜色
|
||||
const lineColor =
|
||||
theme.palette.mode === 'dark'
|
||||
? `rgba(255, 255, 255, ${opacity * 0.2})`
|
||||
: `rgba(42, 92, 170, ${opacity * 0.2})`;
|
||||
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.lineWidth = opacity * 1.5; // 根据距离调整线宽
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('touchmove', handleTouchMove);
|
||||
|
||||
// 开始动画
|
||||
drawParticles();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [theme.palette.mode]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none', // 确保不会干扰下方元素的交互
|
||||
zIndex: 0
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
252
easy-dataset-main/components/home/ProjectCard.js
Normal file
252
easy-dataset-main/components/home/ProjectCard.js
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Card,
|
||||
Box,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
Typography,
|
||||
Avatar,
|
||||
Divider,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon
|
||||
} from '@mui/material';
|
||||
import Link from 'next/link';
|
||||
import { styles } from '@/styles/home';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
import DataObjectIcon from '@mui/icons-material/DataObject';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
|
||||
import TokenIcon from '@mui/icons-material/Token';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import QuizIcon from '@mui/icons-material/Quiz';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* 统计项组件
|
||||
*/
|
||||
const StatItem = ({ icon: Icon, count, label, color, isToken }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
// 格式化数字
|
||||
const displayCount = isToken ? (count || 0).toLocaleString() : count || 0;
|
||||
|
||||
return (
|
||||
<Box sx={styles.statItem(theme)}>
|
||||
<Box sx={styles.statIconBox(theme, color)}>
|
||||
<Icon sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Typography variant="subtitle2" fontWeight="700" sx={{ lineHeight: 1 }}>
|
||||
{displayCount}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ fontSize: '0.7rem' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 项目卡片组件
|
||||
* @param {Object} props - 组件属性
|
||||
* @param {Object} props.project - 项目数据
|
||||
* @param {Function} props.onDeleteClick - 删除按钮点击事件处理函数
|
||||
*/
|
||||
export default function ProjectCard({ project, onDeleteClick }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [processingId, setProcessingId] = useState(false);
|
||||
|
||||
// 菜单状态
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
// 打开项目目录
|
||||
const handleOpenDirectory = async event => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (processingId) return;
|
||||
|
||||
try {
|
||||
setProcessingId(true);
|
||||
|
||||
const response = await fetch('/api/projects/open-directory', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ projectId: project.id })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('migration.openDirectoryFailed'));
|
||||
}
|
||||
|
||||
// 成功打开目录,不需要特别处理
|
||||
} catch (error) {
|
||||
console.error('打开目录错误:', error);
|
||||
alert(error.message);
|
||||
} finally {
|
||||
setProcessingId(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理菜单打开
|
||||
const handleMenuClick = event => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
// 处理菜单关闭
|
||||
const handleMenuClose = event => {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
// 处理打开目录点击
|
||||
const handleOpenDirectoryClick = event => {
|
||||
handleMenuClose(event);
|
||||
handleOpenDirectory(event);
|
||||
};
|
||||
|
||||
// 处理删除点击
|
||||
const handleDeleteClick = event => {
|
||||
handleMenuClose(event);
|
||||
onDeleteClick(event, project);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={styles.projectCard(theme)}>
|
||||
<Link
|
||||
href={`/projects/${project.id}`}
|
||||
passHref
|
||||
style={{ textDecoration: 'none', color: 'inherit', height: '100%' }}
|
||||
>
|
||||
<CardActionArea component="div" sx={{ height: '100%' }}>
|
||||
<CardContent sx={styles.projectCardContent}>
|
||||
{/* 头部:Avatar + Title + Menu */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', overflow: 'hidden', flex: 1 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.1),
|
||||
color: theme.palette.primary.main,
|
||||
width: 40,
|
||||
height: 40,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 'bold',
|
||||
borderRadius: '10px'
|
||||
}}
|
||||
>
|
||||
{project.name.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box sx={{ overflow: 'hidden', flex: 1 }}>
|
||||
<Typography variant="h6" sx={styles.projectTitle}>
|
||||
{project.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.7rem' }}>
|
||||
ID: {project.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleMenuClick}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
padding: '4px',
|
||||
'&:hover': { color: 'primary.main', bgcolor: alpha(theme.palette.primary.main, 0.1) }
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* 描述 */}
|
||||
<Typography variant="body2" sx={styles.projectDescription}>
|
||||
{project.description || t('projects.noDescription', { defaultValue: '暂无描述' })}
|
||||
</Typography>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<Box sx={styles.statsContainer}>
|
||||
<StatItem
|
||||
icon={QuizIcon}
|
||||
count={project._count.Questions}
|
||||
label={t('projects.questions')}
|
||||
color="primary"
|
||||
/>
|
||||
<StatItem
|
||||
icon={DataObjectIcon}
|
||||
count={(project._count.ImageDatasets || 0) + (project._count.Datasets || 0)}
|
||||
label={t('projects.datasets')}
|
||||
color="secondary"
|
||||
/>
|
||||
<StatItem
|
||||
icon={AssessmentIcon}
|
||||
count={project._count.EvalDatasets}
|
||||
label={t('projects.evalDatasets')}
|
||||
color="info"
|
||||
/>
|
||||
<StatItem
|
||||
icon={TokenIcon}
|
||||
count={project.totalTokens}
|
||||
label={t('projects.tokens')}
|
||||
color="success"
|
||||
isToken
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Link>
|
||||
|
||||
{/* 操作菜单 */}
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleMenuClose}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
PaperProps={{
|
||||
elevation: 3,
|
||||
sx: {
|
||||
borderRadius: '12px',
|
||||
minWidth: 160,
|
||||
mt: 0.5
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleOpenDirectoryClick}>
|
||||
<ListItemIcon>
|
||||
<FolderOpenIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<Typography variant="body2">{t('projects.openDirectory')}</Typography>
|
||||
</MenuItem>
|
||||
|
||||
<Divider sx={{ my: 0.5, opacity: 0.5 }} />
|
||||
|
||||
<MenuItem onClick={handleDeleteClick} sx={{ color: 'error.main' }}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon fontSize="small" color="error" />
|
||||
</ListItemIcon>
|
||||
<Typography variant="body2">{t('common.delete')}</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
117
easy-dataset-main/components/home/ProjectList.js
Normal file
117
easy-dataset-main/components/home/ProjectList.js
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Grid,
|
||||
Paper,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import ProjectCard from './ProjectCard';
|
||||
|
||||
export default function ProjectList({ projects, onCreateProject }) {
|
||||
const { t } = useTranslation();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// 打开删除确认对话框
|
||||
const handleOpenDeleteDialog = (event, project) => {
|
||||
setProjectToDelete(project);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 关闭删除确认对话框
|
||||
const handleCloseDeleteDialog = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setProjectToDelete(null);
|
||||
};
|
||||
|
||||
// 删除项目
|
||||
const handleDeleteProject = async () => {
|
||||
if (!projectToDelete) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectToDelete.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || t('projects.deleteFailed'));
|
||||
}
|
||||
|
||||
// 刷新页面以更新项目列表
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('删除项目失败:', error);
|
||||
alert(error.message || t('projects.deleteFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
handleCloseDeleteDialog();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={3}>
|
||||
{projects.length === 0 ? (
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
{t('projects.noProjects')}
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={onCreateProject} startIcon={<AddCircleOutlineIcon />} sx={{ mt: 2 }}>
|
||||
{t('projects.createFirst')}
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grid>
|
||||
) : (
|
||||
projects.map(project => (
|
||||
<Grid item xs={12} sm={6} md={4} key={project.id}>
|
||||
<ProjectCard project={project} onDeleteClick={handleOpenDeleteDialog} />
|
||||
</Grid>
|
||||
))
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={handleCloseDeleteDialog}
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-description"
|
||||
>
|
||||
<DialogTitle id="delete-dialog-title">{t('projects.deleteConfirmTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="delete-dialog-description">
|
||||
{projectToDelete && (
|
||||
<>
|
||||
{t('projects.deleteConfirm')}
|
||||
<br />
|
||||
<Typography component="span" fontWeight="bold" sx={{ mt: 1, display: 'inline-block' }}>
|
||||
{projectToDelete.name}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDeleteDialog} disabled={loading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleDeleteProject} color="error" variant="contained" disabled={loading}>
|
||||
{loading ? t('common.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
118
easy-dataset-main/components/home/StatsCard.js
Normal file
118
easy-dataset-main/components/home/StatsCard.js
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { Paper, Grid, Box, Typography, useMediaQuery, Avatar } from '@mui/material';
|
||||
import { styles } from '@/styles/home';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
|
||||
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import MemoryIcon from '@mui/icons-material/Memory';
|
||||
|
||||
// 默认模型列表
|
||||
const mockModels = [
|
||||
{ id: 'deepseek-r1', provider: 'Ollama', name: 'DeepSeek-R1' },
|
||||
{ id: 'gpt-3.5-turbo-openai', provider: 'OpenAI', name: 'gpt-3.5-turbo' },
|
||||
{ id: 'gpt-3.5-turbo-guiji', provider: 'Guiji', name: 'gpt-3.5-turbo' },
|
||||
{ id: 'glm-4-flash', provider: 'Zhipu AI', name: 'GLM-4-Flash' }
|
||||
];
|
||||
|
||||
export default function StatsCard({ projects }) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
// 统计卡片数据
|
||||
const statsItems = [
|
||||
{
|
||||
value: projects.length,
|
||||
label: t('stats.ongoingProjects'),
|
||||
color: 'primary',
|
||||
icon: <FolderOpenIcon />
|
||||
},
|
||||
{
|
||||
value: projects.reduce((sum, project) => sum + (project.questionsCount || 0), 0),
|
||||
label: t('stats.questionCount'),
|
||||
color: 'secondary',
|
||||
icon: <QuestionAnswerIcon />
|
||||
},
|
||||
{
|
||||
value: projects.reduce((sum, project) => sum + (project.datasetsCount || 0), 0),
|
||||
label: t('stats.generatedDatasets'),
|
||||
color: 'success',
|
||||
icon: <StorageIcon />
|
||||
},
|
||||
{
|
||||
value: mockModels.length,
|
||||
label: t('stats.supportedModels'),
|
||||
color: 'warning',
|
||||
icon: <MemoryIcon />
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={styles.statsCard(theme)}
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
{statsItems.map((item, index) => (
|
||||
<Grid item xs={12} sm={6} md={3} key={index}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: `0 10px 20px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.05)'}`
|
||||
}
|
||||
}}
|
||||
component={motion.div}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
mb: 2,
|
||||
bgcolor: theme.palette[item.color].main,
|
||||
color: '#fff',
|
||||
boxShadow: `0 4px 12px ${theme.palette[item.color].main}40`
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</Avatar>
|
||||
<Typography
|
||||
color={item.color + '.main'}
|
||||
variant={isMobile ? 'h3' : 'h2'}
|
||||
fontWeight="bold"
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
background: `linear-gradient(135deg, ${theme.palette[item.color].main} 0%, ${theme.palette[item.color].light} 100%)`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
textFillColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" fontWeight="500" sx={{ opacity: 0.8 }}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
151
easy-dataset-main/components/mga/GaPairsIndicator.js
Normal file
151
easy-dataset-main/components/mga/GaPairsIndicator.js
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import { Psychology as PsychologyIcon, AutoAwesome as AutoFixIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import GaPairsManager from './GaPairsManager';
|
||||
|
||||
/**
|
||||
* GA Pairs Indicator Component - Shows GA pairs status for a file
|
||||
* @param {Object} props
|
||||
* @param {string} props.projectId - Project ID
|
||||
* @param {string} props.fileId - File ID
|
||||
* @param {string} props.fileName - File name for display
|
||||
*/
|
||||
export default function GaPairsIndicator({ projectId, fileId, fileName = '未命名文件' }) {
|
||||
const { t } = useTranslation();
|
||||
const [gaPairs, setGaPairs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
// 获取GA对状态的函数
|
||||
const fetchGaPairsStatus = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setGaPairs([]);
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: Failed to load GA pairs`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 处理响应格式
|
||||
let newGaPairs = [];
|
||||
if (Array.isArray(result)) {
|
||||
newGaPairs = result;
|
||||
} else if (result?.data) {
|
||||
newGaPairs = result.data;
|
||||
}
|
||||
|
||||
setGaPairs(newGaPairs);
|
||||
} catch (error) {
|
||||
console.error('获取GA对状态失败:', error);
|
||||
setGaPairs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, fileId]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
if (projectId && fileId) {
|
||||
fetchGaPairsStatus();
|
||||
}
|
||||
}, [projectId, fileId, fetchGaPairsStatus]);
|
||||
|
||||
//监听外部事件
|
||||
useEffect(() => {
|
||||
const handleRefresh = event => {
|
||||
const { projectId: eventProjectId, fileIds } = event.detail || {};
|
||||
|
||||
if (eventProjectId === projectId && fileIds?.includes(String(fileId))) {
|
||||
fetchGaPairsStatus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('refreshGaPairsIndicators', handleRefresh);
|
||||
return () => window.removeEventListener('refreshGaPairsIndicators', handleRefresh);
|
||||
}, [projectId, fileId, fetchGaPairsStatus]);
|
||||
|
||||
// 计算激活的GA对数量
|
||||
const activePairs = gaPairs.filter(pair => pair.isActive);
|
||||
const hasGaPairs = gaPairs.length > 0;
|
||||
|
||||
//GA对变化回调处理
|
||||
const handleGaPairsChange = useCallback(newGaPairs => {
|
||||
setGaPairs(newGaPairs || []);
|
||||
}, []);
|
||||
|
||||
const handleOpenDialog = useCallback(() => {
|
||||
setDetailsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDetailsOpen(false);
|
||||
}, []);
|
||||
|
||||
//加载状态显示
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Loading...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{hasGaPairs ? (
|
||||
<Chip
|
||||
icon={<PsychologyIcon />}
|
||||
label={`${activePairs.length}/${gaPairs.length} GA Pairs`}
|
||||
size="small"
|
||||
color={activePairs.length > 0 ? 'primary' : 'default'}
|
||||
variant={activePairs.length > 0 ? 'filled' : 'outlined'}
|
||||
onClick={handleOpenDialog}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip title="Generate GA Pairs">
|
||||
<IconButton size="small" onClick={handleOpenDialog} color="primary">
|
||||
<AutoFixIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Details Dialog */}
|
||||
<Dialog open={detailsOpen} onClose={handleCloseDialog} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>GA Pairs for {fileName}</DialogTitle>
|
||||
<DialogContent>
|
||||
{detailsOpen && (
|
||||
<GaPairsManager projectId={projectId} fileId={fileId} onGaPairsChange={handleGaPairsChange} />
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
610
easy-dataset-main/components/mga/GaPairsManager.js
Normal file
610
easy-dataset-main/components/mga/GaPairsManager.js
Normal file
@@ -0,0 +1,610 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
TextField,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
AutoFixHigh as AutoFixHighIcon,
|
||||
Save as SaveIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/lib/i18n';
|
||||
|
||||
/**
|
||||
* GA Pairs Manager Component
|
||||
* @param {Object} props
|
||||
* @param {string} props.projectId - Project ID
|
||||
* @param {string} props.fileId - File ID
|
||||
* @param {Function} props.onGaPairsChange - Callback when GA pairs change
|
||||
*/
|
||||
export default function GaPairsManager({ projectId, fileId, onGaPairsChange }) {
|
||||
const { t } = useTranslation();
|
||||
const [gaPairs, setGaPairs] = useState([]);
|
||||
const [backupGaPairs, setBackupGaPairs] = useState([]); // 备份状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(null);
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [newGaPair, setNewGaPair] = useState({
|
||||
genreTitle: '',
|
||||
genreDesc: '',
|
||||
audienceTitle: '',
|
||||
audienceDesc: '',
|
||||
isActive: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadGaPairs();
|
||||
}, [projectId, fileId]);
|
||||
|
||||
const loadGaPairs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`);
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
console.warn('GA Pairs API not found, using empty data');
|
||||
setGaPairs([]);
|
||||
setBackupGaPairs([]);
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: Failed to load GA pairs`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Load GA pairs result:', result);
|
||||
|
||||
if (result.success) {
|
||||
const loadedData = result.data || [];
|
||||
setGaPairs(loadedData);
|
||||
setBackupGaPairs([...loadedData]); // 创建备份
|
||||
onGaPairsChange?.(loadedData);
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to load GA pairs');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load GA pairs error:', error);
|
||||
setError(t('gaPairs.loadError', { error: error.message }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateGaPairs = async () => {
|
||||
try {
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
|
||||
console.log('Starting GA pairs generation...');
|
||||
|
||||
// Get current language from i18n
|
||||
const currentLanguage = i18n.language === 'en' ? 'en' : '中文';
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
regenerate: false,
|
||||
appendMode: true, // 新增:启用追加模式
|
||||
language: currentLanguage
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = t('gaPairs.generateError');
|
||||
|
||||
if (response.status === 404) {
|
||||
errorMessage = t('gaPairs.serviceNotAvailable');
|
||||
} else if (response.status === 400) {
|
||||
try {
|
||||
const errorResult = await response.json();
|
||||
if (errorResult.error?.includes('No active AI model')) {
|
||||
errorMessage = t('gaPairs.noActiveModel');
|
||||
} else if (errorResult.error?.includes('content might be too short')) {
|
||||
errorMessage = t('gaPairs.contentTooShort');
|
||||
} else {
|
||||
errorMessage = errorResult.error || errorMessage;
|
||||
}
|
||||
} catch (parseError) {
|
||||
errorMessage = t('gaPairs.requestFailed', { status: response.status });
|
||||
}
|
||||
} else if (response.status === 500) {
|
||||
try {
|
||||
const errorResult = await response.json();
|
||||
if (errorResult.error?.includes('model configuration') || errorResult.error?.includes('Module not found')) {
|
||||
errorMessage = t('gaPairs.configError');
|
||||
} else {
|
||||
errorMessage = errorResult.error || 'Internal server error occurred.';
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse error response:', parseError);
|
||||
errorMessage = errorResult.error || t('gaPairs.internalServerError');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 处理成功响应
|
||||
const responseText = await response.text();
|
||||
if (!responseText || responseText.trim() === '') {
|
||||
throw new Error(t('gaPairs.emptyResponse'));
|
||||
}
|
||||
|
||||
const result = JSON.parse(responseText);
|
||||
console.log('Generate GA pairs result:', result);
|
||||
|
||||
if (result.success) {
|
||||
// 在追加模式下,后端只返回新生成的GA对
|
||||
const newGaPairs = result.data || [];
|
||||
|
||||
// 将新生成的GA对追加到现有的GA对
|
||||
const updatedGaPairs = [...gaPairs, ...newGaPairs];
|
||||
|
||||
setGaPairs(updatedGaPairs);
|
||||
setBackupGaPairs([...updatedGaPairs]); // 更新备份
|
||||
onGaPairsChange?.(updatedGaPairs);
|
||||
setSuccess(
|
||||
t('gaPairs.additionalPairsGenerated', {
|
||||
count: newGaPairs.length,
|
||||
total: updatedGaPairs.length
|
||||
})
|
||||
);
|
||||
} else {
|
||||
throw new Error(result.error || t('gaPairs.generationFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Generate GA pairs error:', error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveGaPairs = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
// 验证GA对数据
|
||||
const validatedGaPairs = gaPairs.map((pair, index) => {
|
||||
// 处理不同的数据格式
|
||||
let genreTitle, genreDesc, audienceTitle, audienceDesc;
|
||||
|
||||
if (pair.genre && typeof pair.genre === 'object') {
|
||||
genreTitle = pair.genre.title;
|
||||
genreDesc = pair.genre.description;
|
||||
} else {
|
||||
genreTitle = pair.genreTitle || pair.genre;
|
||||
genreDesc = pair.genreDesc || '';
|
||||
}
|
||||
|
||||
if (pair.audience && typeof pair.audience === 'object') {
|
||||
audienceTitle = pair.audience.title;
|
||||
audienceDesc = pair.audience.description;
|
||||
} else {
|
||||
audienceTitle = pair.audienceTitle || pair.audience;
|
||||
audienceDesc = pair.audienceDesc || '';
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if (!genreTitle || !audienceTitle) {
|
||||
throw new Error(t('gaPairs.validationError', { number: index + 1 }));
|
||||
}
|
||||
|
||||
return {
|
||||
id: pair.id,
|
||||
genreTitle: genreTitle.trim(),
|
||||
genreDesc: genreDesc.trim(),
|
||||
audienceTitle: audienceTitle.trim(),
|
||||
audienceDesc: audienceDesc.trim(),
|
||||
isActive: pair.isActive !== undefined ? pair.isActive : true
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Saving validated GA pairs:', validatedGaPairs);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
updates: validatedGaPairs
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = t('gaPairs.saveError');
|
||||
|
||||
if (response.status === 404) {
|
||||
errorMessage = 'GA Pairs save service is not available.';
|
||||
} else {
|
||||
try {
|
||||
const errorResult = await response.json();
|
||||
errorMessage = errorResult.error || errorMessage;
|
||||
} catch (parseError) {
|
||||
errorMessage = t('gaPairs.serverError', { status: response.status });
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
const result = responseText ? JSON.parse(responseText) : { success: true };
|
||||
|
||||
if (result.success) {
|
||||
// 更新本地状态为服务器返回的数据
|
||||
const savedData = result.data || validatedGaPairs;
|
||||
setGaPairs(savedData);
|
||||
|
||||
// 根据保存的GA对数量显示不同的成功消息
|
||||
if (savedData.length === 0) {
|
||||
setSuccess(t('gaPairs.allPairsDeleted'));
|
||||
} else {
|
||||
setSuccess(t('gaPairs.pairsSaved', { count: savedData.length }));
|
||||
}
|
||||
|
||||
onGaPairsChange?.(savedData);
|
||||
} else {
|
||||
throw new Error(result.error || t('gaPairs.saveOperationFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save GA pairs error:', error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGaPairChange = (index, field, value) => {
|
||||
const updatedGaPairs = [...gaPairs];
|
||||
|
||||
// 确保对象存在
|
||||
if (!updatedGaPairs[index]) {
|
||||
console.error(`GA pair at index ${index} does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
updatedGaPairs[index] = {
|
||||
...updatedGaPairs[index],
|
||||
[field]: value
|
||||
};
|
||||
|
||||
setGaPairs(updatedGaPairs);
|
||||
// 不立即调用 onGaPairsChange,等用户点击保存时再调用
|
||||
};
|
||||
|
||||
const handleDeleteGaPair = index => {
|
||||
const updatedGaPairs = gaPairs.filter((_, i) => i !== index);
|
||||
setGaPairs(updatedGaPairs);
|
||||
onGaPairsChange?.(updatedGaPairs);
|
||||
};
|
||||
|
||||
const handleAddGaPair = () => {
|
||||
// 验证输入
|
||||
if (!newGaPair.genreTitle?.trim() || !newGaPair.audienceTitle?.trim()) {
|
||||
setError(t('gaPairs.requiredFields'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建新的GA对对象
|
||||
const newPair = {
|
||||
id: `temp_${Date.now()}`, // 临时ID
|
||||
genreTitle: newGaPair.genreTitle.trim(),
|
||||
genreDesc: newGaPair.genreDesc?.trim() || '',
|
||||
audienceTitle: newGaPair.audienceTitle.trim(),
|
||||
audienceDesc: newGaPair.audienceDesc?.trim() || '',
|
||||
isActive: true
|
||||
};
|
||||
|
||||
const updatedGaPairs = [...gaPairs, newPair];
|
||||
setGaPairs(updatedGaPairs);
|
||||
onGaPairsChange?.(updatedGaPairs);
|
||||
|
||||
// 重置表单并关闭对话框
|
||||
setNewGaPair({
|
||||
genreTitle: '',
|
||||
genreDesc: '',
|
||||
audienceTitle: '',
|
||||
audienceDesc: '',
|
||||
isActive: true
|
||||
});
|
||||
setAddDialogOpen(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const resetMessages = () => {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
};
|
||||
|
||||
const recoverFromBackup = () => {
|
||||
setGaPairs([...backupGaPairs]);
|
||||
setError(null);
|
||||
setSuccess(t('gaPairs.restoredFromBackup'));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (error || success) {
|
||||
const timer = setTimeout(resetMessages, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error, success]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
<Typography sx={{ ml: 2 }}>{t('gaPairs.loading')}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
{/* Header with action buttons */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6">{t('gaPairs.title')}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{/* 右上角按钮为手动添加GA对 */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
disabled={generating || saving}
|
||||
>
|
||||
{t('gaPairs.addPair')}
|
||||
</Button>
|
||||
<Button variant="contained" startIcon={<SaveIcon />} onClick={saveGaPairs} disabled={generating || saving}>
|
||||
{saving ? <CircularProgress size={20} /> : t('gaPairs.saveChanges')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Error/Success Messages */}
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2 }}
|
||||
action={
|
||||
backupGaPairs.length > 0 && (
|
||||
<Button color="inherit" size="small" onClick={recoverFromBackup}>
|
||||
{t('gaPairs.restoreBackup')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
onClose={resetMessages}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 2 }} onClose={resetMessages}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Generate GA Pairs Section - 只在没有GA对时显示 */}
|
||||
{gaPairs.length === 0 && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('gaPairs.noGaPairsTitle')}
|
||||
</Typography>
|
||||
<Typography color="textSecondary" sx={{ mb: 2 }}>
|
||||
{t('gaPairs.noGaPairsDescription')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={generating ? <CircularProgress size={20} /> : <AutoFixHighIcon />}
|
||||
onClick={generateGaPairs}
|
||||
disabled={generating}
|
||||
size="large"
|
||||
>
|
||||
{generating ? t('gaPairs.generating') : t('gaPairs.generateGaPairs')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* GA Pairs List */}
|
||||
{gaPairs.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||
{t('gaPairs.activePairs', {
|
||||
active: gaPairs.filter(pair => pair.isActive).length,
|
||||
total: gaPairs.length
|
||||
})}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{gaPairs.map((pair, index) => (
|
||||
<Grid item xs={12} key={pair.id || index}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Typography variant="subtitle2" color="primary">
|
||||
{t('gaPairs.pairNumber', { number: index + 1 })}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={pair.isActive}
|
||||
onChange={e => handleGaPairChange(index, 'isActive', e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={t('gaPairs.active')}
|
||||
/>
|
||||
{/* 添加删除按钮 */}
|
||||
<Tooltip title={t('gaPairs.deleteTooltip')}>
|
||||
<IconButton size="small" color="error" onClick={() => handleDeleteGaPair(index)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label={t('gaPairs.genre')}
|
||||
value={pair.genreTitle || pair.genre || ''}
|
||||
onChange={e => handleGaPairChange(index, 'genreTitle', e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
disabled={!pair.isActive}
|
||||
/>
|
||||
<TextField
|
||||
label={t('gaPairs.genreDescription')}
|
||||
value={pair.genreDesc || ''}
|
||||
onChange={e => handleGaPairChange(index, 'genreDesc', e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
disabled={!pair.isActive}
|
||||
/>
|
||||
<TextField
|
||||
label={t('gaPairs.audience')}
|
||||
value={pair.audienceTitle || pair.audience || ''}
|
||||
onChange={e => handleGaPairChange(index, 'audienceTitle', e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
disabled={!pair.isActive}
|
||||
/>
|
||||
<TextField
|
||||
label={t('gaPairs.audienceDescription')}
|
||||
value={pair.audienceDesc || ''}
|
||||
onChange={e => handleGaPairChange(index, 'audienceDesc', e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
disabled={!pair.isActive}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* 在GA对列表下方添加生成按钮 */}
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={generating ? <CircularProgress size={20} /> : <AutoFixHighIcon />}
|
||||
onClick={generateGaPairs}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? t('gaPairs.generating') : t('gaPairs.generateMore')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Add GA Pair Dialog */}
|
||||
<Dialog open={addDialogOpen} onClose={() => setAddDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{t('gaPairs.addDialogTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label={t('gaPairs.genreTitle')}
|
||||
value={newGaPair.genreTitle || ''}
|
||||
onChange={e => setNewGaPair({ ...newGaPair, genreTitle: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
placeholder={t('gaPairs.genreTitlePlaceholder')}
|
||||
/>
|
||||
<TextField
|
||||
label={t('gaPairs.genreDescription')}
|
||||
value={newGaPair.genreDesc || ''}
|
||||
onChange={e => setNewGaPair({ ...newGaPair, genreDesc: e.target.value })}
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
placeholder={t('gaPairs.genreDescPlaceholder')}
|
||||
/>
|
||||
<TextField
|
||||
label={t('gaPairs.audienceTitle')}
|
||||
value={newGaPair.audienceTitle || ''}
|
||||
onChange={e => setNewGaPair({ ...newGaPair, audienceTitle: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
placeholder={t('gaPairs.audienceTitlePlaceholder')}
|
||||
/>
|
||||
<TextField
|
||||
label={t('gaPairs.audienceDescription')}
|
||||
value={newGaPair.audienceDesc || ''}
|
||||
onChange={e => setNewGaPair({ ...newGaPair, audienceDesc: e.target.value })}
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
placeholder={t('gaPairs.audienceDescPlaceholder')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={newGaPair.isActive}
|
||||
onChange={e => setNewGaPair({ ...newGaPair, isActive: e.target.checked })}
|
||||
/>
|
||||
}
|
||||
label={t('gaPairs.active')}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAddDialogOpen(false);
|
||||
// 重置表单
|
||||
setNewGaPair({
|
||||
genreTitle: '',
|
||||
genreDesc: '',
|
||||
audienceTitle: '',
|
||||
audienceDesc: '',
|
||||
isActive: true
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('gaPairs.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddGaPair}
|
||||
variant="contained"
|
||||
disabled={!newGaPair.genreTitle?.trim() || !newGaPair.audienceTitle?.trim()}
|
||||
>
|
||||
{t('gaPairs.addPairButton')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
83
easy-dataset-main/components/playground/ChatArea.js
Normal file
83
easy-dataset-main/components/playground/ChatArea.js
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Box, Typography, Paper, Grid, CircularProgress } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import ChatMessage from './ChatMessage';
|
||||
import { playgroundStyles } from '@/styles/playground';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ChatArea = ({ selectedModels, conversations, loading, getModelName }) => {
|
||||
const theme = useTheme();
|
||||
const styles = playgroundStyles(theme);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 为每个模型创建独立的引用
|
||||
const chatContainerRefs = {
|
||||
model1: useRef(null),
|
||||
model2: useRef(null),
|
||||
model3: useRef(null)
|
||||
};
|
||||
|
||||
// 为每个模型的聊天容器自动滚动到底部
|
||||
useEffect(() => {
|
||||
Object.values(chatContainerRefs).forEach(ref => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}
|
||||
});
|
||||
}, [conversations]);
|
||||
|
||||
if (selectedModels.length === 0) {
|
||||
return (
|
||||
<Box sx={styles.emptyStateBox}>
|
||||
<Typography color="textSecondary">{t('playground.selectModelFirst')}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid container spacing={2} sx={styles.chatContainer}>
|
||||
{selectedModels.map((modelId, index) => {
|
||||
const modelConversation = conversations[modelId] || [];
|
||||
const isLoading = loading[modelId];
|
||||
const refKey = `model${index + 1}`;
|
||||
|
||||
return (
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={selectedModels.length > 1 ? 12 / selectedModels.length : 12}
|
||||
key={modelId}
|
||||
style={{ maxHeight: 'calc(100vh - 300px)' }}
|
||||
>
|
||||
<Paper elevation={1} sx={styles.modelPaper}>
|
||||
<Box sx={styles.modelHeader}>
|
||||
<Typography variant="subtitle2">{getModelName(modelId)}</Typography>
|
||||
{isLoading && <CircularProgress size={16} sx={{ ml: 1 }} color="inherit" />}
|
||||
</Box>
|
||||
|
||||
<Box ref={chatContainerRefs[refKey]} sx={styles.modelChatBox}>
|
||||
{modelConversation.length === 0 ? (
|
||||
<Box sx={styles.emptyChatBox}>
|
||||
<Typography color="textSecondary" variant="body2">
|
||||
{t('playground.sendFirstMessage')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
modelConversation.map((message, msgIndex) => (
|
||||
<React.Fragment key={msgIndex}>
|
||||
<ChatMessage message={message} modelName={null} />
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatArea;
|
||||
215
easy-dataset-main/components/playground/ChatMessage.js
Normal file
215
easy-dataset-main/components/playground/ChatMessage.js
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Paper, Typography, Alert, useTheme, IconButton, Collapse } from '@mui/material';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 聊天消息组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.message - 消息对象
|
||||
* @param {string} props.message.role - 消息角色:'user'、'assistant' 或 'error'
|
||||
* @param {string} props.message.content - 消息内容
|
||||
* @param {string} props.modelName - 模型名称(仅在 assistant 或 error 类型消息中显示)
|
||||
*/
|
||||
export default function ChatMessage({ message, modelName }) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 用户消息
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: '16px 16px 0 16px',
|
||||
maxWidth: '80%',
|
||||
bgcolor: theme.palette.primary.main,
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{typeof message.content === 'string' ? (
|
||||
<Typography variant="body1">{message.content}</Typography>
|
||||
) : (
|
||||
// 如果是数组类型(用于视觉模型的用户输入)
|
||||
<>
|
||||
{Array.isArray(message.content) &&
|
||||
message.content.map((item, i) => {
|
||||
if (item.type === 'text') {
|
||||
return (
|
||||
<Typography key={i} variant="body1">
|
||||
{item.text}
|
||||
</Typography>
|
||||
);
|
||||
} else if (item.type === 'image_url') {
|
||||
return (
|
||||
<Box key={i} sx={{ mt: 1, mb: 1 }}>
|
||||
<img
|
||||
src={item.image_url.url}
|
||||
alt="上传图片"
|
||||
style={{ maxWidth: '100%', borderRadius: '4px' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 助手消息
|
||||
if (message.role === 'assistant') {
|
||||
// 处理推理过程的展示状态
|
||||
const [showThinking, setShowThinking] = useState(message.showThinking || false);
|
||||
const hasThinking = message.thinking && message.thinking.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: '16px 16px 16px 0',
|
||||
maxWidth: '80%',
|
||||
width: hasThinking ? '80%' : 'auto',
|
||||
bgcolor: theme.palette.mode === 'dark' ? theme.palette.grey[800] : theme.palette.grey[100]
|
||||
}}
|
||||
>
|
||||
{modelName && (
|
||||
<Typography variant="caption" color="textSecondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
{modelName}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 推理过程显示区域 */}
|
||||
{hasThinking && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{message.isStreaming ? (
|
||||
<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="primary" />
|
||||
)}
|
||||
<Typography variant="caption" color="primary" fontWeight="bold">
|
||||
{t('playground.reasoningProcess', '推理过程')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => setShowThinking(!showThinking)} sx={{ p: 0 }}>
|
||||
{showThinking ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showThinking}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: theme.palette.text.secondary }}>
|
||||
{message.thinking}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 回答内容 */}
|
||||
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{typeof message.content === 'string' ? (
|
||||
<>
|
||||
{message.content}
|
||||
{message.isStreaming && <span className="blinking-cursor">|</span>}
|
||||
</>
|
||||
) : (
|
||||
// 如果是数组类型(用于视觉模型的响应)
|
||||
<>
|
||||
{Array.isArray(message.content) &&
|
||||
message.content.map((item, i) => {
|
||||
if (item.type === 'text') {
|
||||
return <span key={i}>{item.text}</span>;
|
||||
} else if (item.type === 'image_url') {
|
||||
return (
|
||||
<Box key={i} sx={{ mt: 1, mb: 1 }}>
|
||||
<img src={item.image_url.url} alt="图片" style={{ maxWidth: '100%', borderRadius: '4px' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{message.isStreaming && <span className="blinking-cursor">|</span>}
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误消息
|
||||
if (message.role === 'error') {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
<Alert severity="error" sx={{ maxWidth: '80%' }}>
|
||||
{modelName && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
{modelName}
|
||||
</Typography>
|
||||
)}
|
||||
{message.content}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
104
easy-dataset-main/components/playground/MessageInput.js
Normal file
104
easy-dataset-main/components/playground/MessageInput.js
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box, TextField, Button, IconButton, Badge, Tooltip } from '@mui/material';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { playgroundStyles } from '@/styles/playground';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MessageInput = ({
|
||||
userInput,
|
||||
handleInputChange,
|
||||
handleSendMessage,
|
||||
loading,
|
||||
selectedModels,
|
||||
uploadedImage,
|
||||
handleImageUpload,
|
||||
handleRemoveImage,
|
||||
availableModels
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = playgroundStyles(theme);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isDisabled = Object.values(loading).some(value => value) || selectedModels.length === 0;
|
||||
const isSendDisabled = isDisabled || (!userInput.trim() && !uploadedImage);
|
||||
|
||||
// 检查是否有视觉模型被选中
|
||||
const hasVisionModel = selectedModels.some(modelId => {
|
||||
const model = availableModels.find(m => m.id === modelId);
|
||||
return model && model.type === 'vision';
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={styles.inputContainer}>
|
||||
{uploadedImage && (
|
||||
<Box sx={{ position: 'relative', mb: 1, display: 'inline-block', maxWidth: '100%' }}>
|
||||
<Badge
|
||||
badgeContent={
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemoveImage}
|
||||
sx={{ bgcolor: 'rgba(0, 0, 0, 0.4)', '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.6)' } }}
|
||||
>
|
||||
<CancelIcon fontSize="small" sx={{ color: '#fff' }} />
|
||||
</IconButton>
|
||||
}
|
||||
sx={{ width: '100%' }}
|
||||
overlap="rectangular"
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<img
|
||||
src={uploadedImage}
|
||||
alt="上传图片"
|
||||
style={{ maxWidth: '100%', maxHeight: '200px', borderRadius: '4px' }}
|
||||
/>
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', width: '100%' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder={t('playground.inputMessage')}
|
||||
value={userInput}
|
||||
onChange={handleInputChange}
|
||||
disabled={isDisabled}
|
||||
onKeyPress={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
multiline
|
||||
maxRows={4}
|
||||
/>
|
||||
{hasVisionModel && (
|
||||
<Tooltip title={t('playground.uploadImage')}>
|
||||
<span>
|
||||
<IconButton color="primary" component="label" disabled={isDisabled} sx={{ ml: 1, mr: 1 }}>
|
||||
<input hidden accept="image/*" type="file" onChange={handleImageUpload} />
|
||||
<ImageIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
endIcon={<SendIcon />}
|
||||
onClick={handleSendMessage}
|
||||
disabled={isSendDisabled}
|
||||
sx={styles.sendButton}
|
||||
>
|
||||
{t('playground.send')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageInput;
|
||||
81
easy-dataset-main/components/playground/ModelSelector.js
Normal file
81
easy-dataset-main/components/playground/ModelSelector.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
OutlinedInput,
|
||||
Box,
|
||||
Chip,
|
||||
Checkbox,
|
||||
ListItemText
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ITEM_HEIGHT = 48;
|
||||
const ITEM_PADDING_TOP = 8;
|
||||
const MenuProps = {
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
|
||||
width: 250
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 模型选择组件
|
||||
* @param {Object} props
|
||||
* @param {Array} props.models - 可用模型列表
|
||||
* @param {Array} props.selectedModels - 已选择的模型ID列表
|
||||
* @param {Function} props.onChange - 选择改变时的回调函数
|
||||
*/
|
||||
export default function ModelSelector({ models, selectedModels, onChange }) {
|
||||
// 获取模型名称
|
||||
const getModelName = modelId => {
|
||||
const model = models.find(m => m.id === modelId);
|
||||
return model ? `${model.providerName}: ${model.modelName}` : modelId;
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="model-select-label">{t('playground.selectModelMax3')}</InputLabel>
|
||||
<Select
|
||||
labelId="model-select-label"
|
||||
id="model-select"
|
||||
multiple
|
||||
value={selectedModels}
|
||||
onChange={onChange}
|
||||
input={<OutlinedInput label="选择模型(最多3个)" />}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map(modelId => (
|
||||
<Chip key={modelId} label={getModelName(modelId)} color="primary" variant="outlined" size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
MenuProps={MenuProps}
|
||||
>
|
||||
{models
|
||||
.filter(m => {
|
||||
if (m.providerId.toLowerCase() === 'ollama') {
|
||||
return m.modelName && m.endpoint;
|
||||
} else {
|
||||
return m.modelName && m.endpoint && m.apiKey;
|
||||
}
|
||||
})
|
||||
.map(model => (
|
||||
<MenuItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
disabled={selectedModels.length >= 3 && !selectedModels.includes(model.id)}
|
||||
>
|
||||
<Checkbox checked={selectedModels.indexOf(model.id) > -1} />
|
||||
<ListItemText primary={`${model.providerName}: ${model.modelName}`} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
66
easy-dataset-main/components/playground/PlaygroundHeader.js
Normal file
66
easy-dataset-main/components/playground/PlaygroundHeader.js
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Grid, Button, Divider, FormControl, InputLabel, Select, MenuItem } from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import ModelSelector from './ModelSelector';
|
||||
import { playgroundStyles } from '@/styles/playground';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PlaygroundHeader = ({
|
||||
availableModels,
|
||||
selectedModels,
|
||||
handleModelSelection,
|
||||
handleClearConversations,
|
||||
conversations,
|
||||
outputMode,
|
||||
handleOutputModeChange
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = playgroundStyles(theme);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isClearDisabled = selectedModels.length === 0 || Object.values(conversations).every(conv => conv.length === 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} sx={styles.controlsContainer}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<ModelSelector models={availableModels} selectedModels={selectedModels} onChange={handleModelSelection} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="output-mode-label">{t('playground.outputMode')}</InputLabel>
|
||||
<Select
|
||||
labelId="output-mode-label"
|
||||
id="output-mode-select"
|
||||
value={outputMode}
|
||||
label={t('playground.outputMode')}
|
||||
onChange={handleOutputModeChange}
|
||||
>
|
||||
<MenuItem value="normal">{t('playground.normalOutput')}</MenuItem>
|
||||
<MenuItem value="streaming">{t('playground.streamingOutput')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleClearConversations}
|
||||
disabled={isClearDisabled}
|
||||
sx={styles.clearButton}
|
||||
>
|
||||
{t('playground.clearConversation')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Divider sx={styles.divider} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaygroundHeader;
|
||||
374
easy-dataset-main/components/questions/QuestionListView.js
Normal file
374
easy-dataset-main/components/questions/QuestionListView.js
Normal file
@@ -0,0 +1,374 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Pagination,
|
||||
Divider,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
TextField
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import ChatIcon from '@mui/icons-material/Chat';
|
||||
import { useGenerateDataset } from '@/hooks/useGenerateDataset';
|
||||
import { toast } from 'sonner';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
|
||||
export default function QuestionListView({
|
||||
questions = [],
|
||||
currentPage,
|
||||
totalQuestions = 0,
|
||||
handlePageChange,
|
||||
selectedQuestions = [],
|
||||
onSelectQuestion,
|
||||
onDeleteQuestion,
|
||||
projectId,
|
||||
onEditQuestion,
|
||||
refreshQuestions
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// 处理状态
|
||||
const [processingQuestions, setProcessingQuestions] = useState({});
|
||||
const { generateSingleDataset } = useGenerateDataset();
|
||||
// 获取当前选中的模型
|
||||
const selectedModelInfo = useAtomValue(selectedModelInfoAtom);
|
||||
|
||||
// 获取文本块的标题
|
||||
const getChunkTitle = content => {
|
||||
const firstLine = content ? content.split('\n')[0].trim() : '';
|
||||
if (firstLine.startsWith('# ')) {
|
||||
return firstLine.substring(2);
|
||||
} else if (firstLine.length > 0) {
|
||||
return firstLine.length > 200 ? firstLine.substring(0, 200) + '...' : firstLine;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// 检查问题是否被选中
|
||||
const isQuestionSelected = questionId => {
|
||||
return selectedQuestions.includes(questionId);
|
||||
};
|
||||
|
||||
// 处理生成数据集
|
||||
const handleGenerateDataset = async (questionId, questionInfo, imageId, imageName) => {
|
||||
// 设置处理状态
|
||||
setProcessingQuestions(prev => ({
|
||||
...prev,
|
||||
[questionId]: true
|
||||
}));
|
||||
await generateSingleDataset({
|
||||
projectId,
|
||||
questionId,
|
||||
questionInfo,
|
||||
imageId,
|
||||
imageName
|
||||
});
|
||||
// 重置处理状态
|
||||
setProcessingQuestions(prev => ({
|
||||
...prev,
|
||||
[questionId]: false
|
||||
}));
|
||||
refreshQuestions();
|
||||
};
|
||||
|
||||
// 处理生成多轮对话数据集
|
||||
const handleGenerateMultiTurnDataset = async (questionId, questionInfo) => {
|
||||
try {
|
||||
// 设置处理状态
|
||||
setProcessingQuestions(prev => ({
|
||||
...prev,
|
||||
[`${questionId}_multi`]: true
|
||||
}));
|
||||
|
||||
// 首先检查项目是否配置了多轮对话设置
|
||||
const configResponse = await fetch(`/api/projects/${projectId}/tasks`);
|
||||
if (!configResponse.ok) {
|
||||
throw new Error('获取项目配置失败');
|
||||
}
|
||||
|
||||
const config = await configResponse.json();
|
||||
const multiTurnConfig = {
|
||||
systemPrompt: config.multiTurnSystemPrompt,
|
||||
scenario: config.multiTurnScenario,
|
||||
rounds: config.multiTurnRounds,
|
||||
roleA: config.multiTurnRoleA,
|
||||
roleB: config.multiTurnRoleB
|
||||
};
|
||||
|
||||
console.log('multiTurnConfig:', multiTurnConfig);
|
||||
|
||||
// 检查是否已配置必要的多轮对话设置
|
||||
// 系统提示词是可选的,但场景、角色A、角色B和轮数是必需的
|
||||
if (
|
||||
!multiTurnConfig.scenario ||
|
||||
!multiTurnConfig.roleA ||
|
||||
!multiTurnConfig.roleB ||
|
||||
!multiTurnConfig.rounds ||
|
||||
multiTurnConfig.rounds < 1
|
||||
) {
|
||||
toast.error(t('questions.multiTurnNotConfigured', '请先在项目设置中配置多轮对话相关参数'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否选中了模型
|
||||
if (!selectedModelInfo) {
|
||||
toast.error(t('datasets.selectModelFirst', '请先选择模型'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用多轮对话生成API
|
||||
const response = await fetch(`/api/projects/${projectId}/dataset-conversations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
questionId,
|
||||
...multiTurnConfig,
|
||||
model: selectedModelInfo
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || '生成多轮对话数据集失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
toast.success(t('questions.multiTurnGenerated', '多轮对话数据集生成成功!'));
|
||||
} catch (error) {
|
||||
console.error('生成多轮对话数据集失败:', error);
|
||||
toast.error(error.message || '生成多轮对话数据集失败');
|
||||
} finally {
|
||||
// 重置处理状态
|
||||
setProcessingQuestions(prev => ({
|
||||
...prev,
|
||||
[`${questionId}_multi`]: false
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ padding: '20px' }}>
|
||||
{/* 问题列表 */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.05)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', bgcolor: 'background.paper' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, ml: 1 }}>
|
||||
{t('datasets.question')}
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mr: 2, display: { xs: 'none', sm: 'block' } }}>
|
||||
{t('common.label')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 500, width: 150, mr: 2, display: { xs: 'none', md: 'block' } }}
|
||||
>
|
||||
{t('common.dataSource')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, width: 100, textAlign: 'center' }}>
|
||||
{t('common.actions')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{questions.map((question, index) => {
|
||||
const isSelected = isQuestionSelected(question.id);
|
||||
const questionKey = question.id;
|
||||
return (
|
||||
<Box key={questionKey}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
bgcolor: isSelected ? 'action.selected' : 'background.paper',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
onSelectQuestion(questionKey);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Box sx={{ ml: 1, flex: 1, mr: 2 }}>
|
||||
<Typography variant="body2">
|
||||
{question.question}
|
||||
{question.datasetCount > 0 ? (
|
||||
<Chip
|
||||
label={t('datasets.answerCount', { count: question.datasetCount })}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.75rem', maxWidth: 150 }}
|
||||
/>
|
||||
) : null}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: { xs: 'block', sm: 'none' } }}>
|
||||
{question.label || t('datasets.noTag')} • ID: {(question.question || '').substring(0, 8)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' }, mr: 2 }}>
|
||||
{question.label ? (
|
||||
<Chip
|
||||
label={question.label}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.75rem', maxWidth: 150 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{t('datasets.noTag')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: 150, mr: 2, display: { xs: 'none', md: 'block' } }}>
|
||||
<Tooltip title={getChunkTitle(question.chunk?.content)}>
|
||||
<Chip
|
||||
label={
|
||||
question.imageId
|
||||
? `Image: ${question.imageName}`
|
||||
: `${t('chunks.title')}: ${question.chunk?.name}`
|
||||
}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
maxWidth: '100%',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: 160, display: 'flex', justifyContent: 'center' }}>
|
||||
<Tooltip title={t('common.edit')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => onEditQuestion(question)}
|
||||
disabled={processingQuestions[questionKey]}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('datasets.generateDataset')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
handleGenerateDataset(question.id, question.question, question.imageId, question.imageName)
|
||||
}
|
||||
disabled={processingQuestions[questionKey]}
|
||||
>
|
||||
{processingQuestions[questionKey] ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<AutoFixHighIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{!question.imageId && (
|
||||
<Tooltip title={t('questions.generateMultiTurn', '生成多轮对话')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => handleGenerateMultiTurnDataset(question.id, question.question)}
|
||||
disabled={processingQuestions[`${questionKey}_multi`]}
|
||||
>
|
||||
{processingQuestions[`${questionKey}_multi`] ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<ChatIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => onDeleteQuestion(question.id)}
|
||||
disabled={processingQuestions[questionKey]}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
{index < questions.length - 1 && <Divider />}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Paper>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalQuestions > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', mt: 3, mb: 2 }}>
|
||||
<Pagination
|
||||
count={totalQuestions}
|
||||
page={currentPage}
|
||||
onChange={handlePageChange}
|
||||
color="primary"
|
||||
showFirstButton
|
||||
showLastButton
|
||||
shape="rounded"
|
||||
size="medium"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2">{t('common.jumpTo')}:</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: totalQuestions,
|
||||
style: { padding: '4px 8px', width: '50px' }
|
||||
}}
|
||||
onKeyPress={e => {
|
||||
if (e.key === 'Enter') {
|
||||
const pageNum = parseInt(e.target.value, 10);
|
||||
if (pageNum >= 1 && pageNum <= totalQuestions) {
|
||||
handlePageChange(null, pageNum);
|
||||
e.target.value = '';
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
565
easy-dataset-main/components/questions/QuestionTreeView.js
Normal file
565
easy-dataset-main/components/questions/QuestionTreeView.js
Normal file
@@ -0,0 +1,565 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Divider,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
|
||||
import { useGenerateDataset } from '@/hooks/useGenerateDataset';
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* 问题树视图组件
|
||||
* @param {Object} props
|
||||
* @param {Array} props.tags - 标签树
|
||||
* @param {Array} props.selectedQuestions - 已选择的问题ID列表
|
||||
* @param {Function} props.onSelectQuestion - 选择问题的回调函数
|
||||
* @param {Function} props.onDeleteQuestion - 删除问题的回调函数
|
||||
*/
|
||||
export default function QuestionTreeView({
|
||||
tags = [],
|
||||
selectedQuestions = [],
|
||||
onSelectQuestion,
|
||||
onDeleteQuestion,
|
||||
onEditQuestion,
|
||||
projectId,
|
||||
searchTerm
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedTags, setExpandedTags] = useState({});
|
||||
const [questionsByTag, setQuestionsByTag] = useState({});
|
||||
const [processingQuestions, setProcessingQuestions] = useState({});
|
||||
const { generateSingleDataset } = useGenerateDataset();
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [loadedTags, setLoadedTags] = useState({});
|
||||
// 初始化时,将所有标签设置为收起状态(而不是展开状态)
|
||||
useEffect(() => {
|
||||
async function fetchTagsInfo() {
|
||||
try {
|
||||
// 获取标签信息,仅用于标签统计
|
||||
const response = await axios.get(`/api/projects/${projectId}/questions/tree?tagsOnly=true&input=${searchTerm}`);
|
||||
setQuestions(response.data); // 设置数据仅用于标签统计
|
||||
|
||||
// 当搜索条件变化时,重新加载已展开标签的问题数据
|
||||
const expandedTagLabels = Object.entries(expandedTags)
|
||||
.filter(([_, isExpanded]) => isExpanded)
|
||||
.map(([label]) => label);
|
||||
|
||||
// 重新加载已展开标签的数据
|
||||
for (const label of expandedTagLabels) {
|
||||
fetchTagQuestions(label);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取标签信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
fetchTagsInfo();
|
||||
}
|
||||
|
||||
const initialExpandedState = {};
|
||||
const processTag = tag => {
|
||||
// 将默认状态改为 false(收起)而不是 true(展开)
|
||||
initialExpandedState[tag.label] = false;
|
||||
if (tag.child && tag.child.length > 0) {
|
||||
tag.child.forEach(processTag);
|
||||
}
|
||||
};
|
||||
|
||||
tags.forEach(processTag);
|
||||
// 未分类问题也默认收起
|
||||
initialExpandedState['uncategorized'] = false;
|
||||
setExpandedTags(initialExpandedState);
|
||||
}, [tags]);
|
||||
|
||||
// 根据标签对问题进行分类
|
||||
useEffect(() => {
|
||||
const taggedQuestions = {};
|
||||
|
||||
// 初始化标签映射
|
||||
const initTagMap = tag => {
|
||||
taggedQuestions[tag.label] = [];
|
||||
if (tag.child && tag.child.length > 0) {
|
||||
tag.child.forEach(initTagMap);
|
||||
}
|
||||
};
|
||||
|
||||
tags.forEach(initTagMap);
|
||||
|
||||
// 将问题分配到对应的标签下
|
||||
questions.forEach(question => {
|
||||
// 如果问题没有标签,添加到"未分类"
|
||||
if (!question.label) {
|
||||
if (!taggedQuestions['uncategorized']) {
|
||||
taggedQuestions['uncategorized'] = [];
|
||||
}
|
||||
taggedQuestions['uncategorized'].push(question);
|
||||
return;
|
||||
}
|
||||
|
||||
// 将问题添加到匹配的标签下
|
||||
const questionLabel = question.label;
|
||||
|
||||
// 查找最精确匹配的标签
|
||||
// 使用一个数组来存储所有匹配的标签路径,以便找到最精确的匹配
|
||||
const findAllMatchingTags = (tag, path = []) => {
|
||||
const currentPath = [...path, tag.label];
|
||||
|
||||
// 存储所有匹配结果
|
||||
const matches = [];
|
||||
|
||||
// 精确匹配当前标签
|
||||
if (tag.label === questionLabel) {
|
||||
matches.push({ label: tag.label, depth: currentPath.length });
|
||||
}
|
||||
|
||||
// 检查子标签
|
||||
if (tag.child && tag.child.length > 0) {
|
||||
for (const childTag of tag.child) {
|
||||
const childMatches = findAllMatchingTags(childTag, currentPath);
|
||||
matches.push(...childMatches);
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
// 在所有根标签中查找所有匹配
|
||||
let allMatches = [];
|
||||
for (const rootTag of tags) {
|
||||
const matches = findAllMatchingTags(rootTag);
|
||||
allMatches.push(...matches);
|
||||
}
|
||||
|
||||
// 找到深度最大的匹配(最精确的匹配)
|
||||
let matchedTagLabel = null;
|
||||
if (allMatches.length > 0) {
|
||||
// 按深度排序,深度最大的是最精确的匹配
|
||||
allMatches.sort((a, b) => b.depth - a.depth);
|
||||
matchedTagLabel = allMatches[0].label;
|
||||
}
|
||||
|
||||
if (matchedTagLabel) {
|
||||
// 如果找到匹配的标签,将问题添加到该标签下
|
||||
if (!taggedQuestions[matchedTagLabel]) {
|
||||
taggedQuestions[matchedTagLabel] = [];
|
||||
}
|
||||
taggedQuestions[matchedTagLabel].push(question);
|
||||
} else {
|
||||
// 如果找不到匹配的标签,添加到"未分类"
|
||||
if (!taggedQuestions['uncategorized']) {
|
||||
taggedQuestions['uncategorized'] = [];
|
||||
}
|
||||
taggedQuestions['uncategorized'].push(question);
|
||||
}
|
||||
});
|
||||
|
||||
setQuestionsByTag(taggedQuestions);
|
||||
}, [questions, tags]);
|
||||
|
||||
// 处理展开/折叠标签 - 使用 useCallback 优化
|
||||
const handleToggleExpand = useCallback(
|
||||
tagLabel => {
|
||||
// 检查是否需要加载此标签的问题数据
|
||||
const shouldExpand = !expandedTags[tagLabel];
|
||||
|
||||
if (shouldExpand && !loadedTags[tagLabel]) {
|
||||
// 如果要展开且尚未加载数据,则加载数据
|
||||
fetchTagQuestions(tagLabel);
|
||||
}
|
||||
|
||||
setExpandedTags(prev => ({
|
||||
...prev,
|
||||
[tagLabel]: shouldExpand
|
||||
}));
|
||||
},
|
||||
[expandedTags, loadedTags, projectId]
|
||||
);
|
||||
|
||||
// 获取特定标签的问题数据
|
||||
const fetchTagQuestions = useCallback(
|
||||
async tagLabel => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`/api/projects/${projectId}/questions/tree?tag=${encodeURIComponent(tagLabel)}${searchTerm ? `&input=${searchTerm}` : ''}`
|
||||
);
|
||||
|
||||
// 更新问题数据,合并新获取的数据
|
||||
setQuestions(prev => {
|
||||
// 创建一个新数组,包含现有数据
|
||||
const updatedQuestions = [...prev];
|
||||
|
||||
// 添加新获取的问题数据
|
||||
response.data.forEach(newQuestion => {
|
||||
// 检查是否已存在相同 ID 的问题
|
||||
const existingIndex = updatedQuestions.findIndex(q => q.id === newQuestion.id);
|
||||
if (existingIndex === -1) {
|
||||
// 如果不存在,添加到数组
|
||||
updatedQuestions.push(newQuestion);
|
||||
} else {
|
||||
// 如果已存在,更新数据
|
||||
updatedQuestions[existingIndex] = newQuestion;
|
||||
}
|
||||
});
|
||||
|
||||
return updatedQuestions;
|
||||
});
|
||||
|
||||
// 标记该标签已加载数据
|
||||
setLoadedTags(prev => ({
|
||||
...prev,
|
||||
[tagLabel]: true
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`获取标签 "${tagLabel}" 的问题失败:`, error);
|
||||
}
|
||||
},
|
||||
[projectId, searchTerm, expandedTags]
|
||||
);
|
||||
|
||||
// 检查问题是否被选中 - 使用 useCallback 优化
|
||||
const isQuestionSelected = useCallback(
|
||||
questionKey => {
|
||||
return selectedQuestions.includes(questionKey);
|
||||
},
|
||||
[selectedQuestions]
|
||||
);
|
||||
|
||||
// 处理生成数据集 - 使用 useCallback 优化
|
||||
const handleGenerateDataset = async (questionId, questionInfo) => {
|
||||
// 设置处理状态
|
||||
setProcessingQuestions(prev => ({
|
||||
...prev,
|
||||
[questionId]: true
|
||||
}));
|
||||
await generateSingleDataset({ projectId, questionId, questionInfo });
|
||||
// 重置处理状态
|
||||
setProcessingQuestions(prev => ({
|
||||
...prev,
|
||||
[questionId]: false
|
||||
}));
|
||||
};
|
||||
|
||||
// 渲染单个问题项 - 使用 useCallback 优化
|
||||
const renderQuestionItem = useCallback(
|
||||
(question, index, total) => {
|
||||
const questionKey = question.id;
|
||||
return (
|
||||
<QuestionItem
|
||||
key={questionKey}
|
||||
question={question}
|
||||
index={index}
|
||||
total={total}
|
||||
isSelected={isQuestionSelected(questionKey)}
|
||||
onSelect={onSelectQuestion}
|
||||
onDelete={onDeleteQuestion}
|
||||
onGenerate={handleGenerateDataset}
|
||||
onEdit={onEditQuestion}
|
||||
isProcessing={processingQuestions[questionKey]}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[isQuestionSelected, onSelectQuestion, onDeleteQuestion, handleGenerateDataset, processingQuestions, t]
|
||||
);
|
||||
|
||||
// 计算标签及其子标签下的所有问题数量 - 使用 useMemo 缓存计算结果
|
||||
const tagQuestionCounts = useMemo(() => {
|
||||
const counts = {};
|
||||
|
||||
const countQuestions = tag => {
|
||||
const directQuestions = questionsByTag[tag.label] || [];
|
||||
let total = directQuestions.length;
|
||||
|
||||
if (tag.child && tag.child.length > 0) {
|
||||
for (const childTag of tag.child) {
|
||||
total += countQuestions(childTag);
|
||||
}
|
||||
}
|
||||
|
||||
counts[tag.label] = total;
|
||||
return total;
|
||||
};
|
||||
|
||||
tags.forEach(countQuestions);
|
||||
return counts;
|
||||
}, [questionsByTag, tags]);
|
||||
|
||||
// 递归渲染标签树 - 使用 useCallback 优化
|
||||
const renderTagTree = useCallback(
|
||||
(tag, level = 0) => {
|
||||
const questions = questionsByTag[tag.label] || [];
|
||||
const hasQuestions = questions.length > 0;
|
||||
const hasChildren = tag.child && tag.child.length > 0;
|
||||
const isExpanded = expandedTags[tag.label];
|
||||
const totalQuestions = tagQuestionCounts[tag.label] || 0;
|
||||
|
||||
return (
|
||||
<Box key={tag.label}>
|
||||
<TagItem
|
||||
tag={tag}
|
||||
level={level}
|
||||
isExpanded={isExpanded}
|
||||
totalQuestions={totalQuestions}
|
||||
onToggle={handleToggleExpand}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* 只有当标签展开时才渲染子内容,减少不必要的渲染 */}
|
||||
{isExpanded && (
|
||||
<Collapse in={true}>
|
||||
{hasChildren && (
|
||||
<List disablePadding>{tag.child.map(childTag => renderTagTree(childTag, level + 1))}</List>
|
||||
)}
|
||||
|
||||
{hasQuestions && (
|
||||
<List disablePadding sx={{ mt: hasChildren ? 1 : 0 }}>
|
||||
{questions.map((question, index) => renderQuestionItem(question, index, questions.length))}
|
||||
</List>
|
||||
)}
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[questionsByTag, expandedTags, tagQuestionCounts, handleToggleExpand, renderQuestionItem, t]
|
||||
);
|
||||
|
||||
// 渲染未分类问题
|
||||
const renderUncategorizedQuestions = () => {
|
||||
const uncategorizedQuestions = questionsByTag['uncategorized'] || [];
|
||||
if (uncategorizedQuestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<ListItem
|
||||
button
|
||||
onClick={() => handleToggleExpand('uncategorized')}
|
||||
sx={{
|
||||
py: 1,
|
||||
bgcolor: 'primary.light',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.main'
|
||||
},
|
||||
borderRadius: '4px',
|
||||
mb: 0.5,
|
||||
pr: 1
|
||||
}}
|
||||
>
|
||||
<FolderIcon fontSize="small" sx={{ mr: 1, color: 'inherit' }} />
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, fontSize: '1rem' }}>
|
||||
{t('datasets.uncategorized')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={t('datasets.questionCount', { count: uncategorizedQuestions.length })}
|
||||
size="small"
|
||||
sx={{ ml: 1, height: 20, fontSize: '0.7rem', color: '#fff', backgroundColor: '#333' }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<IconButton size="small" edge="end" sx={{ color: 'inherit' }}>
|
||||
{expandedTags['uncategorized'] ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
|
||||
<Collapse in={expandedTags['uncategorized']}>
|
||||
<List disablePadding>
|
||||
{uncategorizedQuestions.map((question, index) =>
|
||||
renderQuestionItem(question, index, uncategorizedQuestions.length)
|
||||
)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 如果没有标签和问题,显示空状态
|
||||
if (tags.length === 0 && Object.keys(questionsByTag).length === 0) {
|
||||
return (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{t('datasets.noTagsAndQuestions')}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
overflow: 'auto',
|
||||
p: 2,
|
||||
maxHeight: '75vh'
|
||||
}}
|
||||
>
|
||||
<List disablePadding>
|
||||
{renderUncategorizedQuestions()}
|
||||
{tags.map(tag => renderTagTree(tag))}
|
||||
</List>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// 使用 memo 优化问题项渲染
|
||||
const QuestionItem = memo(
|
||||
({ question, index, total, isSelected, onSelect, onDelete, onGenerate, onEdit, isProcessing, t }) => {
|
||||
const questionKey = question.id;
|
||||
return (
|
||||
<Box key={question.id}>
|
||||
<ListItem
|
||||
sx={{
|
||||
pl: 4,
|
||||
py: 1,
|
||||
borderRadius: '4px',
|
||||
ml: 2,
|
||||
mr: 1,
|
||||
mb: 0.5,
|
||||
bgcolor: isSelected ? 'action.selected' : 'transparent',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={isSelected} onChange={() => onSelect(questionKey)} size="small" />
|
||||
<QuestionMarkIcon fontSize="small" sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" sx={{ fontWeight: 400 }}>
|
||||
{question.question}
|
||||
{question.dataSites && question.dataSites.length > 0 && (
|
||||
<Chip
|
||||
label={t('datasets.answerCount', { count: question.dataSites.length })}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ ml: 1, fontSize: '0.75rem', maxWidth: 150 }}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
{t('datasets.source')}: {question.chunk?.name || question.chunkId || t('common.unknown')}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Box>
|
||||
<Tooltip title={t('common.edit')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{ mr: 1 }}
|
||||
onClick={() =>
|
||||
onEdit({
|
||||
question: question.question,
|
||||
chunkId: question.chunkId,
|
||||
label: question.label || 'other'
|
||||
})
|
||||
}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('datasets.generateDataset')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{ mr: 1 }}
|
||||
onClick={() => onGenerate(question.id, question.question)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <CircularProgress size={16} /> : <AutoFixHighIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton size="small" onClick={() => onDelete(question.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</ListItem>
|
||||
{index < total - 1 && <Divider component="li" variant="inset" sx={{ ml: 6 }} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// 使用 memo 优化标签项渲染
|
||||
const TagItem = memo(({ tag, level, isExpanded, totalQuestions, onToggle, t }) => {
|
||||
return (
|
||||
<ListItem
|
||||
button
|
||||
onClick={() => onToggle(tag.label)}
|
||||
sx={{
|
||||
pl: level * 2 + 1,
|
||||
py: 1,
|
||||
bgcolor: level === 0 ? 'primary.light' : 'background.paper',
|
||||
color: level === 0 ? 'primary.contrastText' : 'inherit',
|
||||
'&:hover': {
|
||||
bgcolor: level === 0 ? 'primary.main' : 'action.hover'
|
||||
},
|
||||
borderRadius: '4px',
|
||||
mb: 0.5,
|
||||
pr: 1
|
||||
}}
|
||||
>
|
||||
{/* 内部内容保持不变 */}
|
||||
<FolderIcon fontSize="small" sx={{ mr: 1, color: level === 0 ? 'inherit' : 'primary.main' }} />
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: level === 0 ? 600 : 400,
|
||||
fontSize: level === 0 ? '1rem' : '0.9rem'
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</Typography>
|
||||
{totalQuestions > 0 && (
|
||||
<Chip
|
||||
label={t('datasets.questionCount', { count: totalQuestions })}
|
||||
size="small"
|
||||
color={level === 0 ? 'default' : 'primary'}
|
||||
variant={level === 0 ? 'default' : 'outlined'}
|
||||
sx={{ ml: 1, height: 20, fontSize: '0.7rem', color: '#fff', backgroundColor: '#333' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<IconButton size="small" edge="end" sx={{ color: level === 0 ? 'inherit' : 'action.active' }}>
|
||||
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
);
|
||||
});
|
||||
153
easy-dataset-main/components/settings/BasicSettings.js
Normal file
153
easy-dataset-main/components/settings/BasicSettings.js
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Typography, Box, Button, TextField, Grid, Card, CardContent, Alert, Snackbar } from '@mui/material';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function BasicSettings({ projectId }) {
|
||||
const { t } = useTranslation();
|
||||
const [projectInfo, setProjectInfo] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
description: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProjectInfo() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/projects/${projectId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('projects.fetchFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProjectInfo(data);
|
||||
} catch (error) {
|
||||
console.error('获取项目信息出错:', error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProjectInfo();
|
||||
}, [projectId, t]);
|
||||
|
||||
// 处理项目信息变更
|
||||
const handleProjectInfoChange = e => {
|
||||
const { name, value } = e.target;
|
||||
setProjectInfo(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 保存项目信息
|
||||
const handleSaveProjectInfo = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: projectInfo.name,
|
||||
description: projectInfo.description
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('projects.saveFailed'));
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
} catch (error) {
|
||||
console.error('保存项目信息出错:', error);
|
||||
setError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
setSuccess(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Typography>{t('common.loading')}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('settings.basicInfo')}
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('projects.id')}
|
||||
value={projectInfo.id}
|
||||
disabled
|
||||
helperText={t('settings.idNotEditable')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('projects.name')}
|
||||
name="name"
|
||||
value={projectInfo.name}
|
||||
onChange={handleProjectInfoChange}
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('projects.description')}
|
||||
name="description"
|
||||
value={projectInfo.description}
|
||||
onChange={handleProjectInfoChange}
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button variant="contained" startIcon={<SaveIcon />} onClick={handleSaveProjectInfo}>
|
||||
{t('settings.saveBasicInfo')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
|
||||
<Snackbar
|
||||
open={success}
|
||||
autoHideDuration={2000}
|
||||
onClose={handleCloseSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
|
||||
{t('settings.saveSuccess')}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
<Snackbar
|
||||
open={!!error}
|
||||
autoHideDuration={2000}
|
||||
onClose={handleCloseSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={handleCloseSnackbar} severity="error" sx={{ width: '100%' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
1056
easy-dataset-main/components/settings/ModelSettings.js
Normal file
1056
easy-dataset-main/components/settings/ModelSettings.js
Normal file
File diff suppressed because it is too large
Load Diff
709
easy-dataset-main/components/settings/TaskSettings.js
Normal file
709
easy-dataset-main/components/settings/TaskSettings.js
Normal file
@@ -0,0 +1,709 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Slider,
|
||||
InputAdornment,
|
||||
Alert,
|
||||
Snackbar,
|
||||
FormControl,
|
||||
Select,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Chip,
|
||||
FormHelperText
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import useTaskSettings from '@/hooks/useTaskSettings';
|
||||
|
||||
export default function TaskSettings({ projectId }) {
|
||||
const { t } = useTranslation();
|
||||
const { taskSettings, setTaskSettings, loading, error, success, setSuccess } = useTaskSettings(projectId);
|
||||
|
||||
// 确保 multiTurnRounds 有正确的初始值
|
||||
useEffect(() => {
|
||||
if (
|
||||
!loading &&
|
||||
taskSettings &&
|
||||
(taskSettings.multiTurnRounds === undefined || taskSettings.multiTurnRounds === null)
|
||||
) {
|
||||
setTaskSettings(prev => ({
|
||||
...prev,
|
||||
multiTurnRounds: 3 // 默认值
|
||||
}));
|
||||
}
|
||||
}, [loading, taskSettings, setTaskSettings]);
|
||||
|
||||
// 处理设置变更
|
||||
const handleSettingChange = e => {
|
||||
const { name, value } = e.target;
|
||||
setTaskSettings(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 处理滑块变更
|
||||
const handleSliderChange = name => (event, newValue) => {
|
||||
setTaskSettings(prev => ({
|
||||
...prev,
|
||||
[name]: newValue
|
||||
}));
|
||||
};
|
||||
|
||||
// 保存任务配置
|
||||
const handleSaveTaskSettings = async () => {
|
||||
try {
|
||||
// 确保数组类型的数据被正确处理
|
||||
const settingsToSave = { ...taskSettings };
|
||||
|
||||
// 确保递归分块的分隔符数组存在
|
||||
if (settingsToSave.splitType === 'recursive' && settingsToSave.separatorsInput) {
|
||||
if (!settingsToSave.separators || !Array.isArray(settingsToSave.separators)) {
|
||||
settingsToSave.separators = settingsToSave.separatorsInput.split(',').map(item => item.trim());
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Saving settings:', settingsToSave);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/tasks`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(settingsToSave)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('settings.saveTasksFailed'));
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
} catch (error) {
|
||||
console.error('保存任务配置出错:', error);
|
||||
//setError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
setSuccess(false);
|
||||
//setError(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Typography>{t('common.loading')}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', pb: 8 }}>
|
||||
{' '}
|
||||
{/* 添加底部填充,为固定按钮留出空间 */}
|
||||
<Card style={{ marginBottom: 20 }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('settings.textSplitSettings')}
|
||||
</Typography>
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
{/* 分块策略选择 */}
|
||||
<FormControl fullWidth sx={{ mb: 3 }}>
|
||||
<InputLabel id="split-type-label">{t('settings.splitType')}</InputLabel>
|
||||
<Select
|
||||
labelId="split-type-label"
|
||||
value={taskSettings.splitType || 'recursive'}
|
||||
label={t('settings.splitType')}
|
||||
name="splitType"
|
||||
onChange={handleSettingChange}
|
||||
>
|
||||
<MenuItem value="markdown">
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{t('settings.splitTypeMarkdown')}</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||
{t('settings.splitTypeMarkdownDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<MenuItem value="recursive">
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{t('settings.splitTypeRecursive')}</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||
{t('settings.splitTypeRecursiveDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<MenuItem value="text">
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{t('settings.splitTypeText')}</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||
{t('settings.splitTypeTextDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<MenuItem value="token">
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{t('settings.splitTypeToken')}</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||
{t('settings.splitTypeTokenDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<MenuItem value="code">
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{t('settings.splitTypeCode')}</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||
{t('settings.splitTypeCodeDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{/* 添加自定义符号分割策略选项 */}
|
||||
<MenuItem value="custom">
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{t('settings.splitTypeCustom')}</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||
{t('settings.splitTypeCustomDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Markdown模式设置 */}
|
||||
{(!taskSettings.splitType || taskSettings.splitType === 'markdown') && (
|
||||
<>
|
||||
<Typography id="text-split-min-length-slider" gutterBottom>
|
||||
{t('settings.minLength')}: {taskSettings.textSplitMinLength}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={taskSettings.textSplitMinLength || 2000}
|
||||
onChange={handleSliderChange('textSplitMinLength')}
|
||||
aria-labelledby="text-split-min-length-slider"
|
||||
valueLabelDisplay="auto"
|
||||
step={100}
|
||||
marks
|
||||
min={100}
|
||||
max={5000}
|
||||
/>
|
||||
|
||||
<Typography id="text-split-max-length-slider" gutterBottom sx={{ mt: 3 }}>
|
||||
{t('settings.maxLength')}: {taskSettings.textSplitMaxLength}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={taskSettings.textSplitMaxLength || 3000}
|
||||
onChange={handleSliderChange('textSplitMaxLength')}
|
||||
aria-labelledby="text-split-max-length-slider"
|
||||
valueLabelDisplay="auto"
|
||||
step={100}
|
||||
marks
|
||||
min={2000}
|
||||
max={20000}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 通用 LangChain 参数设置 */}
|
||||
{taskSettings.splitType && taskSettings.splitType !== 'markdown' && (
|
||||
<>
|
||||
<Typography id="chunk-size-slider" gutterBottom>
|
||||
{t('settings.chunkSize')}: {taskSettings.chunkSize || 3000}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={taskSettings.chunkSize || 3000}
|
||||
onChange={handleSliderChange('chunkSize')}
|
||||
aria-labelledby="chunk-size-slider"
|
||||
valueLabelDisplay="auto"
|
||||
step={100}
|
||||
marks
|
||||
min={500}
|
||||
max={20000}
|
||||
/>
|
||||
|
||||
<Typography id="chunk-overlap-slider" gutterBottom sx={{ mt: 3 }}>
|
||||
{t('settings.chunkOverlap')}: {taskSettings.chunkOverlap || 200}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={taskSettings.chunkOverlap || 200}
|
||||
onChange={handleSliderChange('chunkOverlap')}
|
||||
aria-labelledby="chunk-overlap-slider"
|
||||
valueLabelDisplay="auto"
|
||||
step={50}
|
||||
marks
|
||||
min={0}
|
||||
max={1000}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Text 分块器特殊设置 */}
|
||||
{taskSettings.splitType === 'text' && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.separator')}
|
||||
name="separator"
|
||||
value={taskSettings.separator || '\\n\\n'}
|
||||
onChange={handleSettingChange}
|
||||
helperText={t('settings.separatorHelper')}
|
||||
sx={{ mt: 3 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 自定义符号分块器特殊设置 */}
|
||||
{taskSettings.splitType === 'custom' && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.customSeparator')}
|
||||
name="customSeparator"
|
||||
value={taskSettings.customSeparator || '---'}
|
||||
onChange={handleSettingChange}
|
||||
helperText={t('settings.customSeparatorHelper')}
|
||||
sx={{ mt: 3 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Code 分块器特殊设置 */}
|
||||
{taskSettings.splitType === 'code' && (
|
||||
<FormControl fullWidth sx={{ mt: 3 }}>
|
||||
<InputLabel id="code-language-label">{t('settings.codeLanguage')}</InputLabel>
|
||||
<Select
|
||||
labelId="code-language-label"
|
||||
value={taskSettings.splitLanguage || 'js'}
|
||||
label={t('settings.codeLanguage')}
|
||||
name="splitLanguage"
|
||||
onChange={handleSettingChange}
|
||||
>
|
||||
<MenuItem value="js">JavaScript</MenuItem>
|
||||
<MenuItem value="python">Python</MenuItem>
|
||||
<MenuItem value="java">Java</MenuItem>
|
||||
<MenuItem value="go">Go</MenuItem>
|
||||
<MenuItem value="ruby">Ruby</MenuItem>
|
||||
<MenuItem value="cpp">C++</MenuItem>
|
||||
<MenuItem value="c">C</MenuItem>
|
||||
<MenuItem value="csharp">C#</MenuItem>
|
||||
<MenuItem value="php">PHP</MenuItem>
|
||||
<MenuItem value="rust">Rust</MenuItem>
|
||||
<MenuItem value="typescript">TypeScript</MenuItem>
|
||||
<MenuItem value="swift">Swift</MenuItem>
|
||||
<MenuItem value="kotlin">Kotlin</MenuItem>
|
||||
<MenuItem value="scala">Scala</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText>{t('settings.codeLanguageHelper')}</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{/* Recursive 分块器特殊设置 */}
|
||||
{taskSettings.splitType === 'recursive' && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography gutterBottom>{t('settings.separators')}</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.separatorsInput')}
|
||||
name="separatorsInput"
|
||||
value={taskSettings.separatorsInput || '|,##,>,-'}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
// 同时更新输入框值和分隔符数组
|
||||
setTaskSettings(prev => ({
|
||||
...prev,
|
||||
separatorsInput: value,
|
||||
separators: value.split(',').map(item => item.trim())
|
||||
}));
|
||||
}}
|
||||
helperText={t('settings.separatorsHelper')}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
|
||||
{(taskSettings.separators || ['|', '##', '>', '-']).map((sep, index) => (
|
||||
<Chip key={index} label={sep} variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 3 }}>
|
||||
{t('settings.textSplitDescription')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: 20 }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('settings.questionGenSettings')}
|
||||
</Typography>
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
<Typography id="question-generation-length-slider" gutterBottom>
|
||||
{t('settings.questionGenLength', { length: taskSettings.questionGenerationLength })}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={taskSettings.questionGenerationLength}
|
||||
onChange={handleSliderChange('questionGenerationLength')}
|
||||
aria-labelledby="question-generation-length-slider"
|
||||
valueLabelDisplay="auto"
|
||||
step={10}
|
||||
marks
|
||||
min={10}
|
||||
max={1000}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
{t('settings.questionGenDescription')}
|
||||
</Typography>
|
||||
|
||||
<Typography id="question-mark-removing-probability-slider" gutterBottom sx={{ mt: 3 }}>
|
||||
{t('settings.questionMaskRemovingProbability', {
|
||||
probability: taskSettings.questionMaskRemovingProbability
|
||||
})}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={taskSettings.questionMaskRemovingProbability}
|
||||
onChange={handleSliderChange('questionMaskRemovingProbability')}
|
||||
aria-labelledby="question-generation-length-slider"
|
||||
valueLabelDisplay="auto"
|
||||
step={5}
|
||||
marks
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
style={{ marginTop: 20 }}
|
||||
fullWidth
|
||||
label={t('settings.concurrencyLimit')}
|
||||
name="concurrencyLimit"
|
||||
value={taskSettings.concurrencyLimit}
|
||||
onChange={handleSettingChange}
|
||||
type="number"
|
||||
helperText={t('settings.concurrencyLimitHelper')}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: 20 }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('settings.pdfSettings')}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.minerUToken')}
|
||||
name="minerUToken"
|
||||
value={taskSettings.minerUToken}
|
||||
onChange={handleSettingChange}
|
||||
type="password"
|
||||
helperText={t('settings.minerUHelper')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.minerULocalUrl')}
|
||||
name="minerULocalUrl"
|
||||
value={taskSettings.minerULocalUrl}
|
||||
onChange={handleSettingChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.visionConcurrencyLimit')}
|
||||
name="visionConcurrencyLimit"
|
||||
value={taskSettings.visionConcurrencyLimit ? taskSettings.visionConcurrencyLimit : 5}
|
||||
onChange={handleSettingChange}
|
||||
type="number"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 多轮对话数据集设置 */}
|
||||
<Card style={{ marginBottom: 20 }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('settings.multiTurnSettings')}
|
||||
</Typography>
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
{/* 系统提示词 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.multiTurnSystemPrompt')}
|
||||
name="multiTurnSystemPrompt"
|
||||
value={taskSettings.multiTurnSystemPrompt || ''}
|
||||
onChange={handleSettingChange}
|
||||
multiline
|
||||
rows={3}
|
||||
helperText={t('settings.multiTurnSystemPromptHelper')}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* 对话场景 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.multiTurnScenario')}
|
||||
name="multiTurnScenario"
|
||||
value={taskSettings.multiTurnScenario || ''}
|
||||
onChange={handleSettingChange}
|
||||
helperText={t('settings.multiTurnScenarioHelper')}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* 对话轮数 */}
|
||||
<Typography id="multi-turn-rounds-slider" gutterBottom sx={{ mt: 2 }}>
|
||||
{t('settings.multiTurnRounds', { rounds: taskSettings.multiTurnRounds || 3 })}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={taskSettings.multiTurnRounds || 3}
|
||||
onChange={handleSliderChange('multiTurnRounds')}
|
||||
aria-labelledby="multi-turn-rounds-slider"
|
||||
valueLabelDisplay="auto"
|
||||
step={1}
|
||||
marks
|
||||
min={2}
|
||||
max={8}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* 角色A设定 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.multiTurnRoleA')}
|
||||
name="multiTurnRoleA"
|
||||
value={taskSettings.multiTurnRoleA || ''}
|
||||
onChange={handleSettingChange}
|
||||
multiline
|
||||
rows={2}
|
||||
helperText={t('settings.multiTurnRoleAHelper')}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* 角色B设定 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.multiTurnRoleB')}
|
||||
name="multiTurnRoleB"
|
||||
value={taskSettings.multiTurnRoleB || ''}
|
||||
onChange={handleSettingChange}
|
||||
multiline
|
||||
rows={2}
|
||||
helperText={t('settings.multiTurnRoleBHelper')}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
{t('settings.multiTurnDescription')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 测试集生成设置 */}
|
||||
<Card style={{ marginBottom: 20 }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('settings.evalQuestionSettings')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t('settings.evalQuestionSettingsDescription')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6} md={2.4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.evalTrueFalseRatio')}
|
||||
type="number"
|
||||
value={taskSettings.evalQuestionTypeRatios?.true_false || 0}
|
||||
onChange={e => {
|
||||
const value = Math.max(0, parseInt(e.target.value) || 0);
|
||||
setTaskSettings(prev => ({
|
||||
...prev,
|
||||
evalQuestionTypeRatios: {
|
||||
...prev.evalQuestionTypeRatios,
|
||||
true_false: value
|
||||
}
|
||||
}));
|
||||
}}
|
||||
InputProps={{ inputProps: { min: 0 } }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 单选题 */}
|
||||
<Grid item xs={12} sm={6} md={2.4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.evalSingleChoiceRatio')}
|
||||
type="number"
|
||||
value={taskSettings.evalQuestionTypeRatios?.single_choice || 0}
|
||||
onChange={e => {
|
||||
const value = Math.max(0, parseInt(e.target.value) || 0);
|
||||
setTaskSettings(prev => ({
|
||||
...prev,
|
||||
evalQuestionTypeRatios: {
|
||||
...prev.evalQuestionTypeRatios,
|
||||
single_choice: value
|
||||
}
|
||||
}));
|
||||
}}
|
||||
InputProps={{ inputProps: { min: 0 } }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 多选题 */}
|
||||
<Grid item xs={12} sm={6} md={2.4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.evalMultipleChoiceRatio')}
|
||||
type="number"
|
||||
value={taskSettings.evalQuestionTypeRatios?.multiple_choice || 0}
|
||||
onChange={e => {
|
||||
const value = Math.max(0, parseInt(e.target.value) || 0);
|
||||
setTaskSettings(prev => ({
|
||||
...prev,
|
||||
evalQuestionTypeRatios: {
|
||||
...prev.evalQuestionTypeRatios,
|
||||
multiple_choice: value
|
||||
}
|
||||
}));
|
||||
}}
|
||||
InputProps={{ inputProps: { min: 0 } }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 固定短答案 */}
|
||||
<Grid item xs={12} sm={6} md={2.4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.evalShortAnswerRatio')}
|
||||
type="number"
|
||||
value={taskSettings.evalQuestionTypeRatios?.short_answer || 0}
|
||||
onChange={e => {
|
||||
const value = Math.max(0, parseInt(e.target.value) || 0);
|
||||
setTaskSettings(prev => ({
|
||||
...prev,
|
||||
evalQuestionTypeRatios: {
|
||||
...prev.evalQuestionTypeRatios,
|
||||
short_answer: value
|
||||
}
|
||||
}));
|
||||
}}
|
||||
InputProps={{ inputProps: { min: 0 } }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 开放式回答 */}
|
||||
<Grid item xs={12} sm={6} md={2.4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.evalOpenEndedRatio')}
|
||||
type="number"
|
||||
value={taskSettings.evalQuestionTypeRatios?.open_ended || 0}
|
||||
onChange={e => {
|
||||
const value = Math.max(0, parseInt(e.target.value) || 0);
|
||||
setTaskSettings(prev => ({
|
||||
...prev,
|
||||
evalQuestionTypeRatios: {
|
||||
...prev.evalQuestionTypeRatios,
|
||||
open_ended: value
|
||||
}
|
||||
}));
|
||||
}}
|
||||
InputProps={{ inputProps: { min: 0 } }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 2 }}>
|
||||
{t('settings.evalQuestionRatioHelper')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card style={{ marginBottom: 20 }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('settings.huggingfaceSettings')}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.huggingfaceToken')}
|
||||
name="huggingfaceToken"
|
||||
value={taskSettings.huggingfaceToken || ''}
|
||||
onChange={handleSettingChange}
|
||||
type="password"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Snackbar
|
||||
open={success}
|
||||
autoHideDuration={2000}
|
||||
onClose={handleCloseSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={handleCloseSnackbar} severity="success" sx={{ width: '100%' }}>
|
||||
{t('settings.saveSuccess')}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Snackbar
|
||||
open={!!error}
|
||||
autoHideDuration={2000}
|
||||
onClose={handleCloseSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={handleCloseSnackbar} severity="error" sx={{ width: '100%' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
{/* 吸底保存按钮 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: '8px',
|
||||
backgroundColor: 'background.paper',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
zIndex: 1100,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
boxShadow: 3
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="medium"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSaveTaskSettings}
|
||||
>
|
||||
{t('settings.saveTaskConfig')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
27
easy-dataset-main/components/tasks/TaskActions.js
Normal file
27
easy-dataset-main/components/tasks/TaskActions.js
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import StopCircleIcon from '@mui/icons-material/StopCircle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// 任务操作组件
|
||||
export default function TaskActions({ task, onAbort, onDelete }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 处理中的任务显示中断按钮,其他状态显示删除按钮
|
||||
return task.status === 0 ? (
|
||||
<Tooltip title={t('tasks.actions.abort')} arrow>
|
||||
<IconButton size="small" onClick={() => onAbort(task.id)}>
|
||||
<StopCircleIcon fontSize="small" color="warning" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={t('tasks.actions.delete')} arrow>
|
||||
<IconButton size="small" onClick={() => onDelete(task.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
74
easy-dataset-main/components/tasks/TaskFilters.js
Normal file
74
easy-dataset-main/components/tasks/TaskFilters.js
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
OutlinedInput,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function TaskFilters({ statusFilter, setStatusFilter, typeFilter, setTypeFilter, loading, onRefresh }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const taskTypeOptions = [
|
||||
'text-processing',
|
||||
'file-processing',
|
||||
'pdf-processing',
|
||||
'question-generation',
|
||||
'answer-generation',
|
||||
'data-cleaning',
|
||||
'data-distillation',
|
||||
'eval-generation',
|
||||
'multi-turn-generation',
|
||||
'image-question-generation'
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>{t('tasks.filters.status')}</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
input={<OutlinedInput label={t('tasks.filters.status')} />}
|
||||
>
|
||||
<MenuItem value="all">{t('datasets.filterAll')}</MenuItem>
|
||||
<MenuItem value="0">{t('tasks.status.processing')}</MenuItem>
|
||||
<MenuItem value="1">{t('tasks.status.completed')}</MenuItem>
|
||||
<MenuItem value="2">{t('tasks.status.failed')}</MenuItem>
|
||||
<MenuItem value="3">{t('tasks.status.aborted')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>{t('tasks.filters.type')}</InputLabel>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
input={<OutlinedInput label={t('tasks.filters.type')} />}
|
||||
>
|
||||
<MenuItem value="all">{t('datasets.filterAll')}</MenuItem>
|
||||
{taskTypeOptions.map(type => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{t(`tasks.types.${type}`, { defaultValue: type })}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Tooltip title={t('tasks.actions.refresh')}>
|
||||
<IconButton onClick={onRefresh} disabled={loading}>
|
||||
{loading ? <CircularProgress size={20} /> : <RefreshIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
36
easy-dataset-main/components/tasks/TaskProgress.js
Normal file
36
easy-dataset-main/components/tasks/TaskProgress.js
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Stack, LinearProgress, Typography } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// 任务进度组件
|
||||
export default function TaskProgress({ task }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 如果没有总数,则不显示进度条
|
||||
if (task.totalCount === 0) return '-';
|
||||
|
||||
// 计算进度百分比
|
||||
const progress = (task.completedCount / task.totalCount) * 100;
|
||||
|
||||
return (
|
||||
<Stack direction="column" spacing={0.5}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
width: 120,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
transition: 'transform 0.5s ease'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.completedCount} / {task.totalCount} ({Math.round(progress)}%)
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
48
easy-dataset-main/components/tasks/TaskStatusChip.js
Normal file
48
easy-dataset-main/components/tasks/TaskStatusChip.js
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Chip, CircularProgress, Box } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// 任务状态显示组件
|
||||
export default function TaskStatusChip({ status }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 状态映射配置
|
||||
const STATUS_CONFIG = {
|
||||
0: {
|
||||
label: t('tasks.status.processing'),
|
||||
color: 'warning',
|
||||
loading: true
|
||||
},
|
||||
1: {
|
||||
label: t('tasks.status.completed'),
|
||||
color: 'success'
|
||||
},
|
||||
2: {
|
||||
label: t('tasks.status.failed'),
|
||||
color: 'error'
|
||||
},
|
||||
3: {
|
||||
label: t('tasks.status.aborted'),
|
||||
color: 'default'
|
||||
}
|
||||
};
|
||||
|
||||
const statusInfo = STATUS_CONFIG[status] || {
|
||||
label: t('tasks.status.unknown'),
|
||||
color: 'default'
|
||||
};
|
||||
|
||||
// 处理中状态显示加载动画
|
||||
if (status === 0) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} color="warning" />
|
||||
<Chip label={statusInfo.label} color={statusInfo.color} size="small" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <Chip label={statusInfo.label} color={statusInfo.color} size="small" />;
|
||||
}
|
||||
293
easy-dataset-main/components/tasks/TasksTable.js
Normal file
293
easy-dataset-main/components/tasks/TasksTable.js
Normal file
@@ -0,0 +1,293 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Box,
|
||||
TablePagination,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN, enUS } from 'date-fns/locale';
|
||||
|
||||
import TaskStatusChip from './TaskStatusChip';
|
||||
import TaskProgress from './TaskProgress';
|
||||
import TaskActions from './TaskActions';
|
||||
|
||||
export default function TasksTable({
|
||||
tasks,
|
||||
loading,
|
||||
handleAbortTask,
|
||||
handleDeleteTask,
|
||||
page,
|
||||
rowsPerPage,
|
||||
handleChangePage,
|
||||
handleChangeRowsPerPage,
|
||||
totalCount
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const formatDate = dateString => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return formatDistanceToNow(date, {
|
||||
addSuffix: true,
|
||||
locale: i18n.language === 'zh-CN' ? zhCN : enUS
|
||||
});
|
||||
};
|
||||
|
||||
const calculateDuration = (startTimeStr, endTimeStr) => {
|
||||
if (!startTimeStr || !endTimeStr) return '-';
|
||||
|
||||
try {
|
||||
const startTime = new Date(startTimeStr);
|
||||
const endTime = new Date(endTimeStr);
|
||||
const duration = endTime - startTime;
|
||||
const seconds = Math.floor(duration / 1000);
|
||||
|
||||
if (seconds < 60) {
|
||||
return t('tasks.duration.seconds', { seconds });
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return t('tasks.duration.minutes', { minutes, seconds: remainingSeconds });
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const remainingMinutes = Math.floor((seconds % 3600) / 60);
|
||||
return t('tasks.duration.hours', { hours, minutes: remainingMinutes });
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate duration:', error);
|
||||
return '-';
|
||||
}
|
||||
};
|
||||
|
||||
const parseModelInfo = modelInfoString => {
|
||||
let modelInfo = '';
|
||||
try {
|
||||
const parsedModel = JSON.parse(modelInfoString);
|
||||
modelInfo = parsedModel.modelName || parsedModel.name || '-';
|
||||
} catch {
|
||||
modelInfo = modelInfoString || '-';
|
||||
}
|
||||
return modelInfo;
|
||||
};
|
||||
|
||||
const toTaskTypeLabel = taskType => {
|
||||
if (!taskType) return '-';
|
||||
return String(taskType)
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const getLocalizedTaskType = taskType => {
|
||||
return t(`tasks.types.${taskType}`, { defaultValue: toTaskTypeLabel(taskType) });
|
||||
};
|
||||
|
||||
const parseJsonSafely = input => {
|
||||
if (!input || typeof input !== 'string') return null;
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTaskNote = task => {
|
||||
const note = String(task?.note || '').trim();
|
||||
if (!note) return '-';
|
||||
|
||||
const noteJson = parseJsonSafely(note);
|
||||
if (noteJson) {
|
||||
if (Array.isArray(noteJson.chunkIds)) {
|
||||
return t('tasks.notes.selectedChunks', { count: noteJson.chunkIds.length });
|
||||
}
|
||||
if (Array.isArray(noteJson.fileList)) {
|
||||
return t('tasks.notes.fileBatch', {
|
||||
count: noteJson.fileList.length,
|
||||
strategy: noteJson.strategy || '-'
|
||||
});
|
||||
}
|
||||
return t('tasks.notes.jsonParams');
|
||||
}
|
||||
|
||||
if (note === 'No chunks require question generation' || note.startsWith('No chunks require question gen')) {
|
||||
return t('tasks.notes.noChunksQuestion');
|
||||
}
|
||||
if (note === 'No chunks require cleaning' || note.startsWith('No chunks require clean')) {
|
||||
return t('tasks.notes.noChunksCleaning');
|
||||
}
|
||||
if (note.startsWith('Processing failed:')) {
|
||||
return t('tasks.notes.processingFailed', {
|
||||
error: note.replace('Processing failed:', '').trim()
|
||||
});
|
||||
}
|
||||
|
||||
const summaryMatch = note.match(/Processed:\s*(\d+)\/(\d+),\s*succeeded:\s*(\d+),\s*failed:\s*(\d+)/i);
|
||||
if (summaryMatch) {
|
||||
const [, processed, total, succeeded, failed] = summaryMatch;
|
||||
|
||||
const questionMatch = note.match(/questions generated:\s*(\d+)/i);
|
||||
if (questionMatch) {
|
||||
return t('tasks.notes.questionSummary', {
|
||||
processed,
|
||||
total,
|
||||
succeeded,
|
||||
failed,
|
||||
generated: questionMatch[1]
|
||||
});
|
||||
}
|
||||
|
||||
const datasetMatch = note.match(/datasets generated:\s*(\d+)/i);
|
||||
if (datasetMatch) {
|
||||
return t('tasks.notes.datasetSummary', {
|
||||
processed,
|
||||
total,
|
||||
succeeded,
|
||||
failed,
|
||||
generated: datasetMatch[1]
|
||||
});
|
||||
}
|
||||
|
||||
const cleaningMatch = note.match(/total original length:\s*(\d+),\s*total cleaned length:\s*(\d+)/i);
|
||||
if (cleaningMatch) {
|
||||
return t('tasks.notes.cleaningSummary', {
|
||||
processed,
|
||||
total,
|
||||
succeeded,
|
||||
failed,
|
||||
original: cleaningMatch[1],
|
||||
cleaned: cleaningMatch[2]
|
||||
});
|
||||
}
|
||||
|
||||
return t('tasks.notes.genericSummary', {
|
||||
processed,
|
||||
total,
|
||||
succeeded,
|
||||
failed
|
||||
});
|
||||
}
|
||||
|
||||
return note;
|
||||
};
|
||||
|
||||
const truncateNote = (note, maxLength = 48) => {
|
||||
if (!note) return '-';
|
||||
if (note.length <= maxLength) return note;
|
||||
return `${note.substring(0, maxLength)}...`;
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableContainer component={Paper} elevation={1} sx={{ borderRadius: 2, mb: 2 }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('tasks.table.type')}</TableCell>
|
||||
<TableCell>{t('tasks.table.status')}</TableCell>
|
||||
<TableCell>{t('tasks.table.progress')}</TableCell>
|
||||
<TableCell>{t('tasks.table.createTime')}</TableCell>
|
||||
<TableCell>{t('tasks.table.duration')}</TableCell>
|
||||
<TableCell>{t('tasks.table.model')}</TableCell>
|
||||
<TableCell>{t('tasks.table.note')}</TableCell>
|
||||
<TableCell align="right">{t('tasks.table.actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading && tasks.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 6 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<CircularProgress size={40} />
|
||||
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||
{t('tasks.loading')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : tasks.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 6 }}>
|
||||
<Typography variant="body1">{t('tasks.empty')}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tasks.map(task => {
|
||||
const noteText = formatTaskNote(task);
|
||||
return (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell>{getLocalizedTaskType(task.taskType)}</TableCell>
|
||||
<TableCell>
|
||||
<TaskStatusChip status={task.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TaskProgress task={task} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{formatDate(task.createAt)}</TableCell>
|
||||
<TableCell>{task.endTime ? calculateDuration(task.startTime, task.endTime) : '-'}</TableCell>
|
||||
<TableCell>{parseModelInfo(task.modelInfo)}</TableCell>
|
||||
<TableCell>
|
||||
{noteText !== '-' ? (
|
||||
<Tooltip title={noteText} arrow placement="top">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': { color: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
{truncateNote(noteText)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<TaskActions task={task} onAbort={handleAbortTask} onDelete={handleDeleteTask} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[5, 10, 25]}
|
||||
labelRowsPerPage={t('datasets.rowsPerPage')}
|
||||
labelDisplayedRows={({ count }) => {
|
||||
const calculatedFrom = page * rowsPerPage + 1;
|
||||
const calculatedTo = Math.min((page + 1) * rowsPerPage, count);
|
||||
return t('datasets.pagination', {
|
||||
from: calculatedFrom,
|
||||
to: calculatedTo,
|
||||
count
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
180
easy-dataset-main/components/text-split/BatchEditChunkDialog.js
Normal file
180
easy-dataset-main/components/text-split/BatchEditChunkDialog.js
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 批量编辑文本块对话框
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 对话框是否打开
|
||||
* @param {Function} props.onClose - 关闭对话框的回调
|
||||
* @param {Function} props.onConfirm - 确认编辑的回调
|
||||
* @param {Array} props.selectedChunks - 选中的文本块ID数组
|
||||
* @param {number} props.totalChunks - 文本块总数
|
||||
* @param {boolean} props.loading - 是否正在处理
|
||||
*/
|
||||
export default function BatchEditChunksDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
selectedChunks = [],
|
||||
totalChunks = 0,
|
||||
loading = false
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [position, setPosition] = useState('start'); // 'start' 或 'end'
|
||||
const [content, setContent] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 处理位置变更
|
||||
const handlePositionChange = event => {
|
||||
setPosition(event.target.value);
|
||||
};
|
||||
|
||||
// 处理内容变更
|
||||
const handleContentChange = event => {
|
||||
setContent(event.target.value);
|
||||
if (error) setError('');
|
||||
};
|
||||
|
||||
// 处理确认
|
||||
const handleConfirm = () => {
|
||||
if (!content.trim()) {
|
||||
setError(t('batchEdit.contentRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirm({
|
||||
position,
|
||||
content: content.trim(),
|
||||
chunkIds: selectedChunks
|
||||
});
|
||||
};
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
if (!loading) {
|
||||
setContent('');
|
||||
setError('');
|
||||
setPosition('start');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth disableEscapeKeyDown={loading}>
|
||||
<DialogTitle>{t('batchEdit.title')}</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ py: 1 }}>
|
||||
{/* 选择提示 */}
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
<Typography variant="body2">
|
||||
{selectedChunks.length === totalChunks
|
||||
? t('batchEdit.allChunksSelected', { count: totalChunks })
|
||||
: t('batchEdit.selectedChunks', {
|
||||
selected: selectedChunks.length,
|
||||
total: totalChunks
|
||||
})}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* 位置选择 */}
|
||||
<FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
|
||||
<FormLabel component="legend" sx={{ mb: 1 }}>
|
||||
{t('batchEdit.position')}
|
||||
</FormLabel>
|
||||
<RadioGroup value={position} onChange={handlePositionChange} row>
|
||||
<FormControlLabel value="start" control={<Radio />} label={t('batchEdit.atBeginning')} />
|
||||
<FormControlLabel value="end" control={<Radio />} label={t('batchEdit.atEnd')} />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{/* 内容输入 */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('batchEdit.contentToAdd')}
|
||||
multiline
|
||||
rows={6}
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder={t('batchEdit.contentPlaceholder')}
|
||||
error={!!error}
|
||||
helperText={error || t('batchEdit.contentHelp')}
|
||||
disabled={loading}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* 预览示例 */}
|
||||
{content.trim() && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.default',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
{t('batchEdit.preview')}:
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
{position === 'start' ? (
|
||||
<>
|
||||
<span style={{ backgroundColor: '#e3f2fd', padding: '2px 4px' }}>{content}</span>
|
||||
{'\n\n[原始文本块内容...]'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{'[原始文本块内容...]\n\n'}
|
||||
<span style={{ backgroundColor: '#e3f2fd', padding: '2px 4px' }}>{content}</span>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={loading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant="contained"
|
||||
disabled={loading || !content.trim() || selectedChunks.length === 0}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{loading ? t('batchEdit.processing') : t('batchEdit.applyToChunks', { count: selectedChunks.length })}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Button,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ChunkBatchDeleteDialog({ open, onClose, onConfirm, loading, count }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={loading ? undefined : onClose}
|
||||
aria-labelledby="batch-delete-dialog-title"
|
||||
aria-describedby="batch-delete-dialog-description"
|
||||
>
|
||||
<DialogTitle id="batch-delete-dialog-title">
|
||||
{t('textSplit.batchDeleteChunksConfirmTitle', { defaultValue: '确认批量删除' })}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="batch-delete-dialog-description">
|
||||
{t('textSplit.batchDeleteChunksConfirmMessage', {
|
||||
count,
|
||||
defaultValue: `您确定要删除选中的 ${count} 个文本块吗?此操作不可恢复。`
|
||||
})}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={loading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} color="error" variant="contained" disabled={loading}>
|
||||
{loading ? <CircularProgress size={20} /> : t('common.confirm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
449
easy-dataset-main/components/text-split/ChunkCard.js
Normal file
449
easy-dataset-main/components/text-split/ChunkCard.js
Normal file
@@ -0,0 +1,449 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Chip,
|
||||
Checkbox,
|
||||
Tooltip,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import QuizIcon from '@mui/icons-material/Quiz';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// 编辑文本块对话框组件
|
||||
const EditChunkDialog = ({ open, chunk, onClose, onSave }) => {
|
||||
const [content, setContent] = useState(chunk?.content || '');
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 当文本块变化时更新内容
|
||||
useEffect(() => {
|
||||
if (chunk?.content) {
|
||||
setContent(chunk.content);
|
||||
}
|
||||
}, [chunk]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(content);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{t('textSplit.editChunk', { chunkId: chunk?.name })}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={15}
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
variant="outlined"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} variant="contained" color="primary">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ChunkCard({
|
||||
chunk,
|
||||
selected,
|
||||
onSelect,
|
||||
onView,
|
||||
onDelete,
|
||||
onGenerateQuestions,
|
||||
onDataCleaning,
|
||||
onEdit,
|
||||
onGenerateEvalQuestions, // 新增:生成测评题目的回调
|
||||
projectId,
|
||||
selectedModel // 添加selectedModel参数
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [chunkForEdit, setChunkForEdit] = useState(null);
|
||||
const [generatingQuestions, setGeneratingQuestions] = useState(false);
|
||||
const [generatingEval, setGeneratingEval] = useState(false);
|
||||
|
||||
// 获取文本预览
|
||||
const getTextPreview = (content, maxLength = 150) => {
|
||||
if (!content) return '';
|
||||
return content.length > maxLength ? `${content.substring(0, maxLength)}...` : content;
|
||||
};
|
||||
|
||||
// 检查是否有已生成的问题
|
||||
const hasQuestions = chunk.questions && chunk.questions.length > 0;
|
||||
|
||||
// 处理编辑按钮点击
|
||||
const handleEditClick = async () => {
|
||||
try {
|
||||
// 显示加载状态
|
||||
console.log('正在获取文本块完整内容...');
|
||||
console.log('projectId:', projectId, 'chunkId:', chunk.id);
|
||||
|
||||
// 先获取完整的文本块内容,使用从外部传入的 projectId
|
||||
const response = await fetch(`/api/projects/${projectId}/chunks/${encodeURIComponent(chunk.id)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('textSplit.fetchChunkFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('获取文本块完整内容成功:', data);
|
||||
|
||||
// 先设置完整数据,再打开对话框(与 ChunkList.js 中的实现一致)
|
||||
setChunkForEdit(data);
|
||||
setEditDialogOpen(true);
|
||||
} catch (error) {
|
||||
console.error(t('textSplit.fetchChunkError'), error);
|
||||
// 如果出错,使用原始预览数据
|
||||
alert(t('textSplit.fetchChunkError'));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理保存编辑内容
|
||||
const handleSaveEdit = newContent => {
|
||||
if (onEdit) {
|
||||
onEdit(chunk.id, newContent);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理生成单个问题 - 后台执行,不阻塞UI
|
||||
const handleGenerateQuestionsClick = async () => {
|
||||
setGeneratingQuestions(true);
|
||||
try {
|
||||
await onGenerateQuestions([chunk.id]);
|
||||
} finally {
|
||||
// Always release loading state, even when generation fails.
|
||||
setTimeout(() => {
|
||||
setGeneratingQuestions(false);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理生成测评题目
|
||||
const handleGenerateEvalQuestionsClick = async () => {
|
||||
if (!onGenerateEvalQuestions) return;
|
||||
|
||||
setGeneratingEval(true);
|
||||
try {
|
||||
await onGenerateEvalQuestions(chunk.id);
|
||||
} finally {
|
||||
// 延迟关闭加载状态
|
||||
setTimeout(() => {
|
||||
setGeneratingEval(false);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mb: 1,
|
||||
position: 'relative',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
borderColor: selected ? theme.palette.primary.main : theme.palette.divider,
|
||||
bgcolor: selected ? `${theme.palette.primary.main}10` : 'transparent',
|
||||
borderRadius: 2,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 4px 12px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.1)'}`
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ pt: 2.5, px: 2.5, pb: '16px !important' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
sx={{
|
||||
mr: 1,
|
||||
'&.Mui-checked': {
|
||||
color: theme.palette.primary.main
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1.5,
|
||||
flexWrap: 'wrap',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight="600"
|
||||
sx={{
|
||||
color: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.dark
|
||||
}}
|
||||
>
|
||||
{chunk.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={`${chunk.fileName || t('textSplit.unknownFile')}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
fontWeight: 500,
|
||||
'& .MuiChip-label': { px: 1 }
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={`${chunk.size || 0} ${t('textSplit.characters')}`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
fontWeight: 500,
|
||||
'& .MuiChip-label': { px: 1 }
|
||||
}}
|
||||
/>
|
||||
{chunk.Questions.length > 0 && (
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }} style={{ maxHeight: '200px', overflow: 'auto' }}>
|
||||
{chunk.Questions.map((q, index) => (
|
||||
<Typography key={index} variant="body2" sx={{ mb: 0.5 }}>
|
||||
{index + 1}. {q.question}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Chip
|
||||
label={`${t('textSplit.generatedQuestions', { count: chunk.Questions.length })}`}
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
fontWeight: 500,
|
||||
'& .MuiChip-label': { px: 1 }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!projectId) return;
|
||||
router.push(`/projects/${projectId}/questions`);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{chunk.EvalDatasets && chunk.EvalDatasets.length > 0 && (
|
||||
<Chip
|
||||
label={`${t('textSplit.generatedEvalQuestions', { count: chunk.EvalDatasets.length })}`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
fontWeight: 500,
|
||||
'& .MuiChip-label': { px: 1 }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!projectId) return;
|
||||
router.push(`/projects/${projectId}/eval-datasets`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
sx={{
|
||||
mb: 1,
|
||||
lineHeight: 1.6,
|
||||
opacity: 0.85
|
||||
}}
|
||||
>
|
||||
{getTextPreview(chunk.content)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
<CardActions
|
||||
sx={{
|
||||
justifyContent: 'flex-end',
|
||||
px: 2.5,
|
||||
pb: 2,
|
||||
gap: 1,
|
||||
'& .MuiIconButton-root': {
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip title={t('datasets.viewDetails')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={onView}
|
||||
sx={{
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(33, 150, 243, 0.08)'
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
selectedModel?.id
|
||||
? t('textSplit.generateQuestions')
|
||||
: t('textSplit.selectModelFirst', { defaultValue: '请先在右上角选择模型' })
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="info"
|
||||
onClick={handleGenerateQuestionsClick}
|
||||
disabled={!selectedModel?.id || generatingQuestions}
|
||||
sx={{
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(41, 182, 246, 0.08)' : 'rgba(2, 136, 209, 0.08)',
|
||||
'&.Mui-disabled': {
|
||||
opacity: 0.6,
|
||||
pointerEvents: 'auto' // 允许鼠标悬停显示tooltip
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generatingQuestions ? <CircularProgress size={20} color="inherit" /> : <QuizIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
selectedModel?.id
|
||||
? t('textSplit.generateEvalQuestions', { defaultValue: '生成测试集' })
|
||||
: t('textSplit.selectModelFirst', { defaultValue: '请先在右上角选择模型' })
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={handleGenerateEvalQuestionsClick}
|
||||
disabled={!selectedModel?.id || generatingEval}
|
||||
sx={{
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(156, 39, 176, 0.08)' : 'rgba(123, 31, 162, 0.08)',
|
||||
'&.Mui-disabled': {
|
||||
opacity: 0.6,
|
||||
pointerEvents: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generatingEval ? <CircularProgress size={20} color="inherit" /> : <AssignmentIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
selectedModel?.id
|
||||
? t('textSplit.dataCleaning', { defaultValue: '数据清洗' })
|
||||
: t('textSplit.selectModelFirst', { defaultValue: '请先在右上角选择模型' })
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="success"
|
||||
onClick={onDataCleaning}
|
||||
disabled={!selectedModel?.id}
|
||||
sx={{
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(76, 175, 80, 0.08)' : 'rgba(46, 125, 50, 0.08)',
|
||||
'&.Mui-disabled': {
|
||||
opacity: 0.6,
|
||||
pointerEvents: 'auto' // 允许鼠标悬停显示tooltip
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CleaningServicesIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('textSplit.editChunk', { chunkId: chunk.name })}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="warning"
|
||||
onClick={handleEditClick}
|
||||
sx={{
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 152, 0, 0.08)' : 'rgba(237, 108, 2, 0.08)'
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={onDelete}
|
||||
sx={{
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(244, 67, 54, 0.08)' : 'rgba(211, 47, 47, 0.08)'
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</CardActions>
|
||||
</Card>
|
||||
|
||||
{/* 编辑文本块对话框 */}
|
||||
<EditChunkDialog
|
||||
open={editDialogOpen}
|
||||
chunk={chunkForEdit || chunk}
|
||||
onClose={() => {
|
||||
setEditDialogOpen(false);
|
||||
setChunkForEdit(null);
|
||||
}}
|
||||
onSave={handleSaveEdit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
easy-dataset-main/components/text-split/ChunkDeleteDialog.js
Normal file
27
easy-dataset-main/components/text-split/ChunkDeleteDialog.js
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ChunkDeleteDialog({ open, onClose, onConfirm }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-description"
|
||||
>
|
||||
<DialogTitle id="delete-dialog-title">{t('common.confirmDelete')}?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="delete-dialog-description">{t('common.confirmDelete')}?</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={onConfirm} color="error" variant="contained">
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
124
easy-dataset-main/components/text-split/ChunkFilterDialog.js
Normal file
124
easy-dataset-main/components/text-split/ChunkFilterDialog.js
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Slider,
|
||||
FormControlLabel,
|
||||
Checkbox
|
||||
} from '@mui/material';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function ChunkFilterDialog({ open, onClose, onApply, initialFilters = {} }) {
|
||||
const { t } = useTranslation();
|
||||
const [contentKeyword, setContentKeyword] = useState(initialFilters.contentKeyword || '');
|
||||
const [sizeRange, setSizeRange] = useState(initialFilters.sizeRange || [0, 10000]);
|
||||
const [hasQuestions, setHasQuestions] = useState(initialFilters.hasQuestions || null);
|
||||
|
||||
// 重置筛选条件
|
||||
const handleReset = () => {
|
||||
setContentKeyword('');
|
||||
setSizeRange([0, 10000]);
|
||||
setHasQuestions(null);
|
||||
};
|
||||
|
||||
// 应用筛选
|
||||
const handleApply = () => {
|
||||
onApply({
|
||||
contentKeyword,
|
||||
sizeRange,
|
||||
hasQuestions
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 处理大小范围变化
|
||||
const handleSizeRangeChange = (event, newValue) => {
|
||||
setSizeRange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('datasets.moreFilters', { defaultValue: '更多筛选' })}</DialogTitle>
|
||||
<DialogContent dividers sx={{ display: 'flex', flexDirection: 'column', gap: 3, py: 3 }}>
|
||||
{/* 文本块内容筛选 */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
{t('textSplit.contentKeyword', { defaultValue: '文本块内容' })}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder={t('textSplit.contentKeywordPlaceholder', { defaultValue: '输入关键词搜索文本块内容' })}
|
||||
value={contentKeyword}
|
||||
onChange={e => setContentKeyword(e.target.value)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 字数范围筛选 */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{t('textSplit.characterRange', { defaultValue: '字数范围' })}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{sizeRange[0]} - {sizeRange[1]}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Slider
|
||||
value={sizeRange}
|
||||
onChange={handleSizeRangeChange}
|
||||
valueLabelDisplay="auto"
|
||||
min={0}
|
||||
max={10000}
|
||||
step={100}
|
||||
marks={[
|
||||
{ value: 0, label: '0' },
|
||||
{ value: 10000, label: '10000' }
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 是否有问题的筛选 */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
{t('textSplit.questionStatus', { defaultValue: '问题状态' })}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={hasQuestions === null} onChange={() => setHasQuestions(null)} />}
|
||||
label={t('textSplit.allChunks', { defaultValue: '全部' })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={hasQuestions === true} onChange={() => setHasQuestions(true)} />}
|
||||
label={t('textSplit.generatedQuestions2', { defaultValue: '已生成问题' })}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={hasQuestions === false} onChange={() => setHasQuestions(false)} />}
|
||||
label={t('textSplit.ungeneratedQuestions', { defaultValue: '未生成问题' })}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={handleReset} color="inherit">
|
||||
{t('common.reset', { defaultValue: '重置' })}
|
||||
</Button>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
{t('common.cancel', { defaultValue: '取消' })}
|
||||
</Button>
|
||||
<Button onClick={handleApply} variant="contained" color="primary">
|
||||
{t('common.apply', { defaultValue: '应用' })}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
413
easy-dataset-main/components/text-split/ChunkList.js
Normal file
413
easy-dataset-main/components/text-split/ChunkList.js
Normal file
@@ -0,0 +1,413 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Box, Paper, Typography, CircularProgress, Pagination, Grid } from '@mui/material';
|
||||
import ChunkListHeader from './ChunkListHeader';
|
||||
import ChunkCard from './ChunkCard';
|
||||
import ChunkViewDialog from './ChunkViewDialog';
|
||||
import ChunkDeleteDialog from './ChunkDeleteDialog';
|
||||
import BatchEditChunksDialog from './BatchEditChunkDialog';
|
||||
import ChunkBatchDeleteDialog from './ChunkBatchDeleteDialog';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Chunk list component
|
||||
* @param {Object} props
|
||||
* @param {string} props.projectId - Project ID
|
||||
* @param {Array} props.chunks - Chunk array
|
||||
* @param {Function} props.onDelete - Delete callback
|
||||
* @param {Function} props.onEdit - Edit callback
|
||||
* @param {Function} props.onGenerateQuestions - Generate questions callback
|
||||
* @param {Function} props.onDataCleaning - Data cleaning callback
|
||||
* @param {string} props.questionFilter - Question filter
|
||||
* @param {Function} props.onQuestionFilterChange - Question filter change callback
|
||||
* @param {Object} props.selectedModel - 閫変腑鐨勬ā鍨嬩俊鎭?
|
||||
*/
|
||||
export default function ChunkList({
|
||||
projectId,
|
||||
chunks = [],
|
||||
onDelete,
|
||||
onEdit,
|
||||
onGenerateQuestions,
|
||||
onGenerateEvalQuestions,
|
||||
onDataCleaning,
|
||||
loading = false,
|
||||
questionFilter,
|
||||
setQuestionFilter,
|
||||
selectedModel,
|
||||
onChunksUpdate
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedChunks, setSelectedChunks] = useState([]);
|
||||
const [viewChunk, setViewChunk] = useState(null);
|
||||
const [viewDialogOpen, setViewDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [chunkToDelete, setChunkToDelete] = useState(null);
|
||||
const [batchEditDialogOpen, setBatchEditDialogOpen] = useState(false);
|
||||
const [batchEditLoading, setBatchEditLoading] = useState(false);
|
||||
const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false);
|
||||
const [batchDeleteLoading, setBatchDeleteLoading] = useState(false);
|
||||
|
||||
// 娣诲姞楂樼骇绛涢€夌姸鎬?
|
||||
const [advancedFilters, setAdvancedFilters] = useState({
|
||||
contentKeyword: '',
|
||||
sizeRange: [0, 10000],
|
||||
hasQuestions: null
|
||||
});
|
||||
|
||||
// 璁$畻娲昏穬绛涢€夋潯浠舵暟
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (advancedFilters.contentKeyword) count++;
|
||||
if (advancedFilters.sizeRange[0] > 0 || advancedFilters.sizeRange[1] < 10000) count++;
|
||||
if (advancedFilters.hasQuestions !== null) count++;
|
||||
return count;
|
||||
}, [advancedFilters]);
|
||||
|
||||
const sortedChunks = useMemo(
|
||||
() =>
|
||||
[...chunks].sort((a, b) => {
|
||||
if (a.fileId !== b.fileId) {
|
||||
return a.fileId.localeCompare(b.fileId);
|
||||
}
|
||||
|
||||
const getPartNumber = name => {
|
||||
const match = name.match(/part-(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
};
|
||||
|
||||
const numA = getPartNumber(a.name);
|
||||
const numB = getPartNumber(b.name);
|
||||
|
||||
return numA - numB;
|
||||
}),
|
||||
[chunks]
|
||||
);
|
||||
|
||||
const filteredChunks = useMemo(() => {
|
||||
return sortedChunks.filter(chunk => {
|
||||
if (advancedFilters.contentKeyword) {
|
||||
const keyword = advancedFilters.contentKeyword.toLowerCase();
|
||||
if (!chunk.content?.toLowerCase().includes(keyword)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const size = chunk.size || 0;
|
||||
if (size < advancedFilters.sizeRange[0] || size > advancedFilters.sizeRange[1]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (advancedFilters.hasQuestions !== null) {
|
||||
const hasQuestions = chunk.Questions && chunk.Questions.length > 0;
|
||||
if (advancedFilters.hasQuestions !== hasQuestions) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [sortedChunks, advancedFilters]);
|
||||
|
||||
// 褰撶瓫閫夋潯浠跺彉鍖栨椂锛屾竻闄や笉鍦ㄧ瓫閫夌粨鏋滀腑鐨勯€変腑椤?
|
||||
useEffect(() => {
|
||||
const filteredChunkIds = filteredChunks.map(chunk => chunk.id);
|
||||
setSelectedChunks(prev => prev.filter(id => filteredChunkIds.includes(id)));
|
||||
}, [filteredChunks]);
|
||||
|
||||
const itemsPerPage = 5;
|
||||
const displayedChunks = useMemo(() => {
|
||||
const startIndex = (page - 1) * itemsPerPage;
|
||||
return filteredChunks.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [filteredChunks, page]);
|
||||
const totalPages = useMemo(() => Math.ceil(filteredChunks.length / itemsPerPage), [filteredChunks.length]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handlePageChange = (event, value) => {
|
||||
setPage(value);
|
||||
};
|
||||
|
||||
const handleViewChunk = async chunkId => {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/chunks/${chunkId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(t('textSplit.fetchChunksFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setViewChunk(data);
|
||||
setViewDialogOpen(true);
|
||||
} catch (error) {
|
||||
console.error(t('textSplit.fetchChunksError'), error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseViewDialog = () => {
|
||||
setViewDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenDeleteDialog = chunkId => {
|
||||
setChunkToDelete(chunkId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDeleteDialog = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setChunkToDelete(null);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (chunkToDelete && onDelete) {
|
||||
onDelete(chunkToDelete);
|
||||
}
|
||||
handleCloseDeleteDialog();
|
||||
};
|
||||
|
||||
// 澶勭悊缂栬緫鏂囨湰鍧?
|
||||
const handleEditChunk = async (chunkId, newContent) => {
|
||||
if (onEdit) {
|
||||
onEdit(chunkId, newContent);
|
||||
onChunksUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
// 澶勭悊閫夋嫨鏂囨湰鍧?
|
||||
const handleSelectChunk = chunkId => {
|
||||
setSelectedChunks(prev => {
|
||||
if (prev.includes(chunkId)) {
|
||||
return prev.filter(id => id !== chunkId);
|
||||
} else {
|
||||
return [...prev, chunkId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedChunks.length === filteredChunks.length) {
|
||||
setSelectedChunks([]);
|
||||
} else {
|
||||
setSelectedChunks(filteredChunks.map(chunk => chunk.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchGenerateQuestions = () => {
|
||||
if (onGenerateQuestions && selectedChunks.length > 0) {
|
||||
onGenerateQuestions(selectedChunks);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchEdit = async editData => {
|
||||
try {
|
||||
setBatchEditLoading(true);
|
||||
|
||||
// 璋冪敤鎵归噺缂栬緫API
|
||||
const response = await fetch(`/api/projects/${projectId}/chunks/batch-edit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
position: editData.position,
|
||||
content: editData.content,
|
||||
chunkIds: editData.chunkIds
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('鎵归噺缂栬緫澶辫触');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 缂栬緫鎴愬姛鍚庯紝鍒锋柊鏂囨湰鍧楁暟鎹?
|
||||
if (onChunksUpdate) {
|
||||
onChunksUpdate();
|
||||
}
|
||||
|
||||
// 娓呯┖閫変腑鐘舵€?
|
||||
setSelectedChunks([]);
|
||||
|
||||
// 鍏抽棴瀵硅瘽妗?
|
||||
setBatchEditDialogOpen(false);
|
||||
|
||||
// 鏄剧ず鎴愬姛娑堟伅
|
||||
console.log(`鎴愬姛鏇存柊浜?${result.updatedCount} 涓枃鏈潡`);
|
||||
} else {
|
||||
throw new Error(result.message || '鎵归噺缂栬緫澶辫触');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('鎵归噺缂栬緫澶辫触:', error);
|
||||
// 杩欓噷鍙互娣诲姞閿欒鎻愮ず
|
||||
} finally {
|
||||
setBatchEditLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 鎵撳紑鎵归噺缂栬緫瀵硅瘽妗?
|
||||
const handleOpenBatchEdit = () => {
|
||||
setBatchEditDialogOpen(true);
|
||||
};
|
||||
|
||||
// 鍏抽棴鎵归噺缂栬緫瀵硅瘽妗?
|
||||
const handleCloseBatchEdit = () => {
|
||||
setBatchEditDialogOpen(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 澶勭悊绛涢€夊彉鍖?
|
||||
const handleFilterChange = filters => {
|
||||
setAdvancedFilters(filters);
|
||||
setPage(1); // 閲嶇疆鍒扮涓€椤?
|
||||
};
|
||||
|
||||
// 鎵撳紑鎵归噺鍒犻櫎瀵硅瘽妗?
|
||||
const handleOpenBatchDelete = () => {
|
||||
setBatchDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 鍏抽棴鎵归噺鍒犻櫎瀵硅瘽妗?
|
||||
const handleCloseBatchDelete = () => {
|
||||
setBatchDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
// 纭鎵归噺鍒犻櫎
|
||||
const handleConfirmBatchDelete = async () => {
|
||||
if (selectedChunks.length === 0) return;
|
||||
|
||||
try {
|
||||
setBatchDeleteLoading(true);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// 寰幆璋冪敤鍗曚釜鍒犻櫎鎺ュ彛
|
||||
for (const chunkId of selectedChunks) {
|
||||
try {
|
||||
await onDelete(chunkId);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`鍒犻櫎鏂囨湰鍧?${chunkId} 澶辫触:`, error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 鏄剧ず鍒犻櫎缁撴灉
|
||||
if (failCount === 0) {
|
||||
console.log(`鎴愬姛鍒犻櫎 ${successCount} 涓枃鏈潡`);
|
||||
} else {
|
||||
console.log(`删除完成:成功 ${successCount} 个,失败 ${failCount} 个`);
|
||||
}
|
||||
|
||||
// 娓呯┖閫変腑鐘舵€?
|
||||
setSelectedChunks([]);
|
||||
|
||||
// 鍒锋柊鏁版嵁
|
||||
if (onChunksUpdate) {
|
||||
onChunksUpdate();
|
||||
}
|
||||
|
||||
// 鍏抽棴瀵硅瘽妗?
|
||||
setBatchDeleteDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('鎵归噺鍒犻櫎澶辫触:', error);
|
||||
} finally {
|
||||
setBatchDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<ChunkListHeader
|
||||
projectId={projectId}
|
||||
totalChunks={filteredChunks.length}
|
||||
selectedChunks={selectedChunks}
|
||||
onSelectAll={handleSelectAll}
|
||||
onBatchGenerateQuestions={handleBatchGenerateQuestions}
|
||||
onBatchEditChunks={handleOpenBatchEdit}
|
||||
onBatchDeleteChunks={handleOpenBatchDelete}
|
||||
questionFilter={questionFilter}
|
||||
setQuestionFilter={event => setQuestionFilter(event.target.value)}
|
||||
chunks={chunks}
|
||||
selectedModel={selectedModel}
|
||||
onFilterChange={handleFilterChange}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{displayedChunks.map(chunk => (
|
||||
<Grid item xs={12} key={chunk.id}>
|
||||
<ChunkCard
|
||||
chunk={chunk}
|
||||
selected={selectedChunks.includes(chunk.id)}
|
||||
onSelect={() => handleSelectChunk(chunk.id)}
|
||||
onView={() => handleViewChunk(chunk.id)}
|
||||
onDelete={() => handleOpenDeleteDialog(chunk.id)}
|
||||
onEdit={handleEditChunk}
|
||||
onGenerateQuestions={() => onGenerateQuestions && onGenerateQuestions([chunk.id])}
|
||||
onGenerateEvalQuestions={() => onGenerateEvalQuestions && onGenerateEvalQuestions(chunk.id)}
|
||||
onDataCleaning={() => onDataCleaning && onDataCleaning([chunk.id])}
|
||||
projectId={projectId}
|
||||
selectedModel={selectedModel}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{chunks.length === 0 && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
{t('textSplit.noChunks')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
||||
<Pagination count={totalPages} page={page} onChange={handlePageChange} color="primary" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 鏂囨湰鍧楄鎯呭璇濇 */}
|
||||
<ChunkViewDialog open={viewDialogOpen} chunk={viewChunk} onClose={handleCloseViewDialog} />
|
||||
|
||||
{/* 鍒犻櫎纭瀵硅瘽妗?*/}
|
||||
<ChunkDeleteDialog open={deleteDialogOpen} onClose={handleCloseDeleteDialog} onConfirm={handleConfirmDelete} />
|
||||
|
||||
{/* 鎵归噺缂栬緫瀵硅瘽妗?*/}
|
||||
<BatchEditChunksDialog
|
||||
open={batchEditDialogOpen}
|
||||
onClose={handleCloseBatchEdit}
|
||||
onConfirm={handleBatchEdit}
|
||||
selectedChunks={selectedChunks}
|
||||
totalChunks={chunks.length}
|
||||
loading={batchEditLoading}
|
||||
/>
|
||||
|
||||
{/* 鎵归噺鍒犻櫎纭瀵硅瘽妗?*/}
|
||||
<ChunkBatchDeleteDialog
|
||||
open={batchDeleteDialogOpen}
|
||||
onClose={handleCloseBatchDelete}
|
||||
onConfirm={handleConfirmBatchDelete}
|
||||
loading={batchDeleteLoading}
|
||||
count={selectedChunks.length}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
400
easy-dataset-main/components/text-split/ChunkListHeader.js
Normal file
400
easy-dataset-main/components/text-split/ChunkListHeader.js
Normal file
@@ -0,0 +1,400 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Checkbox, Button, Select, MenuItem, Tooltip, Menu, IconButton, Badge } from '@mui/material';
|
||||
import QuizIcon from '@mui/icons-material/Quiz';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import ChunkFilterDialog from './ChunkFilterDialog';
|
||||
|
||||
export default function ChunkListHeader({
|
||||
projectId,
|
||||
totalChunks,
|
||||
selectedChunks,
|
||||
onSelectAll,
|
||||
onBatchGenerateQuestions,
|
||||
onBatchEditChunks,
|
||||
onBatchDeleteChunks,
|
||||
questionFilter,
|
||||
setQuestionFilter,
|
||||
chunks = [], // 添加chunks参数,用于导出文本块
|
||||
selectedModel = {},
|
||||
onFilterChange = null,
|
||||
activeFilterCount = 0
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// 添加更多菜单的状态和锚点
|
||||
const [moreMenuAnchorEl, setMoreMenuAnchorEl] = useState(null);
|
||||
const isMoreMenuOpen = Boolean(moreMenuAnchorEl);
|
||||
|
||||
// 添加筛选对话框状态
|
||||
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
|
||||
|
||||
// 自动任务菜单状态
|
||||
const [autoTasksMenuAnchorEl, setAutoTasksMenuAnchorEl] = useState(null);
|
||||
const isAutoTasksMenuOpen = Boolean(autoTasksMenuAnchorEl);
|
||||
|
||||
const handleAutoTasksClick = event => {
|
||||
setAutoTasksMenuAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleAutoTasksClose = () => {
|
||||
setAutoTasksMenuAnchorEl(null);
|
||||
};
|
||||
|
||||
// 打开更多菜单
|
||||
const handleMoreMenuClick = event => {
|
||||
setMoreMenuAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
// 关闭更多菜单
|
||||
const handleMoreMenuClose = () => {
|
||||
setMoreMenuAnchorEl(null);
|
||||
};
|
||||
|
||||
// 处理批量编辑,关闭菜单并调用原有函数
|
||||
const handleBatchEdit = () => {
|
||||
handleMoreMenuClose();
|
||||
onBatchEditChunks();
|
||||
};
|
||||
|
||||
// 处理批量删除,关闭菜单并调用原有函数
|
||||
const handleBatchDelete = () => {
|
||||
handleMoreMenuClose();
|
||||
onBatchDeleteChunks();
|
||||
};
|
||||
|
||||
// 处理导出文本块,关闭菜单并调用原有函数
|
||||
const handleExport = () => {
|
||||
handleMoreMenuClose();
|
||||
handleExportChunks();
|
||||
};
|
||||
|
||||
// 创建自动提取问题任务
|
||||
const handleCreateAutoQuestionTask = async () => {
|
||||
if (!projectId || !selectedModel?.id) {
|
||||
toast.error(t('textSplit.selectModelFirst', { defaultValue: '请先选择模型' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用创建任务接口
|
||||
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
|
||||
taskType: 'question-generation',
|
||||
modelInfo: selectedModel,
|
||||
language: i18n.language,
|
||||
detail: '批量生成问题任务'
|
||||
});
|
||||
|
||||
if (response.data?.code === 0) {
|
||||
toast.success(t('tasks.createSuccess', { defaultValue: '后台任务已创建,系统将自动处理未生成问题的文本块' }));
|
||||
} else {
|
||||
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + response.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建自动提取问题任务失败:', error);
|
||||
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建自动数据清洗任务
|
||||
const handleCreateAutoDataCleaningTask = async () => {
|
||||
if (!projectId || !selectedModel?.id) {
|
||||
toast.error(t('textSplit.selectModelFirst', { defaultValue: '请先选择模型' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用创建任务接口
|
||||
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
|
||||
taskType: 'data-cleaning',
|
||||
modelInfo: selectedModel,
|
||||
language: i18n.language,
|
||||
detail: '批量数据清洗任务'
|
||||
});
|
||||
|
||||
if (response.data?.code === 0) {
|
||||
toast.success(
|
||||
t('tasks.createSuccess', { defaultValue: '后台任务已创建,系统将自动处理所有文本块进行数据清洗' })
|
||||
);
|
||||
} else {
|
||||
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + response.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建自动数据清洗任务失败:', error);
|
||||
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建自动生成评估数据集任务
|
||||
const handleCreateAutoEvalGenerationTask = async () => {
|
||||
if (!projectId || !selectedModel?.id) {
|
||||
toast.error(t('textSplit.selectModelFirst', { defaultValue: '请先选择模型' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用创建任务接口
|
||||
const response = await axios.post(`/api/projects/${projectId}/tasks`, {
|
||||
taskType: 'eval-generation',
|
||||
modelInfo: selectedModel,
|
||||
language: i18n.language,
|
||||
detail: '批量生成评估数据集任务'
|
||||
});
|
||||
|
||||
if (response.data?.code === 0) {
|
||||
toast.success(
|
||||
t('tasks.createSuccess', {
|
||||
defaultValue: '后台任务已创建,系统将自动为所有未生成评估题目的文本块生成评估数据集'
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + response.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建自动生成评估数据集任务失败:', error);
|
||||
toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出文本块为JSON文件的函数
|
||||
const handleExportChunks = () => {
|
||||
if (!chunks || chunks.length === 0) return;
|
||||
|
||||
// 创建要导出的数据对象
|
||||
const exportData = chunks.map(chunk => ({
|
||||
name: chunk.name,
|
||||
projectId: chunk.projectId,
|
||||
fileName: chunk.fileName,
|
||||
content: chunk.content,
|
||||
summary: chunk.summary,
|
||||
size: chunk.size
|
||||
}));
|
||||
|
||||
// 将数据转换为JSON字符串
|
||||
const jsonString = JSON.stringify(exportData, null, 2);
|
||||
|
||||
// 创建Blob对象
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `text-chunks-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// 清理
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
justifyContent: 'space-between',
|
||||
alignItems: { xs: 'flex-start', md: 'center' },
|
||||
gap: 2,
|
||||
mb: 3
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Checkbox
|
||||
checked={selectedChunks.length === totalChunks}
|
||||
indeterminate={selectedChunks.length > 0 && selectedChunks.length < totalChunks}
|
||||
onChange={onSelectAll}
|
||||
/>
|
||||
<Typography variant="body1">{t('textSplit.selectedCount', { count: selectedChunks.length })}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
alignItems: { xs: 'flex-start', sm: 'center' },
|
||||
flexWrap: 'wrap',
|
||||
gap: 1.5,
|
||||
width: { xs: '100%', md: 'auto' }
|
||||
}}
|
||||
>
|
||||
{/* 更多筛选按钮 */}
|
||||
<Tooltip title={t('datasets.moreFilters', { defaultValue: '更多筛选' })}>
|
||||
<Badge badgeContent={activeFilterCount} color="error" overlap="circular">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FilterListIcon />}
|
||||
onClick={() => setFilterDialogOpen(true)}
|
||||
size="small"
|
||||
sx={{ borderRadius: 1 }}
|
||||
>
|
||||
{t('datasets.moreFilters', { defaultValue: '更多筛选' })}
|
||||
</Button>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1.5,
|
||||
mt: { xs: 1, sm: 0 },
|
||||
width: { xs: '100%', sm: 'auto' }
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<QuizIcon />}
|
||||
disabled={selectedChunks.length === 0}
|
||||
onClick={onBatchGenerateQuestions}
|
||||
size="medium"
|
||||
sx={{ minWidth: { xs: '48%', sm: 'auto' } }}
|
||||
>
|
||||
{t('textSplit.batchGenerateQuestions')}
|
||||
</Button>
|
||||
|
||||
{/* 自动任务下拉菜单 */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon={<AutoFixHighIcon />}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
onClick={handleAutoTasksClick}
|
||||
disabled={!projectId || !selectedModel?.id}
|
||||
size="medium"
|
||||
sx={{ minWidth: { xs: '48%', sm: 'auto' } }}
|
||||
>
|
||||
{t('textSplit.autoTasks', { defaultValue: '自动任务' })}
|
||||
</Button>
|
||||
|
||||
<Menu
|
||||
anchorEl={autoTasksMenuAnchorEl}
|
||||
open={isAutoTasksMenuOpen}
|
||||
onClose={handleAutoTasksClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
title={t('textSplit.autoGenerateQuestionsTip', {
|
||||
defaultValue: '创建后台批量处理任务:自动查询待生成问题的文本块并提取问题'
|
||||
})}
|
||||
placement="left"
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleCreateAutoQuestionTask();
|
||||
handleAutoTasksClose();
|
||||
}}
|
||||
>
|
||||
<QuizIcon fontSize="small" sx={{ mr: 1, color: 'secondary.main' }} />
|
||||
{t('textSplit.autoGenerateQuestions')}
|
||||
</MenuItem>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={t('textSplit.autoEvalGenerationTip', {
|
||||
defaultValue: '创建后台批量处理任务:自动为所有未生成评估题目的文本块生成评估数据集'
|
||||
})}
|
||||
placement="left"
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleCreateAutoEvalGenerationTask();
|
||||
handleAutoTasksClose();
|
||||
}}
|
||||
>
|
||||
<AssessmentIcon fontSize="small" sx={{ mr: 1, color: 'secondary.main' }} />
|
||||
{t('textSplit.autoEvalGeneration', { defaultValue: '自动生成评估集' })}
|
||||
</MenuItem>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={t('textSplit.autoDataCleaningTip', {
|
||||
defaultValue: '创建后台批量处理任务:自动对所有文本块进行数据清洗'
|
||||
})}
|
||||
placement="left"
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleCreateAutoDataCleaningTask();
|
||||
handleAutoTasksClose();
|
||||
}}
|
||||
>
|
||||
<CleaningServicesIcon fontSize="small" sx={{ mr: 1, color: 'success.main' }} />
|
||||
{t('textSplit.autoDataCleaning', { defaultValue: '自动数据清洗' })}
|
||||
</MenuItem>
|
||||
</Tooltip>
|
||||
</Menu>
|
||||
|
||||
{/* 更多菜单按钮 */}
|
||||
<Tooltip title={t('common.more', { defaultValue: '更多操作' })}>
|
||||
<IconButton
|
||||
onClick={handleMoreMenuClick}
|
||||
color="primary"
|
||||
size="medium"
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* 更多操作下拉菜单 */}
|
||||
<Menu
|
||||
anchorEl={moreMenuAnchorEl}
|
||||
open={isMoreMenuOpen}
|
||||
onClose={handleMoreMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleBatchEdit} disabled={selectedChunks.length === 0}>
|
||||
<EditIcon fontSize="small" sx={{ mr: 1 }} />
|
||||
{t('batchEdit.batchEdit', { defaultValue: '批量编辑' })}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleBatchDelete} disabled={selectedChunks.length === 0}>
|
||||
<DeleteIcon fontSize="small" sx={{ mr: 1, color: 'error.main' }} />
|
||||
{t('textSplit.batchDeleteChunks', { defaultValue: '批量删除' })}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleExport} disabled={chunks.length === 0}>
|
||||
<DownloadIcon fontSize="small" sx={{ mr: 1 }} />
|
||||
{t('textSplit.exportChunks', { defaultValue: '导出文本块' })}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 筛选对话框 */}
|
||||
<ChunkFilterDialog open={filterDialogOpen} onClose={() => setFilterDialogOpen(false)} onApply={onFilterChange} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
31
easy-dataset-main/components/text-split/ChunkViewDialog.js
Normal file
31
easy-dataset-main/components/text-split/ChunkViewDialog.js
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Button, Dialog, DialogTitle, DialogContent, DialogActions, CircularProgress } from '@mui/material';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
|
||||
export default function ChunkViewDialog({ open, chunk, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{t('textSplit.chunkDetails', { chunkId: chunk?.name })}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{chunk ? (
|
||||
<Box sx={{ maxHeight: '60vh', overflow: 'auto' }}>
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown>{chunk.content}</ReactMarkdown>
|
||||
</div>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t('common.close')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
560
easy-dataset-main/components/text-split/DomainAnalysis.js
Normal file
560
easy-dataset-main/components/text-split/DomainAnalysis.js
Normal file
@@ -0,0 +1,560 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Tabs,
|
||||
Tab,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
IconButton,
|
||||
TextField,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Tooltip,
|
||||
Menu,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import TabPanel from './components/TabPanel';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
|
||||
/**
|
||||
* 领域分析组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.projectId - 项目ID
|
||||
* @param {Array} props.toc - 目录结构数组
|
||||
* @param {Array} props.tags - 标签树数组
|
||||
* @param {boolean} props.loading - 是否加载中
|
||||
* @param {Function} props.onTagsUpdate - 标签更新回调
|
||||
*/
|
||||
|
||||
// 领域树节点组件
|
||||
function TreeNode({ node, level = 0, onEdit, onDelete, onAddChild }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const theme = useTheme();
|
||||
const hasChildren = node.child && node.child.length > 0;
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleClick = () => {
|
||||
if (hasChildren) {
|
||||
setOpen(!open);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuOpen = event => {
|
||||
event.stopPropagation();
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = event => {
|
||||
if (event) event.stopPropagation();
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleEdit = event => {
|
||||
event.stopPropagation();
|
||||
onEdit(node);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleDelete = event => {
|
||||
event.stopPropagation();
|
||||
onDelete(node);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleAddChild = event => {
|
||||
event.stopPropagation();
|
||||
onAddChild(node);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
button
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
pl: level * 2 + 1,
|
||||
bgcolor: level === 0 ? theme.palette.primary.light : 'transparent',
|
||||
color: level === 0 ? theme.palette.primary.contrastText : 'inherit',
|
||||
'&:hover': {
|
||||
bgcolor: level === 0 ? theme.palette.primary.main : theme.palette.action.hover
|
||||
},
|
||||
borderRadius: '4px',
|
||||
mb: 0.5,
|
||||
pr: 1
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={node.label}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: level === 0 ? 600 : 400,
|
||||
fontSize: level === 0 ? '1rem' : '0.9rem'
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleMenuOpen}
|
||||
sx={{
|
||||
color: level === 0 ? 'inherit' : theme.palette.text.secondary,
|
||||
'&:hover': { bgcolor: 'rgba(255, 255, 255, 0.1)' }
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{hasChildren && (open ? <ExpandLess /> : <ExpandMore />)}
|
||||
</Box>
|
||||
|
||||
<Menu anchorEl={anchorEl} open={menuOpen} onClose={handleMenuClose} onClick={e => e.stopPropagation()}>
|
||||
<MenuItem onClick={handleEdit}>
|
||||
<EditIcon fontSize="small" sx={{ mr: 1 }} />
|
||||
{t('textSplit.editTag')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleDelete}>
|
||||
<DeleteIcon fontSize="small" sx={{ mr: 1 }} />
|
||||
{t('textSplit.deleteTag')}
|
||||
</MenuItem>
|
||||
{level === 0 && (
|
||||
<MenuItem onClick={handleAddChild}>
|
||||
<AddIcon fontSize="small" sx={{ mr: 1 }} />
|
||||
{t('textSplit.addTag')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</ListItem>
|
||||
|
||||
{hasChildren && (
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{node.child.map((childNode, index) => (
|
||||
<TreeNode
|
||||
key={index}
|
||||
node={childNode}
|
||||
level={level + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 领域树组件
|
||||
function DomainTree({ tags, onEdit, onDelete, onAddChild }) {
|
||||
return (
|
||||
<List component="nav" aria-label="domain tree">
|
||||
{tags.map((node, index) => (
|
||||
<TreeNode key={index} node={node} onEdit={onEdit} onDelete={onDelete} onAddChild={onAddChild} />
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DomainAnalysis({ projectId, toc = '', loading = false }) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [currentNode, setCurrentNode] = useState(null);
|
||||
const [parentNode, setParentNode] = useState('');
|
||||
const [dialogMode, setDialogMode] = useState('add');
|
||||
const [labelValue, setLabelValue] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [tags, setTags] = useState([]);
|
||||
const [snackbar, setSnackbar] = useState({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
setSnackbar(prev => ({ ...prev, open: false }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getTags();
|
||||
}, []);
|
||||
const getTags = async () => {
|
||||
const response = await axios.get(`/api/projects/${projectId}/tags`);
|
||||
setTags(response.data.tags);
|
||||
};
|
||||
// 处理标签切换
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
// 打开添加标签对话框
|
||||
const handleAddTag = () => {
|
||||
setDialogMode('add');
|
||||
setCurrentNode(null);
|
||||
setParentNode(null);
|
||||
setLabelValue({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 打开编辑标签对话框
|
||||
const handleEditTag = node => {
|
||||
setDialogMode('edit');
|
||||
setCurrentNode({ id: node.id, label: node.label });
|
||||
setLabelValue({ id: node.id, label: node.label });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 打开添加子标签对话框
|
||||
const handleAddChildTag = parentNode => {
|
||||
setDialogMode('addChild');
|
||||
setParentNode(parentNode.label);
|
||||
setLabelValue({ parentId: parentNode.id });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 打开删除标签对话框
|
||||
const handleDeleteTag = node => {
|
||||
setCurrentNode(node);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
// 查找并更新节点
|
||||
const findAndUpdateNode = (nodes, targetNode, newLabel) => {
|
||||
return nodes.map(node => {
|
||||
if (node === targetNode) {
|
||||
return { ...node, label: newLabel };
|
||||
}
|
||||
if (node.child && node.child.length > 0) {
|
||||
return { ...node, child: findAndUpdateNode(node.child, targetNode, newLabel) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
// 查找并删除节点
|
||||
const findAndDeleteNode = (nodes, targetNode) => {
|
||||
return nodes
|
||||
.filter(node => node !== targetNode)
|
||||
.map(node => {
|
||||
if (node.child && node.child.length > 0) {
|
||||
return { ...node, child: findAndDeleteNode(node.child, targetNode) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
// 查找并添加子节点
|
||||
const findAndAddChildNode = (nodes, parentNode, childLabel) => {
|
||||
return nodes.map(node => {
|
||||
if (node === parentNode) {
|
||||
const childArray = node.child || [];
|
||||
return {
|
||||
...node,
|
||||
child: [...childArray, { label: childLabel, child: [] }]
|
||||
};
|
||||
}
|
||||
if (node.child && node.child.length > 0) {
|
||||
return { ...node, child: findAndAddChildNode(node.child, parentNode, childLabel) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
// 保存标签更改
|
||||
const saveTagChanges = async updatedTags => {
|
||||
console.log('保存标签更改:', updatedTags);
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/tags`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ tags: updatedTags })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || t('domain.errors.saveFailed'));
|
||||
}
|
||||
getTags();
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: t('domain.messages.updateSuccess'),
|
||||
severity: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存标签失败:', error);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: error.message || '保存标签失败',
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!labelValue.label.trim()) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: '标签名称不能为空',
|
||||
severity: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await saveTagChanges(labelValue);
|
||||
handleCloseDialog();
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!currentNode) return;
|
||||
|
||||
const res = await axios.delete(`/api/projects/${projectId}/tags?id=${currentNode.id}`);
|
||||
if (res.status === 200) {
|
||||
toast.success('删除成功');
|
||||
getTags();
|
||||
}
|
||||
|
||||
setDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (toc.length === 0) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
{t('domain.noToc')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 0,
|
||||
mb: 3,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="fullWidth"
|
||||
indicatorColor="secondary"
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)',
|
||||
borderTopLeftRadius: 2,
|
||||
borderTopRightRadius: 2
|
||||
}}
|
||||
>
|
||||
<Tab label={t('domain.tabs.tree')} />
|
||||
<Tab label={t('domain.tabs.structure')} />
|
||||
</Tabs>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.02)' : 'rgba(255, 255, 255, 0.8)',
|
||||
borderBottomLeftRadius: 2,
|
||||
borderBottomRightRadius: 2,
|
||||
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.03)'
|
||||
}}
|
||||
>
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">{t('domain.tabs.tree')}</Typography>
|
||||
<Tooltip title="添加一级标签">
|
||||
<Button variant="outlined" size="small" startIcon={<AddIcon />} onClick={handleAddTag}>
|
||||
{t('domain.addRootTag')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: theme.palette.background.paper,
|
||||
borderRadius: 1,
|
||||
maxHeight: '800px',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
{tags && tags.length > 0 ? (
|
||||
<DomainTree
|
||||
tags={tags}
|
||||
onEdit={handleEditTag}
|
||||
onDelete={handleDeleteTag}
|
||||
onAddChild={handleAddChildTag}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
{t('domain.noTags')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleAddTag}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{t('domain.addFirstTag')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('domain.docStructure')}
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: theme.palette.background.paper,
|
||||
borderRadius: 1,
|
||||
maxHeight: '600px',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
root: ({ children }) => (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{toc}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* 添加/编辑标签对话框 */}
|
||||
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{dialogMode === 'add'
|
||||
? t('domain.dialog.addTitle')
|
||||
: dialogMode === 'edit'
|
||||
? t('domain.dialog.editTitle')
|
||||
: t('domain.dialog.addChildTitle')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
{dialogMode === 'add'
|
||||
? t('domain.dialog.inputRoot')
|
||||
: dialogMode === 'edit'
|
||||
? t('domain.dialog.inputEdit')
|
||||
: t('domain.dialog.inputChild', { label: parentNode })}
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label={t('domain.dialog.labelName')}
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={labelValue.label}
|
||||
onChange={e => setLabelValue({ ...labelValue, label: e.target.value })}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSubmit} variant="contained" disabled={saving || !labelValue?.label?.trim()}>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onClose={handleCloseDialog}>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{t('domain.dialog.deleteConfirm', { label: currentNode?.label })}
|
||||
{currentNode?.child && currentNode.child.length > 0 && t('domain.dialog.deleteWarning')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleConfirmDelete} color="error" variant="contained">
|
||||
{saving ? t('common.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
360
easy-dataset-main/components/text-split/FileUploader.js
Normal file
360
easy-dataset-main/components/text-split/FileUploader.js
Normal file
@@ -0,0 +1,360 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Paper, Grid } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { useAtomValue } from 'jotai/index';
|
||||
import { selectedModelInfoAtom } from '@/lib/store';
|
||||
import UploadArea from './components/UploadArea';
|
||||
import FileList from './components/FileList';
|
||||
import DeleteConfirmDialog from './components/DeleteConfirmDialog';
|
||||
import PdfProcessingDialog from './components/PdfProcessingDialog';
|
||||
import DomainTreeActionDialog from './components/DomainTreeActionDialog';
|
||||
import FileLoadingProgress from './components/FileLoadingProgress';
|
||||
import { fileApi, taskApi } from '@/lib/api';
|
||||
import { getContent, checkMaxSize, checkInvalidFiles, getvalidFiles } from '@/lib/file/file-process';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function FileUploader({
|
||||
projectId,
|
||||
onUploadSuccess,
|
||||
onFileDeleted,
|
||||
sendToPages,
|
||||
setPdfStrategy,
|
||||
pdfStrategy,
|
||||
selectedViosnModel,
|
||||
setSelectedViosnModel,
|
||||
setPageLoading,
|
||||
taskFileProcessing,
|
||||
fileTask
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [files, setFiles] = useState([]);
|
||||
const [pdfFiles, setPdfFiles] = useState([]);
|
||||
const [uploadedFiles, setUploadedFiles] = useState({});
|
||||
const selectedModelInfo = useAtomValue(selectedModelInfoAtom);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [pdfProcessConfirmOpen, setpdfProcessConfirmOpen] = useState(false);
|
||||
const [fileToDelete, setFileToDelete] = useState({});
|
||||
const [domainTreeActionOpen, setDomainTreeActionOpen] = useState(false);
|
||||
const [domainTreeAction, setDomainTreeAction] = useState('');
|
||||
const [isFirstUpload, setIsFirstUpload] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState(null);
|
||||
const [taskSettings, setTaskSettings] = useState(null);
|
||||
const [visionModels, setVisionModels] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [searchFileName, setSearchFileName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchUploadedFiles();
|
||||
}, [currentPage, searchFileName]);
|
||||
|
||||
/**
|
||||
* 处理 PDF 处理方式选择
|
||||
*/
|
||||
const handleRadioChange = event => {
|
||||
const modelId = event.target.selectedVision;
|
||||
setPdfStrategy(event.target.value);
|
||||
|
||||
if (event.target.value === 'mineru') {
|
||||
toast.success(t('textSplit.mineruSelected'));
|
||||
} else if (event.target.value === 'mineru-local') {
|
||||
toast.success(t('textSplit.mineruLocalSelected'));
|
||||
} else if (event.target.value === 'vision') {
|
||||
const model = visionModels.find(item => item.id === modelId);
|
||||
toast.success(
|
||||
t('textSplit.customVisionModelSelected', {
|
||||
name: model.modelName,
|
||||
provider: model.projectName
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.success(t('textSplit.defaultSelected'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取上传的文件列表
|
||||
* @param {*} page
|
||||
* @param {*} size
|
||||
* @param {*} fileName
|
||||
*/
|
||||
const fetchUploadedFiles = async (page = currentPage, size = pageSize, fileName = searchFileName) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fileApi.getFiles({ projectId, page, size, fileName, t });
|
||||
setUploadedFiles(data);
|
||||
|
||||
setIsFirstUpload(data.total === 0);
|
||||
|
||||
const taskData = await taskApi.getProjectTasks(projectId);
|
||||
setTaskSettings(taskData);
|
||||
|
||||
//使用Jotai会出现数据获取的延迟,导致这里模型获取不到,改用localStorage获取模型信息
|
||||
const model = JSON.parse(localStorage.getItem('modelConfigList'));
|
||||
|
||||
//过滤出视觉模型
|
||||
const visionItems = model.filter(item => item.type === 'vision');
|
||||
|
||||
//先默认选择第一个配置的视觉模型
|
||||
if (visionItems.length > 0) {
|
||||
setSelectedViosnModel(visionItems[0].id);
|
||||
}
|
||||
|
||||
setVisionModels(visionItems);
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理文件选择
|
||||
*/
|
||||
const handleFileSelect = event => {
|
||||
const selectedFiles = Array.from(event.target.files);
|
||||
|
||||
checkMaxSize(selectedFiles);
|
||||
checkInvalidFiles(selectedFiles);
|
||||
|
||||
const validFiles = getvalidFiles(selectedFiles);
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
setFiles(prev => [...prev, ...validFiles]);
|
||||
}
|
||||
const hasPdfFiles = selectedFiles.filter(file => file.name.endsWith('.pdf'));
|
||||
if (hasPdfFiles.length > 0) {
|
||||
setpdfProcessConfirmOpen(true);
|
||||
setPdfFiles(hasPdfFiles);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从待上传文件列表中移除文件
|
||||
*/
|
||||
const removeFile = index => {
|
||||
const fileToRemove = files[index];
|
||||
setFiles(prev => prev.filter((_, i) => i !== index));
|
||||
if (fileToRemove && fileToRemove.name.toLowerCase().endsWith('.pdf')) {
|
||||
setPdfFiles(prevPdfFiles => prevPdfFiles.filter(pdfFile => pdfFile.name !== fileToRemove.name));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
const uploadFiles = async () => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
// 如果是第一次上传,直接走默认逻辑
|
||||
if (isFirstUpload) {
|
||||
handleStartUpload('rebuild');
|
||||
return;
|
||||
}
|
||||
|
||||
// 否则打开领域树操作选择对话框
|
||||
setDomainTreeAction('upload');
|
||||
setPendingAction({ type: 'upload' });
|
||||
setDomainTreeActionOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理领域树操作选择
|
||||
*/
|
||||
const handleDomainTreeAction = action => {
|
||||
setDomainTreeActionOpen(false);
|
||||
|
||||
// 执行挂起的操作
|
||||
if (pendingAction && pendingAction.type === 'upload') {
|
||||
handleStartUpload(action);
|
||||
} else if (pendingAction && pendingAction.type === 'delete') {
|
||||
handleDeleteFile(action);
|
||||
}
|
||||
|
||||
// 清除挂起的操作
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始上传文件
|
||||
*/
|
||||
const handleStartUpload = async domainTreeActionType => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploadedFileInfos = [];
|
||||
for (const file of files) {
|
||||
const { fileContent, fileName } = await getContent(file);
|
||||
const data = await fileApi.uploadFile({ file, projectId, fileContent, fileName, t });
|
||||
uploadedFileInfos.push({ fileName: data.fileName, fileId: data.fileId });
|
||||
}
|
||||
toast.success(t('textSplit.uploadSuccess', { count: files.length }));
|
||||
setFiles([]);
|
||||
setCurrentPage(1);
|
||||
await fetchUploadedFiles();
|
||||
if (onUploadSuccess) {
|
||||
await onUploadSuccess(uploadedFileInfos, pdfFiles, domainTreeActionType);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('textSplit.uploadFailed'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开删除确认对话框
|
||||
const openDeleteConfirm = (fileId, fileName) => {
|
||||
setFileToDelete({ fileId, fileName });
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
// 关闭删除确认对话框
|
||||
const closeDeleteConfirm = () => {
|
||||
setDeleteConfirmOpen(false);
|
||||
setFileToDelete(null);
|
||||
};
|
||||
|
||||
// 删除文件前确认领域树操作
|
||||
const confirmDeleteFile = () => {
|
||||
setDeleteConfirmOpen(false);
|
||||
|
||||
// 如果没有其他文件了(删除后会变为空),直接删除
|
||||
if (uploadedFiles.total <= 1) {
|
||||
handleDeleteFile('keep');
|
||||
return;
|
||||
}
|
||||
|
||||
// 否则打开领域树操作选择对话框
|
||||
setDomainTreeAction('delete');
|
||||
setPendingAction({ type: 'delete' });
|
||||
setDomainTreeActionOpen(true);
|
||||
};
|
||||
|
||||
// 处理删除文件
|
||||
const handleDeleteFile = async domainTreeActionType => {
|
||||
if (!fileToDelete) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
closeDeleteConfirm();
|
||||
|
||||
await fileApi.deleteFile({
|
||||
fileToDelete,
|
||||
projectId,
|
||||
domainTreeActionType,
|
||||
modelInfo: selectedModelInfo || {},
|
||||
t
|
||||
});
|
||||
await fetchUploadedFiles();
|
||||
|
||||
if (onFileDeleted) {
|
||||
const filesLength = uploadedFiles.total;
|
||||
onFileDeleted(fileToDelete, filesLength);
|
||||
}
|
||||
|
||||
if (uploadedFiles.data && uploadedFiles.data.length <= 1 && currentPage > 1) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
|
||||
toast.success(t('textSplit.deleteSuccess', { fileName: fileToDelete.fileName }));
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setFileToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 3,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
{taskFileProcessing ? (
|
||||
<FileLoadingProgress fileTask={fileTask} />
|
||||
) : (
|
||||
<>
|
||||
<Grid container spacing={3}>
|
||||
{/* 左侧:上传文件区域 */}
|
||||
<Grid item xs={10} md={5} sx={{ maxWidth: '100%', width: '100%' }}>
|
||||
<UploadArea
|
||||
theme={theme}
|
||||
files={files}
|
||||
uploading={uploading}
|
||||
uploadedFiles={uploadedFiles}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveFile={removeFile}
|
||||
onUpload={uploadFiles}
|
||||
selectedModel={selectedModelInfo}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 右侧:已上传文件列表 */}
|
||||
<Grid item xs={14} md={7} sx={{ maxWidth: '100%', width: '100%' }}>
|
||||
<FileList
|
||||
theme={theme}
|
||||
files={uploadedFiles}
|
||||
loading={loading}
|
||||
setPageLoading={setPageLoading}
|
||||
sendToFileUploader={array => sendToPages(array)}
|
||||
onDeleteFile={openDeleteConfirm}
|
||||
projectId={projectId}
|
||||
currentPage={currentPage}
|
||||
onPageChange={(page, fileName) => {
|
||||
if (fileName !== undefined) {
|
||||
// 搜索时更新搜索关键词和页码
|
||||
setSearchFileName(fileName);
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
// 翻页时只更新页码
|
||||
setCurrentPage(page);
|
||||
}
|
||||
}}
|
||||
onRefresh={fetchUploadedFiles} // 传递刷新函数
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
fileName={fileToDelete?.fileName}
|
||||
onClose={closeDeleteConfirm}
|
||||
onConfirm={confirmDeleteFile}
|
||||
/>
|
||||
|
||||
{/* 领域树操作选择对话框 */}
|
||||
<DomainTreeActionDialog
|
||||
open={domainTreeActionOpen}
|
||||
onClose={() => setDomainTreeActionOpen(false)}
|
||||
onConfirm={handleDomainTreeAction}
|
||||
isFirstUpload={isFirstUpload}
|
||||
action={domainTreeAction}
|
||||
/>
|
||||
{/* 检测到pdf的处理框 */}
|
||||
<PdfProcessingDialog
|
||||
open={pdfProcessConfirmOpen}
|
||||
onClose={() => setpdfProcessConfirmOpen(false)}
|
||||
onRadioChange={handleRadioChange}
|
||||
value={pdfStrategy}
|
||||
projectId={projectId}
|
||||
taskSettings={taskSettings}
|
||||
visionModels={visionModels}
|
||||
selectedViosnModel={selectedViosnModel}
|
||||
setSelectedViosnModel={setSelectedViosnModel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
114
easy-dataset-main/components/text-split/LoadingBackdrop.js
Normal file
114
easy-dataset-main/components/text-split/LoadingBackdrop.js
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { Backdrop, Paper, CircularProgress, Typography, Box, LinearProgress } from '@mui/material';
|
||||
|
||||
export default function LoadingBackdrop({ open, title, description, progress = null }) {
|
||||
return (
|
||||
<Backdrop
|
||||
sx={{
|
||||
color: '#fff',
|
||||
zIndex: theme => theme.zIndex.drawer + 1,
|
||||
position: 'fixed',
|
||||
backdropFilter: 'blur(5px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'background.paper',
|
||||
minWidth: 280,
|
||||
maxWidth: '90%',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
size={48}
|
||||
thickness={4}
|
||||
sx={{
|
||||
mb: 3,
|
||||
color: theme => theme.palette.primary.main
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
textAlign: 'center',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mb: 2,
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
mx: 'auto'
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
|
||||
{progress && progress.total > 0 && (
|
||||
<Box sx={{ width: '100%', mt: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
mb: 1.5
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
mb: 0.5
|
||||
}}
|
||||
>
|
||||
{progress.completed}/{progress.total} ({progress.percentage}%)
|
||||
</Typography>
|
||||
|
||||
{progress.questionCount > 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
已生成问题数: {progress.questionCount}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress.percentage}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 3
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
433
easy-dataset-main/components/text-split/MarkdownViewDialog.js
Normal file
433
easy-dataset-main/components/text-split/MarkdownViewDialog.js
Normal file
@@ -0,0 +1,433 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
Divider,
|
||||
Chip,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Alert,
|
||||
DialogContentText
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import 'github-markdown-css/github-markdown-light.css';
|
||||
|
||||
export default function MarkdownViewDialog({ open, text, onClose, projectId, onSaveSuccess }) {
|
||||
const { t } = useTranslation();
|
||||
const [customSplitMode, setCustomSplitMode] = useState(false);
|
||||
const [splitPoints, setSplitPoints] = useState([]);
|
||||
const [selectedText, setSelectedText] = useState('');
|
||||
const [savedMessage, setSavedMessage] = useState('');
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const contentRef = useRef(null);
|
||||
const [chunksPreview, setChunksPreview] = useState([]);
|
||||
|
||||
// 根据分块点计算每个块的字数
|
||||
const calculateChunksPreview = points => {
|
||||
if (!text || !text.content) return [];
|
||||
|
||||
const content = text.content;
|
||||
const sortedPoints = [...points].sort((a, b) => a.position - b.position);
|
||||
|
||||
const chunks = [];
|
||||
let startPos = 0;
|
||||
|
||||
// 计算每个分块
|
||||
for (let i = 0; i < sortedPoints.length; i++) {
|
||||
const endPos = sortedPoints[i].position;
|
||||
const chunkContent = content.substring(startPos, endPos);
|
||||
|
||||
if (chunkContent.trim().length > 0) {
|
||||
chunks.push({
|
||||
index: i + 1,
|
||||
length: chunkContent.length,
|
||||
preview: chunkContent.substring(0, 20) + (chunkContent.length > 20 ? '...' : '')
|
||||
});
|
||||
}
|
||||
|
||||
startPos = endPos;
|
||||
}
|
||||
|
||||
// 添加最后一个分块
|
||||
const lastChunkContent = content.substring(startPos);
|
||||
if (lastChunkContent.trim().length > 0) {
|
||||
chunks.push({
|
||||
index: chunks.length + 1,
|
||||
length: lastChunkContent.length,
|
||||
preview: lastChunkContent.substring(0, 20) + (lastChunkContent.length > 20 ? '...' : '')
|
||||
});
|
||||
}
|
||||
|
||||
return chunks;
|
||||
};
|
||||
|
||||
// 重置组件状态
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSplitPoints([]);
|
||||
setCustomSplitMode(false);
|
||||
setSelectedText('');
|
||||
setSavedMessage('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 当分块点变化时更新预览
|
||||
useEffect(() => {
|
||||
if (splitPoints.length > 0 && text?.content) {
|
||||
const preview = calculateChunksPreview(splitPoints);
|
||||
setChunksPreview(preview);
|
||||
} else {
|
||||
setChunksPreview([]);
|
||||
}
|
||||
}, [splitPoints, text?.content]);
|
||||
|
||||
// 处理用户选择文本事件
|
||||
const handleTextSelection = () => {
|
||||
if (!customSplitMode) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection.toString().trim()) return;
|
||||
|
||||
// 获取选择的文本内容和位置
|
||||
const selectedContent = selection.toString();
|
||||
|
||||
// 计算选择位置在文档中的偏移量
|
||||
const range = selection.getRangeAt(0);
|
||||
const preCaretRange = range.cloneRange();
|
||||
preCaretRange.selectNodeContents(contentRef.current);
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
||||
const position = preCaretRange.toString().length;
|
||||
|
||||
// 添加到分割点列表
|
||||
const newPoint = {
|
||||
id: Date.now(),
|
||||
position,
|
||||
preview: selectedContent.substring(0, 40) + (selectedContent.length > 40 ? '...' : '')
|
||||
};
|
||||
|
||||
setSplitPoints(prev => [...prev, newPoint].sort((a, b) => a.position - b.position));
|
||||
setSelectedText('');
|
||||
};
|
||||
|
||||
// 删除分割点
|
||||
const handleDeletePoint = id => {
|
||||
setSplitPoints(prev => prev.filter(point => point.id !== id));
|
||||
};
|
||||
|
||||
// 弹出确认对话框
|
||||
const handleConfirmSave = () => {
|
||||
setConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
// 取消保存
|
||||
const handleCancelSave = () => {
|
||||
setConfirmDialogOpen(false);
|
||||
};
|
||||
|
||||
// 确认并执行保存
|
||||
const handleSavePoints = async () => {
|
||||
// 输出调试信息
|
||||
console.log('保存分块点时的数据:', {
|
||||
projectId,
|
||||
text: text
|
||||
? {
|
||||
fileId: text.fileId,
|
||||
fileName: text.fileName,
|
||||
contentLength: text.content ? text.content.length : 0
|
||||
}
|
||||
: null,
|
||||
splitPointsCount: splitPoints.length
|
||||
});
|
||||
|
||||
if (!text) {
|
||||
setError(t('textSplit.missingRequiredData') + ': text 为空');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.fileId) {
|
||||
setError(t('textSplit.missingRequiredData') + ': fileId 不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.fileName) {
|
||||
setError(t('textSplit.missingRequiredData') + ': fileName 不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.content) {
|
||||
setError(t('textSplit.missingRequiredData') + ': content 不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
setError(t('textSplit.missingRequiredData') + ': projectId 不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmDialogOpen(false);
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// 准备要发送的数据
|
||||
const customSplitData = {
|
||||
fileId: text.fileId,
|
||||
fileName: text.fileName,
|
||||
content: text.content,
|
||||
splitPoints: splitPoints.map(point => ({
|
||||
position: point.position,
|
||||
preview: point.preview
|
||||
}))
|
||||
};
|
||||
|
||||
// 发送请求到待创建的API接口
|
||||
const response = await fetch(`/api/projects/${projectId}/custom-split`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(customSplitData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || t('textSplit.customSplitFailed'));
|
||||
}
|
||||
|
||||
// 保存成功
|
||||
setSavedMessage(t('textSplit.customSplitSuccess'));
|
||||
|
||||
// 短暂显示成功消息后关闭对话框并刷新列表
|
||||
setTimeout(() => {
|
||||
setSavedMessage('');
|
||||
|
||||
// 关闭对话框
|
||||
onClose();
|
||||
|
||||
// 调用父组件的刷新方法(如果提供了)
|
||||
if (typeof onSaveSuccess === 'function') {
|
||||
onSaveSuccess();
|
||||
}
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
console.error('保存自定义分块出错:', err);
|
||||
setError(err.message || t('textSplit.customSplitFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6">{text ? text.fileName : ''}</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={customSplitMode} onChange={e => setCustomSplitMode(e.target.checked)} color="primary" />
|
||||
}
|
||||
label={t('textSplit.customSplitMode')}
|
||||
sx={{ ml: 2 }}
|
||||
/>
|
||||
</DialogTitle>
|
||||
|
||||
{customSplitMode && (
|
||||
<Box sx={{ px: 3, py: 1, bgcolor: 'action.hover' }}>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
||||
{t('textSplit.customSplitInstructions')}
|
||||
</Typography>
|
||||
|
||||
{/* 分割点列表 */}
|
||||
{splitPoints.length > 0 && (
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{t('textSplit.splitPointsList')} ({splitPoints.length}):
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{splitPoints.map((point, index) => (
|
||||
<Chip
|
||||
key={point.id}
|
||||
label={`${index + 1}. ${point.preview}`}
|
||||
onDelete={() => handleDeletePoint(point.id)}
|
||||
deleteIcon={<DeleteIcon />}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 文本块字数预览 */}
|
||||
{chunksPreview.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 1,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('textSplit.chunksPreview')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{chunksPreview.map(chunk => (
|
||||
<Chip
|
||||
key={chunk.index}
|
||||
size="small"
|
||||
label={`${t('textSplit.chunk')} ${chunk.index}: ${chunk.length}${t('textSplit.characters')}`}
|
||||
color="info"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
disabled={splitPoints.length === 0 || saving}
|
||||
onClick={handleConfirmSave}
|
||||
size="small"
|
||||
>
|
||||
{saving ? t('common.saving') : t('textSplit.saveSplitPoints')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 提示消息 */}
|
||||
{savedMessage && (
|
||||
<Alert severity="success" sx={{ mt: 1 }}>
|
||||
{savedMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<DialogContent dividers>
|
||||
{text ? (
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: '60vh',
|
||||
overflow: 'auto',
|
||||
cursor: customSplitMode ? 'text' : 'default',
|
||||
position: 'relative',
|
||||
'::selection': {
|
||||
backgroundColor: customSplitMode ? 'primary.light' : 'inherit',
|
||||
color: customSplitMode ? 'primary.contrastText' : 'inherit'
|
||||
}
|
||||
}}
|
||||
onMouseUp={handleTextSelection}
|
||||
ref={contentRef}
|
||||
>
|
||||
{/* 渲染带有分割点标记的内容 */}
|
||||
{customSplitMode && splitPoints.length > 0 ? (
|
||||
<Box>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', wordWrap: 'break-word', fontFamily: 'inherit' }}>
|
||||
{text.content.split('').map((char, index) => {
|
||||
const isSplitPoint = splitPoints.some(point => point.position === index);
|
||||
const splitPointIndex = splitPoints.findIndex(point => point.position === index);
|
||||
|
||||
if (isSplitPoint) {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
borderTop: '2px dashed #1976d2',
|
||||
marginTop: '8px',
|
||||
marginBottom: '8px',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '-15px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
padding: '0 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
{splitPointIndex + 1}
|
||||
</span>
|
||||
</span>
|
||||
{char}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return char;
|
||||
})}
|
||||
</pre>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown>{text.content}</ReactMarkdown>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t('common.close')}</Button>
|
||||
</DialogActions>
|
||||
|
||||
{/* 确认对话框 */}
|
||||
<Dialog
|
||||
open={confirmDialogOpen}
|
||||
onClose={handleCancelSave}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
>
|
||||
<DialogTitle id="alert-dialog-title">{t('textSplit.confirmCustomSplitTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
{t('textSplit.confirmCustomSplitMessage')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancelSave}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSavePoints} color="primary" variant="contained" autoFocus>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
43
easy-dataset-main/components/text-split/PdfSettings.js
Normal file
43
easy-dataset-main/components/text-split/PdfSettings.js
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Select, MenuItem, Typography, FormControl, InputLabel } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function PdfSettings({ pdfStrategy, setPdfStrategy, selectedViosnModel, setSelectedViosnModel }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2, mt: 2 }}>
|
||||
<FormControl sx={{ minWidth: 200 }}>
|
||||
<InputLabel id="pdf-strategy-label">{t('textSplit.pdfStrategy')}</InputLabel>
|
||||
<Select
|
||||
labelId="pdf-strategy-label"
|
||||
value={pdfStrategy}
|
||||
onChange={e => setPdfStrategy(e.target.value)}
|
||||
label={t('textSplit.pdfStrategy')}
|
||||
size="small"
|
||||
>
|
||||
<MenuItem value="default">{t('textSplit.defaultStrategy')}</MenuItem>
|
||||
<MenuItem value="vision">{t('textSplit.visionStrategy')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{pdfStrategy === 'vision' && (
|
||||
<FormControl sx={{ minWidth: 200 }}>
|
||||
<InputLabel id="vision-model-label">{t('textSplit.visionModel')}</InputLabel>
|
||||
<Select
|
||||
labelId="vision-model-label"
|
||||
value={selectedViosnModel}
|
||||
onChange={e => setSelectedViosnModel(e.target.value)}
|
||||
label={t('textSplit.visionModel')}
|
||||
size="small"
|
||||
>
|
||||
<MenuItem value="gpt-4-vision-preview">GPT-4 Vision</MenuItem>
|
||||
<MenuItem value="claude-3-opus">Claude-3 Opus</MenuItem>
|
||||
<MenuItem value="claude-3-sonnet">Claude-3 Sonnet</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function DeleteConfirmDialog({ open, fileName, onClose, onConfirm }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-describedby="delete-dialog-description"
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle id="delete-dialog-title">
|
||||
{t('common.confirmDelete')}「{fileName}」?
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="delete-dialog-description">{t('common.confirmDeleteDescription')}</DialogContentText>
|
||||
|
||||
<Alert severity="warning" sx={{ my: 2 }}>
|
||||
<Typography variant="body2" component="div" fontWeight="medium">
|
||||
{t('textSplit.deleteFileWarning')}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="body2" component="div">
|
||||
• {t('textSplit.deleteFileWarningChunks')}
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div">
|
||||
• {t('textSplit.deleteFileWarningQuestions')}
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div">
|
||||
• {t('textSplit.deleteFileWarningDatasets')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Alert>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color="primary">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} color="error" variant="contained">
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { Box, List, ListItem, ListItemIcon, ListItemText, Collapse, IconButton } from '@mui/material';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
/**
|
||||
* 目录结构组件
|
||||
* @param {Object} props
|
||||
* @param {Array} props.items - 目录项数组
|
||||
* @param {Object} props.expandedItems - 展开状态对象
|
||||
* @param {Function} props.onToggleItem - 展开/折叠回调
|
||||
* @param {number} props.level - 当前层级
|
||||
* @param {string} props.parentId - 父级ID
|
||||
*/
|
||||
export default function DirectoryView({ items, expandedItems, onToggleItem, level = 0, parentId = '' }) {
|
||||
const theme = useTheme();
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<List sx={{ pl: level > 0 ? 2 : 0 }}>
|
||||
{items.map((item, index) => {
|
||||
const itemId = `${parentId}-${index}`;
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expandedItems[itemId] || false;
|
||||
|
||||
return (
|
||||
<Box key={itemId}>
|
||||
<ListItem
|
||||
sx={{
|
||||
pl: level * 2,
|
||||
borderLeft: level > 0 ? `1px solid ${theme.palette.divider}` : 'none',
|
||||
ml: level > 0 ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
{hasChildren ? <FolderIcon color="primary" /> : <ArticleIcon color="info" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.text}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: level === 0 ? 'bold' : 'normal',
|
||||
variant: level === 0 ? 'subtitle1' : 'body2'
|
||||
}}
|
||||
/>
|
||||
{hasChildren && (
|
||||
<IconButton size="small" onClick={() => onToggleItem(itemId)}>
|
||||
{isExpanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
)}
|
||||
</ListItem>
|
||||
|
||||
{hasChildren && (
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<DirectoryView
|
||||
items={item.children}
|
||||
expandedItems={expandedItems}
|
||||
onToggleItem={onToggleItem}
|
||||
level={level + 1}
|
||||
parentId={itemId}
|
||||
/>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
FormControl,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 领域树操作选择对话框
|
||||
* 提供三种选项:修订领域树、重建领域树、不更改领域树
|
||||
*/
|
||||
export default function DomainTreeActionDialog({ open, onClose, onConfirm, isFirstUpload, action }) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(isFirstUpload ? 'rebuild' : 'revise');
|
||||
|
||||
// 处理选项变更
|
||||
const handleChange = event => {
|
||||
setValue(event.target.value);
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
onConfirm(value);
|
||||
};
|
||||
|
||||
// 获取对话框标题
|
||||
const getDialogTitle = () => {
|
||||
if (isFirstUpload) {
|
||||
return t('textSplit.domainTree.firstUploadTitle');
|
||||
}
|
||||
return action === 'upload' ? t('textSplit.domainTree.uploadTitle') : t('textSplit.domainTree.deleteTitle');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{getDialogTitle()}</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormControl component="fieldset">
|
||||
<RadioGroup value={value} onChange={handleChange}>
|
||||
{!isFirstUpload && (
|
||||
<FormControlLabel
|
||||
value="revise"
|
||||
control={<Radio />}
|
||||
label={
|
||||
<>
|
||||
<Typography variant="subtitle1">{t('textSplit.domainTree.reviseOption')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('textSplit.domainTree.reviseDesc')}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FormControlLabel
|
||||
value="rebuild"
|
||||
control={<Radio />}
|
||||
label={
|
||||
<>
|
||||
<Typography variant="subtitle1">{t('textSplit.domainTree.rebuildOption')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('textSplit.domainTree.rebuildDesc')}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{!isFirstUpload && (
|
||||
<FormControlLabel
|
||||
value="keep"
|
||||
control={<Radio />}
|
||||
label={
|
||||
<>
|
||||
<Typography variant="subtitle1">{t('textSplit.domainTree.keepOption')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('textSplit.domainTree.keepDesc')}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleConfirm} variant="contained" color="primary">
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@mui/material';
|
||||
import { TreeView, TreeItem } from '@mui/lab';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
|
||||
/**
|
||||
* 领域知识树组件
|
||||
* @param {Object} props
|
||||
* @param {Array} props.nodes - 树节点数组
|
||||
*/
|
||||
export default function DomainTreeView({ nodes = [] }) {
|
||||
if (!nodes || nodes.length === 0) return null;
|
||||
|
||||
const renderTreeItems = nodes => {
|
||||
return nodes.map((node, index) => (
|
||||
<TreeItem key={`node-${index}`} nodeId={`node-${index}`} label={node.text} sx={{ mb: 1 }}>
|
||||
{node.children && node.children.length > 0 && renderTreeItems(node.children)}
|
||||
</TreeItem>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<TreeView
|
||||
defaultCollapseIcon={<ExpandMoreIcon />}
|
||||
defaultExpandIcon={<ChevronRightIcon />}
|
||||
sx={{ flexGrow: 1, overflowY: 'auto' }}
|
||||
>
|
||||
{renderTreeItems(nodes)}
|
||||
</TreeView>
|
||||
);
|
||||
}
|
||||
1068
easy-dataset-main/components/text-split/components/FileList.js
Normal file
1068
easy-dataset-main/components/text-split/components/FileList.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, keyframes, Paper } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { handleLongFileName } from '@/lib/file/file-process';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// 定义动画效果
|
||||
const pulse = keyframes`
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(32, 76, 255, 0.2);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 15px rgba(32, 76, 255, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(32, 76, 255, 0);
|
||||
}
|
||||
`;
|
||||
|
||||
const rotateAnimation = keyframes`
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
`;
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* 文件处理进度展示组件 - 美化版
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.fileTask - 文件处理任务信息
|
||||
*/
|
||||
export default function FileLoadingProgress({ fileTask }) {
|
||||
const { t } = useTranslation();
|
||||
const [animationStep, setAnimationStep] = useState(0);
|
||||
|
||||
// 创建动态效果
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setAnimationStep(prev => (prev + 1) % 4);
|
||||
}, 600);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (!fileTask) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageProgress = (fileTask.current.processedPage / fileTask.current.totalPage) * 100;
|
||||
const filesProgress = (fileTask.processedFiles / fileTask.totalFiles) * 100;
|
||||
|
||||
// 生成进度指示器文本
|
||||
const getProgressIndicator = () => {
|
||||
const dots = '.';
|
||||
return dots.repeat(animationStep + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 'auto',
|
||||
minHeight: '25vh',
|
||||
width: '80%',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
padding: 4,
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: 'linear-gradient(45deg, #f9f9f9 0%, #ffffff 100%)',
|
||||
animation: `${pulse} 2s infinite`
|
||||
}}
|
||||
>
|
||||
{/* 背景动画元素 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '-50%',
|
||||
left: '-50%',
|
||||
width: '200%',
|
||||
height: '200%',
|
||||
background: 'radial-gradient(circle, rgba(32,76,255,0.05) 0%, rgba(255,255,255,0) 70%)',
|
||||
animation: `${rotateAnimation} 15s linear infinite`,
|
||||
zIndex: 0
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 主标题 */}
|
||||
<Typography
|
||||
variant="h5"
|
||||
fontWeight="bold"
|
||||
sx={{
|
||||
mb: 3,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
background: 'linear-gradient(90deg, #3a7bd5 0%, #00d2ff 100%)',
|
||||
backgroundClip: 'text',
|
||||
textFillColor: 'transparent',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
{t('textSplit.pdfProcessingLoading')}
|
||||
{getProgressIndicator()}
|
||||
</Typography>
|
||||
|
||||
{/* 处理进度显示区域 */}
|
||||
<Box sx={{ width: '90%', mt: 2, mb: 3, position: 'relative', zIndex: 1 }}>
|
||||
{/* 当前文件进度 */}
|
||||
<ProgressSection
|
||||
label={t('textSplit.pdfPageProcessStatus', {
|
||||
fileName: handleLongFileName(fileTask.current.fileName),
|
||||
total: fileTask.current.totalPage,
|
||||
completed: fileTask.current.processedPage
|
||||
})}
|
||||
progress={pageProgress}
|
||||
color="#3a7bd5"
|
||||
/>
|
||||
|
||||
{/* 总文件进度 */}
|
||||
<ProgressSection
|
||||
label={t('textSplit.pdfProcessStatus', {
|
||||
total: fileTask.totalFiles,
|
||||
completed: fileTask.processedFiles
|
||||
})}
|
||||
progress={filesProgress}
|
||||
color="#00d2ff"
|
||||
mt={3}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度条区域组件
|
||||
*/
|
||||
function ProgressSection({ label, progress, color, mt = 0 }) {
|
||||
return (
|
||||
<Box sx={{ mt }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1,
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="medium"
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight="bold"
|
||||
sx={{
|
||||
color,
|
||||
fontSize: '1.1rem'
|
||||
}}
|
||||
>
|
||||
{Math.round(progress)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 自定义进度条 */}
|
||||
<Box
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
background: '#f0f0f0',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
width: `${progress}%`,
|
||||
borderRadius: 5,
|
||||
background: `linear-gradient(90deg, ${color} 0%, ${color}80 100%)`,
|
||||
transition: 'width 0.5s ease',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: `${shimmer} 2s infinite linear`
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Stack,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined';
|
||||
import ScienceOutlinedIcon from '@mui/icons-material/ScienceOutlined';
|
||||
import LaunchOutlinedIcon from '@mui/icons-material/LaunchOutlined';
|
||||
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
||||
import ChangeCircleOutlinedIcon from '@mui/icons-material/ChangeCircleOutlined';
|
||||
|
||||
const StyledCard = styled(Card)(({ theme, disabled }) => ({
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
|
||||
'&:hover': disabled
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: theme.shadows[4]
|
||||
}
|
||||
}));
|
||||
|
||||
const OptionCard = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
disabled,
|
||||
onClick,
|
||||
selected,
|
||||
isVisionEnabled,
|
||||
visionModels,
|
||||
selectorName,
|
||||
handleSettingChange,
|
||||
selectedViosnModel
|
||||
}) => (
|
||||
<StyledCard
|
||||
disabled={disabled}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
sx={{
|
||||
height: '100%',
|
||||
border: selected ? '2px solid primary.main' : '1px solid divider',
|
||||
backgroundColor: selected ? 'action.selected' : 'background.paper'
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
<Box sx={{ color: 'primary.main', mb: 1 }}>{icon}</Box>
|
||||
<Typography variant="h6" component="div">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{description}
|
||||
</Typography>
|
||||
{isVisionEnabled && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{selectorName}</InputLabel>
|
||||
<Select
|
||||
label={selectorName}
|
||||
value={selectedViosnModel}
|
||||
onChange={e => handleSettingChange(e)}
|
||||
name="vision"
|
||||
>
|
||||
{visionModels.map(item => (
|
||||
<MenuItem key={item.id} value={item.id}>
|
||||
{item.modelName} ({item.providerName})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</StyledCard>
|
||||
);
|
||||
|
||||
export default function PdfProcessingDialog({
|
||||
open,
|
||||
onClose,
|
||||
onRadioChange,
|
||||
value,
|
||||
taskSettings,
|
||||
visionModels,
|
||||
selectedViosnModel,
|
||||
setSelectedViosnModel
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
//检查配置中是否启用MinerU
|
||||
const isMinerUEnabled = taskSettings && taskSettings.minerUToken ? true : false;
|
||||
|
||||
const isMinerULocalEnabled = taskSettings && taskSettings.minerULocalUrl ? true : false;
|
||||
|
||||
//检查配置中是否启用Vision策略
|
||||
const isVisionEnabled = visionModels.length > 0 ? true : false;
|
||||
|
||||
//用于传递到父组件,显示当前选中的模型
|
||||
let selectedModel = selectedViosnModel;
|
||||
|
||||
const handleOptionClick = optionValue => {
|
||||
if (optionValue === 'mineru-web') {
|
||||
window.open('https://mineru.net/OpenSourceTools/Extractor', '_blank');
|
||||
} else {
|
||||
onRadioChange({ target: { value: optionValue, selectedVision: selectedModel } });
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理设置变更
|
||||
const handleSettingChange = e => {
|
||||
const { value } = e.target;
|
||||
selectedModel = value;
|
||||
setSelectedViosnModel(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>{t('textSplit.pdfProcess')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: 2,
|
||||
p: 1
|
||||
}}
|
||||
>
|
||||
<OptionCard
|
||||
icon={<ArticleOutlinedIcon fontSize="large" />}
|
||||
title={t('textSplit.basicPdfParsing')}
|
||||
description={t('textSplit.basicPdfParsingDesc')}
|
||||
onClick={() => handleOptionClick('default')}
|
||||
selected={value === 'default'}
|
||||
/>
|
||||
<OptionCard
|
||||
icon={<ScienceOutlinedIcon fontSize="large" />}
|
||||
title="MinerU API"
|
||||
description={isMinerUEnabled ? t('textSplit.mineruApiDesc') : t('textSplit.mineruApiDescDisabled')}
|
||||
disabled={!isMinerUEnabled}
|
||||
onClick={() => handleOptionClick('mineru')}
|
||||
selected={value === 'mineru'}
|
||||
/>
|
||||
<OptionCard
|
||||
icon={<ChangeCircleOutlinedIcon fontSize="large" />}
|
||||
title="MinerU Local"
|
||||
description={isMinerULocalEnabled ? t('textSplit.mineruLocalDesc') : t('textSplit.mineruLocalDisabled')}
|
||||
disabled={!isMinerULocalEnabled}
|
||||
onClick={() => handleOptionClick('mineru-local')}
|
||||
selected={value === 'mineru-local'}
|
||||
/>
|
||||
<OptionCard
|
||||
icon={<LaunchOutlinedIcon fontSize="large" />}
|
||||
title={t('textSplit.mineruWebPlatform')}
|
||||
description={t('textSplit.mineruWebPlatformDesc')}
|
||||
onClick={() => handleOptionClick('mineru-web')}
|
||||
/>
|
||||
<OptionCard
|
||||
icon={<SmartToyOutlinedIcon fontSize="large" />}
|
||||
title={t('textSplit.customVisionModel')}
|
||||
description={t('textSplit.customVisionModelDesc')}
|
||||
disabled={!isVisionEnabled}
|
||||
onClick={() => handleOptionClick('vision')}
|
||||
selected={value === 'vision'}
|
||||
isVisionEnabled={isVisionEnabled}
|
||||
visionModels={visionModels}
|
||||
selectorName={t('settings.vision')}
|
||||
selectedViosnModel={selectedViosnModel}
|
||||
handleSettingChange={handleSettingChange}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
/**
|
||||
* 标签页面板组件
|
||||
* @param {Object} props
|
||||
* @param {number} props.value - 当前激活的标签索引
|
||||
* @param {number} props.index - 当前面板对应的索引
|
||||
* @param {ReactNode} props.children - 子组件
|
||||
*/
|
||||
export default function TabPanel({ value, index, children }) {
|
||||
return (
|
||||
<Box
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`domain-tabpanel-${index}`}
|
||||
aria-labelledby={`domain-tab-${index}`}
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
{value === index && <Box sx={{ height: '100%' }}>{children}</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
207
easy-dataset-main/components/text-split/components/UploadArea.js
Normal file
207
easy-dataset-main/components/text-split/components/UploadArea.js
Normal file
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
export default function UploadArea({
|
||||
theme,
|
||||
files,
|
||||
uploading,
|
||||
uploadedFiles,
|
||||
onFileSelect,
|
||||
onRemoveFile,
|
||||
onUpload,
|
||||
selectedModel
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// 拖拽进入
|
||||
const handleDragOver = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!dragActive) setDragActive(true);
|
||||
};
|
||||
// 拖拽离开
|
||||
const handleDragLeave = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
};
|
||||
// 拖拽释放
|
||||
const handleDrop = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (!selectedModel?.id || uploading) return;
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
// 构造一个模拟的 event 以复用 onFileSelect
|
||||
const event = { target: { files } };
|
||||
onFileSelect(event);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
height: '100%',
|
||||
border: `2px dashed ${dragActive ? theme.palette.primary.main : alpha(theme.palette.primary.main, 0.2)}`,
|
||||
borderRadius: 2,
|
||||
bgcolor: dragActive ? alpha(theme.palette.primary.main, 0.12) : alpha(theme.palette.primary.main, 0.05),
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.08),
|
||||
borderColor: alpha(theme.palette.primary.main, 0.3)
|
||||
},
|
||||
cursor: uploading || !selectedModel?.id ? 'not-allowed' : 'pointer',
|
||||
position: 'relative'
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{dragActive && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.3),
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
borderRadius: 2,
|
||||
border: `3px solid ${theme.palette.primary.main}`,
|
||||
backdropFilter: 'blur(2px)'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 255, 255, 0.9)',
|
||||
p: 3,
|
||||
borderRadius: 1,
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.1)',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
border: `1px solid ${theme.palette.primary.main}`
|
||||
}}
|
||||
>
|
||||
<UploadFileIcon color="primary" sx={{ fontSize: 40, mb: 1 }} />
|
||||
<Typography variant="h6" color="primary" sx={{ fontWeight: 'bold' }}>
|
||||
{t('textSplit.dragToUpload', { defaultValue: '拖拽文件到此处上传' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('textSplit.uploadNewDocument')}
|
||||
</Typography>
|
||||
|
||||
<Tooltip
|
||||
title={!selectedModel?.id ? t('textSplit.selectModelFirst', { defaultValue: '请先在右上角选择模型' }) : ''}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
component="label"
|
||||
variant="contained"
|
||||
startIcon={<UploadFileIcon />}
|
||||
sx={{ mb: 2, mt: 2 }}
|
||||
disabled={!selectedModel?.id || uploading}
|
||||
>
|
||||
{t('textSplit.selectFile')}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".md,.txt,.docx,.pdf,.epub"
|
||||
multiple
|
||||
onChange={onFileSelect}
|
||||
disabled={!selectedModel?.id || uploading}
|
||||
/>
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{uploadedFiles.total > 0 ? t('textSplit.mutilFileMessage') : t('textSplit.supportedFormats')}
|
||||
</Typography>
|
||||
|
||||
{files.length > 0 && (
|
||||
<Box sx={{ mt: 3, width: '100%' }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('textSplit.selectedFiles', { count: files.length })}
|
||||
</Typography>
|
||||
|
||||
<List sx={{ bgcolor: theme.palette.background.paper, borderRadius: 1, maxHeight: '200px', overflow: 'auto' }}>
|
||||
{files.map((file, index) => (
|
||||
<Box key={index}>
|
||||
<ListItem
|
||||
secondaryAction={
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => onRemoveFile(index)}
|
||||
disabled={uploading}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListItemText primary={file.name} secondary={`${(file.size / 1024).toFixed(2)} KB`} />
|
||||
</ListItem>
|
||||
{index < files.length - 1 && <Divider />}
|
||||
</Box>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'center' }}>
|
||||
<Tooltip
|
||||
title={
|
||||
!selectedModel?.id ? t('textSplit.selectModelFirst', { defaultValue: '请先在右上角选择模型' }) : ''
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={onUpload}
|
||||
disabled={uploading || !selectedModel?.id}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
{uploading ? <CircularProgress size={24} /> : t('textSplit.uploadAndProcess')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user