🎨 refactor(playground): Refactor the structure of the playground and implement responsive design adaptation
This commit is contained in:
@@ -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 />
|
||||
|
||||
112
web/src/components/playground/ChatArea.js
Normal file
112
web/src/components/playground/ChatArea.js
Normal file
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
234
web/src/components/playground/ConfigManager.js
Normal file
234
web/src/components/playground/ConfigManager.js
Normal file
@@ -0,0 +1,234 @@
|
||||
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;
|
||||
27
web/src/components/playground/CustomInputRender.js
Normal file
27
web/src/components/playground/CustomInputRender.js
Normal file
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
120
web/src/components/playground/DebugPanel.js
Normal file
120
web/src/components/playground/DebugPanel.js
Normal file
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
71
web/src/components/playground/FloatingButtons.js
Normal file
71
web/src/components/playground/FloatingButtons.js
Normal file
@@ -0,0 +1,71 @@
|
||||
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;
|
||||
92
web/src/components/playground/ImageUrlInput.js
Normal file
92
web/src/components/playground/ImageUrlInput.js
Normal file
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
69
web/src/components/playground/MessageActions.js
Normal file
69
web/src/components/playground/MessageActions.js
Normal file
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
248
web/src/components/playground/MessageContent.js
Normal file
248
web/src/components/playground/MessageContent.js
Normal file
@@ -0,0 +1,248 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Typography,
|
||||
MarkdownRender,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Brain,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MessageContent = ({ message, className, styleState, onToggleReasoningExpansion }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (message.status === 'error') {
|
||||
return (
|
||||
<div className={`${className} flex items-center p-4 bg-red-50 rounded-xl`}>
|
||||
<Typography.Text type="danger" className="text-sm">
|
||||
{message.content || t('请求发生错误')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
|
||||
let currentExtractedThinkingContent = null;
|
||||
let currentDisplayableFinalContent = message.content || "";
|
||||
let thinkingSource = null;
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
let baseContentForDisplay = message.content || "";
|
||||
let combinedThinkingContent = "";
|
||||
|
||||
if (message.reasoningContent) {
|
||||
combinedThinkingContent = message.reasoningContent;
|
||||
thinkingSource = 'reasoningContent';
|
||||
}
|
||||
|
||||
if (baseContentForDisplay.includes('<think>')) {
|
||||
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
|
||||
let match;
|
||||
let thoughtsFromPairedTags = [];
|
||||
let replyParts = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
|
||||
replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
|
||||
thoughtsFromPairedTags.push(match[1]);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
replyParts.push(baseContentForDisplay.substring(lastIndex));
|
||||
|
||||
if (thoughtsFromPairedTags.length > 0) {
|
||||
const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n');
|
||||
if (combinedThinkingContent) {
|
||||
combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr;
|
||||
} else {
|
||||
combinedThinkingContent = pairedThoughtsStr;
|
||||
}
|
||||
thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
|
||||
}
|
||||
|
||||
baseContentForDisplay = replyParts.join('');
|
||||
}
|
||||
|
||||
if (isThinkingStatus) {
|
||||
const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
|
||||
if (lastOpenThinkIndex !== -1) {
|
||||
const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
|
||||
if (!fragmentAfterLastOpen.includes('</think>')) {
|
||||
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
|
||||
if (unclosedThought) {
|
||||
if (combinedThinkingContent) {
|
||||
combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
|
||||
} else {
|
||||
combinedThinkingContent = unclosedThought;
|
||||
}
|
||||
thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
|
||||
}
|
||||
baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentExtractedThinkingContent = combinedThinkingContent || null;
|
||||
currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
|
||||
}
|
||||
|
||||
const headerText = isThinkingStatus ? t('思考中...') : t('思考过程');
|
||||
const finalExtractedThinkingContent = currentExtractedThinkingContent;
|
||||
const finalDisplayableFinalContent = currentDisplayableFinalContent;
|
||||
|
||||
if (message.role === 'assistant' &&
|
||||
isThinkingStatus &&
|
||||
!finalExtractedThinkingContent &&
|
||||
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
|
||||
return (
|
||||
<div className={`${className} flex items-center gap-2 sm:gap-4 p-4 sm:p-6 bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl sm:rounded-2xl`}>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
|
||||
<Loader2 className="animate-spin text-white" size={styleState.isMobile ? 16 : 20} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Typography.Text strong className="text-gray-800 text-sm sm:text-base">
|
||||
{t('正在思考...')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-gray-500 text-xs sm:text-sm">
|
||||
AI 正在分析您的问题
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* 渲染推理内容 */}
|
||||
{message.role === 'assistant' && finalExtractedThinkingContent && (
|
||||
<div className="bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 sm:p-5 cursor-pointer hover:bg-gradient-to-r hover:from-white/40 hover:to-purple-50/60 transition-all"
|
||||
onClick={() => onToggleReasoningExpansion(message.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
|
||||
<Brain className="text-white" size={styleState.isMobile ? 12 : 16} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Typography.Text strong className="text-gray-800 text-sm sm:text-base">
|
||||
{headerText}
|
||||
</Typography.Text>
|
||||
{thinkingSource && (
|
||||
<Typography.Text className="text-gray-500 text-xs mt-0.5 hidden sm:block">
|
||||
来源: {thinkingSource}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{isThinkingStatus && (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<Loader2 className="animate-spin text-purple-500" size={styleState.isMobile ? 14 : 18} />
|
||||
<Typography.Text className="text-purple-600 text-xs sm:text-sm font-medium">
|
||||
思考中
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{!isThinkingStatus && (
|
||||
<div className="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-purple-100 flex items-center justify-center">
|
||||
{message.isReasoningExpanded ?
|
||||
<ChevronUp size={styleState.isMobile ? 12 : 16} className="text-purple-600" /> :
|
||||
<ChevronRight size={styleState.isMobile ? 12 : 16} className="text-purple-600" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
|
||||
} overflow-hidden`}
|
||||
>
|
||||
{message.isReasoningExpanded && (
|
||||
<div className="p-3 sm:p-5 pt-2 sm:pt-4">
|
||||
<div className="bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto max-h-50 overflow-y-auto">
|
||||
<div className="prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm">
|
||||
<MarkdownRender raw={finalExtractedThinkingContent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 渲染消息内容 */}
|
||||
{(() => {
|
||||
// 处理多模态内容(文本+图片)
|
||||
if (Array.isArray(message.content)) {
|
||||
const textContent = message.content.find(item => item.type === 'text');
|
||||
const imageContents = message.content.filter(item => item.type === 'image_url');
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 显示图片 */}
|
||||
{imageContents.length > 0 && (
|
||||
<div className="mb-3 space-y-2">
|
||||
{imageContents.map((imgItem, index) => (
|
||||
<div key={index} className="max-w-sm">
|
||||
<img
|
||||
src={imgItem.image_url.url}
|
||||
alt={`用户上传的图片 ${index + 1}`}
|
||||
className="rounded-lg max-w-full h-auto shadow-sm border"
|
||||
style={{ maxHeight: '300px' }}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'block';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200"
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
图片加载失败: {imgItem.image_url.url}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 显示文本内容 */}
|
||||
{textContent && textContent.text && textContent.text.trim() !== '' && (
|
||||
<div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
|
||||
<MarkdownRender raw={textContent.text} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 处理纯文本内容或助手回复
|
||||
if (typeof message.content === 'string') {
|
||||
if (message.role === 'assistant') {
|
||||
// 助手回复使用处理后的内容
|
||||
if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
|
||||
return (
|
||||
<div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
|
||||
<MarkdownRender raw={finalDisplayableFinalContent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 用户文本消息
|
||||
return (
|
||||
<div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
|
||||
<MarkdownRender raw={message.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageContent;
|
||||
234
web/src/components/playground/ParameterControl.js
Normal file
234
web/src/components/playground/ParameterControl.js
Normal file
@@ -0,0 +1,234 @@
|
||||
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;
|
||||
195
web/src/components/playground/SettingsPanel.js
Normal file
195
web/src/components/playground/SettingsPanel.js
Normal file
@@ -0,0 +1,195 @@
|
||||
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;
|
||||
178
web/src/components/playground/configStorage.js
Normal file
178
web/src/components/playground/configStorage.js
Normal file
@@ -0,0 +1,178 @@
|
||||
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));
|
||||
}
|
||||
});
|
||||
};
|
||||
20
web/src/components/playground/index.js
Normal file
20
web/src/components/playground/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
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';
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user