diff --git a/web/src/components/PageLayout.js b/web/src/components/PageLayout.js
index d9d5471e..88bf2b15 100644
--- a/web/src/components/PageLayout.js
+++ b/web/src/components/PageLayout.js
@@ -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 = () => {
>
-
-
-
+ {!isPlaygroundRoute && (
+
+
+
+ )}
diff --git a/web/src/components/playground/ChatArea.js b/web/src/components/playground/ChatArea.js
new file mode 100644
index 00000000..6b42040d
--- /dev/null
+++ b/web/src/components/playground/ChatArea.js
@@ -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 ;
+ }, []);
+
+ return (
+
+ {/* 聊天头部 */}
+ {styleState.isMobile ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+ {t('AI 对话')}
+
+
+ {inputs.model || t('选择模型开始对话')}
+
+
+
+
+ : }
+ onClick={onToggleDebugPanel}
+ theme="borderless"
+ type="primary"
+ size="small"
+ className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10"
+ >
+ {showDebugPanel ? t('隐藏调试') : t('显示调试')}
+
+
+
+
+ )}
+
+ {/* 聊天内容区域 */}
+
+ 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('请输入您的问题...')}
+ />
+
+
+ );
+};
+
+export default ChatArea;
\ No newline at end of file
diff --git a/web/src/components/playground/ConfigManager.js b/web/src/components/playground/ConfigManager.js
new file mode 100644
index 00000000..c5b9eea4
--- /dev/null
+++ b/web/src/components/playground/ConfigManager.js
@@ -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: (
+
+
+ {t('导出配置')}
+
+ ),
+ },
+ {
+ node: 'item',
+ name: 'import',
+ onClick: handleImportClick,
+ children: (
+
+
+ {t('导入配置')}
+
+ ),
+ },
+ {
+ node: 'divider',
+ },
+ {
+ node: 'item',
+ name: 'reset',
+ onClick: handleReset,
+ children: (
+
+
+ {t('重置配置')}
+
+ ),
+ },
+ ];
+
+ if (styleState.isMobile) {
+ // 移动端显示简化的下拉菜单
+ return (
+ <>
+
+ }
+ theme="borderless"
+ type="tertiary"
+ size="small"
+ className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50"
+ />
+
+
+
+ >
+ );
+ }
+
+ // 桌面端显示紧凑的按钮组
+ return (
+
+ {/* 配置状态信息,使用较小的字体 */}
+
+
+ {getConfigStatus()}
+
+
+
+ {/* 紧凑的按钮布局 */}
+
+ }
+ size="small"
+ theme="solid"
+ type="primary"
+ onClick={handleExport}
+ className="!rounded-lg flex-1 !text-xs !h-7"
+ >
+ {t('导出')}
+
+
+ }
+ size="small"
+ theme="outline"
+ type="primary"
+ onClick={handleImportClick}
+ className="!rounded-lg flex-1 !text-xs !h-7"
+ >
+ {t('导入')}
+
+
+ }
+ size="small"
+ theme="borderless"
+ type="danger"
+ onClick={handleReset}
+ className="!rounded-lg !text-xs !h-7 !px-2"
+ style={{ minWidth: 'auto' }}
+ />
+
+
+
+
+ );
+};
+
+export default ConfigManager;
\ No newline at end of file
diff --git a/web/src/components/playground/CustomInputRender.js b/web/src/components/playground/CustomInputRender.js
new file mode 100644
index 00000000..90bc33bc
--- /dev/null
+++ b/web/src/components/playground/CustomInputRender.js
@@ -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 (
+
+
+
+ {inputNode}
+
+ {styledSendNode}
+
+
+ );
+};
+
+export default CustomInputRender;
\ No newline at end of file
diff --git a/web/src/components/playground/DebugPanel.js b/web/src/components/playground/DebugPanel.js
new file mode 100644
index 00000000..935bd681
--- /dev/null
+++ b/web/src/components/playground/DebugPanel.js
@@ -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 (
+
+
+
+
+
+
+
+ {t('调试信息')}
+
+
+
+ {/* 移动端关闭按钮 */}
+ {styleState.isMobile && onCloseDebugPanel && (
+
}
+ onClick={onCloseDebugPanel}
+ theme="borderless"
+ type="tertiary"
+ size="small"
+ className="!rounded-lg"
+ />
+ )}
+
+
+
+
+
+
+ {t('请求体')}
+
+ } itemKey="request">
+
+ {debugData.request ? (
+
+ {JSON.stringify(debugData.request, null, 2)}
+
+ ) : (
+
+ {t('暂无请求数据')}
+
+ )}
+
+
+
+
+
+ {t('响应内容')}
+
+ } itemKey="response">
+
+ {debugData.response ? (
+
+ {debugData.response}
+
+ ) : (
+
+ {t('暂无响应数据')}
+
+ )}
+
+
+
+
+
+ {debugData.timestamp && (
+
+
+
+ {t('最后更新')}: {new Date(debugData.timestamp).toLocaleString()}
+
+
+ )}
+
+ );
+};
+
+export default DebugPanel;
\ No newline at end of file
diff --git a/web/src/components/playground/FloatingButtons.js b/web/src/components/playground/FloatingButtons.js
new file mode 100644
index 00000000..4b629770
--- /dev/null
+++ b/web/src/components/playground/FloatingButtons.js
@@ -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 && (
+ }
+ 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 && (
+ : }
+ 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
diff --git a/web/src/components/playground/ImageUrlInput.js b/web/src/components/playground/ImageUrlInput.js
new file mode 100644
index 00000000..88bbbecb
--- /dev/null
+++ b/web/src/components/playground/ImageUrlInput.js
@@ -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 (
+
+
+
+
+
+ 图片地址
+
+
+ (多模态对话)
+
+
+
}
+ size="small"
+ theme="solid"
+ type="primary"
+ onClick={handleAddImageUrl}
+ className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+ disabled={imageUrls.length >= 5}
+ />
+
+
+ {imageUrls.length === 0 ? (
+
+ 点击 + 按钮添加图片URL,支持最多5张图片
+
+ ) : (
+
+ 已添加 {imageUrls.length}/5 张图片
+
+ )}
+
+
+ {imageUrls.map((url, index) => (
+
+
+ handleUpdateImageUrl(index, value)}
+ className="!rounded-lg"
+ size="small"
+ prefix={}
+ />
+
+
}
+ 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"
+ />
+
+ ))}
+
+
+ );
+};
+
+export default ImageUrlInput;
\ No newline at end of file
diff --git a/web/src/components/playground/MessageActions.js b/web/src/components/playground/MessageActions.js
new file mode 100644
index 00000000..7ad2b529
--- /dev/null
+++ b/web/src/components/playground/MessageActions.js
@@ -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 (
+
+ {!isLoading && (
+
+ }
+ 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('重试')}
+ />
+
+ )}
+
+ {message.content && (
+
+ }
+ 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('复制')}
+ />
+
+ )}
+
+ {!isLoading && (
+
+ }
+ 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('删除')}
+ />
+
+ )}
+
+ );
+};
+
+export default MessageActions;
\ No newline at end of file
diff --git a/web/src/components/playground/MessageContent.js b/web/src/components/playground/MessageContent.js
new file mode 100644
index 00000000..714a6550
--- /dev/null
+++ b/web/src/components/playground/MessageContent.js
@@ -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 (
+
+
+ {message.content || t('请求发生错误')}
+
+
+ );
+ }
+
+ 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('')) {
+ const thinkTagRegex = /([\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 + ' & tags' : ' tags';
+ }
+
+ baseContentForDisplay = replyParts.join('');
+ }
+
+ if (isThinkingStatus) {
+ const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('');
+ if (lastOpenThinkIndex !== -1) {
+ const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
+ if (!fragmentAfterLastOpen.includes('')) {
+ const unclosedThought = fragmentAfterLastOpen.substring(''.length).trim();
+ if (unclosedThought) {
+ if (combinedThinkingContent) {
+ combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
+ } else {
+ combinedThinkingContent = unclosedThought;
+ }
+ thinkingSource = thinkingSource ? thinkingSource + ' + streaming ' : 'streaming ';
+ }
+ 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 (
+
+
+
+
+
+
+ {t('正在思考...')}
+
+
+ AI 正在分析您的问题
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 渲染推理内容 */}
+ {message.role === 'assistant' && finalExtractedThinkingContent && (
+
+
onToggleReasoningExpansion(message.id)}
+ >
+
+
+
+
+
+
+ {headerText}
+
+ {thinkingSource && (
+
+ 来源: {thinkingSource}
+
+ )}
+
+
+
+ {isThinkingStatus && (
+
+
+
+ 思考中
+
+
+ )}
+ {!isThinkingStatus && (
+
+ {message.isReasoningExpanded ?
+ :
+
+ }
+
+ )}
+
+
+
+ {message.isReasoningExpanded && (
+
+ )}
+
+
+ )}
+
+ {/* 渲染消息内容 */}
+ {(() => {
+ // 处理多模态内容(文本+图片)
+ 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 (
+
+ {/* 显示图片 */}
+ {imageContents.length > 0 && (
+
+ {imageContents.map((imgItem, index) => (
+
+

{
+ e.target.style.display = 'none';
+ e.target.nextSibling.style.display = 'block';
+ }}
+ />
+
+ 图片加载失败: {imgItem.image_url.url}
+
+
+ ))}
+
+ )}
+
+ {/* 显示文本内容 */}
+ {textContent && textContent.text && textContent.text.trim() !== '' && (
+
+
+
+ )}
+
+ );
+ }
+
+ // 处理纯文本内容或助手回复
+ if (typeof message.content === 'string') {
+ if (message.role === 'assistant') {
+ // 助手回复使用处理后的内容
+ if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
+ return (
+
+
+
+ );
+ }
+ } else {
+ // 用户文本消息
+ return (
+
+
+
+ );
+ }
+ }
+
+ return null;
+ })()}
+
+ );
+};
+
+export default MessageContent;
\ No newline at end of file
diff --git a/web/src/components/playground/ParameterControl.js b/web/src/components/playground/ParameterControl.js
new file mode 100644
index 00000000..cd7e939c
--- /dev/null
+++ b/web/src/components/playground/ParameterControl.js
@@ -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 */}
+
+
+
+
+
+ Temperature
+
+
+ {inputs.temperature}
+
+
+
:
}
+ onClick={() => onParameterToggle('temperature')}
+ className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+ />
+
+
+ 控制输出的随机性和创造性
+
+
onInputChange('temperature', value)}
+ className="mt-2"
+ disabled={!parameterEnabled.temperature}
+ />
+
+
+ {/* Top P */}
+
+
+
+
+
+ Top P
+
+
+ {inputs.top_p}
+
+
+
:
}
+ onClick={() => onParameterToggle('top_p')}
+ className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+ />
+
+
+ 核采样,控制词汇选择的多样性
+
+
onInputChange('top_p', value)}
+ className="mt-2"
+ disabled={!parameterEnabled.top_p}
+ />
+
+
+ {/* Frequency Penalty */}
+
+
+
+
+
+ Frequency Penalty
+
+
+ {inputs.frequency_penalty}
+
+
+
:
}
+ onClick={() => onParameterToggle('frequency_penalty')}
+ className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+ />
+
+
+ 频率惩罚,减少重复词汇的出现
+
+
onInputChange('frequency_penalty', value)}
+ className="mt-2"
+ disabled={!parameterEnabled.frequency_penalty}
+ />
+
+
+ {/* Presence Penalty */}
+
+
+
+
+
+ Presence Penalty
+
+
+ {inputs.presence_penalty}
+
+
+
:
}
+ onClick={() => onParameterToggle('presence_penalty')}
+ className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+ />
+
+
+ 存在惩罚,鼓励讨论新话题
+
+
onInputChange('presence_penalty', value)}
+ className="mt-2"
+ disabled={!parameterEnabled.presence_penalty}
+ />
+
+
+ {/* MaxTokens */}
+
+
+
+
+
+ Max Tokens
+
+
+
:
}
+ onClick={() => onParameterToggle('max_tokens')}
+ className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+ />
+
+
onInputChange('max_tokens', value)}
+ className="!rounded-lg"
+ disabled={!parameterEnabled.max_tokens}
+ />
+
+
+ {/* Seed */}
+
+
+
+
+
+ Seed
+
+
+ (可选,用于复现结果)
+
+
+
:
}
+ onClick={() => onParameterToggle('seed')}
+ className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+ />
+
+
onInputChange('seed', value === '' ? null : value)}
+ className="!rounded-lg"
+ disabled={!parameterEnabled.seed}
+ />
+
+ >
+ );
+};
+
+export default ParameterControl;
\ No newline at end of file
diff --git a/web/src/components/playground/SettingsPanel.js b/web/src/components/playground/SettingsPanel.js
new file mode 100644
index 00000000..150f9836
--- /dev/null
+++ b/web/src/components/playground/SettingsPanel.js
@@ -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 (
+
+ {styleState.isMobile && (
+
+ {/* 移动端显示配置管理下拉菜单和关闭按钮 */}
+
+ }
+ onClick={onCloseSettings}
+ theme="borderless"
+ type="tertiary"
+ size="small"
+ className="!rounded-lg !text-gray-600 hover:!text-red-600 hover:!bg-red-50"
+ />
+
+ )}
+
+
+ {/* 分组选择 */}
+
+
+
+
+ {t('分组')}
+
+
+
+
+ {/* 模型选择 */}
+
+
+
+
+ {t('模型')}
+
+
+
+
+ {/* 图片URL输入 */}
+
onInputChange('imageUrls', urls)}
+ />
+
+ {/* 参数控制组件 */}
+
+
+ {/* 流式输出开关 */}
+
+
+
+
+
+ 流式输出
+
+
+
onInputChange('stream', checked)}
+ checkedText="开"
+ uncheckedText="关"
+ size="small"
+ />
+
+
+
+ {/* System Prompt */}
+
+
+
+
+ System Prompt
+
+
+
+
+
+
+ {/* 桌面端的配置管理放在底部 */}
+ {!styleState.isMobile && (
+
+
+
+ )}
+
+ );
+};
+
+export default SettingsPanel;
\ No newline at end of file
diff --git a/web/src/components/playground/configStorage.js b/web/src/components/playground/configStorage.js
new file mode 100644
index 00000000..23ba5ccd
--- /dev/null
+++ b/web/src/components/playground/configStorage.js
@@ -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