Przeglądaj źródła

✨ 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
Apple\Apple 9 miesięcy temu
rodzic
commit
5107f1b84a

+ 34 - 6
web/src/components/playground/ConfigManager.js

@@ -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,
+            });
+          },
         });
       },
     });

+ 197 - 0
web/src/components/playground/CustomRequestEditor.js

@@ -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; 

+ 6 - 0
web/src/components/playground/DebugPanel.js

@@ -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

+ 16 - 10
web/src/components/playground/ImageUrlInput.js

@@ -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>
         ))}

+ 56 - 0
web/src/components/playground/OptimizedComponents.js

@@ -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
+  );
+}); 

+ 19 - 12
web/src/components/playground/ParameterControl.js

@@ -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>
     </>

+ 61 - 40
web/src/components/playground/SettingsPanel.js

@@ -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>
       )}

+ 98 - 0
web/src/components/playground/ThinkingContent.js

@@ -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; 

+ 71 - 37
web/src/components/playground/configStorage.js

@@ -1,30 +1,6 @@
-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: [],
-    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,
-};
+import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../utils/constants';
+
+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 {

+ 70 - 0
web/src/hooks/useDataLoader.js

@@ -0,0 +1,70 @@
+import { useCallback, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { API, showError } from '../helpers/index.js';
+import { API_ENDPOINTS } from '../utils/constants';
+import { processModelsData, processGroupsData } from '../utils/apiUtils';
+
+export const useDataLoader = (
+  userState,
+  inputs,
+  handleInputChange,
+  setModels,
+  setGroups
+) => {
+  const { t } = useTranslation();
+
+  const loadModels = useCallback(async () => {
+    try {
+      const res = await API.get(API_ENDPOINTS.USER_MODELS);
+      const { success, message, data } = res.data;
+
+      if (success) {
+        const { modelOptions, selectedModel } = processModelsData(data, inputs.model);
+        setModels(modelOptions);
+
+        if (selectedModel !== inputs.model) {
+          handleInputChange('model', selectedModel);
+        }
+      } else {
+        showError(t(message));
+      }
+    } catch (error) {
+      showError(t('加载模型失败'));
+    }
+  }, [inputs.model, handleInputChange, setModels, t]);
+
+  const loadGroups = useCallback(async () => {
+    try {
+      const res = await API.get(API_ENDPOINTS.USER_GROUPS);
+      const { success, message, data } = res.data;
+
+      if (success) {
+        const userGroup = userState?.user?.group || JSON.parse(localStorage.getItem('user'))?.group;
+        const groupOptions = processGroupsData(data, userGroup);
+        setGroups(groupOptions);
+
+        const hasCurrentGroup = groupOptions.some(option => option.value === inputs.group);
+        if (!hasCurrentGroup) {
+          handleInputChange('group', groupOptions[0]?.value || '');
+        }
+      } else {
+        showError(t(message));
+      }
+    } catch (error) {
+      showError(t('加载分组失败'));
+    }
+  }, [userState, inputs.group, handleInputChange, setGroups, t]);
+
+  // 自动加载数据
+  useEffect(() => {
+    if (userState?.user) {
+      loadModels();
+      loadGroups();
+    }
+  }, [userState?.user, loadModels, loadGroups]);
+
+  return {
+    loadModels,
+    loadGroups
+  };
+}; 

+ 93 - 0
web/src/hooks/useMessageEdit.js

@@ -0,0 +1,93 @@
+import { useCallback, useState } from 'react';
+import { Toast, Modal } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../utils/messageUtils';
+import { MESSAGE_ROLES } from '../utils/constants';
+
+export const useMessageEdit = (
+  setMessage,
+  inputs,
+  parameterEnabled,
+  sendRequest
+) => {
+  const { t } = useTranslation();
+  const [editingMessageId, setEditingMessageId] = useState(null);
+  const [editValue, setEditValue] = useState('');
+
+  const handleMessageEdit = useCallback((targetMessage) => {
+    const editableContent = getTextContent(targetMessage);
+    setEditingMessageId(targetMessage.id);
+    setEditValue(editableContent);
+  }, []);
+
+  const handleEditSave = useCallback(() => {
+    if (!editingMessageId || !editValue.trim()) return;
+
+    setMessage(prevMessages => {
+      const messageIndex = prevMessages.findIndex(msg => msg.id === editingMessageId);
+      if (messageIndex === -1) return prevMessages;
+
+      const targetMessage = prevMessages[messageIndex];
+      let newContent;
+
+      if (Array.isArray(targetMessage.content)) {
+        newContent = targetMessage.content.map(item =>
+          item.type === 'text' ? { ...item, text: editValue.trim() } : item
+        );
+      } else {
+        newContent = editValue.trim();
+      }
+
+      const updatedMessages = prevMessages.map(msg =>
+        msg.id === editingMessageId ? { ...msg, content: newContent } : msg
+      );
+
+      // 处理用户消息编辑后的重新生成
+      if (targetMessage.role === MESSAGE_ROLES.USER) {
+        const hasSubsequentAssistantReply = messageIndex < prevMessages.length - 1 &&
+          prevMessages[messageIndex + 1].role === MESSAGE_ROLES.ASSISTANT;
+
+        if (hasSubsequentAssistantReply) {
+          Modal.confirm({
+            title: t('消息已编辑'),
+            content: t('检测到该消息后有AI回复,是否删除后续回复并重新生成?'),
+            okText: t('重新生成'),
+            cancelText: t('仅保存'),
+            onOk: () => {
+              const messagesUntilUser = updatedMessages.slice(0, messageIndex + 1);
+              setMessage(messagesUntilUser);
+
+              setTimeout(() => {
+                const payload = buildApiPayload(messagesUntilUser, null, inputs, parameterEnabled);
+                setMessage(prevMsg => [...prevMsg, createLoadingAssistantMessage()]);
+                sendRequest(payload, inputs.stream);
+              }, 100);
+            },
+            onCancel: () => setMessage(updatedMessages)
+          });
+          return prevMessages;
+        }
+      }
+
+      return updatedMessages;
+    });
+
+    setEditingMessageId(null);
+    setEditValue('');
+    Toast.success({ content: t('消息已更新'), duration: 2 });
+  }, [editingMessageId, editValue, t, inputs, parameterEnabled, sendRequest, setMessage]);
+
+  const handleEditCancel = useCallback(() => {
+    setEditingMessageId(null);
+    setEditValue('');
+  }, []);
+
+  return {
+    editingMessageId,
+    editValue,
+    setEditValue,
+    handleMessageEdit,
+    handleEditSave,
+    handleEditCancel
+  };
+}; 

+ 69 - 15
web/src/hooks/usePlaygroundState.js

@@ -1,6 +1,6 @@
 import { useState, useCallback, useRef, useEffect } from 'react';
 import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS } from '../utils/constants';
-import { loadConfig, saveConfig } from '../components/playground/configStorage';
+import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage';
 
 export const usePlaygroundState = () => {
   // 使用 ref 缓存初始配置,只加载一次
@@ -10,17 +10,23 @@ export const usePlaygroundState = () => {
   }
   const savedConfig = initialConfigRef.current;
 
+  // 加载保存的消息,如果没有则使用默认消息
+  const initialMessages = loadMessages() || DEFAULT_MESSAGES;
+
   // 基础配置状态
   const [inputs, setInputs] = useState(savedConfig.inputs || DEFAULT_CONFIG.inputs);
   const [parameterEnabled, setParameterEnabled] = useState(
     savedConfig.parameterEnabled || DEFAULT_CONFIG.parameterEnabled
   );
-  const [systemPrompt, setSystemPrompt] = useState(
-    savedConfig.systemPrompt || DEFAULT_CONFIG.systemPrompt
-  );
   const [showDebugPanel, setShowDebugPanel] = useState(
     savedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel
   );
+  const [customRequestMode, setCustomRequestMode] = useState(
+    savedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode
+  );
+  const [customRequestBody, setCustomRequestBody] = useState(
+    savedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody
+  );
 
   // UI状态
   const [showSettings, setShowSettings] = useState(false);
@@ -28,8 +34,8 @@ export const usePlaygroundState = () => {
   const [groups, setGroups] = useState([]);
   const [status, setStatus] = useState({});
 
-  // 消息相关状态
-  const [message, setMessage] = useState(DEFAULT_MESSAGES);
+  // 消息相关状态 - 使用加载的消息初始化
+  const [message, setMessage] = useState(initialMessages);
 
   // 调试状态
   const [debugData, setDebugData] = useState({
@@ -50,6 +56,7 @@ export const usePlaygroundState = () => {
   const sseSourceRef = useRef(null);
   const chatRef = useRef(null);
   const saveConfigTimeoutRef = useRef(null);
+  const saveMessagesTimeoutRef = useRef(null);
 
   // 配置更新函数
   const handleInputChange = useCallback((name, value) => {
@@ -63,6 +70,17 @@ export const usePlaygroundState = () => {
     }));
   }, []);
 
+  // 消息保存函数
+  const debouncedSaveMessages = useCallback(() => {
+    if (saveMessagesTimeoutRef.current) {
+      clearTimeout(saveMessagesTimeoutRef.current);
+    }
+
+    saveMessagesTimeoutRef.current = setTimeout(() => {
+      saveMessages(message);
+    }, 1000);
+  }, [message]);
+
   // 配置保存
   const debouncedSaveConfig = useCallback(() => {
     if (saveConfigTimeoutRef.current) {
@@ -73,12 +91,13 @@ export const usePlaygroundState = () => {
       const configToSave = {
         inputs,
         parameterEnabled,
-        systemPrompt,
         showDebugPanel,
+        customRequestMode,
+        customRequestBody,
       };
       saveConfig(configToSave);
     }, 1000);
-  }, [inputs, parameterEnabled, systemPrompt, showDebugPanel]);
+  }, [inputs, parameterEnabled, showDebugPanel, customRequestMode, customRequestBody]);
 
   // 配置导入/重置
   const handleConfigImport = useCallback((importedConfig) => {
@@ -88,27 +107,60 @@ export const usePlaygroundState = () => {
     if (importedConfig.parameterEnabled) {
       setParameterEnabled(prev => ({ ...prev, ...importedConfig.parameterEnabled }));
     }
-    if (importedConfig.systemPrompt) {
-      setSystemPrompt(importedConfig.systemPrompt);
-    }
     if (typeof importedConfig.showDebugPanel === 'boolean') {
       setShowDebugPanel(importedConfig.showDebugPanel);
     }
+    if (importedConfig.customRequestMode) {
+      setCustomRequestMode(importedConfig.customRequestMode);
+    }
+    if (importedConfig.customRequestBody) {
+      setCustomRequestBody(importedConfig.customRequestBody);
+    }
+    // 如果导入的配置包含消息,也恢复消息
+    if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
+      setMessage(importedConfig.messages);
+    }
   }, []);
 
-  const handleConfigReset = useCallback(() => {
+  const handleConfigReset = useCallback((options = {}) => {
+    const { resetMessages = false } = options;
+
     setInputs(DEFAULT_CONFIG.inputs);
     setParameterEnabled(DEFAULT_CONFIG.parameterEnabled);
-    setSystemPrompt(DEFAULT_CONFIG.systemPrompt);
     setShowDebugPanel(DEFAULT_CONFIG.showDebugPanel);
+    setCustomRequestMode(DEFAULT_CONFIG.customRequestMode);
+    setCustomRequestBody(DEFAULT_CONFIG.customRequestBody);
+
+    // 只有在明确指定时才重置消息
+    if (resetMessages) {
+      setMessage(DEFAULT_MESSAGES);
+    }
+  }, []);
+
+  // 监听消息变化并自动保存
+  useEffect(() => {
+    debouncedSaveMessages();
+  }, [debouncedSaveMessages]);
+
+  // 清理定时器
+  useEffect(() => {
+    return () => {
+      if (saveConfigTimeoutRef.current) {
+        clearTimeout(saveConfigTimeoutRef.current);
+      }
+      if (saveMessagesTimeoutRef.current) {
+        clearTimeout(saveMessagesTimeoutRef.current);
+      }
+    };
   }, []);
 
   return {
     // 配置状态
     inputs,
     parameterEnabled,
-    systemPrompt,
     showDebugPanel,
+    customRequestMode,
+    customRequestBody,
 
     // UI状态
     showSettings,
@@ -136,8 +188,9 @@ export const usePlaygroundState = () => {
     // 更新函数
     setInputs,
     setParameterEnabled,
-    setSystemPrompt,
     setShowDebugPanel,
+    setCustomRequestMode,
+    setCustomRequestBody,
     setShowSettings,
     setModels,
     setGroups,
@@ -153,6 +206,7 @@ export const usePlaygroundState = () => {
     handleInputChange,
     handleParameterToggle,
     debouncedSaveConfig,
+    debouncedSaveMessages,
     handleConfigImport,
     handleConfigReset,
   };

+ 111 - 0
web/src/hooks/useSyncMessageAndCustomBody.js

@@ -0,0 +1,111 @@
+import { useCallback, useRef } from 'react';
+import { MESSAGE_ROLES } from '../utils/constants';
+
+export const useSyncMessageAndCustomBody = (
+  customRequestMode,
+  customRequestBody,
+  message,
+  inputs,
+  setCustomRequestBody,
+  setMessage,
+  debouncedSaveConfig
+) => {
+  const isUpdatingFromMessage = useRef(false);
+  const isUpdatingFromCustomBody = useRef(false);
+  const lastMessageHash = useRef('');
+  const lastCustomBodyHash = useRef('');
+
+  const getMessageHash = useCallback((messages) => {
+    return JSON.stringify(messages.map(msg => ({
+      id: msg.id,
+      role: msg.role,
+      content: msg.content
+    })));
+  }, []);
+
+  const getCustomBodyHash = useCallback((customBody) => {
+    try {
+      const parsed = JSON.parse(customBody);
+      return JSON.stringify(parsed.messages || []);
+    } catch {
+      return '';
+    }
+  }, []);
+
+  const syncMessageToCustomBody = useCallback(() => {
+    if (!customRequestMode || isUpdatingFromCustomBody.current) return;
+
+    const currentMessageHash = getMessageHash(message);
+    if (currentMessageHash === lastMessageHash.current) return;
+
+    try {
+      isUpdatingFromMessage.current = true;
+      let customPayload;
+
+      try {
+        customPayload = JSON.parse(customRequestBody || '{}');
+      } catch {
+        customPayload = {
+          model: inputs.model || 'gpt-4o',
+          messages: [],
+          temperature: inputs.temperature || 0.7,
+          stream: inputs.stream !== false
+        };
+      }
+
+      customPayload.messages = message.map(msg => ({
+        role: msg.role,
+        content: msg.content
+      }));
+
+      const newCustomBody = JSON.stringify(customPayload, null, 2);
+      setCustomRequestBody(newCustomBody);
+      lastMessageHash.current = currentMessageHash;
+      lastCustomBodyHash.current = getCustomBodyHash(newCustomBody);
+
+      setTimeout(() => {
+        debouncedSaveConfig();
+      }, 0);
+    } finally {
+      isUpdatingFromMessage.current = false;
+    }
+  }, [customRequestMode, customRequestBody, message, inputs.model, inputs.temperature, inputs.stream, getMessageHash, getCustomBodyHash, setCustomRequestBody, debouncedSaveConfig]);
+
+  const syncCustomBodyToMessage = useCallback(() => {
+    if (!customRequestMode || isUpdatingFromMessage.current) return;
+
+    const currentCustomBodyHash = getCustomBodyHash(customRequestBody);
+    if (currentCustomBodyHash === lastCustomBodyHash.current) return;
+
+    try {
+      isUpdatingFromCustomBody.current = true;
+      const customPayload = JSON.parse(customRequestBody || '{}');
+
+      if (customPayload.messages && Array.isArray(customPayload.messages)) {
+        const newMessages = customPayload.messages.map((msg, index) => ({
+          id: msg.id || (index + 1).toString(),
+          role: msg.role || MESSAGE_ROLES.USER,
+          content: msg.content || '',
+          createAt: Date.now(),
+          ...(msg.role === MESSAGE_ROLES.ASSISTANT && {
+            reasoningContent: msg.reasoningContent || '',
+            isReasoningExpanded: false
+          })
+        }));
+
+        setMessage(newMessages);
+        lastCustomBodyHash.current = currentCustomBodyHash;
+        lastMessageHash.current = getMessageHash(newMessages);
+      }
+    } catch (error) {
+      console.warn('同步自定义请求体到消息失败:', error);
+    } finally {
+      isUpdatingFromCustomBody.current = false;
+    }
+  }, [customRequestMode, customRequestBody, getCustomBodyHash, getMessageHash, setMessage]);
+
+  return {
+    syncMessageToCustomBody,
+    syncCustomBodyToMessage
+  };
+}; 

+ 132 - 192
web/src/pages/Playground/index.js

@@ -1,4 +1,4 @@
-import React, { useContext, useEffect, useCallback } from 'react';
+import React, { useContext, useEffect, useCallback, useRef } from 'react';
 import { useSearchParams } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
@@ -8,36 +8,37 @@ import { UserContext } from '../../context/User/index.js';
 import { StyleContext } from '../../context/Style/index.js';
 
 // Utils and hooks
-import { API, showError, getLogo, isMobile } from '../../helpers/index.js';
+import { getLogo } from '../../helpers/index.js';
 import { stringToColor } from '../../helpers/render.js';
 import { usePlaygroundState } from '../../hooks/usePlaygroundState.js';
 import { useMessageActions } from '../../hooks/useMessageActions.js';
 import { useApiRequest } from '../../hooks/useApiRequest.js';
+import { useSyncMessageAndCustomBody } from '../../hooks/useSyncMessageAndCustomBody.js';
+import { useMessageEdit } from '../../hooks/useMessageEdit.js';
+import { useDataLoader } from '../../hooks/useDataLoader.js';
 
 // Constants and utils
 import {
   DEFAULT_MESSAGES,
   MESSAGE_ROLES,
-  API_ENDPOINTS
+  ERROR_MESSAGES
 } from '../../utils/constants.js';
 import {
   buildMessageContent,
   createMessage,
   createLoadingAssistantMessage,
-  getTextContent
+  getTextContent,
+  buildApiPayload
 } from '../../utils/messageUtils.js';
-import {
-  buildApiPayload,
-  processModelsData,
-  processGroupsData
-} from '../../utils/apiUtils.js';
 
 // Components
-import SettingsPanel from '../../components/playground/SettingsPanel.js';
+import {
+  OptimizedSettingsPanel,
+  OptimizedDebugPanel,
+  OptimizedMessageContent,
+  OptimizedMessageActions
+} from '../../components/playground/OptimizedComponents.js';
 import ChatArea from '../../components/playground/ChatArea.js';
-import DebugPanel from '../../components/playground/DebugPanel.js';
-import MessageContent from '../../components/playground/MessageContent.js';
-import MessageActions from '../../components/playground/MessageActions.js';
 import FloatingButtons from '../../components/playground/FloatingButtons.js';
 
 // 生成头像
@@ -67,8 +68,9 @@ const Playground = () => {
   const {
     inputs,
     parameterEnabled,
-    systemPrompt,
     showDebugPanel,
+    customRequestMode,
+    customRequestBody,
     showSettings,
     models,
     groups,
@@ -77,8 +79,6 @@ const Playground = () => {
     debugData,
     activeDebugTab,
     previewPayload,
-    editingMessageId,
-    editValue,
     sseSourceRef,
     chatRef,
     handleInputChange,
@@ -94,10 +94,9 @@ const Playground = () => {
     setDebugData,
     setActiveDebugTab,
     setPreviewPayload,
-    setEditingMessageId,
-    setEditValue,
-    setSystemPrompt,
     setShowDebugPanel,
+    setCustomRequestMode,
+    setCustomRequestBody,
   } = state;
 
   // API 请求相关
@@ -108,6 +107,30 @@ const Playground = () => {
     sseSourceRef
   );
 
+  // 数据加载
+  useDataLoader(userState, inputs, handleInputChange, setModels, setGroups);
+
+  // 消息编辑
+  const {
+    editingMessageId,
+    editValue,
+    setEditValue,
+    handleMessageEdit,
+    handleEditSave,
+    handleEditCancel
+  } = useMessageEdit(setMessage, inputs, parameterEnabled, sendRequest);
+
+  // 消息和自定义请求体同步
+  const { syncMessageToCustomBody, syncCustomBodyToMessage } = useSyncMessageAndCustomBody(
+    customRequestMode,
+    customRequestBody,
+    message,
+    inputs,
+    setCustomRequestBody,
+    setMessage,
+    debouncedSaveConfig
+  );
+
   // 角色信息
   const roleInfo = {
     user: {
@@ -130,12 +153,16 @@ const Playground = () => {
   // 构建预览请求体
   const constructPreviewPayload = useCallback(() => {
     try {
-      const systemMessage = systemPrompt !== '' ? createMessage(
-        MESSAGE_ROLES.SYSTEM,
-        systemPrompt,
-        { id: '1', createAt: 1715676751919 }
-      ) : null;
+      // 如果是自定义请求体模式且有自定义内容,直接返回解析后的自定义请求体
+      if (customRequestMode && customRequestBody && customRequestBody.trim()) {
+        try {
+          return JSON.parse(customRequestBody);
+        } catch (parseError) {
+          console.warn('自定义请求体JSON解析失败,回退到默认预览:', parseError);
+        }
+      }
 
+      // 默认预览逻辑
       let messages = [...message];
 
       // 如果没有用户消息,添加默认消息
@@ -145,7 +172,6 @@ const Playground = () => {
         messages = [createMessage(MESSAGE_ROLES.USER, content)];
       } else {
         // 处理最后一个用户消息的图片
-        const lastUserMessageIndex = messages.length - 1;
         for (let i = messages.length - 1; i >= 0; i--) {
           if (messages[i].role === MESSAGE_ROLES.USER) {
             if (inputs.imageEnabled && inputs.imageUrls) {
@@ -161,33 +187,51 @@ const Playground = () => {
         }
       }
 
-      return buildApiPayload(messages, systemMessage, inputs, parameterEnabled);
+      return buildApiPayload(messages, null, inputs, parameterEnabled);
     } catch (error) {
       console.error('构造预览请求体失败:', error);
       return null;
     }
-  }, [inputs, parameterEnabled, systemPrompt, message]);
+  }, [inputs, parameterEnabled, message, customRequestMode, customRequestBody]);
 
   // 发送消息
   function onMessageSend(content, attachment) {
     console.log('attachment: ', attachment);
 
+    // 创建用户消息和加载消息
+    const userMessage = createMessage(MESSAGE_ROLES.USER, content);
+    const loadingMessage = createLoadingAssistantMessage();
+
+    // 如果是自定义请求体模式
+    if (customRequestMode && customRequestBody) {
+      try {
+        const customPayload = JSON.parse(customRequestBody);
+
+        setMessage(prevMessage => {
+          const newMessages = [...prevMessage, userMessage, loadingMessage];
+
+          // 发送自定义请求体
+          sendRequest(customPayload, customPayload.stream !== false);
+
+          return newMessages;
+        });
+        return;
+      } catch (error) {
+        console.error('自定义请求体JSON解析失败:', error);
+        Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR);
+        return;
+      }
+    }
+
+    // 默认模式
     const validImageUrls = inputs.imageUrls.filter(url => url.trim() !== '');
     const messageContent = buildMessageContent(content, validImageUrls, inputs.imageEnabled);
-
-    const userMessage = createMessage(MESSAGE_ROLES.USER, messageContent);
-    const loadingMessage = createLoadingAssistantMessage();
+    const userMessageWithImages = createMessage(MESSAGE_ROLES.USER, messageContent);
 
     setMessage(prevMessage => {
-      const newMessages = [...prevMessage, userMessage];
-
-      const systemMessage = systemPrompt !== '' ? createMessage(
-        MESSAGE_ROLES.SYSTEM,
-        systemPrompt,
-        { id: '1', createAt: 1715676751919 }
-      ) : null;
+      const newMessages = [...prevMessage, userMessageWithImages];
 
-      const payload = buildApiPayload(newMessages, systemMessage, inputs, parameterEnabled);
+      const payload = buildApiPayload(newMessages, null, inputs, parameterEnabled);
       sendRequest(payload, inputs.stream);
 
       // 禁用图片模式
@@ -201,127 +245,8 @@ const Playground = () => {
     });
   }
 
-  // 加载模型和分组
-  const loadModels = async () => {
-    try {
-      const res = await API.get(API_ENDPOINTS.USER_MODELS);
-      const { success, message, data } = res.data;
-
-      if (success) {
-        const { modelOptions, selectedModel } = processModelsData(data, inputs.model);
-        setModels(modelOptions);
-
-        if (selectedModel !== inputs.model) {
-          handleInputChange('model', selectedModel);
-        }
-      } else {
-        showError(t(message));
-      }
-    } catch (error) {
-      showError(t('加载模型失败'));
-    }
-  };
-
-  const loadGroups = async () => {
-    try {
-      const res = await API.get(API_ENDPOINTS.USER_GROUPS);
-      const { success, message, data } = res.data;
-
-      if (success) {
-        const userGroup = userState?.user?.group || JSON.parse(localStorage.getItem('user'))?.group;
-        const groupOptions = processGroupsData(data, userGroup);
-        setGroups(groupOptions);
-
-        const hasCurrentGroup = groupOptions.some(option => option.value === inputs.group);
-        if (!hasCurrentGroup) {
-          handleInputChange('group', groupOptions[0]?.value || '');
-        }
-      } else {
-        showError(t(message));
-      }
-    } catch (error) {
-      showError(t('加载分组失败'));
-    }
-  };
-
-  // 编辑消息相关
-  const handleMessageEdit = useCallback((targetMessage) => {
-    const editableContent = getTextContent(targetMessage);
-    setEditingMessageId(targetMessage.id);
-    setEditValue(editableContent);
-  }, [setEditingMessageId, setEditValue]);
-
-  const handleEditSave = useCallback(() => {
-    if (!editingMessageId || !editValue.trim()) return;
-
-    setMessage(prevMessages => {
-      const messageIndex = prevMessages.findIndex(msg => msg.id === editingMessageId);
-      if (messageIndex === -1) return prevMessages;
-
-      const targetMessage = prevMessages[messageIndex];
-      let newContent;
-
-      if (Array.isArray(targetMessage.content)) {
-        newContent = targetMessage.content.map(item =>
-          item.type === 'text' ? { ...item, text: editValue.trim() } : item
-        );
-      } else {
-        newContent = editValue.trim();
-      }
-
-      const updatedMessages = prevMessages.map(msg =>
-        msg.id === editingMessageId ? { ...msg, content: newContent } : msg
-      );
-
-      // 处理用户消息编辑后的重新生成
-      if (targetMessage.role === MESSAGE_ROLES.USER) {
-        const hasSubsequentAssistantReply = messageIndex < prevMessages.length - 1 &&
-          prevMessages[messageIndex + 1].role === MESSAGE_ROLES.ASSISTANT;
-
-        if (hasSubsequentAssistantReply) {
-          Modal.confirm({
-            title: t('消息已编辑'),
-            content: t('检测到该消息后有AI回复,是否删除后续回复并重新生成?'),
-            okText: t('重新生成'),
-            cancelText: t('仅保存'),
-            onOk: () => {
-              const messagesUntilUser = updatedMessages.slice(0, messageIndex + 1);
-              setMessage(messagesUntilUser);
-
-              setTimeout(() => {
-                const systemMessage = systemPrompt !== '' ? createMessage(
-                  MESSAGE_ROLES.SYSTEM,
-                  systemPrompt,
-                  { id: '1', createAt: 1715676751919 }
-                ) : null;
-
-                const payload = buildApiPayload(messagesUntilUser, systemMessage, inputs, parameterEnabled);
-
-                setMessage(prevMsg => [...prevMsg, createLoadingAssistantMessage()]);
-                sendRequest(payload, inputs.stream);
-              }, 100);
-            },
-            onCancel: () => setMessage(updatedMessages)
-          });
-          return prevMessages;
-        }
-      }
-
-      return updatedMessages;
-    });
-
-    setEditingMessageId(null);
-    setEditValue('');
-    Toast.success({ content: t('消息已更新'), duration: 2 });
-  }, [editingMessageId, editValue, t, systemPrompt, inputs, parameterEnabled, sendRequest, setMessage, setEditingMessageId, setEditValue]);
-
-  const handleEditCancel = useCallback(() => {
-    setEditingMessageId(null);
-    setEditValue('');
-  }, [setEditingMessageId, setEditValue]);
-
   // 切换推理展开状态
-  const toggleReasoningExpansion = (messageId) => {
+  const toggleReasoningExpansion = useCallback((messageId) => {
     setMessage(prevMessages =>
       prevMessages.map(msg =>
         msg.id === messageId && msg.role === MESSAGE_ROLES.ASSISTANT
@@ -329,7 +254,7 @@ const Playground = () => {
           : msg
       )
     );
-  };
+  }, [setMessage]);
 
   // 渲染函数
   const renderCustomChatContent = useCallback(
@@ -337,7 +262,7 @@ const Playground = () => {
       const isCurrentlyEditing = editingMessageId === message.id;
 
       return (
-        <MessageContent
+        <OptimizedMessageContent
           message={message}
           className={className}
           styleState={styleState}
@@ -350,7 +275,7 @@ const Playground = () => {
         />
       );
     },
-    [styleState, editingMessageId, editValue, handleEditSave, handleEditCancel, setEditValue],
+    [styleState, editingMessageId, editValue, handleEditSave, handleEditCancel, setEditValue, toggleReasoningExpansion],
   );
 
   const renderChatBoxAction = useCallback((props) => {
@@ -361,7 +286,7 @@ const Playground = () => {
     const isCurrentlyEditing = editingMessageId === currentMessage.id;
 
     return (
-      <MessageActions
+      <OptimizedMessageActions
         message={currentMessage}
         styleState={styleState}
         onMessageReset={messageActions.handleMessageReset}
@@ -376,47 +301,56 @@ const Playground = () => {
   }, [messageActions, styleState, message, editingMessageId, handleMessageEdit]);
 
   // Effects
+
+  // 同步消息和自定义请求体
   useEffect(() => {
-    if (searchParams.get('expired')) {
-      showError(t('未登录或登录已过期,请重新登录!'));
-    }
+    syncMessageToCustomBody();
+  }, [message, syncMessageToCustomBody]);
 
-    const savedStatus = localStorage.getItem('status');
-    if (savedStatus) {
-      setStatus(JSON.parse(savedStatus));
-    }
+  useEffect(() => {
+    syncCustomBodyToMessage();
+  }, [customRequestBody, syncCustomBodyToMessage]);
 
-    loadModels();
-    loadGroups();
+  // 处理URL参数
+  useEffect(() => {
+    if (searchParams.get('expired')) {
+      Toast.warning(t('登录过期,请重新登录!'));
+    }
   }, [searchParams, t]);
 
+  // 处理窗口大小变化
   useEffect(() => {
     const handleResize = () => {
-      styleDispatch({
-        type: 'set_is_mobile',
-        payload: isMobile(),
-      });
+      const mobile = window.innerWidth < 768;
+      if (styleState.isMobile !== mobile) {
+        styleDispatch({ type: 'SET_IS_MOBILE', payload: mobile });
+      }
     };
 
     handleResize();
     window.addEventListener('resize', handleResize);
     return () => window.removeEventListener('resize', handleResize);
-  }, [styleDispatch]);
+  }, [styleState.isMobile, styleDispatch]);
 
+  // 构建预览payload
   useEffect(() => {
-    const newPreviewPayload = constructPreviewPayload();
-    setPreviewPayload(newPreviewPayload);
-    setDebugData(prev => ({
-      ...prev,
-      previewRequest: newPreviewPayload,
-      previewTimestamp: new Date().toISOString()
-    }));
-  }, [constructPreviewPayload, setPreviewPayload, setDebugData]);
-
-  // 监听配置变化并自动保存
+    const timer = setTimeout(() => {
+      const preview = constructPreviewPayload();
+      setPreviewPayload(preview);
+      setDebugData(prev => ({
+        ...prev,
+        previewRequest: preview ? JSON.stringify(preview, null, 2) : null,
+        previewTimestamp: preview ? new Date().toISOString() : null
+      }));
+    }, 300);
+
+    return () => clearTimeout(timer);
+  }, [message, inputs, parameterEnabled, customRequestMode, customRequestBody, constructPreviewPayload, setPreviewPayload, setDebugData]);
+
+  // 自动保存配置
   useEffect(() => {
     debouncedSaveConfig();
-  }, [inputs, parameterEnabled, systemPrompt, showDebugPanel]);
+  }, [inputs, parameterEnabled, showDebugPanel, customRequestMode, customRequestBody, debouncedSaveConfig]);
 
   return (
     <div className="h-full bg-gray-50">
@@ -442,21 +376,25 @@ const Playground = () => {
             width={styleState.isMobile ? '100%' : 320}
             className={styleState.isMobile ? 'bg-white shadow-lg' : ''}
           >
-            <SettingsPanel
+            <OptimizedSettingsPanel
               inputs={inputs}
               parameterEnabled={parameterEnabled}
               models={models}
               groups={groups}
-              systemPrompt={systemPrompt}
               styleState={styleState}
               showSettings={showSettings}
               showDebugPanel={showDebugPanel}
+              customRequestMode={customRequestMode}
+              customRequestBody={customRequestBody}
               onInputChange={handleInputChange}
               onParameterToggle={handleParameterToggle}
-              onSystemPromptChange={setSystemPrompt}
               onCloseSettings={() => setShowSettings(false)}
               onConfigImport={handleConfigImport}
               onConfigReset={handleConfigReset}
+              onCustomRequestModeChange={setCustomRequestMode}
+              onCustomRequestBodyChange={setCustomRequestBody}
+              previewPayload={previewPayload}
+              messages={message}
             />
           </Layout.Sider>
         )}
@@ -487,11 +425,12 @@ const Playground = () => {
             {/* 调试面板 - 桌面端 */}
             {showDebugPanel && !styleState.isMobile && (
               <div className="w-96 flex-shrink-0 h-full">
-                <DebugPanel
+                <OptimizedDebugPanel
                   debugData={debugData}
                   activeDebugTab={activeDebugTab}
                   onActiveDebugTabChange={setActiveDebugTab}
                   styleState={styleState}
+                  customRequestMode={customRequestMode}
                 />
               </div>
             )}
@@ -512,13 +451,14 @@ const Playground = () => {
               }}
               className="shadow-lg"
             >
-              <DebugPanel
+              <OptimizedDebugPanel
                 debugData={debugData}
                 activeDebugTab={activeDebugTab}
                 onActiveDebugTabChange={setActiveDebugTab}
                 styleState={styleState}
                 showDebugPanel={showDebugPanel}
                 onCloseDebugPanel={() => setShowDebugPanel(false)}
+                customRequestMode={customRequestMode}
               />
             </div>
           )}

+ 30 - 25
web/src/utils/apiUtils.js

@@ -1,37 +1,42 @@
 import { formatMessageForAPI } from './messageUtils';
 
 // 构建API请求载荷
-export const buildApiPayload = (messages, systemMessage, inputs, parameterEnabled) => {
-  const formattedMessages = messages.map(formatMessageForAPI);
-
-  if (systemMessage) {
-    formattedMessages.unshift(formatMessageForAPI(systemMessage));
+export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled) => {
+  const processedMessages = messages.map(formatMessageForAPI);
+
+  // 如果有系统提示,插入到消息开头
+  if (systemPrompt && systemPrompt.trim()) {
+    processedMessages.unshift({
+      role: 'system',
+      content: systemPrompt.trim()
+    });
   }
 
   const payload = {
-    messages: formattedMessages,
-    stream: inputs.stream,
     model: inputs.model,
-    group: inputs.group,
+    messages: processedMessages,
+    stream: inputs.stream,
   };
 
-  // 添加可选参数
-  const optionalParams = [
-    'max_tokens', 'temperature', 'top_p',
-    'frequency_penalty', 'presence_penalty', 'seed'
-  ];
-
-  optionalParams.forEach(param => {
-    if (parameterEnabled[param]) {
-      if (param === 'max_tokens' && inputs[param] > 0) {
-        payload[param] = parseInt(inputs[param]);
-      } else if (param === 'seed' && inputs[param] !== null && inputs[param] !== '') {
-        payload[param] = parseInt(inputs[param]);
-      } else if (param !== 'max_tokens' && param !== 'seed') {
-        payload[param] = inputs[param];
-      }
-    }
-  });
+  // 添加启用的参数
+  if (parameterEnabled.temperature && inputs.temperature !== undefined) {
+    payload.temperature = inputs.temperature;
+  }
+  if (parameterEnabled.top_p && inputs.top_p !== undefined) {
+    payload.top_p = inputs.top_p;
+  }
+  if (parameterEnabled.max_tokens && inputs.max_tokens !== undefined) {
+    payload.max_tokens = inputs.max_tokens;
+  }
+  if (parameterEnabled.frequency_penalty && inputs.frequency_penalty !== undefined) {
+    payload.frequency_penalty = inputs.frequency_penalty;
+  }
+  if (parameterEnabled.presence_penalty && inputs.presence_penalty !== undefined) {
+    payload.presence_penalty = inputs.presence_penalty;
+  }
+  if (parameterEnabled.seed && inputs.seed !== undefined && inputs.seed !== null) {
+    payload.seed = inputs.seed;
+  }
 
   return payload;
 };

+ 34 - 17
web/src/utils/constants.js

@@ -1,13 +1,27 @@
-// Playground 相关常量
+// ========== 消息相关常量 ==========
+export const MESSAGE_STATUS = {
+  LOADING: 'loading',
+  INCOMPLETE: 'incomplete',
+  COMPLETE: 'complete',
+  ERROR: 'error',
+};
+
+export const MESSAGE_ROLES = {
+  USER: 'user',
+  ASSISTANT: 'assistant',
+  SYSTEM: 'system',
+};
+
+// 默认消息示例
 export const DEFAULT_MESSAGES = [
   {
-    role: 'user',
+    role: MESSAGE_ROLES.USER,
     id: '2',
     createAt: 1715676751919,
     content: '你好',
   },
   {
-    role: 'assistant',
+    role: MESSAGE_ROLES.ASSISTANT,
     id: '3',
     createAt: 1715676751919,
     content: '你好,请问有什么可以帮助您的吗?',
@@ -16,34 +30,24 @@ export const DEFAULT_MESSAGES = [
   },
 ];
 
-export const MESSAGE_STATUS = {
-  LOADING: 'loading',
-  INCOMPLETE: 'incomplete',
-  COMPLETE: 'complete',
-  ERROR: 'error',
-};
-
-export const MESSAGE_ROLES = {
-  USER: 'user',
-  ASSISTANT: 'assistant',
-  SYSTEM: 'system',
-};
-
+// ========== UI 相关常量 ==========
 export const DEBUG_TABS = {
   PREVIEW: 'preview',
   REQUEST: 'request',
   RESPONSE: 'response',
 };
 
+// ========== API 相关常量 ==========
 export const API_ENDPOINTS = {
   CHAT_COMPLETIONS: '/pg/chat/completions',
   USER_MODELS: '/api/user/models',
   USER_GROUPS: '/api/user/self/groups',
 };
 
+// ========== 配置默认值 ==========
 export const DEFAULT_CONFIG = {
   inputs: {
-    model: 'gpt-4',
+    model: 'gpt-4o',
     group: '',
     temperature: 0.7,
     top_p: 1,
@@ -65,14 +69,27 @@ export const DEFAULT_CONFIG = {
   },
   systemPrompt: '',
   showDebugPanel: false,
+  customRequestMode: false,
+  customRequestBody: '',
 };
 
+// ========== 正则表达式 ==========
 export const THINK_TAG_REGEX = /<think>([\s\S]*?)<\/think>/g;
 
+// ========== 错误消息 ==========
 export const ERROR_MESSAGES = {
   NO_TEXT_CONTENT: '此消息没有可复制的文本内容',
   INVALID_MESSAGE_TYPE: '无法复制此类型的消息内容',
   COPY_FAILED: '复制失败,请手动选择文本复制',
   COPY_HTTPS_REQUIRED: '复制功能需要 HTTPS 环境,请手动复制',
   BROWSER_NOT_SUPPORTED: '浏览器不支持复制功能,请手动复制',
+  JSON_PARSE_ERROR: '自定义请求体格式错误,请检查JSON格式',
+  API_REQUEST_ERROR: '请求发生错误',
+  NETWORK_ERROR: '网络连接失败或服务器无响应',
+};
+
+// ========== 存储键名 ==========
+export const STORAGE_KEYS = {
+  CONFIG: 'playground_config',
+  MESSAGES: 'playground_messages',
 }; 

+ 102 - 26
web/src/utils/messageUtils.js

@@ -6,6 +6,8 @@ export const generateMessageId = () => `${messageId++}`;
 
 // 提取消息中的文本内容
 export const getTextContent = (message) => {
+  if (!message || !message.content) return '';
+
   if (Array.isArray(message.content)) {
     const textContent = message.content.find(item => item.type === 'text');
     return textContent?.text || '';
@@ -15,12 +17,12 @@ export const getTextContent = (message) => {
 
 // 处理 think 标签
 export const processThinkTags = (content, reasoningContent = '') => {
-  if (!content.includes('<think>')) {
+  if (!content || !content.includes('<think>')) {
     return { content, reasoningContent };
   }
 
-  let thoughts = [];
-  let replyParts = [];
+  const thoughts = [];
+  const replyParts = [];
   let lastIndex = 0;
   let match;
 
@@ -33,14 +35,10 @@ export const processThinkTags = (content, reasoningContent = '') => {
   replyParts.push(content.substring(lastIndex));
 
   const processedContent = replyParts.join('').replace(/<\/?think>/g, '').trim();
-
-  let processedReasoningContent = reasoningContent;
-  if (thoughts.length > 0) {
-    const thoughtsStr = thoughts.join('\n\n---\n\n');
-    processedReasoningContent = reasoningContent
-      ? `${reasoningContent}\n\n---\n\n${thoughtsStr}`
-      : thoughtsStr;
-  }
+  const thoughtsStr = thoughts.join('\n\n---\n\n');
+  const processedReasoningContent = reasoningContent && thoughtsStr
+    ? `${reasoningContent}\n\n---\n\n${thoughtsStr}`
+    : reasoningContent || thoughtsStr;
 
   return {
     content: processedContent,
@@ -50,6 +48,8 @@ export const processThinkTags = (content, reasoningContent = '') => {
 
 // 处理未完成的 think 标签
 export const processIncompleteThinkTags = (content, reasoningContent = '') => {
+  if (!content) return { content: '', reasoningContent };
+
   const lastOpenThinkIndex = content.lastIndexOf('<think>');
   if (lastOpenThinkIndex === -1) {
     return processThinkTags(content, reasoningContent);
@@ -59,13 +59,9 @@ export const processIncompleteThinkTags = (content, reasoningContent = '') => {
   if (!fragmentAfterLastOpen.includes('</think>')) {
     const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
     const cleanContent = content.substring(0, lastOpenThinkIndex);
-
-    let processedReasoningContent = reasoningContent;
-    if (unclosedThought) {
-      processedReasoningContent = reasoningContent
-        ? `${reasoningContent}\n\n---\n\n${unclosedThought}`
-        : unclosedThought;
-    }
+    const processedReasoningContent = unclosedThought
+      ? reasoningContent ? `${reasoningContent}\n\n---\n\n${unclosedThought}` : unclosedThought
+      : reasoningContent;
 
     return processThinkTags(cleanContent, processedReasoningContent);
   }
@@ -75,11 +71,15 @@ export const processIncompleteThinkTags = (content, reasoningContent = '') => {
 
 // 构建消息内容(包含图片)
 export const buildMessageContent = (textContent, imageUrls = [], imageEnabled = false) => {
-  const validImageUrls = imageUrls.filter(url => url.trim() !== '');
+  if (!textContent && (!imageUrls || imageUrls.length === 0)) {
+    return '';
+  }
+
+  const validImageUrls = imageUrls.filter(url => url && url.trim() !== '');
 
   if (imageEnabled && validImageUrls.length > 0) {
     return [
-      { type: 'text', text: textContent },
+      { type: 'text', text: textContent || '' },
       ...validImageUrls.map(url => ({
         type: 'image_url',
         image_url: { url: url.trim() }
@@ -87,7 +87,7 @@ export const buildMessageContent = (textContent, imageUrls = [], imageEnabled =
     ];
   }
 
-  return textContent;
+  return textContent || '';
 };
 
 // 创建新消息
@@ -114,12 +114,88 @@ export const createLoadingAssistantMessage = () => createMessage(
 
 // 检查消息是否包含图片
 export const hasImageContent = (message) => {
-  return Array.isArray(message.content) &&
+  return message &&
+    Array.isArray(message.content) &&
     message.content.some(item => item.type === 'image_url');
 };
 
 // 格式化消息用于API请求
-export const formatMessageForAPI = (message) => ({
-  role: message.role,
-  content: message.content
-}); 
+export const formatMessageForAPI = (message) => {
+  if (!message) return null;
+
+  return {
+    role: message.role,
+    content: message.content
+  };
+};
+
+// 验证消息是否有效
+export const isValidMessage = (message) => {
+  return message &&
+    message.role &&
+    (message.content || message.content === '');
+};
+
+// 获取最后一条用户消息
+export const getLastUserMessage = (messages) => {
+  if (!Array.isArray(messages)) return null;
+
+  for (let i = messages.length - 1; i >= 0; i--) {
+    if (messages[i].role === MESSAGE_ROLES.USER) {
+      return messages[i];
+    }
+  }
+  return null;
+};
+
+// 获取最后一条助手消息
+export const getLastAssistantMessage = (messages) => {
+  if (!Array.isArray(messages)) return null;
+
+  for (let i = messages.length - 1; i >= 0; i--) {
+    if (messages[i].role === MESSAGE_ROLES.ASSISTANT) {
+      return messages[i];
+    }
+  }
+  return null;
+};
+
+// 构建API请求负载(从apiUtils移动过来)
+export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled) => {
+  const processedMessages = messages
+    .filter(isValidMessage)
+    .map(formatMessageForAPI)
+    .filter(Boolean);
+
+  // 如果有系统提示,插入到消息开头
+  if (systemPrompt && systemPrompt.trim()) {
+    processedMessages.unshift({
+      role: MESSAGE_ROLES.SYSTEM,
+      content: systemPrompt.trim()
+    });
+  }
+
+  const payload = {
+    model: inputs.model,
+    messages: processedMessages,
+    stream: inputs.stream,
+  };
+
+  // 添加启用的参数
+  const parameterMappings = {
+    temperature: 'temperature',
+    top_p: 'top_p',
+    max_tokens: 'max_tokens',
+    frequency_penalty: 'frequency_penalty',
+    presence_penalty: 'presence_penalty',
+    seed: 'seed'
+  };
+
+  Object.entries(parameterMappings).forEach(([key, param]) => {
+    if (parameterEnabled[key] && inputs[param] !== undefined && inputs[param] !== null) {
+      payload[param] = inputs[param];
+    }
+  });
+
+  return payload;
+};