Commit 554ac319 by Apple\Apple

🎨 refactor(playground): Refactor the structure of the playground and implement…

🎨 refactor(playground): Refactor the structure of the playground and implement responsive design adaptation
parent f3dd960c
......@@ -11,6 +11,7 @@ import { API, getLogo, getSystemName, showError } from '../helpers/index.js';
import { setStatusData } from '../helpers/data.js';
import { UserContext } from '../context/User/index.js';
import { StatusContext } from '../context/Status/index.js';
import { useLocation } from 'react-router-dom';
const { Sider, Content, Header, Footer } = Layout;
const PageLayout = () => {
......@@ -18,6 +19,9 @@ const PageLayout = () => {
const [statusState, statusDispatch] = useContext(StatusContext);
const [styleState, styleDispatch] = useContext(StyleContext);
const { i18n } = useTranslation();
const location = useLocation();
const isPlaygroundRoute = location.pathname === '/console/playground';
const loadUser = () => {
let user = localStorage.getItem('user');
......@@ -144,14 +148,16 @@ const PageLayout = () => {
>
<App />
</Content>
<Layout.Footer
style={{
flex: '0 0 auto',
width: '100%',
}}
>
<FooterBar />
</Layout.Footer>
{!isPlaygroundRoute && (
<Layout.Footer
style={{
flex: '0 0 auto',
width: '100%',
}}
>
<FooterBar />
</Layout.Footer>
)}
</Layout>
</Layout>
<ToastContainer />
......
import React from 'react';
import {
Card,
Chat,
Typography,
Button,
} from '@douyinfe/semi-ui';
import {
MessageSquare,
Eye,
EyeOff,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CustomInputRender from './CustomInputRender';
const ChatArea = ({
chatRef,
message,
inputs,
styleState,
showDebugPanel,
roleInfo,
onMessageSend,
onMessageCopy,
onMessageReset,
onMessageDelete,
onStopGenerator,
onClearMessages,
onToggleDebugPanel,
renderCustomChatContent,
renderChatBoxAction,
}) => {
const { t } = useTranslation();
const renderInputArea = React.useCallback((props) => {
return <CustomInputRender {...props} />;
}, []);
return (
<Card
className="!rounded-2xl h-full"
bodyStyle={{ padding: 0, height: 'calc(100vh - 101px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
{/* 聊天头部 */}
{styleState.isMobile ? (
<div className="pt-4"></div>
) : (
<div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
<MessageSquare size={20} className="text-white" />
</div>
<div>
<Typography.Title heading={5} className="!text-white mb-0">
{t('AI 对话')}
</Typography.Title>
<Typography.Text className="!text-white/80 text-sm hidden sm:inline">
{inputs.model || t('选择模型开始对话')}
</Typography.Text>
</div>
</div>
<div className="flex items-center gap-2">
<Button
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={onToggleDebugPanel}
theme="borderless"
type="primary"
size="small"
className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10"
>
{showDebugPanel ? t('隐藏调试') : t('显示调试')}
</Button>
</div>
</div>
</div>
)}
{/* 聊天内容区域 */}
<div className="flex-1 overflow-hidden">
<Chat
ref={chatRef}
chatBoxRenderConfig={{
renderChatBoxContent: renderCustomChatContent,
renderChatBoxAction: renderChatBoxAction,
renderChatBoxTitle: () => null,
}}
renderInputArea={renderInputArea}
roleConfig={roleInfo}
style={{
height: '100%',
maxWidth: '100%',
overflow: 'hidden'
}}
chats={message}
onMessageSend={onMessageSend}
onMessageCopy={onMessageCopy}
onMessageReset={onMessageReset}
onMessageDelete={onMessageDelete}
showClearContext
showStopGenerate
onStopGenerator={onStopGenerator}
onClear={onClearMessages}
className="h-full"
placeholder={t('请输入您的问题...')}
/>
</div>
</Card>
);
};
export default ChatArea;
\ No newline at end of file
import React, { useRef } from 'react';
import {
Button,
Typography,
Toast,
Modal,
Dropdown,
} from '@douyinfe/semi-ui';
import {
Download,
Upload,
RotateCcw,
Settings2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage';
const ConfigManager = ({
currentConfig,
onConfigImport,
onConfigReset,
styleState,
}) => {
const { t } = useTranslation();
const fileInputRef = useRef(null);
const handleExport = () => {
try {
exportConfig(currentConfig);
Toast.success({
content: t('配置已导出到下载文件夹'),
duration: 3,
});
} catch (error) {
Toast.error({
content: t('导出配置失败: ') + error.message,
duration: 3,
});
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (event) => {
const file = event.target.files[0];
if (!file) return;
try {
const importedConfig = await importConfig(file);
Modal.confirm({
title: t('确认导入配置'),
content: t('导入的配置将覆盖当前设置,是否继续?'),
okText: t('确定导入'),
cancelText: t('取消'),
onOk: () => {
onConfigImport(importedConfig);
Toast.success({
content: t('配置导入成功'),
duration: 3,
});
},
});
} catch (error) {
Toast.error({
content: t('导入配置失败: ') + error.message,
duration: 3,
});
} finally {
// 重置文件输入,允许重复选择同一文件
event.target.value = '';
}
};
const handleReset = () => {
Modal.confirm({
title: t('重置配置'),
content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'),
okText: t('确定重置'),
cancelText: t('取消'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
clearConfig();
onConfigReset();
Toast.success({
content: t('配置已重置为默认值'),
duration: 3,
});
},
});
};
const getConfigStatus = () => {
if (hasStoredConfig()) {
const timestamp = getConfigTimestamp();
if (timestamp) {
const date = new Date(timestamp);
return t('上次保存: ') + date.toLocaleString();
}
return t('已有保存的配置');
}
return t('暂无保存的配置');
};
const dropdownItems = [
{
node: 'item',
name: 'export',
onClick: handleExport,
children: (
<div className="flex items-center gap-2">
<Download size={14} />
{t('导出配置')}
</div>
),
},
{
node: 'item',
name: 'import',
onClick: handleImportClick,
children: (
<div className="flex items-center gap-2">
<Upload size={14} />
{t('导入配置')}
</div>
),
},
{
node: 'divider',
},
{
node: 'item',
name: 'reset',
onClick: handleReset,
children: (
<div className="flex items-center gap-2 text-red-600">
<RotateCcw size={14} />
{t('重置配置')}
</div>
),
},
];
if (styleState.isMobile) {
// 移动端显示简化的下拉菜单
return (
<>
<Dropdown
trigger="click"
position="bottomLeft"
showTick
menu={dropdownItems}
>
<Button
icon={<Settings2 size={14} />}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50"
/>
</Dropdown>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</>
);
}
// 桌面端显示紧凑的按钮组
return (
<div className="space-y-3">
{/* 配置状态信息,使用较小的字体 */}
<div className="text-center">
<Typography.Text className="text-xs text-gray-500">
{getConfigStatus()}
</Typography.Text>
</div>
{/* 紧凑的按钮布局 */}
<div className="flex gap-2">
<Button
icon={<Download size={12} />}
size="small"
theme="solid"
type="primary"
onClick={handleExport}
className="!rounded-lg flex-1 !text-xs !h-7"
>
{t('导出')}
</Button>
<Button
icon={<Upload size={12} />}
size="small"
theme="outline"
type="primary"
onClick={handleImportClick}
className="!rounded-lg flex-1 !text-xs !h-7"
>
{t('导入')}
</Button>
<Button
icon={<RotateCcw size={12} />}
size="small"
theme="borderless"
type="danger"
onClick={handleReset}
className="!rounded-lg !text-xs !h-7 !px-2"
style={{ minWidth: 'auto' }}
/>
</div>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
);
};
export default ConfigManager;
\ No newline at end of file
import React from 'react';
const CustomInputRender = (props) => {
const { detailProps } = props;
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps;
const styledSendNode = React.cloneElement(sendNode, {
className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 ${sendNode.props.className || ''}`
});
return (
<div className="p-2 sm:p-4">
<div
className="flex items-end gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow"
style={{ border: '1px solid var(--semi-color-border)' }}
onClick={onClick}
>
<div className="flex-1">
{inputNode}
</div>
{styledSendNode}
</div>
</div>
);
};
export default CustomInputRender;
\ No newline at end of file
import React from 'react';
import {
Card,
Typography,
Tabs,
TabPane,
Button,
} from '@douyinfe/semi-ui';
import {
Code,
FileText,
Zap,
Clock,
X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const DebugPanel = ({
debugData,
activeDebugTab,
onActiveDebugTabChange,
styleState,
onCloseDebugPanel,
}) => {
const { t } = useTranslation();
return (
<Card
className="!rounded-2xl h-full flex flex-col"
bodyStyle={{
padding: styleState.isMobile ? '16px' : '24px',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
<Code size={20} className="text-white" />
</div>
<Typography.Title heading={5} className="mb-0">
{t('调试信息')}
</Typography.Title>
</div>
{/* 移动端关闭按钮 */}
{styleState.isMobile && onCloseDebugPanel && (
<Button
icon={<X size={16} />}
onClick={onCloseDebugPanel}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg"
/>
)}
</div>
<div className="flex-1 overflow-hidden debug-panel">
<Tabs
type="line"
className="h-full"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
activeKey={activeDebugTab}
onChange={onActiveDebugTabChange}
>
<TabPane tab={
<div className="flex items-center gap-2">
<FileText size={16} />
{t('请求体')}
</div>
} itemKey="request">
<div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
{debugData.request ? (
<pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
{JSON.stringify(debugData.request, null, 2)}
</pre>
) : (
<Typography.Text type="secondary" className="text-sm">
{t('暂无请求数据')}
</Typography.Text>
)}
</div>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Zap size={16} />
{t('响应内容')}
</div>
} itemKey="response">
<div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
{debugData.response ? (
<pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
{debugData.response}
</pre>
) : (
<Typography.Text type="secondary" className="text-sm">
{t('暂无响应数据')}
</Typography.Text>
)}
</div>
</TabPane>
</Tabs>
</div>
{debugData.timestamp && (
<div className="flex items-center gap-2 mt-4 pt-4 flex-shrink-0">
<Clock size={14} className="text-gray-500" />
<Typography.Text className="text-xs text-gray-500">
{t('最后更新')}: {new Date(debugData.timestamp).toLocaleString()}
</Typography.Text>
</div>
)}
</Card>
);
};
export default DebugPanel;
\ No newline at end of file
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import {
Settings,
Eye,
EyeOff,
} from 'lucide-react';
const FloatingButtons = ({
styleState,
showSettings,
showDebugPanel,
onToggleSettings,
onToggleDebugPanel,
}) => {
if (!styleState.isMobile) return null;
return (
<>
{/* 设置按钮 */}
{!showSettings && (
<Button
icon={<Settings size={18} />}
style={{
position: 'fixed',
right: 16,
bottom: 90,
zIndex: 1000,
width: 36,
height: 36,
borderRadius: '50%',
padding: 0,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
background: 'linear-gradient(to right, #8b5cf6, #6366f1)',
}}
onClick={onToggleSettings}
theme='solid'
type='primary'
className="lg:hidden"
/>
)}
{/* 调试按钮 */}
{!showSettings && (
<Button
icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
onClick={onToggleDebugPanel}
theme="solid"
type={showDebugPanel ? "danger" : "primary"}
style={{
position: 'fixed',
right: 16,
bottom: 140,
zIndex: 1000,
width: 36,
height: 36,
borderRadius: '50%',
padding: 0,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
background: showDebugPanel
? 'linear-gradient(to right, #e11d48, #be123c)'
: 'linear-gradient(to right, #4f46e5, #6366f1)',
}}
className="lg:hidden !rounded-full !p-0"
/>
)}
</>
);
};
export default FloatingButtons;
\ No newline at end of file
import React from 'react';
import {
Input,
Typography,
Button,
} from '@douyinfe/semi-ui';
import { IconFile } from '@douyinfe/semi-icons';
import {
FileText,
Plus,
X,
} from 'lucide-react';
const ImageUrlInput = ({ imageUrls, onImageUrlsChange }) => {
const handleAddImageUrl = () => {
const newUrls = [...imageUrls, ''];
onImageUrlsChange(newUrls);
};
const handleUpdateImageUrl = (index, value) => {
const newUrls = [...imageUrls];
newUrls[index] = value;
onImageUrlsChange(newUrls);
};
const handleRemoveImageUrl = (index) => {
const newUrls = imageUrls.filter((_, i) => i !== index);
onImageUrlsChange(newUrls);
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
图片地址
</Typography.Text>
<Typography.Text className="text-xs text-gray-400">
(多模态对话)
</Typography.Text>
</div>
<Button
icon={<Plus size={14} />}
size="small"
theme="solid"
type="primary"
onClick={handleAddImageUrl}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={imageUrls.length >= 5}
/>
</div>
{imageUrls.length === 0 ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
点击 + 按钮添加图片URL,支持最多5张图片
</Typography.Text>
) : (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
已添加 {imageUrls.length}/5 张图
</Typography.Text>
)}
<div className="space-y-2 max-h-32 overflow-y-auto">
{imageUrls.map((url, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
<Input
placeholder={`https://example.com/image${index + 1}.jpg`}
value={url}
onChange={(value) => handleUpdateImageUrl(index, value)}
className="!rounded-lg"
size="small"
prefix={<IconFile size='small' />}
/>
</div>
<Button
icon={<X size={12} />}
size="small"
theme="borderless"
type="danger"
onClick={() => handleRemoveImageUrl(index)}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0"
/>
</div>
))}
</div>
</div>
);
};
export default ImageUrlInput;
\ No newline at end of file
import React from 'react';
import {
Button,
Tooltip,
} from '@douyinfe/semi-ui';
import {
RefreshCw,
Copy,
Trash2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MessageActions = ({ message, styleState, onMessageReset, onMessageCopy, onMessageDelete, isAnyMessageGenerating = false }) => {
const { t } = useTranslation();
const isLoading = message.status === 'loading' || message.status === 'incomplete';
const shouldDisableActions = isAnyMessageGenerating;
return (
<div className="flex items-center gap-0.5">
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('正在生成中,请稍候...') : t('重试')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageReset(message)}
disabled={shouldDisableActions}
className={`!rounded-md ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-blue-600 hover:!bg-blue-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('重试')}
/>
</Tooltip>
)}
{message.content && (
<Tooltip content={t('复制')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Copy size={styleState.isMobile ? 12 : 14} />}
onClick={() => onMessageCopy(message)}
className={`!rounded-md !text-gray-400 hover:!text-green-600 hover:!bg-green-50 ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('复制')}
/>
</Tooltip>
)}
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('正在生成中,请稍候...') : t('删除')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageDelete(message)}
disabled={shouldDisableActions}
className={`!rounded-md ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-red-600 hover:!bg-red-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('删除')}
/>
</Tooltip>
)}
</div>
);
};
export default MessageActions;
\ No newline at end of file
import React from 'react';
import {
Input,
Slider,
Typography,
Button,
Tag,
} from '@douyinfe/semi-ui';
import {
Hash,
Thermometer,
Target,
Repeat,
Ban,
Shuffle,
Check,
X,
} from 'lucide-react';
const ParameterControl = ({
inputs,
parameterEnabled,
onInputChange,
onParameterToggle,
}) => {
return (
<>
{/* Temperature */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.temperature ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Thermometer size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Temperature
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.temperature}
</Tag>
</div>
<Button
theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('temperature')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
控制输出的随机性和创造性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.temperature}
onChange={(value) => onInputChange('temperature', value)}
className="mt-2"
disabled={!parameterEnabled.temperature}
/>
</div>
{/* Top P */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.top_p ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Top P
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.top_p}
</Tag>
</div>
<Button
theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('top_p')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
核采样,控制词汇选择的多样性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.top_p}
onChange={(value) => onInputChange('top_p', value)}
className="mt-2"
disabled={!parameterEnabled.top_p}
/>
</div>
{/* Frequency Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.frequency_penalty ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Repeat size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Frequency Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.frequency_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('frequency_penalty')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
频率惩罚,减少重复词汇的出现
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.frequency_penalty}
onChange={(value) => onInputChange('frequency_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.frequency_penalty}
/>
</div>
{/* Presence Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.presence_penalty ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Ban size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Presence Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.presence_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('presence_penalty')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
存在惩罚,鼓励讨论新话题
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.presence_penalty}
onChange={(value) => onInputChange('presence_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.presence_penalty}
/>
</div>
{/* MaxTokens */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.max_tokens ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Hash size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Max Tokens
</Typography.Text>
</div>
<Button
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('max_tokens')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
/>
</div>
<Input
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)}
className="!rounded-lg"
disabled={!parameterEnabled.max_tokens}
/>
</div>
{/* Seed */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.seed ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shuffle size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Seed
</Typography.Text>
<Typography.Text className="text-xs text-gray-400">
(可选,用于复现结果)
</Typography.Text>
</div>
<Button
theme={parameterEnabled.seed ? 'solid' : 'borderless'}
type={parameterEnabled.seed ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('seed')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
/>
</div>
<Input
placeholder='随机种子 (留空为随机)'
name='seed'
autoComplete='new-password'
value={inputs.seed || ''}
onChange={(value) => onInputChange('seed', value === '' ? null : value)}
className="!rounded-lg"
disabled={!parameterEnabled.seed}
/>
</div>
</>
);
};
export default ParameterControl;
\ No newline at end of file
import React from 'react';
import {
Card,
Select,
TextArea,
Typography,
Button,
Switch,
Divider,
} from '@douyinfe/semi-ui';
import {
Sparkles,
Users,
Type,
ToggleLeft,
X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { renderGroupOption } from '../../helpers/render.js';
import ParameterControl from './ParameterControl';
import ImageUrlInput from './ImageUrlInput';
import ConfigManager from './ConfigManager';
const SettingsPanel = ({
inputs,
parameterEnabled,
models,
groups,
systemPrompt,
styleState,
showDebugPanel,
onInputChange,
onParameterToggle,
onSystemPromptChange,
onCloseSettings,
onConfigImport,
onConfigReset,
}) => {
const { t } = useTranslation();
const currentConfig = {
inputs,
parameterEnabled,
systemPrompt,
showDebugPanel,
};
return (
<Card
className={`!rounded-2xl h-full flex flex-col ${styleState.isMobile ? 'rounded-none border-none shadow-none' : ''}`}
bodyStyle={{
padding: styleState.isMobile ? '24px' : '24px 24px 16px 24px',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
{styleState.isMobile && (
<div className="flex items-center justify-between mb-4">
{/* 移动端显示配置管理下拉菜单和关闭按钮 */}
<ConfigManager
currentConfig={currentConfig}
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={styleState}
/>
<Button
icon={<X size={16} />}
onClick={onCloseSettings}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg !text-gray-600 hover:!text-red-600 hover:!bg-red-50"
/>
</div>
)}
<div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
{/* 分组选择 */}
<div>
<div className="flex items-center gap-2 mb-2">
<Users size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
{t('分组')}
</Typography.Text>
</div>
<Select
placeholder={t('请选择分组')}
name='group'
required
selection
onChange={(value) => onInputChange('group', value)}
value={inputs.group}
autoComplete='new-password'
optionList={groups}
renderOptionItem={renderGroupOption}
style={{ width: '100%' }}
className="!rounded-lg"
/>
</div>
{/* 模型选择 */}
<div>
<div className="flex items-center gap-2 mb-2">
<Sparkles size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
{t('模型')}
</Typography.Text>
</div>
<Select
placeholder={t('请选择模型')}
name='model'
required
selection
searchPosition='dropdown'
filter
onChange={(value) => onInputChange('model', value)}
value={inputs.model}
autoComplete='new-password'
optionList={models}
className="!rounded-lg"
/>
</div>
{/* 图片URL输入 */}
<ImageUrlInput
imageUrls={inputs.imageUrls}
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
/>
{/* 参数控制组件 */}
<ParameterControl
inputs={inputs}
parameterEnabled={parameterEnabled}
onInputChange={onInputChange}
onParameterToggle={onParameterToggle}
/>
{/* 流式输出开关 */}
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ToggleLeft size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
流式输出
</Typography.Text>
</div>
<Switch
checked={inputs.stream}
onChange={(checked) => onInputChange('stream', checked)}
checkedText="开"
uncheckedText="关"
size="small"
/>
</div>
</div>
{/* System Prompt */}
<div>
<div className="flex items-center gap-2 mb-2">
<Type size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
System Prompt
</Typography.Text>
</div>
<TextArea
placeholder='System Prompt'
name='system'
required
autoComplete='new-password'
autosize
defaultValue={systemPrompt}
onChange={onSystemPromptChange}
className="!rounded-lg"
maxHeight={200}
/>
</div>
</div>
{/* 桌面端的配置管理放在底部 */}
{!styleState.isMobile && (
<div className="flex-shrink-0 mt-4 pt-3">
<ConfigManager
currentConfig={currentConfig}
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={styleState}
/>
</div>
)}
</Card>
);
};
export default SettingsPanel;
\ No newline at end of file
const STORAGE_KEY = 'playground_config';
const DEFAULT_CONFIG = {
inputs: {
model: 'deepseek-r1',
group: '',
max_tokens: 0,
temperature: 0,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
seed: null,
stream: true,
imageUrls: [],
},
parameterEnabled: {
max_tokens: true,
temperature: true,
top_p: false,
frequency_penalty: false,
presence_penalty: false,
seed: false,
},
systemPrompt: 'You are a helpful assistant. You can help me by answering my questions. You can also ask me questions.',
showDebugPanel: false,
};
/**
* 保存配置到 localStorage
* @param {Object} config - 要保存的配置对象
*/
export const saveConfig = (config) => {
try {
const configToSave = {
...config,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(configToSave));
console.log('配置已保存到本地存储');
} catch (error) {
console.error('保存配置失败:', error);
}
};
/**
* 从 localStorage 加载配置
* @returns {Object} 配置对象,如果不存在则返回默认配置
*/
export const loadConfig = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEY);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
const mergedConfig = {
inputs: {
...DEFAULT_CONFIG.inputs,
...parsedConfig.inputs,
},
parameterEnabled: {
...DEFAULT_CONFIG.parameterEnabled,
...parsedConfig.parameterEnabled,
},
systemPrompt: parsedConfig.systemPrompt || DEFAULT_CONFIG.systemPrompt,
showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
};
console.log('配置已从本地存储加载');
return mergedConfig;
}
} catch (error) {
console.error('加载配置失败:', error);
}
console.log('使用默认配置');
return DEFAULT_CONFIG;
};
/**
* 清除保存的配置
*/
export const clearConfig = () => {
try {
localStorage.removeItem(STORAGE_KEY);
console.log('配置已清除');
} catch (error) {
console.error('清除配置失败:', error);
}
};
/**
* 检查是否有保存的配置
* @returns {boolean} 是否存在保存的配置
*/
export const hasStoredConfig = () => {
try {
return localStorage.getItem(STORAGE_KEY) !== null;
} catch (error) {
console.error('检查配置失败:', error);
return false;
}
};
/**
* 获取配置的最后保存时间
* @returns {string|null} 最后保存时间的 ISO 字符串
*/
export const getConfigTimestamp = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEY);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
return parsedConfig.timestamp || null;
}
} catch (error) {
console.error('获取配置时间戳失败:', error);
}
return null;
};
/**
* 导出配置为 JSON 文件
* @param {Object} config - 要导出的配置
*/
export const exportConfig = (config) => {
try {
const configToExport = {
...config,
exportTime: new Date().toISOString(),
version: '1.0',
};
const dataStr = JSON.stringify(configToExport, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `playground-config-${new Date().toISOString().split('T')[0]}.json`;
link.click();
URL.revokeObjectURL(link.href);
console.log('配置已导出');
} catch (error) {
console.error('导出配置失败:', error);
}
};
/**
* 从文件导入配置
* @param {File} file - 包含配置的 JSON 文件
* @returns {Promise<Object>} 导入的配置对象
*/
export const importConfig = (file) => {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedConfig = JSON.parse(e.target.result);
if (importedConfig.inputs && importedConfig.parameterEnabled) {
console.log('配置已从文件导入');
resolve(importedConfig);
} else {
reject(new Error('配置文件格式无效'));
}
} catch (parseError) {
reject(new Error('解析配置文件失败: ' + parseError.message));
}
};
reader.onerror = () => reject(new Error('读取文件失败'));
reader.readAsText(file);
} catch (error) {
reject(new Error('导入配置失败: ' + error.message));
}
});
};
\ No newline at end of file
export { default as SettingsPanel } from './SettingsPanel';
export { default as ChatArea } from './ChatArea';
export { default as DebugPanel } from './DebugPanel';
export { default as MessageContent } from './MessageContent';
export { default as MessageActions } from './MessageActions';
export { default as CustomInputRender } from './CustomInputRender';
export { default as ParameterControl } from './ParameterControl';
export { default as ImageUrlInput } from './ImageUrlInput';
export { default as FloatingButtons } from './FloatingButtons';
export { default as ConfigManager } from './ConfigManager';
export {
saveConfig,
loadConfig,
clearConfig,
hasStoredConfig,
getConfigTimestamp,
exportConfig,
importConfig,
} from './configStorage';
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment