| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314 |
- /*
- 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;
|