first-update
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user