feat: Add custom request body editor with persistent message storage

- Add CustomRequestEditor component with JSON validation and real-time formatting
- Implement bidirectional sync between chat messages and custom request body
- Add persistent local storage for chat messages (separate from config)
- Remove redundant System Prompt field in custom mode
- Refactor configuration storage to separate messages and settings

New Features:
• Custom request body mode with JSON editor and syntax highlighting
• Real-time bidirectional synchronization between chat UI and custom request body
• Persistent message storage that survives page refresh
• Enhanced configuration export/import including message data
• Improved parameter organization with collapsible sections

Technical Changes:
• Add loadMessages/saveMessages functions in configStorage
• Update usePlaygroundState hook to handle message persistence
• Refactor SettingsPanel to remove System Prompt in custom mode
• Add STORAGE_KEYS constants for better storage key management
• Implement debounced auto-save for both config and messages
• Add hash-based change detection to prevent unnecessary updates

UI/UX Improvements:
• Disabled state styling for parameters in custom mode
• Warning banners and visual feedback for mode switching
• Mobile-responsive design for custom request editor
• Consistent styling with existing design system
This commit is contained in:
Apple\Apple
2025-06-01 17:07:36 +08:00
parent ffdedde6ac
commit 5107f1b84a
17 changed files with 1199 additions and 380 deletions

View File

@@ -20,13 +20,21 @@ const ConfigManager = ({
onConfigImport,
onConfigReset,
styleState,
messages,
}) => {
const { t } = useTranslation();
const fileInputRef = useRef(null);
const handleExport = () => {
try {
exportConfig(currentConfig);
// 在导出前先保存当前配置,确保导出的是最新内容
const configWithTimestamp = {
...currentConfig,
timestamp: new Date().toISOString(),
};
localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp));
exportConfig(currentConfig, messages);
Toast.success({
content: t('配置已导出到下载文件夹'),
duration: 3,
@@ -84,11 +92,31 @@ const ConfigManager = ({
type: 'danger',
},
onOk: () => {
clearConfig();
onConfigReset();
Toast.success({
content: t('配置已重置为默认值'),
duration: 3,
// 询问是否同时重置消息
Modal.confirm({
title: t('重置选项'),
content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'),
okText: t('同时重置消息'),
cancelText: t('仅重置配置'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
clearConfig();
onConfigReset({ resetMessages: true });
Toast.success({
content: t('配置和消息已全部重置'),
duration: 3,
});
},
onCancel: () => {
clearConfig();
onConfigReset({ resetMessages: false });
Toast.success({
content: t('配置已重置,对话消息已保留'),
duration: 3,
});
},
});
},
});

View File

@@ -0,0 +1,197 @@
import React, { useState, useEffect } from 'react';
import {
Card,
TextArea,
Typography,
Button,
Switch,
Banner,
Tag,
} from '@douyinfe/semi-ui';
import {
Code,
Edit,
Check,
X,
AlertTriangle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const CustomRequestEditor = ({
customRequestMode,
customRequestBody,
onCustomRequestModeChange,
onCustomRequestBodyChange,
defaultPayload,
}) => {
const { t } = useTranslation();
const [isValid, setIsValid] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [localValue, setLocalValue] = useState(customRequestBody || '');
// 当切换到自定义模式时用默认payload初始化
useEffect(() => {
if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) {
const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : '';
setLocalValue(defaultJson);
onCustomRequestBodyChange(defaultJson);
}
}, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]);
// 同步外部传入的customRequestBody到本地状态
useEffect(() => {
if (customRequestBody !== localValue) {
setLocalValue(customRequestBody || '');
validateJson(customRequestBody || '');
}
}, [customRequestBody]);
// 验证JSON格式
const validateJson = (value) => {
if (!value.trim()) {
setIsValid(true);
setErrorMessage('');
return true;
}
try {
JSON.parse(value);
setIsValid(true);
setErrorMessage('');
return true;
} catch (error) {
setIsValid(false);
setErrorMessage(`JSON格式错误: ${error.message}`);
return false;
}
};
const handleValueChange = (value) => {
setLocalValue(value);
validateJson(value);
// 始终保存用户输入让预览逻辑处理JSON解析错误
onCustomRequestBodyChange(value);
};
const handleModeToggle = (enabled) => {
onCustomRequestModeChange(enabled);
if (enabled && defaultPayload) {
const defaultJson = JSON.stringify(defaultPayload, null, 2);
setLocalValue(defaultJson);
onCustomRequestBodyChange(defaultJson);
}
};
const formatJson = () => {
try {
const parsed = JSON.parse(localValue);
const formatted = JSON.stringify(parsed, null, 2);
setLocalValue(formatted);
onCustomRequestBodyChange(formatted);
setIsValid(true);
setErrorMessage('');
} catch (error) {
// 如果格式化失败,保持原样
}
};
return (
<div className="space-y-4">
{/* 自定义模式开关 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Code size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
自定义请求体模式
</Typography.Text>
{customRequestMode && (
<Tag color="green" size="small" shape='circle'>
已启用
</Tag>
)}
</div>
<Switch
checked={customRequestMode}
onChange={handleModeToggle}
checkedText="开"
uncheckedText="关"
size="small"
/>
</div>
{customRequestMode && (
<>
{/* 提示信息 */}
<Banner
type="warning"
description="启用此模式后将使用您自定义的请求体发送API请求模型配置面板的参数设置将被忽略。"
icon={<AlertTriangle size={16} />}
className="!rounded-lg"
closable={false}
/>
{/* JSON编辑器 */}
<div>
<div className="flex items-center justify-between mb-2">
<Typography.Text strong className="text-sm">
请求体 JSON
</Typography.Text>
<div className="flex items-center gap-2">
{isValid ? (
<div className="flex items-center gap-1 text-green-600">
<Check size={14} />
<Typography.Text className="text-xs">
格式正确
</Typography.Text>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<X size={14} />
<Typography.Text className="text-xs">
格式错误
</Typography.Text>
</div>
)}
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Edit size={14} />}
onClick={formatJson}
disabled={!isValid}
className="!rounded-lg"
>
格式化
</Button>
</div>
</div>
<TextArea
value={localValue}
onChange={handleValueChange}
placeholder='{"model": "gpt-4o", "messages": [...], ...}'
autosize={{ minRows: 8, maxRows: 20 }}
className={`!rounded-lg font-mono text-sm ${!isValid ? '!border-red-500' : ''}`}
style={{
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
lineHeight: '1.5',
}}
/>
{!isValid && errorMessage && (
<Typography.Text type="danger" className="text-xs mt-1 block">
{errorMessage}
</Typography.Text>
)}
<Typography.Text className="text-xs text-gray-500 mt-2 block">
请输入有效的JSON格式的请求体您可以参考预览面板中的默认请求体格式
</Typography.Text>
</div>
</>
)}
</div>
);
};
export default CustomRequestEditor;

View File

@@ -24,6 +24,7 @@ const DebugPanel = ({
onActiveDebugTabChange,
styleState,
onCloseDebugPanel,
customRequestMode,
}) => {
const { t } = useTranslation();
@@ -128,6 +129,11 @@ const DebugPanel = ({
<div className="flex items-center gap-2">
<Eye size={16} />
{t('预览请求体')}
{customRequestMode && (
<span className="px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full">
自定义
</span>
)}
</div>
} itemKey="preview">
<CodeViewer

View File

@@ -13,7 +13,7 @@ import {
Image,
} from 'lucide-react';
const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnabledChange }) => {
const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnabledChange, disabled = false }) => {
const handleAddImageUrl = () => {
const newUrls = [...imageUrls, ''];
onImageUrlsChange(newUrls);
@@ -31,13 +31,18 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
};
return (
<div>
<div className={disabled ? 'opacity-50' : ''}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Image size={16} className={imageEnabled ? "text-blue-500" : "text-gray-400"} />
<Image size={16} className={imageEnabled && !disabled ? "text-blue-500" : "text-gray-400"} />
<Typography.Text strong className="text-sm">
图片地址
</Typography.Text>
{disabled && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<div className="flex items-center gap-2">
<Switch
@@ -47,6 +52,7 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
uncheckedText="停用"
size="small"
className="flex-shrink-0"
disabled={disabled}
/>
<Button
icon={<Plus size={14} />}
@@ -55,26 +61,26 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
type="primary"
onClick={handleAddImageUrl}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={!imageEnabled || imageUrls.length >= 5}
disabled={!imageEnabled || imageUrls.length >= 5 || disabled}
/>
</div>
</div>
{!imageEnabled ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
图片发送已停用启用后可添加图片URL进行多模态对话
{disabled ? '图片功能在自定义请求体模式下不可用' : '启用后可添加图片URL进行多模态对话'}
</Typography.Text>
) : imageUrls.length === 0 ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
点击 + 按钮添加图片URL支持最多5张图片
{disabled ? '图片功能在自定义请求体模式下不可用' : '点击 + 按钮添加图片URL支持最多5张图片'}
</Typography.Text>
) : (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
已添加 {imageUrls.length}/5 张图片
已添加 {imageUrls.length}/5 张图片{disabled ? ' (自定义模式下不可用)' : ''}
</Typography.Text>
)}
<div className={`space-y-2 max-h-32 overflow-y-auto ${!imageEnabled ? 'opacity-50' : ''}`}>
<div className={`space-y-2 max-h-32 overflow-y-auto ${!imageEnabled || disabled ? 'opacity-50' : ''}`}>
{imageUrls.map((url, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
@@ -85,7 +91,7 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
className="!rounded-lg"
size="small"
prefix={<IconFile size='small' />}
disabled={!imageEnabled}
disabled={!imageEnabled || disabled}
/>
</div>
<Button
@@ -95,7 +101,7 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
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"
disabled={!imageEnabled}
disabled={!imageEnabled || disabled}
/>
</div>
))}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import MessageContent from './MessageContent';
import MessageActions from './MessageActions';
import SettingsPanel from './SettingsPanel';
import DebugPanel from './DebugPanel';
// 优化的消息内容组件
export const OptimizedMessageContent = React.memo(MessageContent, (prevProps, nextProps) => {
// 只有这些属性变化时才重新渲染
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.content === nextProps.message.content &&
prevProps.message.status === nextProps.message.status &&
prevProps.message.isReasoningExpanded === nextProps.message.isReasoningExpanded &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.editValue === nextProps.editValue &&
prevProps.styleState.isMobile === nextProps.styleState.isMobile
);
});
// 优化的消息操作组件
export const OptimizedMessageActions = React.memo(MessageActions, (prevProps, nextProps) => {
return (
prevProps.message.id === nextProps.message.id &&
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
prevProps.isEditing === nextProps.isEditing
);
});
// 优化的设置面板组件
export const OptimizedSettingsPanel = React.memo(SettingsPanel, (prevProps, nextProps) => {
return (
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
JSON.stringify(prevProps.parameterEnabled) === JSON.stringify(nextProps.parameterEnabled) &&
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.customRequestBody === nextProps.customRequestBody &&
prevProps.showDebugPanel === nextProps.showDebugPanel &&
prevProps.showSettings === nextProps.showSettings &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
);
});
// 优化的调试面板组件
export const OptimizedDebugPanel = React.memo(DebugPanel, (prevProps, nextProps) => {
return (
prevProps.show === nextProps.show &&
prevProps.activeTab === nextProps.activeTab &&
JSON.stringify(prevProps.debugData) === JSON.stringify(nextProps.debugData) &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.showDebugPanel === nextProps.showDebugPanel
);
});

View File

@@ -22,11 +22,12 @@ const ParameterControl = ({
parameterEnabled,
onInputChange,
onParameterToggle,
disabled = false,
}) => {
return (
<>
{/* Temperature */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.temperature ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.temperature || disabled ? '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" />
@@ -44,6 +45,7 @@ const ParameterControl = ({
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('temperature')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
@@ -56,12 +58,12 @@ const ParameterControl = ({
value={inputs.temperature}
onChange={(value) => onInputChange('temperature', value)}
className="mt-2"
disabled={!parameterEnabled.temperature}
disabled={!parameterEnabled.temperature || disabled}
/>
</div>
{/* Top P */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.top_p ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.top_p || disabled ? '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" />
@@ -79,6 +81,7 @@ const ParameterControl = ({
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"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
@@ -91,12 +94,12 @@ const ParameterControl = ({
value={inputs.top_p}
onChange={(value) => onInputChange('top_p', value)}
className="mt-2"
disabled={!parameterEnabled.top_p}
disabled={!parameterEnabled.top_p || disabled}
/>
</div>
{/* Frequency Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.frequency_penalty ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.frequency_penalty || disabled ? '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" />
@@ -114,6 +117,7 @@ const ParameterControl = ({
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"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
@@ -126,12 +130,12 @@ const ParameterControl = ({
value={inputs.frequency_penalty}
onChange={(value) => onInputChange('frequency_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.frequency_penalty}
disabled={!parameterEnabled.frequency_penalty || disabled}
/>
</div>
{/* Presence Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.presence_penalty ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.presence_penalty || disabled ? '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" />
@@ -149,6 +153,7 @@ const ParameterControl = ({
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"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
@@ -161,12 +166,12 @@ const ParameterControl = ({
value={inputs.presence_penalty}
onChange={(value) => onInputChange('presence_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.presence_penalty}
disabled={!parameterEnabled.presence_penalty || disabled}
/>
</div>
{/* MaxTokens */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.max_tokens ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.max_tokens || disabled ? '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" />
@@ -181,6 +186,7 @@ const ParameterControl = ({
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"
disabled={disabled}
/>
</div>
<Input
@@ -192,12 +198,12 @@ const ParameterControl = ({
value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)}
className="!rounded-lg"
disabled={!parameterEnabled.max_tokens}
disabled={!parameterEnabled.max_tokens || disabled}
/>
</div>
{/* Seed */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.seed ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.seed || disabled ? '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" />
@@ -215,6 +221,7 @@ const ParameterControl = ({
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('seed')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Input
@@ -224,7 +231,7 @@ const ParameterControl = ({
value={inputs.seed || ''}
onChange={(value) => onInputChange('seed', value === '' ? null : value)}
className="!rounded-lg"
disabled={!parameterEnabled.seed}
disabled={!parameterEnabled.seed || disabled}
/>
</div>
</>

View File

@@ -7,42 +7,49 @@ import {
Button,
Switch,
Divider,
Banner,
} from '@douyinfe/semi-ui';
import {
Sparkles,
Users,
Type,
ToggleLeft,
X,
AlertTriangle,
} 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';
import CustomRequestEditor from './CustomRequestEditor';
const SettingsPanel = ({
inputs,
parameterEnabled,
models,
groups,
systemPrompt,
styleState,
showDebugPanel,
customRequestMode,
customRequestBody,
onInputChange,
onParameterToggle,
onSystemPromptChange,
onCloseSettings,
onConfigImport,
onConfigReset,
onCustomRequestModeChange,
onCustomRequestBodyChange,
previewPayload,
messages,
}) => {
const { t } = useTranslation();
const currentConfig = {
inputs,
parameterEnabled,
systemPrompt,
showDebugPanel,
customRequestMode,
customRequestBody,
};
return (
@@ -63,6 +70,7 @@ const SettingsPanel = ({
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={styleState}
messages={messages}
/>
<Button
icon={<X size={16} />}
@@ -76,13 +84,27 @@ const SettingsPanel = ({
)}
<div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
{/* 自定义请求体编辑器 */}
<CustomRequestEditor
customRequestMode={customRequestMode}
customRequestBody={customRequestBody}
onCustomRequestModeChange={onCustomRequestModeChange}
onCustomRequestBodyChange={onCustomRequestBodyChange}
defaultPayload={previewPayload}
/>
{/* 分组选择 */}
<div>
<div className={customRequestMode ? 'opacity-50' : ''}>
<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>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择分组')}
@@ -96,16 +118,22 @@ const SettingsPanel = ({
renderOptionItem={renderGroupOption}
style={{ width: '100%' }}
className="!rounded-lg"
disabled={customRequestMode}
/>
</div>
{/* 模型选择 */}
<div>
<div className={customRequestMode ? 'opacity-50' : ''}>
<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>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择模型')}
@@ -119,33 +147,45 @@ const SettingsPanel = ({
autoComplete='new-password'
optionList={models}
className="!rounded-lg"
disabled={customRequestMode}
/>
</div>
{/* 图片URL输入 */}
<ImageUrlInput
imageUrls={inputs.imageUrls}
imageEnabled={inputs.imageEnabled}
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
onImageEnabledChange={(enabled) => onInputChange('imageEnabled', enabled)}
/>
<div className={customRequestMode ? 'opacity-50' : ''}>
<ImageUrlInput
imageUrls={inputs.imageUrls}
imageEnabled={inputs.imageEnabled}
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
onImageEnabledChange={(enabled) => onInputChange('imageEnabled', enabled)}
disabled={customRequestMode}
/>
</div>
{/* 参数控制组件 */}
<ParameterControl
inputs={inputs}
parameterEnabled={parameterEnabled}
onInputChange={onInputChange}
onParameterToggle={onParameterToggle}
/>
<div className={customRequestMode ? 'opacity-50' : ''}>
<ParameterControl
inputs={inputs}
parameterEnabled={parameterEnabled}
onInputChange={onInputChange}
onParameterToggle={onParameterToggle}
disabled={customRequestMode}
/>
</div>
{/* 流式输出开关 */}
<div>
<div className={customRequestMode ? 'opacity-50' : ''}>
<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>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Switch
checked={inputs.stream}
@@ -153,30 +193,10 @@ const SettingsPanel = ({
checkedText="开"
uncheckedText="关"
size="small"
disabled={customRequestMode}
/>
</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>
{/* 桌面端的配置管理放在底部 */}
@@ -187,6 +207,7 @@ const SettingsPanel = ({
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={styleState}
messages={messages}
/>
</div>
)}

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { Typography } from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/MarkdownRenderer';
import { ChevronRight, ChevronUp, Brain, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const ThinkingContent = ({
message,
finalExtractedThinkingContent,
thinkingSource,
styleState,
onToggleReasoningExpansion
}) => {
const { t } = useTranslation();
if (!finalExtractedThinkingContent) return null;
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
const headerText = (isThinkingStatus && !message.isThinkingComplete) ? t('思考中...') : t('思考过程');
return (
<div className="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/20 hover:to-purple-50/30 transition-all"
style={{
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative'
}}
onClick={() => onToggleReasoningExpansion(message.id)}
>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
</div>
<div className="flex items-center gap-2 sm:gap-4 relative">
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg">
<Brain style={{ color: 'white' }} size={styleState.isMobile ? 12 : 16} />
</div>
<div className="flex flex-col">
<Typography.Text strong style={{ color: 'white' }} className="text-sm sm:text-base">
{headerText}
</Typography.Text>
{thinkingSource && (
<Typography.Text style={{ color: 'white' }} className="text-xs mt-0.5 opacity-80 hidden sm:block">
来源: {thinkingSource}
</Typography.Text>
)}
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3 relative">
{isThinkingStatus && !message.isThinkingComplete && (
<div className="flex items-center gap-1 sm:gap-2">
<Loader2 style={{ color: 'white' }} className="animate-spin" size={styleState.isMobile ? 14 : 18} />
<Typography.Text style={{ color: 'white' }} className="text-xs sm:text-sm font-medium opacity-90">
思考中
</Typography.Text>
</div>
)}
{(!isThinkingStatus || message.isThinkingComplete) && (
<div className="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center">
{message.isReasoningExpanded ?
<ChevronUp size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} /> :
<ChevronRight size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} />
}
</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 bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
>
{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 overflow-y-auto thinking-content-scroll"
style={{
maxHeight: '200px',
minHeight: '100px',
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent'
}}
>
<div className="prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm">
<MarkdownRenderer
content={finalExtractedThinkingContent}
className=""
/>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default ThinkingContent;

View File

@@ -1,30 +1,6 @@
const STORAGE_KEY = 'playground_config';
import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../utils/constants';
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: [],
imageEnabled: false,
},
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,
};
const MESSAGES_STORAGE_KEY = 'playground_messages';
/**
* 保存配置到 localStorage
@@ -36,20 +12,37 @@ export const saveConfig = (config) => {
...config,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(configToSave));
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configToSave));
console.log('配置已保存到本地存储');
} catch (error) {
console.error('保存配置失败:', error);
}
};
/**
* 保存消息到 localStorage
* @param {Array} messages - 要保存的消息数组
*/
export const saveMessages = (messages) => {
try {
const messagesToSave = {
messages,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messagesToSave));
console.log('消息已保存到本地存储');
} catch (error) {
console.error('保存消息失败:', error);
}
};
/**
* 从 localStorage 加载配置
* @returns {Object} 配置对象,如果不存在则返回默认配置
*/
export const loadConfig = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEY);
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
@@ -62,8 +55,9 @@ export const loadConfig = () => {
...DEFAULT_CONFIG.parameterEnabled,
...parsedConfig.parameterEnabled,
},
systemPrompt: parsedConfig.systemPrompt || DEFAULT_CONFIG.systemPrompt,
showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
customRequestMode: parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
};
console.log('配置已从本地存储加载');
@@ -77,25 +71,58 @@ export const loadConfig = () => {
return DEFAULT_CONFIG;
};
/**
* 从 localStorage 加载消息
* @returns {Array} 消息数组,如果不存在则返回 null
*/
export const loadMessages = () => {
try {
const savedMessages = localStorage.getItem(STORAGE_KEYS.MESSAGES);
if (savedMessages) {
const parsedMessages = JSON.parse(savedMessages);
console.log('消息已从本地存储加载');
return parsedMessages.messages || null;
}
} catch (error) {
console.error('加载消息失败:', error);
}
console.log('没有找到保存的消息');
return null;
};
/**
* 清除保存的配置
*/
export const clearConfig = () => {
try {
localStorage.removeItem(STORAGE_KEY);
console.log('配置已清除');
localStorage.removeItem(STORAGE_KEYS.CONFIG);
localStorage.removeItem(STORAGE_KEYS.MESSAGES); // 同时清除消息
console.log('配置和消息已清除');
} catch (error) {
console.error('清除配置失败:', error);
}
};
/**
* 清除保存的消息
*/
export const clearMessages = () => {
try {
localStorage.removeItem(STORAGE_KEYS.MESSAGES);
console.log('消息已清除');
} catch (error) {
console.error('清除消息失败:', error);
}
};
/**
* 检查是否有保存的配置
* @returns {boolean} 是否存在保存的配置
*/
export const hasStoredConfig = () => {
try {
return localStorage.getItem(STORAGE_KEY) !== null;
return localStorage.getItem(STORAGE_KEYS.CONFIG) !== null;
} catch (error) {
console.error('检查配置失败:', error);
return false;
@@ -108,7 +135,7 @@ export const hasStoredConfig = () => {
*/
export const getConfigTimestamp = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEY);
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
return parsedConfig.timestamp || null;
@@ -120,13 +147,15 @@ export const getConfigTimestamp = () => {
};
/**
* 导出配置为 JSON 文件
* 导出配置为 JSON 文件(包含消息)
* @param {Object} config - 要导出的配置
* @param {Array} messages - 要导出的消息
*/
export const exportConfig = (config) => {
export const exportConfig = (config, messages = null) => {
try {
const configToExport = {
...config,
messages: messages || loadMessages(), // 包含消息数据
exportTime: new Date().toISOString(),
version: '1.0',
};
@@ -148,7 +177,7 @@ export const exportConfig = (config) => {
};
/**
* 从文件导入配置
* 从文件导入配置(包含消息)
* @param {File} file - 包含配置的 JSON 文件
* @returns {Promise<Object>} 导入的配置对象
*/
@@ -161,6 +190,11 @@ export const importConfig = (file) => {
const importedConfig = JSON.parse(e.target.result);
if (importedConfig.inputs && importedConfig.parameterEnabled) {
// 如果导入的配置包含消息,也一起导入
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
saveMessages(importedConfig.messages);
}
console.log('配置已从文件导入');
resolve(importedConfig);
} else {