فهرست منبع

feat(playground): enhance SSE debugging and add image paste support with i18n

- Add SSEViewer component for interactive SSE message inspection
  * Display SSE data stream with collapsible panels
  * Show parsed JSON with syntax highlighting
  * Display key information badges (content, tokens, finish reason)
  * Support copy individual or all SSE messages
  * Show error messages with detailed information

- Support Ctrl+V to paste images in chat input
  * Enable image paste in CustomInputRender component
  * Auto-detect and add pasted images to image list
  * Show toast notifications for paste results

- Add complete i18n support for 6 languages
  * Chinese (zh): Complete translations
  * English (en): Complete translations
  * Japanese (ja): Add 28 new translations
  * French (fr): Add 28 new translations
  * Russian (ru): Add 28 new translations
  * Vietnamese (vi): Add 32 new translations

- Update .gitignore to exclude data directory
ImogeneOctaviap794 3 ماه پیش
والد
کامیت
9140dee70c

+ 1 - 0
.gitignore

@@ -21,3 +21,4 @@ web/bun.lock
 
 electron/node_modules
 electron/dist
+data/

+ 78 - 2
web/src/components/playground/CustomInputRender.jsx

@@ -17,12 +17,87 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import React from 'react';
+import React, { useRef, useEffect, useCallback } from 'react';
+import { Toast } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import { usePlayground } from '../../contexts/PlaygroundContext';
 
 const CustomInputRender = (props) => {
+  const { t } = useTranslation();
+  const { onPasteImage, imageEnabled } = usePlayground();
   const { detailProps } = props;
   const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
     detailProps;
+  const containerRef = useRef(null);
+
+  const handlePaste = useCallback(async (e) => {
+    const items = e.clipboardData?.items;
+    if (!items) return;
+
+    for (let i = 0; i < items.length; i++) {
+      const item = items[i];
+      
+      if (item.type.indexOf('image') !== -1) {
+        e.preventDefault();
+        const file = item.getAsFile();
+        
+        if (file) {
+          try {
+            if (!imageEnabled) {
+              Toast.warning({
+                content: t('请先在设置中启用图片功能'),
+                duration: 3,
+              });
+              return;
+            }
+
+            const reader = new FileReader();
+            reader.onload = (event) => {
+              const base64 = event.target.result;
+              
+              if (onPasteImage) {
+                onPasteImage(base64);
+                Toast.success({
+                  content: t('图片已添加'),
+                  duration: 2,
+                });
+              } else {
+                Toast.error({
+                  content: t('无法添加图片'),
+                  duration: 2,
+                });
+              }
+            };
+            reader.onerror = () => {
+              console.error('Failed to read image file:', reader.error);
+              Toast.error({
+                content: t('粘贴图片失败'),
+                duration: 2,
+              });
+            };
+            reader.readAsDataURL(file);
+          } catch (error) {
+            console.error('Failed to paste image:', error);
+            Toast.error({
+              content: t('粘贴图片失败'),
+              duration: 2,
+            });
+          }
+        }
+        break;
+      }
+    }
+  }, [onPasteImage, imageEnabled, t]);
+
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container) return;
+
+    container.addEventListener('paste', handlePaste);
+    return () => {
+      container.removeEventListener('paste', handlePaste);
+    };
+  }, [handlePaste]);
 
   // 清空按钮
   const styledClearNode = clearContextNode
@@ -57,11 +132,12 @@ const CustomInputRender = (props) => {
   });
 
   return (
-    <div className='p-2 sm:p-4'>
+    <div className='p-2 sm:p-4' ref={containerRef}>
       <div
         className='flex items-center 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}
+        title={t('支持 Ctrl+V 粘贴图片')}
       >
         {/* 清空对话按钮 - 左边 */}
         {styledClearNode}

+ 10 - 10
web/src/components/playground/CustomRequestEditor.jsx

@@ -82,7 +82,7 @@ const CustomRequestEditor = ({
       return true;
     } catch (error) {
       setIsValid(false);
-      setErrorMessage(`JSON格式错误: ${error.message}`);
+      setErrorMessage(`${t('JSON格式错误')}: ${error.message}`);
       return false;
     }
   };
@@ -123,14 +123,14 @@ const CustomRequestEditor = ({
         <div className='flex items-center gap-2'>
           <Code size={16} className='text-gray-500' />
           <Typography.Text strong className='text-sm'>
-            自定义请求体模式
+            {t('自定义请求体模式')}
           </Typography.Text>
         </div>
         <Switch
           checked={customRequestMode}
           onChange={handleModeToggle}
-          checkedText='开'
-          uncheckedText='关'
+          checkedText={t('开')}
+          uncheckedText={t('关')}
           size='small'
         />
       </div>
@@ -140,7 +140,7 @@ const CustomRequestEditor = ({
           {/* 提示信息 */}
           <Banner
             type='warning'
-            description='启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。'
+            description={t('启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。')}
             icon={<AlertTriangle size={16} />}
             className='!rounded-lg'
             closeIcon={null}
@@ -150,21 +150,21 @@ const CustomRequestEditor = ({
           <div>
             <div className='flex items-center justify-between mb-2'>
               <Typography.Text strong className='text-sm'>
-                请求体 JSON
+                {t('请求体 JSON')}
               </Typography.Text>
               <div className='flex items-center gap-2'>
                 {isValid ? (
                   <div className='flex items-center gap-1 text-green-600'>
                     <Check size={14} />
                     <Typography.Text className='text-xs'>
-                      格式正确
+                      {t('格式正确')}
                     </Typography.Text>
                   </div>
                 ) : (
                   <div className='flex items-center gap-1 text-red-600'>
                     <X size={14} />
                     <Typography.Text className='text-xs'>
-                      格式错误
+                      {t('格式错误')}
                     </Typography.Text>
                   </div>
                 )}
@@ -177,7 +177,7 @@ const CustomRequestEditor = ({
                   disabled={!isValid}
                   className='!rounded-lg'
                 >
-                  格式化
+                  {t('格式化')}
                 </Button>
               </div>
             </div>
@@ -201,7 +201,7 @@ const CustomRequestEditor = ({
             )}
 
             <Typography.Text className='text-xs text-gray-500 mt-2 block'>
-              请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。
+              {t('请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。')}
             </Typography.Text>
           </div>
         </>

+ 18 - 5
web/src/components/playground/DebugPanel.jsx

@@ -29,6 +29,7 @@ import {
 import { Code, Zap, Clock, X, Eye, Send } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import CodeViewer from './CodeViewer';
+import SSEViewer from './SSEViewer';
 
 const DebugPanel = ({
   debugData,
@@ -180,15 +181,27 @@ const DebugPanel = ({
               <div className='flex items-center gap-2'>
                 <Zap size={16} />
                 {t('响应')}
+                {debugData.sseMessages && debugData.sseMessages.length > 0 && (
+                  <span className='px-1.5 py-0.5 text-xs bg-blue-100 text-blue-600 rounded-full'>
+                    SSE ({debugData.sseMessages.length})
+                  </span>
+                )}
               </div>
             }
             itemKey='response'
           >
-            <CodeViewer
-              content={debugData.response}
-              title='response'
-              language='json'
-            />
+            {debugData.sseMessages && debugData.sseMessages.length > 0 ? (
+              <SSEViewer
+                sseData={debugData.sseMessages}
+                title='response'
+              />
+            ) : (
+              <CodeViewer
+                content={debugData.response}
+                title='response'
+                language='json'
+              />
+            )}
           </TabPane>
         </Tabs>
       </div>

+ 12 - 10
web/src/components/playground/ImageUrlInput.jsx

@@ -21,6 +21,7 @@ import React from 'react';
 import { Input, Typography, Button, Switch } from '@douyinfe/semi-ui';
 import { IconFile } from '@douyinfe/semi-icons';
 import { FileText, Plus, X, Image } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 
 const ImageUrlInput = ({
   imageUrls,
@@ -29,6 +30,7 @@ const ImageUrlInput = ({
   onImageEnabledChange,
   disabled = false,
 }) => {
+  const { t } = useTranslation();
   const handleAddImageUrl = () => {
     const newUrls = [...imageUrls, ''];
     onImageUrlsChange(newUrls);
@@ -56,11 +58,11 @@ const ImageUrlInput = ({
             }
           />
           <Typography.Text strong className='text-sm'>
-            图片地址
+            {t('图片地址')}
           </Typography.Text>
           {disabled && (
             <Typography.Text className='text-xs text-orange-600'>
-              (已在自定义模式中忽略)
+              ({t('已在自定义模式中忽略')})
             </Typography.Text>
           )}
         </div>
@@ -68,8 +70,8 @@ const ImageUrlInput = ({
           <Switch
             checked={imageEnabled}
             onChange={onImageEnabledChange}
-            checkedText='启用'
-            uncheckedText='停用'
+            checkedText={t('启用')}
+            uncheckedText={t('停用')}
             size='small'
             className='flex-shrink-0'
             disabled={disabled}
@@ -89,19 +91,19 @@ const ImageUrlInput = ({
       {!imageEnabled ? (
         <Typography.Text className='text-xs text-gray-500 mb-2 block'>
           {disabled
-            ? '图片功能在自定义请求体模式下不可用'
-            : '启用后可添加图片URL进行多模态对话'}
+            ? t('图片功能在自定义请求体模式下不可用')
+            : t('启用后可添加图片URL进行多模态对话')}
         </Typography.Text>
       ) : imageUrls.length === 0 ? (
         <Typography.Text className='text-xs text-gray-500 mb-2 block'>
           {disabled
-            ? '图片功能在自定义请求体模式下不可用'
-            : '点击 + 按钮添加图片URL进行多模态对话'}
+            ? t('图片功能在自定义请求体模式下不可用')
+            : t('点击 + 按钮添加图片URL进行多模态对话')}
         </Typography.Text>
       ) : (
         <Typography.Text className='text-xs text-gray-500 mb-2 block'>
-          已添加 {imageUrls.length} 张图片
-          {disabled ? ' (自定义模式下不可用)' : ''}
+          {t('已添加')} {imageUrls.length} {t('张图片')}
+          {disabled ? ` (${t('自定义模式下不可用')})` : ''}
         </Typography.Text>
       )}
 

+ 9 - 6
web/src/components/playground/ParameterControl.jsx

@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
 
 import React from 'react';
 import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
 import {
   Hash,
   Thermometer,
@@ -37,6 +38,8 @@ const ParameterControl = ({
   onParameterToggle,
   disabled = false,
 }) => {
+  const { t } = useTranslation();
+
   return (
     <>
       {/* Temperature */}
@@ -70,7 +73,7 @@ const ParameterControl = ({
           />
         </div>
         <Typography.Text className='text-xs text-gray-500 mb-2'>
-          控制输出的随机性和创造性
+          {t('控制输出的随机性和创造性')}
         </Typography.Text>
         <Slider
           step={0.1}
@@ -110,7 +113,7 @@ const ParameterControl = ({
           />
         </div>
         <Typography.Text className='text-xs text-gray-500 mb-2'>
-          核采样,控制词汇选择的多样性
+          {t('核采样,控制词汇选择的多样性')}
         </Typography.Text>
         <Slider
           step={0.1}
@@ -154,7 +157,7 @@ const ParameterControl = ({
           />
         </div>
         <Typography.Text className='text-xs text-gray-500 mb-2'>
-          频率惩罚,减少重复词汇的出现
+          {t('频率惩罚,减少重复词汇的出现')}
         </Typography.Text>
         <Slider
           step={0.1}
@@ -198,7 +201,7 @@ const ParameterControl = ({
           />
         </div>
         <Typography.Text className='text-xs text-gray-500 mb-2'>
-          存在惩罚,鼓励讨论新话题
+          {t('存在惩罚,鼓励讨论新话题')}
         </Typography.Text>
         <Slider
           step={0.1}
@@ -262,7 +265,7 @@ const ParameterControl = ({
               Seed
             </Typography.Text>
             <Typography.Text className='text-xs text-gray-400'>
-              (可选,用于复现结果)
+              ({t('可选,用于复现结果')})
             </Typography.Text>
           </div>
           <Button
@@ -276,7 +279,7 @@ const ParameterControl = ({
           />
         </div>
         <Input
-          placeholder='随机种子 (留空为随机)'
+          placeholder={t('随机种子 (留空为随机)')}
           name='seed'
           autoComplete='new-password'
           value={inputs.seed || ''}

+ 266 - 0
web/src/components/playground/SSEViewer.jsx

@@ -0,0 +1,266 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState, useMemo, useCallback } from 'react';
+import { Button, Tooltip, Toast, Collapse, Badge, Typography } from '@douyinfe/semi-ui';
+import { Copy, ChevronDown, ChevronUp, Zap, CheckCircle, XCircle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { copy } from '../../helpers';
+
+/**
+ * SSEViewer component for displaying Server-Sent Events in an interactive format
+ * @param {Object} props - Component props
+ * @param {Array} props.sseData - Array of SSE messages to display
+ * @returns {JSX.Element} Rendered SSE viewer component
+ */
+const SSEViewer = ({ sseData }) => {
+  const { t } = useTranslation();
+  const [expandedKeys, setExpandedKeys] = useState([]);
+  const [copied, setCopied] = useState(false);
+
+  const parsedSSEData = useMemo(() => {
+    if (!sseData || !Array.isArray(sseData)) {
+      return [];
+    }
+
+    return sseData.map((item, index) => {
+      let parsed = null;
+      let error = null;
+      let isDone = false;
+
+      if (item === '[DONE]') {
+        isDone = true;
+      } else {
+        try {
+          parsed = typeof item === 'string' ? JSON.parse(item) : item;
+        } catch (e) {
+          error = e.message;
+        }
+      }
+
+      return {
+        index,
+        raw: item,
+        parsed,
+        error,
+        isDone,
+        key: `sse-${index}`,
+      };
+    });
+  }, [sseData]);
+
+  const stats = useMemo(() => {
+    const total = parsedSSEData.length;
+    const errors = parsedSSEData.filter(item => item.error).length;
+    const done = parsedSSEData.filter(item => item.isDone).length;
+    const valid = total - errors - done;
+
+    return { total, errors, done, valid };
+  }, [parsedSSEData]);
+
+  const handleToggleAll = useCallback(() => {
+    setExpandedKeys(prev => {
+      if (prev.length === parsedSSEData.length) {
+        return [];
+      } else {
+        return parsedSSEData.map(item => item.key);
+      }
+    });
+  }, [parsedSSEData]);
+
+  const handleCopyAll = useCallback(async () => {
+    try {
+      const allData = parsedSSEData
+        .map(item => (item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw))
+        .join('\n\n');
+
+      await copy(allData);
+      setCopied(true);
+      Toast.success(t('已复制全部数据'));
+      setTimeout(() => setCopied(false), 2000);
+    } catch (err) {
+      Toast.error(t('复制失败'));
+      console.error('Copy failed:', err);
+    }
+  }, [parsedSSEData, t]);
+
+  const handleCopySingle = useCallback(async (item) => {
+    try {
+      const textToCopy = item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw;
+      await copy(textToCopy);
+      Toast.success(t('已复制'));
+    } catch (err) {
+      Toast.error(t('复制失败'));
+    }
+  }, [t]);
+
+  const renderSSEItem = (item) => {
+    if (item.isDone) {
+      return (
+        <div className='flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg'>
+          <CheckCircle size={16} className='text-green-600' />
+          <Typography.Text className='text-green-600 font-medium'>
+            {t('流式响应完成')} [DONE]
+          </Typography.Text>
+        </div>
+      );
+    }
+
+    if (item.error) {
+      return (
+        <div className='space-y-2'>
+          <div className='flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg'>
+            <XCircle size={16} className='text-red-600' />
+            <Typography.Text className='text-red-600'>
+              {t('解析错误')}: {item.error}
+            </Typography.Text>
+          </div>
+          <div className='p-3 bg-gray-100 dark:bg-gray-800 rounded-lg font-mono text-xs overflow-auto'>
+            <pre>{item.raw}</pre>
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <div className='space-y-2'>
+        {/* JSON 格式化显示 */}
+        <div className='relative'>
+          <pre className='p-4 bg-gray-900 text-gray-100 rounded-lg overflow-auto text-xs font-mono leading-relaxed'>
+            {JSON.stringify(item.parsed, null, 2)}
+          </pre>
+          <Button
+            icon={<Copy size={12} />}
+            size='small'
+            theme='borderless'
+            onClick={() => handleCopySingle(item)}
+            className='absolute top-2 right-2 !bg-gray-800/80 !text-gray-300 hover:!bg-gray-700'
+          />
+        </div>
+
+        {/* 关键信息摘要 */}
+        {item.parsed?.choices?.[0] && (
+          <div className='flex flex-wrap gap-2 text-xs'>
+            {item.parsed.choices[0].delta?.content && (
+              <Badge count={`${t('内容')}: "${String(item.parsed.choices[0].delta.content).substring(0, 20)}..."`} type='primary' />
+            )}
+            {item.parsed.choices[0].delta?.reasoning_content && (
+              <Badge count={t('有 Reasoning')} type='warning' />
+            )}
+            {item.parsed.choices[0].finish_reason && (
+              <Badge count={`${t('完成')}: ${item.parsed.choices[0].finish_reason}`} type='success' />
+            )}
+            {item.parsed.usage && (
+              <Badge 
+                count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`} 
+                type='tertiary' 
+              />
+            )}
+          </div>
+        )}
+      </div>
+    );
+  };
+
+  if (!parsedSSEData || parsedSSEData.length === 0) {
+    return (
+      <div className='flex items-center justify-center h-full min-h-[200px] text-gray-500'>
+        <span>{t('暂无SSE响应数据')}</span>
+      </div>
+    );
+  }
+
+  return (
+    <div className='h-full flex flex-col bg-gray-50 dark:bg-gray-900/50 rounded-lg'>
+      {/* 头部工具栏 */}
+      <div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0'>
+        <div className='flex items-center gap-3'>
+          <Zap size={16} className='text-blue-500' />
+          <Typography.Text strong>{t('SSE数据流')}</Typography.Text>
+          <Badge count={stats.total} type='primary' />
+          {stats.errors > 0 && <Badge count={`${stats.errors} ${t('错误')}`} type='danger' />}
+        </div>
+
+        <div className='flex items-center gap-2'>
+          <Tooltip content={t('复制全部')}>
+            <Button
+              icon={<Copy size={14} />}
+              size='small'
+              onClick={handleCopyAll}
+              theme='borderless'
+            >
+              {copied ? t('已复制') : t('复制全部')}
+            </Button>
+          </Tooltip>
+          <Tooltip content={expandedKeys.length === parsedSSEData.length ? t('全部收起') : t('全部展开')}>
+            <Button
+              icon={expandedKeys.length === parsedSSEData.length ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
+              size='small'
+              onClick={handleToggleAll}
+              theme='borderless'
+            >
+              {expandedKeys.length === parsedSSEData.length ? t('收起') : t('展开')}
+            </Button>
+          </Tooltip>
+        </div>
+      </div>
+
+      {/* SSE 数据列表 */}
+      <div className='flex-1 overflow-auto p-4'>
+        <Collapse
+          activeKey={expandedKeys}
+          onChange={setExpandedKeys}
+          accordion={false}
+          className='bg-white dark:bg-gray-800 rounded-lg'
+        >
+          {parsedSSEData.map((item) => (
+            <Collapse.Panel
+              key={item.key}
+              header={
+                <div className='flex items-center gap-2'>
+                  <Badge count={`#${item.index + 1}`} type='tertiary' />
+                  {item.isDone ? (
+                    <span className='text-green-600 font-medium'>[DONE]</span>
+                  ) : item.error ? (
+                    <span className='text-red-600'>{t('解析错误')}</span>
+                  ) : (
+                    <>
+                      <span className='text-gray-600'>
+                        {item.parsed?.id || item.parsed?.object || t('SSE 事件')}
+                      </span>
+                      {item.parsed?.choices?.[0]?.delta && (
+                        <span className='text-xs text-gray-400'>
+                          • {Object.keys(item.parsed.choices[0].delta).filter(k => item.parsed.choices[0].delta[k]).join(', ')}
+                        </span>
+                      )}
+                    </>
+                  )}
+                </div>
+              }
+            >
+              {renderSSEItem(item)}
+            </Collapse.Panel>
+          ))}
+        </Collapse>
+      </div>
+    </div>
+  );
+};
+
+export default SSEViewer;

+ 6 - 6
web/src/components/playground/SettingsPanel.jsx

@@ -122,7 +122,7 @@ const SettingsPanel = ({
             </Typography.Text>
             {customRequestMode && (
               <Typography.Text className='text-xs text-orange-600'>
-                (已在自定义模式中忽略)
+                ({t('已在自定义模式中忽略')})
               </Typography.Text>
             )}
           </div>
@@ -154,7 +154,7 @@ const SettingsPanel = ({
             </Typography.Text>
             {customRequestMode && (
               <Typography.Text className='text-xs text-orange-600'>
-                (已在自定义模式中忽略)
+                ({t('已在自定义模式中忽略')})
               </Typography.Text>
             )}
           </div>
@@ -206,19 +206,19 @@ const SettingsPanel = ({
             <div className='flex items-center gap-2'>
               <ToggleLeft size={16} className='text-gray-500' />
               <Typography.Text strong className='text-sm'>
-                流式输出
+                {t('流式输出')}
               </Typography.Text>
               {customRequestMode && (
                 <Typography.Text className='text-xs text-orange-600'>
-                  (已在自定义模式中忽略)
+                  ({t('已在自定义模式中忽略')})
                 </Typography.Text>
               )}
             </div>
             <Switch
               checked={inputs.stream}
               onChange={(checked) => onInputChange('stream', checked)}
-              checkedText='开'
-              uncheckedText='关'
+              checkedText={t('开')}
+              uncheckedText={t('关')}
               size='small'
               disabled={customRequestMode}
             />

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

@@ -23,6 +23,7 @@ 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 SSEViewer } from './SSEViewer';
 export { default as ParameterControl } from './ParameterControl';
 export { default as ImageUrlInput } from './ImageUrlInput';
 export { default as FloatingButtons } from './FloatingButtons';

+ 21 - 3
web/src/constants/playground.constants.js

@@ -30,19 +30,37 @@ export const MESSAGE_ROLES = {
   SYSTEM: 'system',
 };
 
-// 默认消息示例
+// 默认消息示例 - 使用函数生成以支持 i18n
+export const getDefaultMessages = (t) => [
+  {
+    role: MESSAGE_ROLES.USER,
+    id: '2',
+    createAt: 1715676751919,
+    content: t('默认用户消息'),
+  },
+  {
+    role: MESSAGE_ROLES.ASSISTANT,
+    id: '3',
+    createAt: 1715676751919,
+    content: t('默认助手消息'),
+    reasoningContent: '',
+    isReasoningExpanded: false,
+  },
+];
+
+// 保留旧的导出以保持向后兼容
 export const DEFAULT_MESSAGES = [
   {
     role: MESSAGE_ROLES.USER,
     id: '2',
     createAt: 1715676751919,
-    content: '你好',
+    content: 'Hello',
   },
   {
     role: MESSAGE_ROLES.ASSISTANT,
     id: '3',
     createAt: 1715676751919,
-    content: '你好,请问有什么可以帮助您的吗?',
+    content: 'Hello! How can I help you today?',
     reasoningContent: '',
     isReasoningExpanded: false,
   },

+ 60 - 0
web/src/contexts/PlaygroundContext.jsx

@@ -0,0 +1,60 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { createContext, useContext } from 'react';
+
+/**
+ * Context for Playground component to share image handling functionality
+ */
+const PlaygroundContext = createContext(null);
+
+/**
+ * Hook to access Playground context
+ * @returns {Object} Context value with onPasteImage, imageUrls, and imageEnabled
+ */
+export const usePlayground = () => {
+  const context = useContext(PlaygroundContext);
+  if (!context) {
+    return {
+      onPasteImage: () => {
+        console.warn('PlaygroundContext not provided');
+      },
+      imageUrls: [],
+      imageEnabled: false,
+    };
+  }
+  return context;
+};
+
+/**
+ * Provider component for Playground context
+ * @param {Object} props - Component props
+ * @param {React.ReactNode} props.children - Child components
+ * @param {Object} props.value - Context value to provide
+ * @returns {JSX.Element} Provider component
+ */
+export const PlaygroundProvider = ({ children, value }) => {
+  return (
+    <PlaygroundContext.Provider value={value}>
+      {children}
+    </PlaygroundContext.Provider>
+  );
+};
+
+export default PlaygroundContext;

+ 18 - 1
web/src/hooks/playground/useApiRequest.jsx

@@ -179,6 +179,8 @@ export const useApiRequest = (
         request: payload,
         timestamp: new Date().toISOString(),
         response: null,
+        sseMessages: null, // 非流式请求清除 SSE 消息
+        isStreaming: false,
       }));
       setActiveDebugTab(DEBUG_TABS.REQUEST);
 
@@ -291,6 +293,8 @@ export const useApiRequest = (
         request: payload,
         timestamp: new Date().toISOString(),
         response: null,
+        sseMessages: [], // 新增:存储 SSE 消息数组
+        isStreaming: true, // 新增:标记流式状态
       }));
       setActiveDebugTab(DEBUG_TABS.REQUEST);
 
@@ -314,7 +318,12 @@ export const useApiRequest = (
           isStreamComplete = true; // 标记流正常完成
           source.close();
           sseSourceRef.current = null;
-          setDebugData((prev) => ({ ...prev, response: responseData }));
+          setDebugData((prev) => ({ 
+            ...prev, 
+            response: responseData,
+            sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记
+            isStreaming: false,
+          }));
           completeMessage();
           return;
         }
@@ -328,6 +337,12 @@ export const useApiRequest = (
             hasReceivedFirstResponse = true;
           }
 
+          // 新增:将 SSE 消息添加到数组
+          setDebugData((prev) => ({
+            ...prev,
+            sseMessages: [...(prev.sseMessages || []), e.data],
+          }));
+
           const delta = payload.choices?.[0]?.delta;
           if (delta) {
             if (delta.reasoning_content) {
@@ -347,6 +362,8 @@ export const useApiRequest = (
           setDebugData((prev) => ({
             ...prev,
             response: responseData + `\n\nError: ${errorInfo}`,
+            sseMessages: [...(prev.sseMessages || []), e.data], // 即使解析失败也保存原始数据
+            isStreaming: false,
           }));
           setActiveDebugTab(DEBUG_TABS.RESPONSE);
 

+ 32 - 4
web/src/hooks/playground/usePlaygroundState.js

@@ -18,8 +18,10 @@ For commercial licensing, please contact support@quantumnous.com
 */
 
 import { useState, useCallback, useRef, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import {
   DEFAULT_MESSAGES,
+  getDefaultMessages,
   DEFAULT_CONFIG,
   DEBUG_TABS,
   MESSAGE_STATUS,
@@ -33,9 +35,27 @@ import {
 import { processIncompleteThinkTags } from '../../helpers';
 
 export const usePlaygroundState = () => {
+  const { t } = useTranslation();
+  
   // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
   const [savedConfig] = useState(() => loadConfig());
-  const [initialMessages] = useState(() => loadMessages() || DEFAULT_MESSAGES);
+  const [initialMessages] = useState(() => {
+    const loaded = loadMessages();
+    // 检查是否是旧的中文默认消息,如果是则清除
+    if (loaded && loaded.length === 2 && loaded[0].id === '2' && loaded[1].id === '3') {
+      const hasOldChinese = 
+        loaded[0].content === '你好' || 
+        loaded[1].content === '你好,请问有什么可以帮助您的吗?' ||
+        loaded[1].content === '你好!很高兴见到你。有什么我可以帮助你的吗?';
+      
+      if (hasOldChinese) {
+        // 清除旧的默认消息
+        localStorage.removeItem('playground_messages');
+        return null;
+      }
+    }
+    return loaded;
+  });
 
   // 基础配置状态
   const [inputs, setInputs] = useState(
@@ -60,8 +80,16 @@ export const usePlaygroundState = () => {
   const [groups, setGroups] = useState([]);
   const [status, setStatus] = useState({});
 
-  // 消息相关状态 - 使用加载的消息初始化
-  const [message, setMessage] = useState(initialMessages);
+  // 消息相关状态 - 使用加载的消息或默认消息初始化
+  const [message, setMessage] = useState(() => initialMessages || getDefaultMessages(t));
+  
+  // 当语言改变时,如果是默认消息则更新
+  useEffect(() => {
+    // 只在没有保存的消息时才更新默认消息
+    if (!initialMessages) {
+      setMessage(getDefaultMessages(t));
+    }
+  }, [t, initialMessages]); // 当语言改变时
 
   // 调试状态
   const [debugData, setDebugData] = useState({
@@ -168,7 +196,7 @@ export const usePlaygroundState = () => {
     if (resetMessages) {
       setMessage([]);
       setTimeout(() => {
-        setMessage(DEFAULT_MESSAGES);
+        setMessage(getDefaultMessages(t));
       }, 0);
     }
   }, []);

+ 45 - 5
web/src/i18n/locales/en.json

@@ -407,7 +407,7 @@
     "共 {{count}} 个密钥_other": "{{count}} keys",
     "共 {{count}} 个模型": "{{count}} models",
     "共 {{total}} 项,当前显示 {{start}}-{{end}} 项": "{{total}} items total, showing {{start}}-{{end}} items",
-    "关": "close",
+    "关": "Off",
     "关于": "About",
     "关于我们": "About Us",
     "关于系统的详细信息": "Detailed information about the system",
@@ -788,7 +788,7 @@
     "应用覆盖": "Apply overwrite",
     "建立连接时发生错误": "Error occurred while establishing connection",
     "建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。": "It is recommended to use MySQL or PostgreSQL databases in production environments, or ensure that the SQLite database file is mapped to the persistent storage of the host machine.",
-    "开": "open",
+    "开": "On",
     "开启之后会清除用户提示词中的": "After enabling, the user prompt will be cleared",
     "开启之后将上游地址替换为服务器地址": "After enabling, the upstream address will be replaced with the server address",
     "开启后,仅\"消费\"和\"错误\"日志将记录您的客户端IP地址": "After enabling, only \"consumption\" and \"error\" logs will record your client IP address",
@@ -2004,7 +2004,7 @@
     "重试": "Retry",
     "钱包管理": "Wallet Management",
     "链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1": "The {key} in the link will be automatically replaced with sk-xxxx, the {address} will be automatically replaced with the server address in system settings, and the end will not have / and /v1",
-    "错误": "Error",
+    "错误": "errors",
     "键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{\"vip\": {\"default\": 0.5, \"test\": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5,使用test分组时倍率为1": "The key is the group name, and the value is another JSON object. The key is the group name, and the value is the special group ratio for users in that group. For example: {\"vip\": {\"default\": 0.5, \"test\": 1}} means that users in the vip group have a ratio of 0.5 when using tokens from the default group, and a ratio of 1 when using tokens from the test group",
     "键为原状态码,值为要复写的状态码,仅影响本地判断": "The key is the original status code, and the value is the status code to override, only affects local judgment",
     "键为端点类型,值为路径和方法对象": "The key is the endpoint type, the value is the path and method object",
@@ -2113,6 +2113,46 @@
     "统一的": "The Unified",
     "大模型接口网关": "LLM API Gateway",
     "正在跳转 GitHub...": "Redirecting to GitHub...",
-    "请求超时,请刷新页面后重新发起 GitHub 登录": "Request timed out, please refresh and restart GitHub login"
+    "请求超时,请刷新页面后重新发起 GitHub 登录": "Request timed out, please refresh and restart GitHub login",
+    "请先在设置中启用图片功能": "Please enable image function in settings first",
+    "图片已添加": "Image added",
+    "无法添加图片": "Cannot add image",
+    "粘贴图片失败": "Failed to paste image",
+    "支持 Ctrl+V 粘贴图片": "Support Ctrl+V to paste image",
+    "已复制全部数据": "All data copied",
+    "流式响应完成": "Stream completed",
+    "图片地址": "Image URL",
+    "已在自定义模式中忽略": "Ignored in custom mode",
+    "停用": "Disable",
+    "图片功能在自定义请求体模式下不可用": "Image function is not available in custom request mode",
+    "启用后可添加图片URL进行多模态对话": "Enable to add image URLs for multimodal conversation",
+    "点击 + 按钮添加图片URL进行多模态对话": "Click + to add image URLs for multimodal conversation",
+    "已添加": "Added",
+    "张图片": "images",
+    "自定义模式下不可用": "Not available in custom mode",
+    "控制输出的随机性和创造性": "Controls randomness and creativity of output",
+    "核采样,控制词汇选择的多样性": "Nucleus sampling, controls diversity of vocabulary selection",
+    "频率惩罚,减少重复词汇的出现": "Frequency penalty, reduces repetition of words",
+    "存在惩罚,鼓励讨论新话题": "Presence penalty, encourages new topics",
+    "流式输出": "Stream Output",
+    "暂无SSE响应数据": "No SSE response data",
+    "SSE数据流": "SSE Data Stream",
+    "解析错误": "Parse Error",
+    "有 Reasoning": "Has Reasoning",
+    "全部收起": "Collapse All",
+    "全部展开": "Expand All",
+    "SSE 事件": "SSE Event",
+    "JSON格式错误": "JSON Format Error",
+    "自定义请求体模式": "Custom Request Body Mode",
+    "启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。": "When enabled, your custom request body will be used for API requests, and parameter settings in the model configuration panel will be ignored.",
+    "请求体 JSON": "Request Body JSON",
+    "格式正确": "Valid Format",
+    "格式错误": "Invalid Format",
+    "格式化": "Format",
+    "请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "Please enter a valid JSON format request body. You can refer to the default request body format in the preview panel.",
+    "默认用户消息": "Hello",
+    "默认助手消息": "Hello! How can I help you today?",
+    "可选,用于复现结果": "Optional, for reproducible results",
+    "随机种子 (留空为随机)": "Random seed (leave empty for random)"
   }
-}
+}

+ 42 - 2
web/src/i18n/locales/fr.json

@@ -2093,6 +2093,46 @@
     "统一的": "La Passerelle",
     "大模型接口网关": "API LLM Unifiée",
     "正在跳转 GitHub...": "Redirection vers GitHub...",
-    "请求超时,请刷新页面后重新发起 GitHub 登录": "Délai dépassé, veuillez actualiser la page puis relancer la connexion GitHub"
+    "请求超时,请刷新页面后重新发起 GitHub 登录": "Délai dépassé, veuillez actualiser la page puis relancer la connexion GitHub",
+    "请先在设置中启用图片功能": "Veuillez d'abord activer la fonction image dans les paramètres",
+    "图片已添加": "Image ajoutée",
+    "无法添加图片": "Impossible d'ajouter l'image",
+    "粘贴图片失败": "Échec du collage de l'image",
+    "支持 Ctrl+V 粘贴图片": "Supporte Ctrl+V pour coller l'image",
+    "已复制全部数据": "Toutes les données copiées",
+    "流式响应完成": "Flux terminé",
+    "图片地址": "URL de l'image",
+    "已在自定义模式中忽略": "Ignoré en mode personnalisé",
+    "停用": "Désactiver",
+    "图片功能在自定义请求体模式下不可用": "La fonction image n'est pas disponible en mode requête personnalisée",
+    "启用后可添加图片URL进行多模态对话": "Activer pour ajouter des URL d'images pour une conversation multimodale",
+    "点击 + 按钮添加图片URL进行多模态对话": "Cliquez sur + pour ajouter des URL d'images pour une conversation multimodale",
+    "已添加": "Ajouté",
+    "张图片": "images",
+    "自定义模式下不可用": "Non disponible en mode personnalisé",
+    "控制输出的随机性和创造性": "Contrôle l'aléatoire et la créativité de la sortie",
+    "核采样,控制词汇选择的多样性": "Échantillonnage nucléaire, contrôle la diversité de la sélection du vocabulaire",
+    "频率惩罚,减少重复词汇的出现": "Pénalité de fréquence, réduit la répétition des mots",
+    "存在惩罚,鼓励讨论新话题": "Pénalité de présence, encourage de nouveaux sujets",
+    "流式输出": "Sortie en flux",
+    "暂无SSE响应数据": "Aucune donnée de réponse SSE",
+    "SSE数据流": "Flux de données SSE",
+    "解析错误": "Erreur d'analyse",
+    "有 Reasoning": "A un raisonnement",
+    "全部收起": "Tout réduire",
+    "全部展开": "Tout développer",
+    "SSE 事件": "Événement SSE",
+    "JSON格式错误": "Erreur de format JSON",
+    "自定义请求体模式": "Mode de corps de requête personnalisé",
+    "启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。": "Lorsqu'il est activé, votre corps de requête personnalisé sera utilisé pour les requêtes API et les paramètres du panneau de configuration du modèle seront ignorés.",
+    "请求体 JSON": "Corps de requête JSON",
+    "格式正确": "Format valide",
+    "格式错误": "Format invalide",
+    "格式化": "Formater",
+    "请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "Veuillez entrer un corps de requête au format JSON valide. Vous pouvez vous référer au format de corps de requête par défaut dans le panneau d'aperçu.",
+    "默认用户消息": "Bonjour",
+    "默认助手消息": "Bonjour ! Comment puis-je vous aider aujourd'hui ?",
+    "可选,用于复现结果": "Optionnel, pour des résultats reproductibles",
+    "随机种子 (留空为随机)": "Graine aléatoire (laisser vide pour aléatoire)"
   }
-}
+}

+ 42 - 2
web/src/i18n/locales/ja.json

@@ -2084,6 +2084,46 @@
     "统一的": "統合型",
     "大模型接口网关": "LLM APIゲートウェイ",
     "正在跳转 GitHub...": "GitHub にリダイレクトしています...",
-    "请求超时,请刷新页面后重新发起 GitHub 登录": "タイムアウトしました。ページをリロードして GitHub ログインをやり直してください"
+    "请求超时,请刷新页面后重新发起 GitHub 登录": "タイムアウトしました。ページをリロードして GitHub ログインをやり直してください",
+    "请先在设置中启用图片功能": "まず設定で画像機能を有効にしてください",
+    "图片已添加": "画像が追加されました",
+    "无法添加图片": "画像を追加できません",
+    "粘贴图片失败": "画像の貼り付けに失敗しました",
+    "支持 Ctrl+V 粘贴图片": "Ctrl+V で画像を貼り付け可能",
+    "已复制全部数据": "すべてのデータをコピーしました",
+    "流式响应完成": "ストリーム完了",
+    "图片地址": "画像URL",
+    "已在自定义模式中忽略": "カスタムモードで無視されました",
+    "停用": "無効",
+    "图片功能在自定义请求体模式下不可用": "カスタムリクエストモードでは画像機能は利用できません",
+    "启用后可添加图片URL进行多模态对话": "有効にすると画像URLを追加してマルチモーダル会話ができます",
+    "点击 + 按钮添加图片URL进行多模态对话": "+ ボタンをクリックして画像URLを追加し、マルチモーダル会話を行います",
+    "已添加": "追加済み",
+    "张图片": "枚の画像",
+    "自定义模式下不可用": "カスタムモードでは利用できません",
+    "控制输出的随机性和创造性": "出力のランダム性と創造性を制御",
+    "核采样,控制词汇选择的多样性": "ニュークリアスサンプリング、語彙選択の多様性を制御",
+    "频率惩罚,减少重复词汇的出现": "頻度ペナルティ、単語の繰り返しを減少",
+    "存在惩罚,鼓励讨论新话题": "存在ペナルティ、新しいトピックを促進",
+    "流式输出": "ストリーム出力",
+    "暂无SSE响应数据": "SSE応答データがありません",
+    "SSE数据流": "SSEデータストリーム",
+    "解析错误": "解析エラー",
+    "有 Reasoning": "推論あり",
+    "全部收起": "すべて折りたたむ",
+    "全部展开": "すべて展開",
+    "SSE 事件": "SSEイベント",
+    "JSON格式错误": "JSON形式エラー",
+    "自定义请求体模式": "カスタムリクエストボディモード",
+    "启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。": "このモードを有効にすると、カスタムリクエストボディがAPIリクエストに使用され、モデル設定パネルのパラメータ設定は無視されます。",
+    "请求体 JSON": "リクエストボディJSON",
+    "格式正确": "有効な形式",
+    "格式错误": "無効な形式",
+    "格式化": "フォーマット",
+    "请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "有効なJSON形式のリクエストボディを入力してください。プレビューパネルのデフォルトのリクエストボディ形式を参照できます。",
+    "默认用户消息": "こんにちは",
+    "默认助手消息": "こんにちは!何かお手伝いできることはありますか?",
+    "可选,用于复现结果": "オプション、結果の再現用",
+    "随机种子 (留空为随机)": "ランダムシード(空欄でランダム)"
   }
-}
+}

+ 42 - 2
web/src/i18n/locales/ru.json

@@ -2102,6 +2102,46 @@
     "统一的": "Единый",
     "大模型接口网关": "Шлюз API LLM",
     "正在跳转 GitHub...": "Перенаправление на GitHub...",
-    "请求超时,请刷新页面后重新发起 GitHub 登录": "Время ожидания истекло, обновите страницу и снова запустите вход через GitHub"
+    "请求超时,请刷新页面后重新发起 GitHub 登录": "Время ожидания истекло, обновите страницу и снова запустите вход через GitHub",
+    "请先在设置中启用图片功能": "Сначала включите функцию изображений в настройках",
+    "图片已添加": "Изображение добавлено",
+    "无法添加图片": "Невозможно добавить изображение",
+    "粘贴图片失败": "Ошибка вставки изображения",
+    "支持 Ctrl+V 粘贴图片": "Поддержка Ctrl+V для вставки изображения",
+    "已复制全部数据": "Все данные скопированы",
+    "流式响应完成": "Поток завершён",
+    "图片地址": "URL изображения",
+    "已在自定义模式中忽略": "Игнорируется в пользовательском режиме",
+    "停用": "Отключить",
+    "图片功能在自定义请求体模式下不可用": "Функция изображений недоступна в режиме пользовательского запроса",
+    "启用后可添加图片URL进行多模态对话": "Включите для добавления URL изображений для мультимодального диалога",
+    "点击 + 按钮添加图片URL进行多模态对话": "Нажмите + для добавления URL изображений для мультимодального диалога",
+    "已添加": "Добавлено",
+    "张图片": "изображений",
+    "自定义模式下不可用": "Недоступно в пользовательском режиме",
+    "控制输出的随机性和创造性": "Управляет случайностью и креативностью вывода",
+    "核采样,控制词汇选择的多样性": "Ядерная выборка, управляет разнообразием выбора слов",
+    "频率惩罚,减少重复词汇的出现": "Штраф за частоту, уменьшает повторение слов",
+    "存在惩罚,鼓励讨论新话题": "Штраф за присутствие, поощряет новые темы",
+    "流式输出": "Потоковый вывод",
+    "暂无SSE响应数据": "Нет данных ответа SSE",
+    "SSE数据流": "Поток данных SSE",
+    "解析错误": "Ошибка разбора",
+    "有 Reasoning": "Есть рассуждение",
+    "全部收起": "Свернуть всё",
+    "全部展开": "Развернуть всё",
+    "SSE 事件": "Событие SSE",
+    "JSON格式错误": "Ошибка формата JSON",
+    "自定义请求体模式": "Режим пользовательского тела запроса",
+    "启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。": "При включении ваше пользовательское тело запроса будет использоваться для API-запросов, а настройки параметров на панели конфигурации модели будут игнорироваться.",
+    "请求体 JSON": "Тело запроса JSON",
+    "格式正确": "Действительный формат",
+    "格式错误": "Недействительный формат",
+    "格式化": "Форматировать",
+    "请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "Пожалуйста, введите тело запроса в действительном формате JSON. Вы можете обратиться к формату тела запроса по умолчанию на панели предварительного просмотра.",
+    "默认用户消息": "Здравствуйте",
+    "默认助手消息": "Здравствуйте! Чем я могу вам помочь?",
+    "可选,用于复现结果": "Необязательно, для воспроизводимых результатов",
+    "随机种子 (留空为随机)": "Случайное зерно (оставьте пустым для случайного)"
   }
-}
+}

+ 43 - 3
web/src/i18n/locales/vi.json

@@ -2387,10 +2387,10 @@
     "例如:100000": "Ví dụ: 100000",
     "请填写完整的产品信息": "Vui lòng điền đầy đủ thông tin sản phẩm",
     "产品ID已存在": "ID sản phẩm đã tồn tại",
-    "统一的": "Thống nhất",
+    "统一的": "Cổng thống nhất",
     "大模型接口网关": "Cổng API LLM",
     "正在跳转 GitHub...": "Đang chuyển hướng đến GitHub...",
-    "请求超时,请刷新页面后重新发起 GitHub 登录": "Yêu cầu đã hết thời gian, vui lòng làm mới trang và thử đăng nhập lại GitHub",
+    "请求超时,请刷新页面后重新发起 GitHub 登录": "Hết thời gian chờ, vui lòng làm mới trang và đăng nhập GitHub lại",
     "模型: {{ratio}}": "Mô hình: {{ratio}}",
     "模型专用区域": "Khu vực dành riêng cho mô hình",
     "模型价格": "Giá mô hình",
@@ -2695,6 +2695,46 @@
     "线路描述": "Mô tả tuyến",
     "组列表": "Danh sách nhóm",
     "组名": "Tên nhóm",
-    "组织,不填则为默认组织": "Tổ chức, mặc định nếu để trống"
+    "组织,不填则为默认组织": "Tổ chức, mặc định nếu để trống",
+    "请先在设置中启用图片功能": "Vui lòng bật chức năng hình ảnh trong cài đặt trước",
+    "图片已添加": "Hình ảnh đã được thêm",
+    "无法添加图片": "Không thể thêm hình ảnh",
+    "粘贴图片失败": "Dán hình ảnh thất bại",
+    "支持 Ctrl+V 粘贴图片": "Hỗ trợ Ctrl+V để dán hình ảnh",
+    "已复制全部数据": "Tất cả dữ liệu đã được sao chép",
+    "流式响应完成": "Luồng hoàn tất",
+    "图片地址": "URL hình ảnh",
+    "已在自定义模式中忽略": "Bị bỏ qua trong chế độ tùy chỉnh",
+    "停用": "Vô hiệu hóa",
+    "图片功能在自定义请求体模式下不可用": "Chức năng hình ảnh không khả dụng trong chế độ yêu cầu tùy chỉnh",
+    "启用后可添加图片URL进行多模态对话": "Bật để thêm URL hình ảnh cho cuộc trò chuyện đa phương thức",
+    "点击 + 按钮添加图片URL进行多模态对话": "Nhấp + để thêm URL hình ảnh cho cuộc trò chuyện đa phương thức",
+    "已添加": "Đã thêm",
+    "张图片": "hình ảnh",
+    "自定义模式下不可用": "Không khả dụng trong chế độ tùy chỉnh",
+    "控制输出的随机性和创造性": "Kiểm soát tính ngẫu nhiên và sáng tạo của đầu ra",
+    "核采样,控制词汇选择的多样性": "Lấy mẫu hạt nhân, kiểm soát sự đa dạng của lựa chọn từ vựng",
+    "频率惩罚,减少重复词汇的出现": "Phạt tần suất, giảm sự lặp lại của từ",
+    "存在惩罚,鼓励讨论新话题": "Phạt sự hiện diện, khuyến khích chủ đề mới",
+    "流式输出": "Đầu ra luồng",
+    "暂无SSE响应数据": "Không có dữ liệu phản hồi SSE",
+    "SSE数据流": "Luồng dữ liệu SSE",
+    "解析错误": "Lỗi phân tích",
+    "有 Reasoning": "Có lập luận",
+    "全部收起": "Thu gọn tất cả",
+    "全部展开": "Mở rộng tất cả",
+    "SSE 事件": "Sự kiện SSE",
+    "JSON格式错误": "Lỗi định dạng JSON",
+    "自定义请求体模式": "Chế độ nội dung yêu cầu tùy chỉnh",
+    "启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。": "Khi được bật, nội dung yêu cầu tùy chỉnh của bạn sẽ được sử dụng cho các yêu cầu API và cài đặt tham số trong bảng cấu hình mô hình sẽ bị bỏ qua.",
+    "请求体 JSON": "Nội dung yêu cầu JSON",
+    "格式正确": "Định dạng hợp lệ",
+    "格式错误": "Định dạng không hợp lệ",
+    "格式化": "Định dạng",
+    "请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "Vui lòng nhập nội dung yêu cầu có định dạng JSON hợp lệ. Bạn có thể tham khảo định dạng nội dung yêu cầu mặc định trong bảng xem trước.",
+    "默认用户消息": "Xin chào",
+    "默认助手消息": "Xin chào! Tôi có thể giúp gì cho bạn?",
+    "可选,用于复现结果": "Tùy chọn, để tái tạo kết quả",
+    "随机种子 (留空为随机)": "Hạt giống ngẫu nhiên (để trống cho ngẫu nhiên)"
   }
 }

+ 42 - 2
web/src/i18n/locales/zh.json

@@ -2076,6 +2076,46 @@
     "Creem 介绍": "Creem 是一个简单的支付处理平台,支持固定金额产品销售,以及订阅销售。",
     "Creem Setting Tips": "Creem 只支持预设的固定金额产品,这产品以及价格需要提前在Creem网站内创建配置,所以不支持自定义动态金额充值。在Creem端配置产品的名字以及价格,获取Product Id 后填到下面的产品,在new-api为该产品设置充值额度,以及展示价格。",
     "正在跳转 GitHub...": "正在跳转 GitHub...",
-    "请求超时,请刷新页面后重新发起 GitHub 登录": "请求超时,请刷新页面后重新发起 GitHub 登录"
+    "请求超时,请刷新页面后重新发起 GitHub 登录": "请求超时,请刷新页面后重新发起 GitHub 登录",
+    "请先在设置中启用图片功能": "请先在设置中启用图片功能",
+    "图片已添加": "图片已添加",
+    "无法添加图片": "无法添加图片",
+    "粘贴图片失败": "粘贴图片失败",
+    "支持 Ctrl+V 粘贴图片": "支持 Ctrl+V 粘贴图片",
+    "已复制全部数据": "已复制全部数据",
+    "流式响应完成": "流式响应完成",
+    "图片地址": "图片地址",
+    "已在自定义模式中忽略": "已在自定义模式中忽略",
+    "停用": "停用",
+    "图片功能在自定义请求体模式下不可用": "图片功能在自定义请求体模式下不可用",
+    "启用后可添加图片URL进行多模态对话": "启用后可添加图片URL进行多模态对话",
+    "点击 + 按钮添加图片URL进行多模态对话": "点击 + 按钮添加图片URL进行多模态对话",
+    "已添加": "已添加",
+    "张图片": "张图片",
+    "自定义模式下不可用": "自定义模式下不可用",
+    "控制输出的随机性和创造性": "控制输出的随机性和创造性",
+    "核采样,控制词汇选择的多样性": "核采样,控制词汇选择的多样性",
+    "频率惩罚,减少重复词汇的出现": "频率惩罚,减少重复词汇的出现",
+    "存在惩罚,鼓励讨论新话题": "存在惩罚,鼓励讨论新话题",
+    "流式输出": "流式输出",
+    "暂无SSE响应数据": "暂无SSE响应数据",
+    "SSE数据流": "SSE数据流",
+    "解析错误": "解析错误",
+    "有 Reasoning": "有 Reasoning",
+    "全部收起": "全部收起",
+    "全部展开": "全部展开",
+    "SSE 事件": "SSE 事件",
+    "JSON格式错误": "JSON格式错误",
+    "自定义请求体模式": "自定义请求体模式",
+    "启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。": "启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。",
+    "请求体 JSON": "请求体 JSON",
+    "格式正确": "格式正确",
+    "格式错误": "格式错误",
+    "格式化": "格式化",
+    "请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。",
+    "默认用户消息": "你好",
+    "默认助手消息": "你好!有什么我可以帮助你的吗?",
+    "可选,用于复现结果": "可选,用于复现结果",
+    "随机种子 (留空为随机)": "随机种子 (留空为随机)"
   }
-}
+}

+ 21 - 1
web/src/pages/Playground/index.jsx

@@ -59,6 +59,7 @@ import {
 } from '../../components/playground/OptimizedComponents';
 import ChatArea from '../../components/playground/ChatArea';
 import FloatingButtons from '../../components/playground/FloatingButtons';
+import { PlaygroundProvider } from '../../contexts/PlaygroundContext';
 
 // 生成头像
 const generateAvatarDataUrl = (username) => {
@@ -436,8 +437,26 @@ const Playground = () => {
     setTimeout(() => saveMessagesImmediately([]), 0);
   }, [setMessage, saveMessagesImmediately]);
 
+  // 处理粘贴图片
+  const handlePasteImage = useCallback((base64Data) => {
+    if (!inputs.imageEnabled) {
+      return;
+    }
+    // 添加图片到 imageUrls 数组
+    const newUrls = [...(inputs.imageUrls || []), base64Data];
+    handleInputChange('imageUrls', newUrls);
+  }, [inputs.imageEnabled, inputs.imageUrls, handleInputChange]);
+
+  // Playground Context 值
+  const playgroundContextValue = {
+    onPasteImage: handlePasteImage,
+    imageUrls: inputs.imageUrls || [],
+    imageEnabled: inputs.imageEnabled || false,
+  };
+
   return (
-    <div className='h-full'>
+    <PlaygroundProvider value={playgroundContextValue}>
+      <div className='h-full'>
       <Layout className='h-full bg-transparent flex flex-col md:flex-row'>
         {(showSettings || !isMobile) && (
           <Layout.Sider
@@ -536,6 +555,7 @@ const Playground = () => {
         </Layout.Content>
       </Layout>
     </div>
+    </PlaygroundProvider>
   );
 };