SSEViewer.jsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useState, useMemo, useCallback } from 'react';
  16. import {
  17. Button,
  18. Tooltip,
  19. Toast,
  20. Collapse,
  21. Badge,
  22. Typography,
  23. } from '@douyinfe/semi-ui';
  24. import {
  25. Copy,
  26. ChevronDown,
  27. ChevronUp,
  28. Zap,
  29. CheckCircle,
  30. XCircle,
  31. } from 'lucide-react';
  32. import { useTranslation } from 'react-i18next';
  33. import { copy } from '../../helpers';
  34. /**
  35. * SSEViewer component for displaying Server-Sent Events in an interactive format
  36. * @param {Object} props - Component props
  37. * @param {Array} props.sseData - Array of SSE messages to display
  38. * @returns {JSX.Element} Rendered SSE viewer component
  39. */
  40. const SSEViewer = ({ sseData }) => {
  41. const { t } = useTranslation();
  42. const [expandedKeys, setExpandedKeys] = useState([]);
  43. const [copied, setCopied] = useState(false);
  44. const parsedSSEData = useMemo(() => {
  45. if (!sseData || !Array.isArray(sseData)) {
  46. return [];
  47. }
  48. return sseData.map((item, index) => {
  49. let parsed = null;
  50. let error = null;
  51. let isDone = false;
  52. if (item === '[DONE]') {
  53. isDone = true;
  54. } else {
  55. try {
  56. parsed = typeof item === 'string' ? JSON.parse(item) : item;
  57. } catch (e) {
  58. error = e.message;
  59. }
  60. }
  61. return {
  62. index,
  63. raw: item,
  64. parsed,
  65. error,
  66. isDone,
  67. key: `sse-${index}`,
  68. };
  69. });
  70. }, [sseData]);
  71. const stats = useMemo(() => {
  72. const total = parsedSSEData.length;
  73. const errors = parsedSSEData.filter((item) => item.error).length;
  74. const done = parsedSSEData.filter((item) => item.isDone).length;
  75. const valid = total - errors - done;
  76. return { total, errors, done, valid };
  77. }, [parsedSSEData]);
  78. const handleToggleAll = useCallback(() => {
  79. setExpandedKeys((prev) => {
  80. if (prev.length === parsedSSEData.length) {
  81. return [];
  82. } else {
  83. return parsedSSEData.map((item) => item.key);
  84. }
  85. });
  86. }, [parsedSSEData]);
  87. const handleCopyAll = useCallback(async () => {
  88. try {
  89. const allData = parsedSSEData
  90. .map((item) =>
  91. item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw,
  92. )
  93. .join('\n\n');
  94. await copy(allData);
  95. setCopied(true);
  96. Toast.success(t('已复制全部数据'));
  97. setTimeout(() => setCopied(false), 2000);
  98. } catch (err) {
  99. Toast.error(t('复制失败'));
  100. console.error('Copy failed:', err);
  101. }
  102. }, [parsedSSEData, t]);
  103. const handleCopySingle = useCallback(
  104. async (item) => {
  105. try {
  106. const textToCopy = item.parsed
  107. ? JSON.stringify(item.parsed, null, 2)
  108. : item.raw;
  109. await copy(textToCopy);
  110. Toast.success(t('已复制'));
  111. } catch (err) {
  112. Toast.error(t('复制失败'));
  113. }
  114. },
  115. [t],
  116. );
  117. const renderSSEItem = (item) => {
  118. if (item.isDone) {
  119. return (
  120. <div className='flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg'>
  121. <CheckCircle size={16} className='text-green-600' />
  122. <Typography.Text className='text-green-600 font-medium'>
  123. {t('流式响应完成')} [DONE]
  124. </Typography.Text>
  125. </div>
  126. );
  127. }
  128. if (item.error) {
  129. return (
  130. <div className='space-y-2'>
  131. <div className='flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg'>
  132. <XCircle size={16} className='text-red-600' />
  133. <Typography.Text className='text-red-600'>
  134. {t('解析错误')}: {item.error}
  135. </Typography.Text>
  136. </div>
  137. <div className='p-3 bg-gray-100 dark:bg-gray-800 rounded-lg font-mono text-xs overflow-auto'>
  138. <pre>{item.raw}</pre>
  139. </div>
  140. </div>
  141. );
  142. }
  143. return (
  144. <div className='space-y-2'>
  145. {/* JSON 格式化显示 */}
  146. <div className='relative'>
  147. <pre className='p-4 bg-gray-900 text-gray-100 rounded-lg overflow-auto text-xs font-mono leading-relaxed'>
  148. {JSON.stringify(item.parsed, null, 2)}
  149. </pre>
  150. <Button
  151. icon={<Copy size={12} />}
  152. size='small'
  153. theme='borderless'
  154. onClick={() => handleCopySingle(item)}
  155. className='absolute top-2 right-2 !bg-gray-800/80 !text-gray-300 hover:!bg-gray-700'
  156. />
  157. </div>
  158. {/* 关键信息摘要 */}
  159. {item.parsed?.choices?.[0] && (
  160. <div className='flex flex-wrap gap-2 text-xs'>
  161. {item.parsed.choices[0].delta?.content && (
  162. <Badge
  163. count={`${t('内容')}: "${String(item.parsed.choices[0].delta.content).substring(0, 20)}..."`}
  164. type='primary'
  165. />
  166. )}
  167. {item.parsed.choices[0].delta?.reasoning_content && (
  168. <Badge count={t('有 Reasoning')} type='warning' />
  169. )}
  170. {item.parsed.choices[0].finish_reason && (
  171. <Badge
  172. count={`${t('完成')}: ${item.parsed.choices[0].finish_reason}`}
  173. type='success'
  174. />
  175. )}
  176. {item.parsed.usage && (
  177. <Badge
  178. count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
  179. type='tertiary'
  180. />
  181. )}
  182. </div>
  183. )}
  184. </div>
  185. );
  186. };
  187. if (!parsedSSEData || parsedSSEData.length === 0) {
  188. return (
  189. <div className='flex items-center justify-center h-full min-h-[200px] text-gray-500'>
  190. <span>{t('暂无SSE响应数据')}</span>
  191. </div>
  192. );
  193. }
  194. return (
  195. <div className='h-full flex flex-col bg-gray-50 dark:bg-gray-900/50 rounded-lg'>
  196. {/* 头部工具栏 */}
  197. <div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0'>
  198. <div className='flex items-center gap-3'>
  199. <Zap size={16} className='text-blue-500' />
  200. <Typography.Text strong>{t('SSE数据流')}</Typography.Text>
  201. <Badge count={stats.total} type='primary' />
  202. {stats.errors > 0 && (
  203. <Badge count={`${stats.errors} ${t('错误')}`} type='danger' />
  204. )}
  205. </div>
  206. <div className='flex items-center gap-2'>
  207. <Tooltip content={t('复制全部')}>
  208. <Button
  209. icon={<Copy size={14} />}
  210. size='small'
  211. onClick={handleCopyAll}
  212. theme='borderless'
  213. >
  214. {copied ? t('已复制') : t('复制全部')}
  215. </Button>
  216. </Tooltip>
  217. <Tooltip
  218. content={
  219. expandedKeys.length === parsedSSEData.length
  220. ? t('全部收起')
  221. : t('全部展开')
  222. }
  223. >
  224. <Button
  225. icon={
  226. expandedKeys.length === parsedSSEData.length ? (
  227. <ChevronUp size={14} />
  228. ) : (
  229. <ChevronDown size={14} />
  230. )
  231. }
  232. size='small'
  233. onClick={handleToggleAll}
  234. theme='borderless'
  235. >
  236. {expandedKeys.length === parsedSSEData.length
  237. ? t('收起')
  238. : t('展开')}
  239. </Button>
  240. </Tooltip>
  241. </div>
  242. </div>
  243. {/* SSE 数据列表 */}
  244. <div className='flex-1 overflow-auto p-4'>
  245. <Collapse
  246. activeKey={expandedKeys}
  247. onChange={setExpandedKeys}
  248. accordion={false}
  249. className='bg-white dark:bg-gray-800 rounded-lg'
  250. >
  251. {parsedSSEData.map((item) => (
  252. <Collapse.Panel
  253. key={item.key}
  254. header={
  255. <div className='flex items-center gap-2'>
  256. <Badge count={`#${item.index + 1}`} type='tertiary' />
  257. {item.isDone ? (
  258. <span className='text-green-600 font-medium'>[DONE]</span>
  259. ) : item.error ? (
  260. <span className='text-red-600'>{t('解析错误')}</span>
  261. ) : (
  262. <>
  263. <span className='text-gray-600'>
  264. {item.parsed?.id ||
  265. item.parsed?.object ||
  266. t('SSE 事件')}
  267. </span>
  268. {item.parsed?.choices?.[0]?.delta && (
  269. <span className='text-xs text-gray-400'>
  270. •{' '}
  271. {Object.keys(item.parsed.choices[0].delta)
  272. .filter((k) => item.parsed.choices[0].delta[k])
  273. .join(', ')}
  274. </span>
  275. )}
  276. </>
  277. )}
  278. </div>
  279. }
  280. >
  281. {renderSSEItem(item)}
  282. </Collapse.Panel>
  283. ))}
  284. </Collapse>
  285. </div>
  286. </div>
  287. );
  288. };
  289. export default SSEViewer;