Просмотр исходного кода

🎨 refactor(playground): Refactor the structure of the playground and implement responsive design adaptation

Apple\Apple 9 месяцев назад
Родитель
Сommit
c5ed0753a6

+ 14 - 8
web/src/components/PageLayout.js

@@ -11,6 +11,7 @@ import { API, getLogo, getSystemName, showError } from '../helpers/index.js';
 import { setStatusData } from '../helpers/data.js';
 import { UserContext } from '../context/User/index.js';
 import { StatusContext } from '../context/Status/index.js';
+import { useLocation } from 'react-router-dom';
 const { Sider, Content, Header, Footer } = Layout;
 
 const PageLayout = () => {
@@ -18,6 +19,9 @@ const PageLayout = () => {
   const [statusState, statusDispatch] = useContext(StatusContext);
   const [styleState, styleDispatch] = useContext(StyleContext);
   const { i18n } = useTranslation();
+  const location = useLocation();
+
+  const isPlaygroundRoute = location.pathname === '/console/playground';
 
   const loadUser = () => {
     let user = localStorage.getItem('user');
@@ -144,14 +148,16 @@ const PageLayout = () => {
           >
             <App />
           </Content>
-          <Layout.Footer
-            style={{
-              flex: '0 0 auto',
-              width: '100%',
-            }}
-          >
-            <FooterBar />
-          </Layout.Footer>
+          {!isPlaygroundRoute && (
+            <Layout.Footer
+              style={{
+                flex: '0 0 auto',
+                width: '100%',
+              }}
+            >
+              <FooterBar />
+            </Layout.Footer>
+          )}
         </Layout>
       </Layout>
       <ToastContainer />

+ 112 - 0
web/src/components/playground/ChatArea.js

@@ -0,0 +1,112 @@
+import React from 'react';
+import {
+  Card,
+  Chat,
+  Typography,
+  Button,
+} from '@douyinfe/semi-ui';
+import {
+  MessageSquare,
+  Eye,
+  EyeOff,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import CustomInputRender from './CustomInputRender';
+
+const ChatArea = ({
+  chatRef,
+  message,
+  inputs,
+  styleState,
+  showDebugPanel,
+  roleInfo,
+  onMessageSend,
+  onMessageCopy,
+  onMessageReset,
+  onMessageDelete,
+  onStopGenerator,
+  onClearMessages,
+  onToggleDebugPanel,
+  renderCustomChatContent,
+  renderChatBoxAction,
+}) => {
+  const { t } = useTranslation();
+
+  const renderInputArea = React.useCallback((props) => {
+    return <CustomInputRender {...props} />;
+  }, []);
+
+  return (
+    <Card
+      className="!rounded-2xl h-full"
+      bodyStyle={{ padding: 0, height: 'calc(100vh - 101px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
+    >
+      {/* 聊天头部 */}
+      {styleState.isMobile ? (
+        <div className="pt-4"></div>
+      ) : (
+        <div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-3">
+              <div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
+                <MessageSquare size={20} className="text-white" />
+              </div>
+              <div>
+                <Typography.Title heading={5} className="!text-white mb-0">
+                  {t('AI 对话')}
+                </Typography.Title>
+                <Typography.Text className="!text-white/80 text-sm hidden sm:inline">
+                  {inputs.model || t('选择模型开始对话')}
+                </Typography.Text>
+              </div>
+            </div>
+            <div className="flex items-center gap-2">
+              <Button
+                icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
+                onClick={onToggleDebugPanel}
+                theme="borderless"
+                type="primary"
+                size="small"
+                className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10"
+              >
+                {showDebugPanel ? t('隐藏调试') : t('显示调试')}
+              </Button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* 聊天内容区域 */}
+      <div className="flex-1 overflow-hidden">
+        <Chat
+          ref={chatRef}
+          chatBoxRenderConfig={{
+            renderChatBoxContent: renderCustomChatContent,
+            renderChatBoxAction: renderChatBoxAction,
+            renderChatBoxTitle: () => null,
+          }}
+          renderInputArea={renderInputArea}
+          roleConfig={roleInfo}
+          style={{
+            height: '100%',
+            maxWidth: '100%',
+            overflow: 'hidden'
+          }}
+          chats={message}
+          onMessageSend={onMessageSend}
+          onMessageCopy={onMessageCopy}
+          onMessageReset={onMessageReset}
+          onMessageDelete={onMessageDelete}
+          showClearContext
+          showStopGenerate
+          onStopGenerator={onStopGenerator}
+          onClear={onClearMessages}
+          className="h-full"
+          placeholder={t('请输入您的问题...')}
+        />
+      </div>
+    </Card>
+  );
+};
+
+export default ChatArea; 

+ 234 - 0
web/src/components/playground/ConfigManager.js

@@ -0,0 +1,234 @@
+import React, { useRef } from 'react';
+import {
+  Button,
+  Typography,
+  Toast,
+  Modal,
+  Dropdown,
+} from '@douyinfe/semi-ui';
+import {
+  Download,
+  Upload,
+  RotateCcw,
+  Settings2,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage';
+
+const ConfigManager = ({
+  currentConfig,
+  onConfigImport,
+  onConfigReset,
+  styleState,
+}) => {
+  const { t } = useTranslation();
+  const fileInputRef = useRef(null);
+
+  const handleExport = () => {
+    try {
+      exportConfig(currentConfig);
+      Toast.success({
+        content: t('配置已导出到下载文件夹'),
+        duration: 3,
+      });
+    } catch (error) {
+      Toast.error({
+        content: t('导出配置失败: ') + error.message,
+        duration: 3,
+      });
+    }
+  };
+
+  const handleImportClick = () => {
+    fileInputRef.current?.click();
+  };
+
+  const handleFileChange = async (event) => {
+    const file = event.target.files[0];
+    if (!file) return;
+
+    try {
+      const importedConfig = await importConfig(file);
+
+      Modal.confirm({
+        title: t('确认导入配置'),
+        content: t('导入的配置将覆盖当前设置,是否继续?'),
+        okText: t('确定导入'),
+        cancelText: t('取消'),
+        onOk: () => {
+          onConfigImport(importedConfig);
+          Toast.success({
+            content: t('配置导入成功'),
+            duration: 3,
+          });
+        },
+      });
+    } catch (error) {
+      Toast.error({
+        content: t('导入配置失败: ') + error.message,
+        duration: 3,
+      });
+    } finally {
+      // 重置文件输入,允许重复选择同一文件
+      event.target.value = '';
+    }
+  };
+
+  const handleReset = () => {
+    Modal.confirm({
+      title: t('重置配置'),
+      content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'),
+      okText: t('确定重置'),
+      cancelText: t('取消'),
+      okButtonProps: {
+        type: 'danger',
+      },
+      onOk: () => {
+        clearConfig();
+        onConfigReset();
+        Toast.success({
+          content: t('配置已重置为默认值'),
+          duration: 3,
+        });
+      },
+    });
+  };
+
+  const getConfigStatus = () => {
+    if (hasStoredConfig()) {
+      const timestamp = getConfigTimestamp();
+      if (timestamp) {
+        const date = new Date(timestamp);
+        return t('上次保存: ') + date.toLocaleString();
+      }
+      return t('已有保存的配置');
+    }
+    return t('暂无保存的配置');
+  };
+
+  const dropdownItems = [
+    {
+      node: 'item',
+      name: 'export',
+      onClick: handleExport,
+      children: (
+        <div className="flex items-center gap-2">
+          <Download size={14} />
+          {t('导出配置')}
+        </div>
+      ),
+    },
+    {
+      node: 'item',
+      name: 'import',
+      onClick: handleImportClick,
+      children: (
+        <div className="flex items-center gap-2">
+          <Upload size={14} />
+          {t('导入配置')}
+        </div>
+      ),
+    },
+    {
+      node: 'divider',
+    },
+    {
+      node: 'item',
+      name: 'reset',
+      onClick: handleReset,
+      children: (
+        <div className="flex items-center gap-2 text-red-600">
+          <RotateCcw size={14} />
+          {t('重置配置')}
+        </div>
+      ),
+    },
+  ];
+
+  if (styleState.isMobile) {
+    // 移动端显示简化的下拉菜单
+    return (
+      <>
+        <Dropdown
+          trigger="click"
+          position="bottomLeft"
+          showTick
+          menu={dropdownItems}
+        >
+          <Button
+            icon={<Settings2 size={14} />}
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50"
+          />
+        </Dropdown>
+
+        <input
+          ref={fileInputRef}
+          type="file"
+          accept=".json"
+          onChange={handleFileChange}
+          style={{ display: 'none' }}
+        />
+      </>
+    );
+  }
+
+  // 桌面端显示紧凑的按钮组
+  return (
+    <div className="space-y-3">
+      {/* 配置状态信息,使用较小的字体 */}
+      <div className="text-center">
+        <Typography.Text className="text-xs text-gray-500">
+          {getConfigStatus()}
+        </Typography.Text>
+      </div>
+
+      {/* 紧凑的按钮布局 */}
+      <div className="flex gap-2">
+        <Button
+          icon={<Download size={12} />}
+          size="small"
+          theme="solid"
+          type="primary"
+          onClick={handleExport}
+          className="!rounded-lg flex-1 !text-xs !h-7"
+        >
+          {t('导出')}
+        </Button>
+
+        <Button
+          icon={<Upload size={12} />}
+          size="small"
+          theme="outline"
+          type="primary"
+          onClick={handleImportClick}
+          className="!rounded-lg flex-1 !text-xs !h-7"
+        >
+          {t('导入')}
+        </Button>
+
+        <Button
+          icon={<RotateCcw size={12} />}
+          size="small"
+          theme="borderless"
+          type="danger"
+          onClick={handleReset}
+          className="!rounded-lg !text-xs !h-7 !px-2"
+          style={{ minWidth: 'auto' }}
+        />
+      </div>
+
+      <input
+        ref={fileInputRef}
+        type="file"
+        accept=".json"
+        onChange={handleFileChange}
+        style={{ display: 'none' }}
+      />
+    </div>
+  );
+};
+
+export default ConfigManager; 

+ 27 - 0
web/src/components/playground/CustomInputRender.js

@@ -0,0 +1,27 @@
+import React from 'react';
+
+const CustomInputRender = (props) => {
+  const { detailProps } = props;
+  const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps;
+
+  const styledSendNode = React.cloneElement(sendNode, {
+    className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 ${sendNode.props.className || ''}`
+  });
+
+  return (
+    <div className="p-2 sm:p-4">
+      <div
+        className="flex items-end gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow"
+        style={{ border: '1px solid var(--semi-color-border)' }}
+        onClick={onClick}
+      >
+        <div className="flex-1">
+          {inputNode}
+        </div>
+        {styledSendNode}
+      </div>
+    </div>
+  );
+};
+
+export default CustomInputRender; 

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

@@ -0,0 +1,120 @@
+import React from 'react';
+import {
+  Card,
+  Typography,
+  Tabs,
+  TabPane,
+  Button,
+} from '@douyinfe/semi-ui';
+import {
+  Code,
+  FileText,
+  Zap,
+  Clock,
+  X,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+const DebugPanel = ({
+  debugData,
+  activeDebugTab,
+  onActiveDebugTabChange,
+  styleState,
+  onCloseDebugPanel,
+}) => {
+  const { t } = useTranslation();
+
+  return (
+    <Card
+      className="!rounded-2xl h-full flex flex-col"
+      bodyStyle={{
+        padding: styleState.isMobile ? '16px' : '24px',
+        height: '100%',
+        display: 'flex',
+        flexDirection: 'column'
+      }}
+    >
+      <div className="flex items-center justify-between mb-6 flex-shrink-0">
+        <div className="flex items-center">
+          <div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
+            <Code size={20} className="text-white" />
+          </div>
+          <Typography.Title heading={5} className="mb-0">
+            {t('调试信息')}
+          </Typography.Title>
+        </div>
+
+        {/* 移动端关闭按钮 */}
+        {styleState.isMobile && onCloseDebugPanel && (
+          <Button
+            icon={<X size={16} />}
+            onClick={onCloseDebugPanel}
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            className="!rounded-lg"
+          />
+        )}
+      </div>
+
+      <div className="flex-1 overflow-hidden debug-panel">
+        <Tabs
+          type="line"
+          className="h-full"
+          style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
+          activeKey={activeDebugTab}
+          onChange={onActiveDebugTabChange}
+        >
+          <TabPane tab={
+            <div className="flex items-center gap-2">
+              <FileText size={16} />
+              {t('请求体')}
+            </div>
+          } itemKey="request">
+            <div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
+              {debugData.request ? (
+                <pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
+                  {JSON.stringify(debugData.request, null, 2)}
+                </pre>
+              ) : (
+                <Typography.Text type="secondary" className="text-sm">
+                  {t('暂无请求数据')}
+                </Typography.Text>
+              )}
+            </div>
+          </TabPane>
+
+          <TabPane tab={
+            <div className="flex items-center gap-2">
+              <Zap size={16} />
+              {t('响应内容')}
+            </div>
+          } itemKey="response">
+            <div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
+              {debugData.response ? (
+                <pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
+                  {debugData.response}
+                </pre>
+              ) : (
+                <Typography.Text type="secondary" className="text-sm">
+                  {t('暂无响应数据')}
+                </Typography.Text>
+              )}
+            </div>
+          </TabPane>
+        </Tabs>
+      </div>
+
+      {debugData.timestamp && (
+        <div className="flex items-center gap-2 mt-4 pt-4 flex-shrink-0">
+          <Clock size={14} className="text-gray-500" />
+          <Typography.Text className="text-xs text-gray-500">
+            {t('最后更新')}: {new Date(debugData.timestamp).toLocaleString()}
+          </Typography.Text>
+        </div>
+      )}
+    </Card>
+  );
+};
+
+export default DebugPanel; 

+ 71 - 0
web/src/components/playground/FloatingButtons.js

@@ -0,0 +1,71 @@
+import React from 'react';
+import { Button } from '@douyinfe/semi-ui';
+import {
+  Settings,
+  Eye,
+  EyeOff,
+} from 'lucide-react';
+
+const FloatingButtons = ({
+  styleState,
+  showSettings,
+  showDebugPanel,
+  onToggleSettings,
+  onToggleDebugPanel,
+}) => {
+  if (!styleState.isMobile) return null;
+
+  return (
+    <>
+      {/* 设置按钮 */}
+      {!showSettings && (
+        <Button
+          icon={<Settings size={18} />}
+          style={{
+            position: 'fixed',
+            right: 16,
+            bottom: 90,
+            zIndex: 1000,
+            width: 36,
+            height: 36,
+            borderRadius: '50%',
+            padding: 0,
+            boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
+            background: 'linear-gradient(to right, #8b5cf6, #6366f1)',
+          }}
+          onClick={onToggleSettings}
+          theme='solid'
+          type='primary'
+          className="lg:hidden"
+        />
+      )}
+
+      {/* 调试按钮 */}
+      {!showSettings && (
+        <Button
+          icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
+          onClick={onToggleDebugPanel}
+          theme="solid"
+          type={showDebugPanel ? "danger" : "primary"}
+          style={{
+            position: 'fixed',
+            right: 16,
+            bottom: 140,
+            zIndex: 1000,
+            width: 36,
+            height: 36,
+            borderRadius: '50%',
+            padding: 0,
+            boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
+            background: showDebugPanel
+              ? 'linear-gradient(to right, #e11d48, #be123c)'
+              : 'linear-gradient(to right, #4f46e5, #6366f1)',
+          }}
+          className="lg:hidden !rounded-full !p-0"
+        />
+      )}
+    </>
+  );
+};
+
+export default FloatingButtons; 

+ 92 - 0
web/src/components/playground/ImageUrlInput.js

@@ -0,0 +1,92 @@
+import React from 'react';
+import {
+  Input,
+  Typography,
+  Button,
+} from '@douyinfe/semi-ui';
+import { IconFile } from '@douyinfe/semi-icons';
+import {
+  FileText,
+  Plus,
+  X,
+} from 'lucide-react';
+
+const ImageUrlInput = ({ imageUrls, onImageUrlsChange }) => {
+  const handleAddImageUrl = () => {
+    const newUrls = [...imageUrls, ''];
+    onImageUrlsChange(newUrls);
+  };
+
+  const handleUpdateImageUrl = (index, value) => {
+    const newUrls = [...imageUrls];
+    newUrls[index] = value;
+    onImageUrlsChange(newUrls);
+  };
+
+  const handleRemoveImageUrl = (index) => {
+    const newUrls = imageUrls.filter((_, i) => i !== index);
+    onImageUrlsChange(newUrls);
+  };
+
+  return (
+    <div>
+      <div className="flex items-center justify-between mb-2">
+        <div className="flex items-center gap-2">
+          <FileText size={16} className="text-gray-500" />
+          <Typography.Text strong className="text-sm">
+            图片地址
+          </Typography.Text>
+          <Typography.Text className="text-xs text-gray-400">
+            (多模态对话)
+          </Typography.Text>
+        </div>
+        <Button
+          icon={<Plus size={14} />}
+          size="small"
+          theme="solid"
+          type="primary"
+          onClick={handleAddImageUrl}
+          className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+          disabled={imageUrls.length >= 5}
+        />
+      </div>
+
+      {imageUrls.length === 0 ? (
+        <Typography.Text className="text-xs text-gray-500 mb-2 block">
+          点击 + 按钮添加图片URL,支持最多5张图片
+        </Typography.Text>
+      ) : (
+        <Typography.Text className="text-xs text-gray-500 mb-2 block">
+          已添加 {imageUrls.length}/5 张图片
+        </Typography.Text>
+      )}
+
+      <div className="space-y-2 max-h-32 overflow-y-auto">
+        {imageUrls.map((url, index) => (
+          <div key={index} className="flex items-center gap-2">
+            <div className="flex-1">
+              <Input
+                placeholder={`https://example.com/image${index + 1}.jpg`}
+                value={url}
+                onChange={(value) => handleUpdateImageUrl(index, value)}
+                className="!rounded-lg"
+                size="small"
+                prefix={<IconFile size='small' />}
+              />
+            </div>
+            <Button
+              icon={<X size={12} />}
+              size="small"
+              theme="borderless"
+              type="danger"
+              onClick={() => handleRemoveImageUrl(index)}
+              className="!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0"
+            />
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+export default ImageUrlInput; 

+ 69 - 0
web/src/components/playground/MessageActions.js

@@ -0,0 +1,69 @@
+import React from 'react';
+import {
+  Button,
+  Tooltip,
+} from '@douyinfe/semi-ui';
+import {
+  RefreshCw,
+  Copy,
+  Trash2,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+const MessageActions = ({ message, styleState, onMessageReset, onMessageCopy, onMessageDelete, isAnyMessageGenerating = false }) => {
+  const { t } = useTranslation();
+
+  const isLoading = message.status === 'loading' || message.status === 'incomplete';
+
+  const shouldDisableActions = isAnyMessageGenerating;
+
+  return (
+    <div className="flex items-center gap-0.5">
+      {!isLoading && (
+        <Tooltip content={shouldDisableActions ? t('正在生成中,请稍候...') : t('重试')} position="top">
+          <Button
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
+            onClick={() => !shouldDisableActions && onMessageReset(message)}
+            disabled={shouldDisableActions}
+            className={`!rounded-md ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-blue-600 hover:!bg-blue-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
+            aria-label={t('重试')}
+          />
+        </Tooltip>
+      )}
+
+      {message.content && (
+        <Tooltip content={t('复制')} position="top">
+          <Button
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            icon={<Copy size={styleState.isMobile ? 12 : 14} />}
+            onClick={() => onMessageCopy(message)}
+            className={`!rounded-md !text-gray-400 hover:!text-green-600 hover:!bg-green-50 ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
+            aria-label={t('复制')}
+          />
+        </Tooltip>
+      )}
+
+      {!isLoading && (
+        <Tooltip content={shouldDisableActions ? t('正在生成中,请稍候...') : t('删除')} position="top">
+          <Button
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
+            onClick={() => !shouldDisableActions && onMessageDelete(message)}
+            disabled={shouldDisableActions}
+            className={`!rounded-md ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-red-600 hover:!bg-red-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
+            aria-label={t('删除')}
+          />
+        </Tooltip>
+      )}
+    </div>
+  );
+};
+
+export default MessageActions; 

+ 248 - 0
web/src/components/playground/MessageContent.js

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

+ 234 - 0
web/src/components/playground/ParameterControl.js

@@ -0,0 +1,234 @@
+import React from 'react';
+import {
+  Input,
+  Slider,
+  Typography,
+  Button,
+  Tag,
+} from '@douyinfe/semi-ui';
+import {
+  Hash,
+  Thermometer,
+  Target,
+  Repeat,
+  Ban,
+  Shuffle,
+  Check,
+  X,
+} from 'lucide-react';
+
+const ParameterControl = ({
+  inputs,
+  parameterEnabled,
+  onInputChange,
+  onParameterToggle,
+}) => {
+  return (
+    <>
+      {/* Temperature */}
+      <div className={`transition-opacity duration-200 ${!parameterEnabled.temperature ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Thermometer size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Temperature
+            </Typography.Text>
+            <Tag size="small" className="!rounded-full">
+              {inputs.temperature}
+            </Tag>
+          </div>
+          <Button
+            theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
+            type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('temperature')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+          />
+        </div>
+        <Typography.Text className="text-xs text-gray-500 mb-2">
+          控制输出的随机性和创造性
+        </Typography.Text>
+        <Slider
+          step={0.1}
+          min={0.1}
+          max={1}
+          value={inputs.temperature}
+          onChange={(value) => onInputChange('temperature', value)}
+          className="mt-2"
+          disabled={!parameterEnabled.temperature}
+        />
+      </div>
+
+      {/* Top P */}
+      <div className={`transition-opacity duration-200 ${!parameterEnabled.top_p ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Target size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Top P
+            </Typography.Text>
+            <Tag size="small" className="!rounded-full">
+              {inputs.top_p}
+            </Tag>
+          </div>
+          <Button
+            theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
+            type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('top_p')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+          />
+        </div>
+        <Typography.Text className="text-xs text-gray-500 mb-2">
+          核采样,控制词汇选择的多样性
+        </Typography.Text>
+        <Slider
+          step={0.1}
+          min={0.1}
+          max={1}
+          value={inputs.top_p}
+          onChange={(value) => onInputChange('top_p', value)}
+          className="mt-2"
+          disabled={!parameterEnabled.top_p}
+        />
+      </div>
+
+      {/* Frequency Penalty */}
+      <div className={`transition-opacity duration-200 ${!parameterEnabled.frequency_penalty ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Repeat size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Frequency Penalty
+            </Typography.Text>
+            <Tag size="small" className="!rounded-full">
+              {inputs.frequency_penalty}
+            </Tag>
+          </div>
+          <Button
+            theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
+            type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('frequency_penalty')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+          />
+        </div>
+        <Typography.Text className="text-xs text-gray-500 mb-2">
+          频率惩罚,减少重复词汇的出现
+        </Typography.Text>
+        <Slider
+          step={0.1}
+          min={-2}
+          max={2}
+          value={inputs.frequency_penalty}
+          onChange={(value) => onInputChange('frequency_penalty', value)}
+          className="mt-2"
+          disabled={!parameterEnabled.frequency_penalty}
+        />
+      </div>
+
+      {/* Presence Penalty */}
+      <div className={`transition-opacity duration-200 ${!parameterEnabled.presence_penalty ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Ban size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Presence Penalty
+            </Typography.Text>
+            <Tag size="small" className="!rounded-full">
+              {inputs.presence_penalty}
+            </Tag>
+          </div>
+          <Button
+            theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
+            type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('presence_penalty')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+          />
+        </div>
+        <Typography.Text className="text-xs text-gray-500 mb-2">
+          存在惩罚,鼓励讨论新话题
+        </Typography.Text>
+        <Slider
+          step={0.1}
+          min={-2}
+          max={2}
+          value={inputs.presence_penalty}
+          onChange={(value) => onInputChange('presence_penalty', value)}
+          className="mt-2"
+          disabled={!parameterEnabled.presence_penalty}
+        />
+      </div>
+
+      {/* MaxTokens */}
+      <div className={`transition-opacity duration-200 ${!parameterEnabled.max_tokens ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Hash size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Max Tokens
+            </Typography.Text>
+          </div>
+          <Button
+            theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
+            type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('max_tokens')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+          />
+        </div>
+        <Input
+          placeholder='MaxTokens'
+          name='max_tokens'
+          required
+          autoComplete='new-password'
+          defaultValue={0}
+          value={inputs.max_tokens}
+          onChange={(value) => onInputChange('max_tokens', value)}
+          className="!rounded-lg"
+          disabled={!parameterEnabled.max_tokens}
+        />
+      </div>
+
+      {/* Seed */}
+      <div className={`transition-opacity duration-200 ${!parameterEnabled.seed ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Shuffle size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Seed
+            </Typography.Text>
+            <Typography.Text className="text-xs text-gray-400">
+              (可选,用于复现结果)
+            </Typography.Text>
+          </div>
+          <Button
+            theme={parameterEnabled.seed ? 'solid' : 'borderless'}
+            type={parameterEnabled.seed ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('seed')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+          />
+        </div>
+        <Input
+          placeholder='随机种子 (留空为随机)'
+          name='seed'
+          autoComplete='new-password'
+          value={inputs.seed || ''}
+          onChange={(value) => onInputChange('seed', value === '' ? null : value)}
+          className="!rounded-lg"
+          disabled={!parameterEnabled.seed}
+        />
+      </div>
+    </>
+  );
+};
+
+export default ParameterControl; 

+ 195 - 0
web/src/components/playground/SettingsPanel.js

@@ -0,0 +1,195 @@
+import React from 'react';
+import {
+  Card,
+  Select,
+  TextArea,
+  Typography,
+  Button,
+  Switch,
+  Divider,
+} from '@douyinfe/semi-ui';
+import {
+  Sparkles,
+  Users,
+  Type,
+  ToggleLeft,
+  X,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { renderGroupOption } from '../../helpers/render.js';
+import ParameterControl from './ParameterControl';
+import ImageUrlInput from './ImageUrlInput';
+import ConfigManager from './ConfigManager';
+
+const SettingsPanel = ({
+  inputs,
+  parameterEnabled,
+  models,
+  groups,
+  systemPrompt,
+  styleState,
+  showDebugPanel,
+  onInputChange,
+  onParameterToggle,
+  onSystemPromptChange,
+  onCloseSettings,
+  onConfigImport,
+  onConfigReset,
+}) => {
+  const { t } = useTranslation();
+
+  const currentConfig = {
+    inputs,
+    parameterEnabled,
+    systemPrompt,
+    showDebugPanel,
+  };
+
+  return (
+    <Card
+      className={`!rounded-2xl h-full flex flex-col ${styleState.isMobile ? 'rounded-none border-none shadow-none' : ''}`}
+      bodyStyle={{
+        padding: styleState.isMobile ? '24px' : '24px 24px 16px 24px',
+        height: '100%',
+        display: 'flex',
+        flexDirection: 'column'
+      }}
+    >
+      {styleState.isMobile && (
+        <div className="flex items-center justify-between mb-4">
+          {/* 移动端显示配置管理下拉菜单和关闭按钮 */}
+          <ConfigManager
+            currentConfig={currentConfig}
+            onConfigImport={onConfigImport}
+            onConfigReset={onConfigReset}
+            styleState={styleState}
+          />
+          <Button
+            icon={<X size={16} />}
+            onClick={onCloseSettings}
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            className="!rounded-lg !text-gray-600 hover:!text-red-600 hover:!bg-red-50"
+          />
+        </div>
+      )}
+
+      <div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
+        {/* 分组选择 */}
+        <div>
+          <div className="flex items-center gap-2 mb-2">
+            <Users size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              {t('分组')}
+            </Typography.Text>
+          </div>
+          <Select
+            placeholder={t('请选择分组')}
+            name='group'
+            required
+            selection
+            onChange={(value) => onInputChange('group', value)}
+            value={inputs.group}
+            autoComplete='new-password'
+            optionList={groups}
+            renderOptionItem={renderGroupOption}
+            style={{ width: '100%' }}
+            className="!rounded-lg"
+          />
+        </div>
+
+        {/* 模型选择 */}
+        <div>
+          <div className="flex items-center gap-2 mb-2">
+            <Sparkles size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              {t('模型')}
+            </Typography.Text>
+          </div>
+          <Select
+            placeholder={t('请选择模型')}
+            name='model'
+            required
+            selection
+            searchPosition='dropdown'
+            filter
+            onChange={(value) => onInputChange('model', value)}
+            value={inputs.model}
+            autoComplete='new-password'
+            optionList={models}
+            className="!rounded-lg"
+          />
+        </div>
+
+        {/* 图片URL输入 */}
+        <ImageUrlInput
+          imageUrls={inputs.imageUrls}
+          onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
+        />
+
+        {/* 参数控制组件 */}
+        <ParameterControl
+          inputs={inputs}
+          parameterEnabled={parameterEnabled}
+          onInputChange={onInputChange}
+          onParameterToggle={onParameterToggle}
+        />
+
+        {/* 流式输出开关 */}
+        <div>
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <ToggleLeft size={16} className="text-gray-500" />
+              <Typography.Text strong className="text-sm">
+                流式输出
+              </Typography.Text>
+            </div>
+            <Switch
+              checked={inputs.stream}
+              onChange={(checked) => onInputChange('stream', checked)}
+              checkedText="开"
+              uncheckedText="关"
+              size="small"
+            />
+          </div>
+        </div>
+
+        {/* System Prompt */}
+        <div>
+          <div className="flex items-center gap-2 mb-2">
+            <Type size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              System Prompt
+            </Typography.Text>
+          </div>
+          <TextArea
+            placeholder='System Prompt'
+            name='system'
+            required
+            autoComplete='new-password'
+            autosize
+            defaultValue={systemPrompt}
+            onChange={onSystemPromptChange}
+            className="!rounded-lg"
+            maxHeight={200}
+          />
+        </div>
+      </div>
+
+      {/* 桌面端的配置管理放在底部 */}
+      {!styleState.isMobile && (
+        <div className="flex-shrink-0 mt-4 pt-3">
+          <ConfigManager
+            currentConfig={currentConfig}
+            onConfigImport={onConfigImport}
+            onConfigReset={onConfigReset}
+            styleState={styleState}
+          />
+        </div>
+      )}
+    </Card>
+  );
+};
+
+export default SettingsPanel; 

+ 178 - 0
web/src/components/playground/configStorage.js

@@ -0,0 +1,178 @@
+const STORAGE_KEY = 'playground_config';
+
+const DEFAULT_CONFIG = {
+  inputs: {
+    model: 'deepseek-r1',
+    group: '',
+    max_tokens: 0,
+    temperature: 0,
+    top_p: 1,
+    frequency_penalty: 0,
+    presence_penalty: 0,
+    seed: null,
+    stream: true,
+    imageUrls: [],
+  },
+  parameterEnabled: {
+    max_tokens: true,
+    temperature: true,
+    top_p: false,
+    frequency_penalty: false,
+    presence_penalty: false,
+    seed: false,
+  },
+  systemPrompt: 'You are a helpful assistant. You can help me by answering my questions. You can also ask me questions.',
+  showDebugPanel: false,
+};
+
+/**
+ * 保存配置到 localStorage
+ * @param {Object} config - 要保存的配置对象
+ */
+export const saveConfig = (config) => {
+  try {
+    const configToSave = {
+      ...config,
+      timestamp: new Date().toISOString(),
+    };
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(configToSave));
+    console.log('配置已保存到本地存储');
+  } catch (error) {
+    console.error('保存配置失败:', error);
+  }
+};
+
+/**
+ * 从 localStorage 加载配置
+ * @returns {Object} 配置对象,如果不存在则返回默认配置
+ */
+export const loadConfig = () => {
+  try {
+    const savedConfig = localStorage.getItem(STORAGE_KEY);
+    if (savedConfig) {
+      const parsedConfig = JSON.parse(savedConfig);
+
+      const mergedConfig = {
+        inputs: {
+          ...DEFAULT_CONFIG.inputs,
+          ...parsedConfig.inputs,
+        },
+        parameterEnabled: {
+          ...DEFAULT_CONFIG.parameterEnabled,
+          ...parsedConfig.parameterEnabled,
+        },
+        systemPrompt: parsedConfig.systemPrompt || DEFAULT_CONFIG.systemPrompt,
+        showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
+      };
+
+      console.log('配置已从本地存储加载');
+      return mergedConfig;
+    }
+  } catch (error) {
+    console.error('加载配置失败:', error);
+  }
+
+  console.log('使用默认配置');
+  return DEFAULT_CONFIG;
+};
+
+/**
+ * 清除保存的配置
+ */
+export const clearConfig = () => {
+  try {
+    localStorage.removeItem(STORAGE_KEY);
+    console.log('配置已清除');
+  } catch (error) {
+    console.error('清除配置失败:', error);
+  }
+};
+
+/**
+ * 检查是否有保存的配置
+ * @returns {boolean} 是否存在保存的配置
+ */
+export const hasStoredConfig = () => {
+  try {
+    return localStorage.getItem(STORAGE_KEY) !== null;
+  } catch (error) {
+    console.error('检查配置失败:', error);
+    return false;
+  }
+};
+
+/**
+ * 获取配置的最后保存时间
+ * @returns {string|null} 最后保存时间的 ISO 字符串
+ */
+export const getConfigTimestamp = () => {
+  try {
+    const savedConfig = localStorage.getItem(STORAGE_KEY);
+    if (savedConfig) {
+      const parsedConfig = JSON.parse(savedConfig);
+      return parsedConfig.timestamp || null;
+    }
+  } catch (error) {
+    console.error('获取配置时间戳失败:', error);
+  }
+  return null;
+};
+
+/**
+ * 导出配置为 JSON 文件
+ * @param {Object} config - 要导出的配置
+ */
+export const exportConfig = (config) => {
+  try {
+    const configToExport = {
+      ...config,
+      exportTime: new Date().toISOString(),
+      version: '1.0',
+    };
+
+    const dataStr = JSON.stringify(configToExport, null, 2);
+    const dataBlob = new Blob([dataStr], { type: 'application/json' });
+
+    const link = document.createElement('a');
+    link.href = URL.createObjectURL(dataBlob);
+    link.download = `playground-config-${new Date().toISOString().split('T')[0]}.json`;
+    link.click();
+
+    URL.revokeObjectURL(link.href);
+
+    console.log('配置已导出');
+  } catch (error) {
+    console.error('导出配置失败:', error);
+  }
+};
+
+/**
+ * 从文件导入配置
+ * @param {File} file - 包含配置的 JSON 文件
+ * @returns {Promise<Object>} 导入的配置对象
+ */
+export const importConfig = (file) => {
+  return new Promise((resolve, reject) => {
+    try {
+      const reader = new FileReader();
+      reader.onload = (e) => {
+        try {
+          const importedConfig = JSON.parse(e.target.result);
+
+          if (importedConfig.inputs && importedConfig.parameterEnabled) {
+            console.log('配置已从文件导入');
+            resolve(importedConfig);
+          } else {
+            reject(new Error('配置文件格式无效'));
+          }
+        } catch (parseError) {
+          reject(new Error('解析配置文件失败: ' + parseError.message));
+        }
+      };
+      reader.onerror = () => reject(new Error('读取文件失败'));
+      reader.readAsText(file);
+    } catch (error) {
+      reject(new Error('导入配置失败: ' + error.message));
+    }
+  });
+}; 

+ 20 - 0
web/src/components/playground/index.js

@@ -0,0 +1,20 @@
+export { default as SettingsPanel } from './SettingsPanel';
+export { default as ChatArea } from './ChatArea';
+export { default as DebugPanel } from './DebugPanel';
+export { default as MessageContent } from './MessageContent';
+export { default as MessageActions } from './MessageActions';
+export { default as CustomInputRender } from './CustomInputRender';
+export { default as ParameterControl } from './ParameterControl';
+export { default as ImageUrlInput } from './ImageUrlInput';
+export { default as FloatingButtons } from './FloatingButtons';
+export { default as ConfigManager } from './ConfigManager';
+
+export {
+  saveConfig,
+  loadConfig,
+  clearConfig,
+  hasStoredConfig,
+  getConfigTimestamp,
+  exportConfig,
+  importConfig,
+} from './configStorage'; 

+ 253 - 804
web/src/pages/Playground/Playground.js

@@ -6,60 +6,26 @@ import {
   getUserIdFromLocalStorage,
   showError,
   getLogo,
+  isMobile,
 } from '../../helpers/index.js';
 import {
-  Card,
-  Chat,
-  Input,
   Layout,
-  Select,
-  Slider,
-  TextArea,
-  Typography,
-  Button,
-  MarkdownRender,
-  Tag,
-  Tabs,
-  TabPane,
   Toast,
-  Tooltip,
   Modal,
 } from '@douyinfe/semi-ui';
 import { SSE } from 'sse';
-import {
-  Settings,
-  Sparkles,
-  ChevronRight,
-  ChevronUp,
-  Brain,
-  Zap,
-  MessageSquare,
-  SlidersHorizontal,
-  Hash,
-  Thermometer,
-  Type,
-  Users,
-  Loader2,
-  Target,
-  Repeat,
-  Ban,
-  Shuffle,
-  ToggleLeft,
-  Code,
-  Eye,
-  EyeOff,
-  FileText,
-  Clock,
-  Check,
-  X,
-  Copy,
-  RefreshCw,
-  Trash2,
-} from 'lucide-react';
 import { StyleContext } from '../../context/Style/index.js';
 import { useTranslation } from 'react-i18next';
-import { renderGroupOption, truncateText, stringToColor } from '../../helpers/render.js';
-import { IconSend } from '@douyinfe/semi-icons';
+import { stringToColor } from '../../helpers/render.js';
+
+import SettingsPanel from '../../components/playground/SettingsPanel';
+import ChatArea from '../../components/playground/ChatArea';
+import DebugPanel from '../../components/playground/DebugPanel';
+import MessageContent from '../../components/playground/MessageContent';
+import MessageActions from '../../components/playground/MessageActions';
+import FloatingButtons from '../../components/playground/FloatingButtons';
+
+import { saveConfig, loadConfig } from '../../components/playground/configStorage';
 
 let id = 4;
 function getId() {
@@ -118,36 +84,19 @@ const Playground = () => {
     },
   ];
 
-  const defaultModel = 'deepseek-r1';
-  const [inputs, setInputs] = useState({
-    model: defaultModel,
-    group: '',
-    max_tokens: 0,
-    temperature: 0,
-    top_p: 1,
-    frequency_penalty: 0,
-    presence_penalty: 0,
-    seed: null,
-    stream: true,
-  });
-  const [parameterEnabled, setParameterEnabled] = useState({
-    max_tokens: true,
-    temperature: true,
-    top_p: false,
-    frequency_penalty: false,
-    presence_penalty: false,
-    seed: false,
-  });
+  const savedConfig = loadConfig();
+
+  const [inputs, setInputs] = useState(savedConfig.inputs);
+  const [parameterEnabled, setParameterEnabled] = useState(savedConfig.parameterEnabled);
+  const [systemPrompt, setSystemPrompt] = useState(savedConfig.systemPrompt);
+  const [showDebugPanel, setShowDebugPanel] = useState(savedConfig.showDebugPanel);
+
   const [searchParams, setSearchParams] = useSearchParams();
   const [status, setStatus] = useState({});
-  const [systemPrompt, setSystemPrompt] = useState(
-    'You are a helpful assistant. You can help me by answering my questions. You can also ask me questions.',
-  );
   const [message, setMessage] = useState(defaultMessage);
   const [models, setModels] = useState([]);
   const [groups, setGroups] = useState([]);
-  const [showSettings, setShowSettings] = useState(true);
-  const [showDebugPanel, setShowDebugPanel] = useState(true);
+  const [showSettings, setShowSettings] = useState(false);
   const [debugData, setDebugData] = useState({
     request: null,
     response: null,
@@ -156,6 +105,35 @@ const Playground = () => {
   const [activeDebugTab, setActiveDebugTab] = useState('request');
   const [styleState, styleDispatch] = useContext(StyleContext);
   const sseSourceRef = useRef(null);
+  const chatRef = useRef(null);
+
+  const saveConfigTimeoutRef = useRef(null);
+
+  const debouncedSaveConfig = useCallback(() => {
+    if (saveConfigTimeoutRef.current) {
+      clearTimeout(saveConfigTimeoutRef.current);
+    }
+
+    saveConfigTimeoutRef.current = setTimeout(() => {
+      const configToSave = {
+        inputs,
+        parameterEnabled,
+        systemPrompt,
+        showDebugPanel,
+      };
+      saveConfig(configToSave);
+    }, 1000);
+  }, [inputs, parameterEnabled, systemPrompt, showDebugPanel]);
+
+  useEffect(() => {
+    debouncedSaveConfig();
+
+    return () => {
+      if (saveConfigTimeoutRef.current) {
+        clearTimeout(saveConfigTimeoutRef.current);
+      }
+    };
+  }, [debouncedSaveConfig]);
 
   const handleInputChange = (name, value) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -168,6 +146,38 @@ const Playground = () => {
     }));
   };
 
+  const handleConfigImport = useCallback((importedConfig) => {
+    if (importedConfig.inputs) {
+      setInputs(prev => ({
+        ...prev,
+        ...importedConfig.inputs,
+      }));
+    }
+
+    if (importedConfig.parameterEnabled) {
+      setParameterEnabled(prev => ({
+        ...prev,
+        ...importedConfig.parameterEnabled,
+      }));
+    }
+
+    if (importedConfig.systemPrompt) {
+      setSystemPrompt(importedConfig.systemPrompt);
+    }
+
+    if (typeof importedConfig.showDebugPanel === 'boolean') {
+      setShowDebugPanel(importedConfig.showDebugPanel);
+    }
+  }, []);
+
+  const handleConfigReset = useCallback(() => {
+    const defaultConfig = loadConfig();
+    setInputs(defaultConfig.inputs);
+    setParameterEnabled(defaultConfig.parameterEnabled);
+    setSystemPrompt(defaultConfig.systemPrompt);
+    setShowDebugPanel(defaultConfig.showDebugPanel);
+  }, []);
+
   useEffect(() => {
     if (searchParams.get('expired')) {
       showError(t('未登录或登录已过期,请重新登录!'));
@@ -181,6 +191,23 @@ const Playground = () => {
     loadGroups();
   }, [searchParams, t]);
 
+  useEffect(() => {
+    const handleResize = () => {
+      styleDispatch({
+        type: 'set_is_mobile',
+        payload: isMobile(),
+      });
+    };
+
+    handleResize();
+
+    window.addEventListener('resize', handleResize);
+
+    return () => {
+      window.removeEventListener('resize', handleResize);
+    };
+  }, [styleDispatch]);
+
   const loadModels = async () => {
     let res = await API.get(`/api/user/models`);
     const { success, message, data } = res.data;
@@ -190,9 +217,10 @@ const Playground = () => {
         value: model,
       }));
       setModels(localModelOptions);
-      const hasDefault = localModelOptions.some(option => option.value === defaultModel);
-      if (!hasDefault && localModelOptions.length > 0) {
-        setInputs((inputs) => ({ ...inputs, model: localModelOptions[0].value }));
+
+      const hasCurrentModel = localModelOptions.some(option => option.value === inputs.model);
+      if (!hasCurrentModel && localModelOptions.length > 0) {
+        handleInputChange('model', localModelOptions[0].value);
       }
     } else {
       showError(t(message));
@@ -204,7 +232,7 @@ const Playground = () => {
     const { success, message, data } = res.data;
     if (success) {
       let localGroupOptions = Object.entries(data).map(([group, info]) => ({
-        label: truncateText(info.desc, '50%'),
+        label: info.desc.length > 20 ? info.desc.substring(0, 20) + '...' : info.desc,
         value: group,
         ratio: info.ratio,
         fullLabel: info.desc,
@@ -239,7 +267,11 @@ const Playground = () => {
       }
 
       setGroups(localGroupOptions);
-      handleInputChange('group', localGroupOptions[0].value);
+
+      const hasCurrentGroup = localGroupOptions.some(option => option.value === inputs.group);
+      if (!hasCurrentGroup) {
+        handleInputChange('group', localGroupOptions[0].value);
+      }
     } else {
       showError(t(message));
     }
@@ -533,11 +565,31 @@ const Playground = () => {
     (content, attachment) => {
       console.log('attachment: ', attachment);
       setMessage((prevMessage) => {
+        let messageContent;
+        const validImageUrls = inputs.imageUrls.filter(url => url.trim() !== '');
+
+        if (validImageUrls.length > 0) {
+          messageContent = [
+            {
+              type: 'text',
+              text: content,
+            },
+            ...validImageUrls.map(url => ({
+              type: 'image_url',
+              image_url: {
+                url: url.trim(),
+              },
+            })),
+          ];
+        } else {
+          messageContent = content;
+        }
+
         const newMessage = [
           ...prevMessage,
           {
             role: 'user',
-            content: content,
+            content: messageContent,
             createAt: Date.now(),
             id: getId(),
           },
@@ -603,7 +655,7 @@ const Playground = () => {
         return newMessage;
       });
     },
-    [getSystemMessage, inputs, setMessage],
+    [getSystemMessage, inputs, setMessage, parameterEnabled],
   );
 
   const completeMessage = useCallback((status = 'complete') => {
@@ -860,762 +912,159 @@ const Playground = () => {
     }
   }, [setMessage]);
 
-  const DebugToggle = () => {
-    return (
-      <Button
-        icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
-        onClick={() => setShowDebugPanel(!showDebugPanel)}
-        theme="borderless"
-        type="tertiary"
-        size="small"
-        className="!rounded-lg !text-gray-600 hover:!text-purple-600 hover:!bg-purple-50"
-      >
-        {showDebugPanel ? t('隐藏调试') : t('显示调试')}
-      </Button>
-    );
-  };
-
-  const SettingsToggle = () => {
-    if (!styleState.isMobile) return null;
-    return (
-      <Button
-        icon={<Settings size={16} />}
-        style={{
-          position: 'absolute',
-          left: showSettings ? -10 : -20,
-          top: '50%',
-          transform: 'translateY(-50%)',
-          zIndex: 1000,
-          width: 40,
-          height: 40,
-          borderRadius: '0 20px 20px 0',
-          padding: 0,
-          boxShadow: '2px 0 8px rgba(0, 0, 0, 0.15)',
-        }}
-        onClick={() => setShowSettings(!showSettings)}
-        theme='solid'
-        type='primary'
-      />
+  const toggleReasoningExpansion = (messageId) => {
+    setMessage(prevMessages =>
+      prevMessages.map(msg =>
+        msg.id === messageId && msg.role === 'assistant'
+          ? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded }
+          : msg
+      )
     );
   };
 
-  function CustomInputRender(props) {
-    const { detailProps } = props;
-    const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
-      detailProps;
-
-    return (
-      <div className="p-4">
-        <div
-          className="flex items-end gap-3 p-4 bg-gray-50 rounded-2xl shadow-sm hover:shadow-md transition-shadow"
-          style={{ border: '1px solid var(--semi-color-border)' }}
-          onClick={onClick}
-        >
-          <div className="flex-1">
-            {inputNode}
-          </div>
-          <Button
-            theme="solid"
-            type="primary"
-            className="!rounded-lg !bg-purple-500 hover:!bg-purple-600 flex-shrink-0"
-            icon={<IconSend />}
-          >
-            {t('发送')}
-          </Button>
-        </div>
-      </div>
-    );
-  }
-
-  const renderInputArea = useCallback((props) => {
-    return <CustomInputRender {...props} />;
-  }, []);
-
-  const renderChatBoxAction = useCallback((props) => {
-    const { message } = props;
-
-    const isLoading = message.status === 'loading' || message.status === 'incomplete';
-
-    return (
-      <div className="flex items-center gap-0.5">
-        {!isLoading && (
-          <Tooltip content={t('重试')} position="top">
-            <Button
-              theme="borderless"
-              type="tertiary"
-              size="small"
-              icon={<RefreshCw size={14} />}
-              onClick={() => handleMessageReset(message)}
-              className="!rounded-md !text-gray-400 hover:!text-blue-600 hover:!bg-blue-50 !w-7 !h-7 !p-0 transition-all"
-              aria-label={t('重试')}
-            />
-          </Tooltip>
-        )}
-
-        {message.content && (
-          <Tooltip content={t('复制')} position="top">
-            <Button
-              theme="borderless"
-              type="tertiary"
-              size="small"
-              icon={<Copy size={14} />}
-              onClick={() => handleMessageCopy(message)}
-              className="!rounded-md !text-gray-400 hover:!text-green-600 hover:!bg-green-50 !w-7 !h-7 !p-0 transition-all"
-              aria-label={t('复制')}
-            />
-          </Tooltip>
-        )}
-
-        {!isLoading && (
-          <Tooltip content={t('删除')} position="top">
-            <Button
-              theme="borderless"
-              type="tertiary"
-              size="small"
-              icon={<Trash2 size={14} />}
-              onClick={() => handleMessageDelete(message)}
-              className="!rounded-md !text-gray-400 hover:!text-red-600 hover:!bg-red-50 !w-7 !h-7 !p-0 transition-all"
-              aria-label={t('删除')}
-            />
-          </Tooltip>
-        )}
-      </div>
-    );
-  }, [handleMessageReset, handleMessageCopy, handleMessageDelete, t]);
-
   const renderCustomChatContent = useCallback(
     ({ message, className }) => {
-      if (message.status === 'error') {
-        return (
-          <div className={`${className} flex items-center p-4 bg-red-50 rounded-xl`}>
-            <Typography.Text type="danger" className="text-sm">
-              {message.content || t('请求发生错误')}
-            </Typography.Text>
-          </div>
-        );
-      }
-
-      const toggleReasoningExpansion = (messageId) => {
-        setMessage(prevMessages =>
-          prevMessages.map(msg =>
-            msg.id === messageId && msg.role === 'assistant'
-              ? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded }
-              : msg
-          )
-        );
-      };
-
-      const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
-      let currentExtractedThinkingContent = null;
-      let currentDisplayableFinalContent = message.content || "";
-      let thinkingSource = null;
-
-      if (message.role === 'assistant') {
-        let baseContentForDisplay = message.content || "";
-        let combinedThinkingContent = "";
-
-        if (message.reasoningContent) {
-          combinedThinkingContent = message.reasoningContent;
-          thinkingSource = 'reasoningContent';
-        }
-
-        if (baseContentForDisplay.includes('<think>')) {
-          const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
-          let match;
-          let thoughtsFromPairedTags = [];
-          let replyParts = [];
-          let lastIndex = 0;
-
-          while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
-            replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
-            thoughtsFromPairedTags.push(match[1]);
-            lastIndex = match.index + match[0].length;
-          }
-          replyParts.push(baseContentForDisplay.substring(lastIndex));
-
-          if (thoughtsFromPairedTags.length > 0) {
-            const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n');
-            if (combinedThinkingContent) {
-              combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr;
-            } else {
-              combinedThinkingContent = pairedThoughtsStr;
-            }
-            thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
-          }
-
-          baseContentForDisplay = replyParts.join('');
-        }
-
-        if (isThinkingStatus) {
-          const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
-          if (lastOpenThinkIndex !== -1) {
-            const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
-            if (!fragmentAfterLastOpen.includes('</think>')) {
-              const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
-              if (unclosedThought) {
-                if (combinedThinkingContent) {
-                  combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
-                } else {
-                  combinedThinkingContent = unclosedThought;
-                }
-                thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
-              }
-              baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
-            }
-          }
-        }
-
-        currentExtractedThinkingContent = combinedThinkingContent || null;
-        currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
-      }
-
-      const headerText = isThinkingStatus ? t('思考中...') : t('思考过程');
-      const finalExtractedThinkingContent = currentExtractedThinkingContent;
-      const finalDisplayableFinalContent = currentDisplayableFinalContent;
-
-      if (message.role === 'assistant' &&
-        isThinkingStatus &&
-        !finalExtractedThinkingContent &&
-        (!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
-        return (
-          <div className={`${className} flex items-center gap-4 p-6 bg-gradient-to-r from-purple-50 to-indigo-50 rounded-2xl`}>
-            <div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
-              <Loader2 className="animate-spin text-white" size={20} />
-            </div>
-            <div className="flex flex-col">
-              <Typography.Text strong className="text-gray-800 text-base">
-                {t('正在思考...')}
-              </Typography.Text>
-              <Typography.Text className="text-gray-500 text-sm">
-                AI 正在分析您的问题
-              </Typography.Text>
-            </div>
-          </div>
-        );
-      }
-
       return (
-        <div className={className}>
-          {message.role === 'assistant' && finalExtractedThinkingContent && (
-            <div className="bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 rounded-2xl mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
-              <div
-                className="flex items-center justify-between p-5 cursor-pointer hover:bg-gradient-to-r hover:from-white/40 hover:to-purple-50/60 transition-all"
-                onClick={() => toggleReasoningExpansion(message.id)}
-              >
-                <div className="flex items-center gap-4">
-                  <div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
-                    <Brain className="text-white" size={16} />
-                  </div>
-                  <div className="flex flex-col">
-                    <Typography.Text strong className="text-gray-800 text-base">
-                      {headerText}
-                    </Typography.Text>
-                    {thinkingSource && (
-                      <Typography.Text className="text-gray-500 text-xs mt-0.5">
-                        来源: {thinkingSource}
-                      </Typography.Text>
-                    )}
-                  </div>
-                </div>
-                <div className="flex items-center gap-3">
-                  {isThinkingStatus && (
-                    <div className="flex items-center gap-2">
-                      <Loader2 className="animate-spin text-purple-500" size={18} />
-                      <Typography.Text className="text-purple-600 text-sm font-medium">
-                        思考中
-                      </Typography.Text>
-                    </div>
-                  )}
-                  {!isThinkingStatus && (
-                    <div className="w-6 h-6 rounded-full bg-purple-100 flex items-center justify-center">
-                      {message.isReasoningExpanded ?
-                        <ChevronUp size={16} className="text-purple-600" /> :
-                        <ChevronRight size={16} className="text-purple-600" />
-                      }
-                    </div>
-                  )}
-                </div>
-              </div>
-              <div
-                className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
-                  } overflow-hidden`}
-              >
-                {message.isReasoningExpanded && (
-                  <div className="p-5 pt-4">
-                    <div className="bg-white/70 backdrop-blur-sm rounded-xl p-4 shadow-inner overflow-x-auto max-h-50 overflow-y-auto">
-                      <div className="prose prose-sm prose-purple max-w-none">
-                        <MarkdownRender raw={finalExtractedThinkingContent} />
-                      </div>
-                    </div>
-                  </div>
-                )}
-              </div>
-            </div>
-          )}
-
-          {(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && (
-            <div className="prose prose-sm prose-gray max-w-none overflow-x-auto">
-              <MarkdownRender raw={finalDisplayableFinalContent} />
-            </div>
-          )}
-        </div>
+        <MessageContent
+          message={message}
+          className={className}
+          styleState={styleState}
+          onToggleReasoningExpansion={toggleReasoningExpansion}
+        />
       );
     },
-    [t, setMessage],
+    [styleState],
   );
 
+  const renderChatBoxAction = useCallback((props) => {
+    const { message: currentMessage } = props;
+
+    const isAnyMessageGenerating = message.some(msg => msg.status === 'loading' || msg.status === 'incomplete');
+
+    return (
+      <MessageActions
+        message={currentMessage}
+        styleState={styleState}
+        onMessageReset={handleMessageReset}
+        onMessageCopy={handleMessageCopy}
+        onMessageDelete={handleMessageDelete}
+        isAnyMessageGenerating={isAnyMessageGenerating}
+      />
+    );
+  }, [handleMessageReset, handleMessageCopy, handleMessageDelete, styleState, message]);
+
   return (
-    <div className="min-h-screen bg-gray-50">
-      <Layout style={{ height: '100vh', background: 'transparent' }}>
+    <div className="h-full bg-gray-50">
+      <Layout style={{ height: '100%', background: 'transparent' }} className="flex flex-col md:flex-row">
         {(showSettings || !styleState.isMobile) && (
           <Layout.Sider
             style={{
               background: 'transparent',
               borderRight: 'none',
               flexShrink: 0,
-              minWidth: 320,
-              maxWidth: 320,
-              height: 'calc(100vh - 100px)',
+              minWidth: styleState.isMobile ? '100%' : 320,
+              maxWidth: styleState.isMobile ? '100%' : 320,
+              height: styleState.isMobile ? 'auto' : 'calc(100vh - 100px)',
+              overflow: 'auto',
+              position: styleState.isMobile ? 'fixed' : 'relative',
+              zIndex: styleState.isMobile ? 1000 : 1,
+              width: '100%',
+              top: 0,
+              left: 0,
+              right: 0,
+              bottom: 0,
             }}
-            width={320}
+            width={styleState.isMobile ? '100%' : 320}
+            className={styleState.isMobile ? 'bg-white shadow-lg' : ''}
           >
-            <Card className="!rounded-2xl h-full flex flex-col" bodyStyle={{ padding: '24px', height: '100%', display: 'flex', flexDirection: 'column' }}>
-              <div className="flex items-center justify-between mb-6 flex-shrink-0">
-                <div className="flex items-center">
-                  <div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-blue-500 flex items-center justify-center mr-3">
-                    <SlidersHorizontal size={20} className="text-white" />
-                  </div>
-                  <Typography.Title heading={5} className="mb-0">
-                    {t('模型设置')}
-                  </Typography.Title>
-                </div>
-                <DebugToggle />
-              </div>
-
-              <div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
-                {/* 分组选择 */}
-                <div>
-                  <div className="flex items-center gap-2 mb-2">
-                    <Users size={16} className="text-gray-500" />
-                    <Typography.Text strong className="text-sm">
-                      {t('分组')}
-                    </Typography.Text>
-                  </div>
-                  <Select
-                    placeholder={t('请选择分组')}
-                    name='group'
-                    required
-                    selection
-                    onChange={(value) => handleInputChange('group', value)}
-                    value={inputs.group}
-                    autoComplete='new-password'
-                    optionList={groups}
-                    renderOptionItem={renderGroupOption}
-                    style={{ width: '100%' }}
-                    className="!rounded-lg"
-                  />
-                </div>
-
-                {/* 模型选择 */}
-                <div>
-                  <div className="flex items-center gap-2 mb-2">
-                    <Sparkles size={16} className="text-gray-500" />
-                    <Typography.Text strong className="text-sm">
-                      {t('模型')}
-                    </Typography.Text>
-                  </div>
-                  <Select
-                    placeholder={t('请选择模型')}
-                    name='model'
-                    required
-                    selection
-                    searchPosition='dropdown'
-                    filter
-                    onChange={(value) => handleInputChange('model', value)}
-                    value={inputs.model}
-                    autoComplete='new-password'
-                    optionList={models}
-                    className="!rounded-lg"
-                  />
-                </div>
-
-                {/* Temperature */}
-                <div className={`transition-opacity duration-200 ${!parameterEnabled.temperature ? 'opacity-50' : ''}`}>
-                  <div className="flex items-center justify-between mb-2">
-                    <div className="flex items-center gap-2">
-                      <Thermometer size={16} className="text-gray-500" />
-                      <Typography.Text strong className="text-sm">
-                        Temperature
-                      </Typography.Text>
-                      <Tag size="small" className="!rounded-full">
-                        {inputs.temperature}
-                      </Tag>
-                    </div>
-                    <Button
-                      theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
-                      type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
-                      size="small"
-                      icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
-                      onClick={() => handleParameterToggle('temperature')}
-                      className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
-                    />
-                  </div>
-                  <Typography.Text className="text-xs text-gray-500 mb-2">
-                    控制输出的随机性和创造性
-                  </Typography.Text>
-                  <Slider
-                    step={0.1}
-                    min={0.1}
-                    max={1}
-                    value={inputs.temperature}
-                    onChange={(value) => handleInputChange('temperature', value)}
-                    className="mt-2"
-                    disabled={!parameterEnabled.temperature}
-                  />
-                </div>
-
-                {/* Top P */}
-                <div className={`transition-opacity duration-200 ${!parameterEnabled.top_p ? 'opacity-50' : ''}`}>
-                  <div className="flex items-center justify-between mb-2">
-                    <div className="flex items-center gap-2">
-                      <Target size={16} className="text-gray-500" />
-                      <Typography.Text strong className="text-sm">
-                        Top P
-                      </Typography.Text>
-                      <Tag size="small" className="!rounded-full">
-                        {inputs.top_p}
-                      </Tag>
-                    </div>
-                    <Button
-                      theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
-                      type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
-                      size="small"
-                      icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
-                      onClick={() => handleParameterToggle('top_p')}
-                      className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
-                    />
-                  </div>
-                  <Typography.Text className="text-xs text-gray-500 mb-2">
-                    核采样,控制词汇选择的多样性
-                  </Typography.Text>
-                  <Slider
-                    step={0.1}
-                    min={0.1}
-                    max={1}
-                    value={inputs.top_p}
-                    onChange={(value) => handleInputChange('top_p', value)}
-                    className="mt-2"
-                    disabled={!parameterEnabled.top_p}
-                  />
-                </div>
-
-                {/* Frequency Penalty */}
-                <div className={`transition-opacity duration-200 ${!parameterEnabled.frequency_penalty ? 'opacity-50' : ''}`}>
-                  <div className="flex items-center justify-between mb-2">
-                    <div className="flex items-center gap-2">
-                      <Repeat size={16} className="text-gray-500" />
-                      <Typography.Text strong className="text-sm">
-                        Frequency Penalty
-                      </Typography.Text>
-                      <Tag size="small" className="!rounded-full">
-                        {inputs.frequency_penalty}
-                      </Tag>
-                    </div>
-                    <Button
-                      theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
-                      type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
-                      size="small"
-                      icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
-                      onClick={() => handleParameterToggle('frequency_penalty')}
-                      className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
-                    />
-                  </div>
-                  <Typography.Text className="text-xs text-gray-500 mb-2">
-                    频率惩罚,减少重复词汇的出现
-                  </Typography.Text>
-                  <Slider
-                    step={0.1}
-                    min={-2}
-                    max={2}
-                    value={inputs.frequency_penalty}
-                    onChange={(value) => handleInputChange('frequency_penalty', value)}
-                    className="mt-2"
-                    disabled={!parameterEnabled.frequency_penalty}
-                  />
-                </div>
-
-                {/* Presence Penalty */}
-                <div className={`transition-opacity duration-200 ${!parameterEnabled.presence_penalty ? 'opacity-50' : ''}`}>
-                  <div className="flex items-center justify-between mb-2">
-                    <div className="flex items-center gap-2">
-                      <Ban size={16} className="text-gray-500" />
-                      <Typography.Text strong className="text-sm">
-                        Presence Penalty
-                      </Typography.Text>
-                      <Tag size="small" className="!rounded-full">
-                        {inputs.presence_penalty}
-                      </Tag>
-                    </div>
-                    <Button
-                      theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
-                      type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
-                      size="small"
-                      icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
-                      onClick={() => handleParameterToggle('presence_penalty')}
-                      className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
-                    />
-                  </div>
-                  <Typography.Text className="text-xs text-gray-500 mb-2">
-                    存在惩罚,鼓励讨论新话题
-                  </Typography.Text>
-                  <Slider
-                    step={0.1}
-                    min={-2}
-                    max={2}
-                    value={inputs.presence_penalty}
-                    onChange={(value) => handleInputChange('presence_penalty', value)}
-                    className="mt-2"
-                    disabled={!parameterEnabled.presence_penalty}
-                  />
-                </div>
-
-                {/* MaxTokens */}
-                <div className={`transition-opacity duration-200 ${!parameterEnabled.max_tokens ? 'opacity-50' : ''}`}>
-                  <div className="flex items-center justify-between mb-2">
-                    <div className="flex items-center gap-2">
-                      <Hash size={16} className="text-gray-500" />
-                      <Typography.Text strong className="text-sm">
-                        Max Tokens
-                      </Typography.Text>
-                    </div>
-                    <Button
-                      theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
-                      type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
-                      size="small"
-                      icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
-                      onClick={() => handleParameterToggle('max_tokens')}
-                      className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
-                    />
-                  </div>
-                  <Input
-                    placeholder='MaxTokens'
-                    name='max_tokens'
-                    required
-                    autoComplete='new-password'
-                    defaultValue={0}
-                    value={inputs.max_tokens}
-                    onChange={(value) => handleInputChange('max_tokens', value)}
-                    className="!rounded-lg"
-                    disabled={!parameterEnabled.max_tokens}
-                  />
-                </div>
-
-                {/* Seed */}
-                <div className={`transition-opacity duration-200 ${!parameterEnabled.seed ? 'opacity-50' : ''}`}>
-                  <div className="flex items-center justify-between mb-2">
-                    <div className="flex items-center gap-2">
-                      <Shuffle size={16} className="text-gray-500" />
-                      <Typography.Text strong className="text-sm">
-                        Seed
-                      </Typography.Text>
-                      <Typography.Text className="text-xs text-gray-400">
-                        (可选,用于复现结果)
-                      </Typography.Text>
-                    </div>
-                    <Button
-                      theme={parameterEnabled.seed ? 'solid' : 'borderless'}
-                      type={parameterEnabled.seed ? 'primary' : 'tertiary'}
-                      size="small"
-                      icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
-                      onClick={() => handleParameterToggle('seed')}
-                      className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
-                    />
-                  </div>
-                  <Input
-                    placeholder='随机种子 (留空为随机)'
-                    name='seed'
-                    autoComplete='new-password'
-                    value={inputs.seed || ''}
-                    onChange={(value) => handleInputChange('seed', value === '' ? null : value)}
-                    className="!rounded-lg"
-                    disabled={!parameterEnabled.seed}
-                  />
-                </div>
-
-                {/* Stream Toggle */}
-                <div>
-                  <div className="flex items-center justify-between">
-                    <div className="flex items-center gap-2">
-                      <ToggleLeft size={16} className="text-gray-500" />
-                      <Typography.Text strong className="text-sm">
-                        流式输出
-                      </Typography.Text>
-                    </div>
-                    <Button
-                      theme={inputs.stream ? 'solid' : 'borderless'}
-                      type={inputs.stream ? 'primary' : 'tertiary'}
-                      size="small"
-                      onClick={() => handleInputChange('stream', !inputs.stream)}
-                      className="!rounded-full"
-                    >
-                      {inputs.stream ? '开启' : '关闭'}
-                    </Button>
-                  </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={(value) => setSystemPrompt(value)}
-                    className="!rounded-lg"
-                    maxHeight={200}
-                  />
-                </div>
-              </div>
-            </Card>
+            <SettingsPanel
+              inputs={inputs}
+              parameterEnabled={parameterEnabled}
+              models={models}
+              groups={groups}
+              systemPrompt={systemPrompt}
+              styleState={styleState}
+              showSettings={showSettings}
+              showDebugPanel={showDebugPanel}
+              onInputChange={handleInputChange}
+              onParameterToggle={handleParameterToggle}
+              onSystemPromptChange={setSystemPrompt}
+              onCloseSettings={() => setShowSettings(false)}
+              onConfigImport={handleConfigImport}
+              onConfigReset={handleConfigReset}
+            />
           </Layout.Sider>
         )}
 
         <Layout.Content className="relative flex-1 overflow-hidden">
-          <div className="px-4 overflow-hidden flex gap-4" style={{ height: 'calc(100vh - 100px)' }}>
+          <div className="sm:px-4 overflow-hidden flex flex-col lg:flex-row gap-2 sm:gap-4 h-[calc(100vh-100px)]">
             <div className="flex-1 flex flex-col">
-              <SettingsToggle />
-              <Card
-                className="!rounded-2xl h-full"
-                bodyStyle={{ padding: 0, height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
-              >
-                {/* 聊天头部 */}
-                <div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
-                  <div className="flex items-center gap-3">
-                    <div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
-                      <MessageSquare size={20} className="text-white" />
-                    </div>
-                    <div>
-                      <Typography.Title heading={5} className="!text-white mb-0">
-                        {t('AI 对话')}
-                      </Typography.Title>
-                      <Typography.Text className="!text-white/80 text-sm">
-                        {inputs.model || t('选择模型开始对话')}
-                      </Typography.Text>
-                    </div>
-                  </div>
-                </div>
-
-                {/* 聊天内容区域 */}
-                <div className="flex-1 overflow-hidden">
-                  <Chat
-                    chatBoxRenderConfig={{
-                      renderChatBoxContent: renderCustomChatContent,
-                      renderChatBoxAction: renderChatBoxAction,
-                    }}
-                    renderInputArea={renderInputArea}
-                    roleConfig={roleInfo}
-                    style={{
-                      height: '100%',
-                      maxWidth: '100%',
-                      overflow: 'hidden'
-                    }}
-                    chats={message}
-                    onMessageSend={onMessageSend}
-                    onMessageCopy={handleMessageCopy}
-                    onMessageReset={handleMessageReset}
-                    onMessageDelete={handleMessageDelete}
-                    showClearContext
-                    showStopGenerate
-                    onStopGenerator={onStopGenerator}
-                    onClear={() => setMessage([])}
-                    className="h-full"
-                    placeholder={t('请输入您的问题...')}
-                  />
-                </div>
-              </Card>
+              <ChatArea
+                chatRef={chatRef}
+                message={message}
+                inputs={inputs}
+                styleState={styleState}
+                showDebugPanel={showDebugPanel}
+                roleInfo={roleInfo}
+                onMessageSend={onMessageSend}
+                onMessageCopy={handleMessageCopy}
+                onMessageReset={handleMessageReset}
+                onMessageDelete={handleMessageDelete}
+                onStopGenerator={onStopGenerator}
+                onClearMessages={() => setMessage([])}
+                onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
+                renderCustomChatContent={renderCustomChatContent}
+                renderChatBoxAction={renderChatBoxAction}
+              />
             </div>
 
-            {/* 调试面板 */}
-            {showDebugPanel && (
-              <div className="w-96 flex-shrink-0">
-                <Card className="!rounded-2xl h-full flex flex-col" bodyStyle={{ padding: '24px', height: '100%', display: 'flex', flexDirection: 'column' }}>
-                  <div className="flex items-center mb-6 flex-shrink-0">
-                    <div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
-                      <Code size={20} className="text-white" />
-                    </div>
-                    <Typography.Title heading={5} className="mb-0">
-                      {t('调试信息')}
-                    </Typography.Title>
-                  </div>
-
-                  <div className="flex-1 overflow-hidden debug-panel">
-                    <Tabs
-                      type="line"
-                      className="h-full"
-                      style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
-                      activeKey={activeDebugTab}
-                      onChange={setActiveDebugTab}
-                    >
-                      <TabPane tab={
-                        <div className="flex items-center gap-2">
-                          <FileText size={16} />
-                          {t('请求体')}
-                        </div>
-                      } itemKey="request">
-                        <div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
-                          {debugData.request ? (
-                            <pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
-                              {JSON.stringify(debugData.request, null, 2)}
-                            </pre>
-                          ) : (
-                            <Typography.Text type="secondary" className="text-sm">
-                              {t('暂无请求数据')}
-                            </Typography.Text>
-                          )}
-                        </div>
-                      </TabPane>
-
-                      <TabPane tab={
-                        <div className="flex items-center gap-2">
-                          <Zap size={16} />
-                          {t('响应内容')}
-                        </div>
-                      } itemKey="response">
-                        <div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
-                          {debugData.response ? (
-                            <pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
-                              {debugData.response}
-                            </pre>
-                          ) : (
-                            <Typography.Text type="secondary" className="text-sm">
-                              {t('暂无响应数据')}
-                            </Typography.Text>
-                          )}
-                        </div>
-                      </TabPane>
-                    </Tabs>
-                  </div>
-
-                  {debugData.timestamp && (
-                    <div className="flex items-center gap-2 mt-4 pt-4 flex-shrink-0">
-                      <Clock size={14} className="text-gray-500" />
-                      <Typography.Text className="text-xs text-gray-500">
-                        {t('最后更新')}: {new Date(debugData.timestamp).toLocaleString()}
-                      </Typography.Text>
-                    </div>
-                  )}
-                </Card>
+            {/* 调试面板 - 桌面端 */}
+            {showDebugPanel && !styleState.isMobile && (
+              <div className="w-96 flex-shrink-0 h-full">
+                <DebugPanel
+                  debugData={debugData}
+                  activeDebugTab={activeDebugTab}
+                  onActiveDebugTabChange={setActiveDebugTab}
+                  styleState={styleState}
+                />
               </div>
             )}
           </div>
+
+          {/* 调试面板 - 移动端覆盖层 */}
+          {showDebugPanel && styleState.isMobile && (
+            <div
+              style={{
+                position: 'fixed',
+                top: 0,
+                left: 0,
+                right: 0,
+                bottom: 0,
+                zIndex: 1000,
+                backgroundColor: 'white',
+                overflow: 'auto',
+              }}
+              className="shadow-lg"
+            >
+              <DebugPanel
+                debugData={debugData}
+                activeDebugTab={activeDebugTab}
+                onActiveDebugTabChange={setActiveDebugTab}
+                styleState={styleState}
+                showDebugPanel={showDebugPanel}
+                onCloseDebugPanel={() => setShowDebugPanel(false)}
+              />
+            </div>
+          )}
+
+          {/* 浮动按钮 */}
+          <FloatingButtons
+            styleState={styleState}
+            showSettings={showSettings}
+            showDebugPanel={showDebugPanel}
+            onToggleSettings={() => setShowSettings(!showSettings)}
+            onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
+          />
         </Layout.Content>
       </Layout>
     </div>