Merge pull request #2312 from ImogeneOctaviap794/feat/enhance-playground-debugging
feat(playground): enhance SSE debugging and add image paste support with i18n
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
web/src/components/playground/SSEViewer.jsx
Normal file
266
web/src/components/playground/SSEViewer.jsx
Normal file
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user