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