/*
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 .
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 (
{t('流式响应完成')} [DONE]
);
}
if (item.error) {
return (
{t('解析错误')}: {item.error}
);
}
return (
{/* JSON 格式化显示 */}
{JSON.stringify(item.parsed, null, 2)}
}
size='small'
theme='borderless'
onClick={() => handleCopySingle(item)}
className='absolute top-2 right-2 !bg-gray-800/80 !text-gray-300 hover:!bg-gray-700'
/>
{/* 关键信息摘要 */}
{item.parsed?.choices?.[0] && (
{item.parsed.choices[0].delta?.content && (
)}
{item.parsed.choices[0].delta?.reasoning_content && (
)}
{item.parsed.choices[0].finish_reason && (
)}
{item.parsed.usage && (
)}
)}
);
};
if (!parsedSSEData || parsedSSEData.length === 0) {
return (
{t('暂无SSE响应数据')}
);
}
return (
{/* 头部工具栏 */}
{t('SSE数据流')}
{stats.errors > 0 && (
)}
}
size='small'
onClick={handleCopyAll}
theme='borderless'
>
{copied ? t('已复制') : t('复制全部')}
) : (
)
}
size='small'
onClick={handleToggleAll}
theme='borderless'
>
{expandedKeys.length === parsedSSEData.length
? t('收起')
: t('展开')}
{/* SSE 数据列表 */}
{parsedSSEData.map((item) => (
{item.isDone ? (
[DONE]
) : item.error ? (
{t('解析错误')}
) : (
<>
{item.parsed?.id ||
item.parsed?.object ||
t('SSE 事件')}
{item.parsed?.choices?.[0]?.delta && (
•{' '}
{Object.keys(item.parsed.choices[0].delta)
.filter((k) => item.parsed.choices[0].delta[k])
.join(', ')}
)}
>
)}
}
>
{renderSSEItem(item)}
))}
);
};
export default SSEViewer;