CodeViewer.jsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  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 { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
  17. import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
  18. import { useTranslation } from 'react-i18next';
  19. import { copy } from '../../helpers';
  20. const PERFORMANCE_CONFIG = {
  21. MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数
  22. PREVIEW_LENGTH: 5000, // 预览长度
  23. VERY_LARGE_MULTIPLIER: 2, // 超大内容倍数
  24. };
  25. const codeThemeStyles = {
  26. container: {
  27. backgroundColor: '#1e1e1e',
  28. color: '#d4d4d4',
  29. fontFamily: 'Consolas, "Courier New", Monaco, "SF Mono", monospace',
  30. fontSize: '13px',
  31. lineHeight: '1.4',
  32. borderRadius: '8px',
  33. border: '1px solid #3c3c3c',
  34. position: 'relative',
  35. overflow: 'hidden',
  36. boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
  37. },
  38. content: {
  39. height: '100%',
  40. overflowY: 'auto',
  41. overflowX: 'auto',
  42. padding: '16px',
  43. margin: 0,
  44. whiteSpace: 'pre',
  45. wordBreak: 'normal',
  46. background: '#1e1e1e',
  47. },
  48. actionButton: {
  49. position: 'absolute',
  50. zIndex: 10,
  51. backgroundColor: 'rgba(45, 45, 45, 0.9)',
  52. border: '1px solid rgba(255, 255, 255, 0.1)',
  53. color: '#d4d4d4',
  54. borderRadius: '6px',
  55. transition: 'all 0.2s ease',
  56. },
  57. actionButtonHover: {
  58. backgroundColor: 'rgba(60, 60, 60, 0.95)',
  59. borderColor: 'rgba(255, 255, 255, 0.2)',
  60. transform: 'scale(1.05)',
  61. },
  62. noContent: {
  63. display: 'flex',
  64. alignItems: 'center',
  65. justifyContent: 'center',
  66. height: '100%',
  67. color: '#666',
  68. fontSize: '14px',
  69. fontStyle: 'italic',
  70. backgroundColor: 'var(--semi-color-fill-0)',
  71. borderRadius: '8px',
  72. },
  73. performanceWarning: {
  74. padding: '8px 12px',
  75. backgroundColor: 'rgba(255, 193, 7, 0.1)',
  76. border: '1px solid rgba(255, 193, 7, 0.3)',
  77. borderRadius: '6px',
  78. color: '#ffc107',
  79. fontSize: '12px',
  80. marginBottom: '8px',
  81. display: 'flex',
  82. alignItems: 'center',
  83. gap: '8px',
  84. },
  85. };
  86. const escapeHtml = (str) => {
  87. return str
  88. .replace(/&/g, '&amp;')
  89. .replace(/</g, '&lt;')
  90. .replace(/>/g, '&gt;')
  91. .replace(/"/g, '&quot;')
  92. .replace(/'/g, '&#039;');
  93. };
  94. const highlightJson = (str) => {
  95. const tokenRegex =
  96. /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g;
  97. let result = '';
  98. let lastIndex = 0;
  99. let match;
  100. while ((match = tokenRegex.exec(str)) !== null) {
  101. // Escape non-token text (structural chars like {, }, [, ], :, comma, whitespace)
  102. result += escapeHtml(str.slice(lastIndex, match.index));
  103. const token = match[0];
  104. let color = '#b5cea8';
  105. if (/^"/.test(token)) {
  106. color = /:$/.test(token) ? '#9cdcfe' : '#ce9178';
  107. } else if (/true|false|null/.test(token)) {
  108. color = '#569cd6';
  109. }
  110. // Escape token content before wrapping in span
  111. result += `<span style="color: ${color}">${escapeHtml(token)}</span>`;
  112. lastIndex = tokenRegex.lastIndex;
  113. }
  114. // Escape remaining text
  115. result += escapeHtml(str.slice(lastIndex));
  116. return result;
  117. };
  118. const linkRegex = /(https?:\/\/(?:[^\s<"'\]),;&}]|&amp;)+)/g;
  119. const linkifyHtml = (html) => {
  120. const parts = html.split(/(<[^>]+>)/g);
  121. return parts
  122. .map((part) => {
  123. if (part.startsWith('<')) return part;
  124. return part.replace(
  125. linkRegex,
  126. (url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
  127. );
  128. })
  129. .join('');
  130. };
  131. const isJsonLike = (content, language) => {
  132. if (language === 'json') return true;
  133. const trimmed = content.trim();
  134. return (
  135. (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
  136. (trimmed.startsWith('[') && trimmed.endsWith(']'))
  137. );
  138. };
  139. const formatContent = (content) => {
  140. if (!content) return '';
  141. if (typeof content === 'object') {
  142. try {
  143. return JSON.stringify(content, null, 2);
  144. } catch (e) {
  145. return String(content);
  146. }
  147. }
  148. if (typeof content === 'string') {
  149. try {
  150. const parsed = JSON.parse(content);
  151. return JSON.stringify(parsed, null, 2);
  152. } catch (e) {
  153. return content;
  154. }
  155. }
  156. return String(content);
  157. };
  158. const CodeViewer = ({ content, title, language = 'json' }) => {
  159. const { t } = useTranslation();
  160. const [copied, setCopied] = useState(false);
  161. const [isHoveringCopy, setIsHoveringCopy] = useState(false);
  162. const [isExpanded, setIsExpanded] = useState(false);
  163. const [isProcessing, setIsProcessing] = useState(false);
  164. const formattedContent = useMemo(() => formatContent(content), [content]);
  165. const contentMetrics = useMemo(() => {
  166. const length = formattedContent.length;
  167. const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH;
  168. const isVeryLarge =
  169. length >
  170. PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH *
  171. PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
  172. return { length, isLarge, isVeryLarge };
  173. }, [formattedContent.length]);
  174. const displayContent = useMemo(() => {
  175. if (!contentMetrics.isLarge || isExpanded) {
  176. return formattedContent;
  177. }
  178. return (
  179. formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
  180. '\n\n// ... 内容被截断以提升性能 ...'
  181. );
  182. }, [formattedContent, contentMetrics.isLarge, isExpanded]);
  183. const highlightedContent = useMemo(() => {
  184. if (contentMetrics.isVeryLarge && !isExpanded) {
  185. return escapeHtml(displayContent);
  186. }
  187. if (isJsonLike(displayContent, language)) {
  188. return highlightJson(displayContent);
  189. }
  190. return escapeHtml(displayContent);
  191. }, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
  192. const renderedContent = useMemo(() => {
  193. return linkifyHtml(highlightedContent);
  194. }, [highlightedContent]);
  195. const handleCopy = useCallback(async () => {
  196. try {
  197. const textToCopy =
  198. typeof content === 'object' && content !== null
  199. ? JSON.stringify(content, null, 2)
  200. : content;
  201. const success = await copy(textToCopy);
  202. setCopied(true);
  203. Toast.success(t('已复制到剪贴板'));
  204. setTimeout(() => setCopied(false), 2000);
  205. if (!success) {
  206. throw new Error('Copy operation failed');
  207. }
  208. } catch (err) {
  209. Toast.error(t('复制失败'));
  210. console.error('Copy failed:', err);
  211. }
  212. }, [content, t]);
  213. const handleToggleExpand = useCallback(() => {
  214. if (contentMetrics.isVeryLarge && !isExpanded) {
  215. setIsProcessing(true);
  216. setTimeout(() => {
  217. setIsExpanded(true);
  218. setIsProcessing(false);
  219. }, 100);
  220. } else {
  221. setIsExpanded(!isExpanded);
  222. }
  223. }, [isExpanded, contentMetrics.isVeryLarge]);
  224. if (!content) {
  225. const placeholderText =
  226. {
  227. preview: t('正在构造请求体预览...'),
  228. request: t('暂无请求数据'),
  229. response: t('暂无响应数据'),
  230. }[title] || t('暂无数据');
  231. return (
  232. <div style={codeThemeStyles.noContent}>
  233. <span>{placeholderText}</span>
  234. </div>
  235. );
  236. }
  237. const warningTop = contentMetrics.isLarge ? '52px' : '12px';
  238. const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
  239. return (
  240. <div style={codeThemeStyles.container} className='h-full'>
  241. {/* 性能警告 */}
  242. {contentMetrics.isLarge && (
  243. <div style={codeThemeStyles.performanceWarning}>
  244. <span>⚡</span>
  245. <span>
  246. {contentMetrics.isVeryLarge
  247. ? t('内容较大,已启用性能优化模式')
  248. : t('内容较大,部分功能可能受限')}
  249. </span>
  250. </div>
  251. )}
  252. {/* 复制按钮 */}
  253. <div
  254. style={{
  255. ...codeThemeStyles.actionButton,
  256. ...(isHoveringCopy ? codeThemeStyles.actionButtonHover : {}),
  257. top: warningTop,
  258. right: '12px',
  259. }}
  260. onMouseEnter={() => setIsHoveringCopy(true)}
  261. onMouseLeave={() => setIsHoveringCopy(false)}
  262. >
  263. <Tooltip content={copied ? t('已复制') : t('复制代码')}>
  264. <Button
  265. icon={<Copy size={14} />}
  266. onClick={handleCopy}
  267. size='small'
  268. theme='borderless'
  269. style={{
  270. backgroundColor: 'transparent',
  271. border: 'none',
  272. color: copied ? '#4ade80' : '#d4d4d4',
  273. padding: '6px',
  274. }}
  275. />
  276. </Tooltip>
  277. </div>
  278. {/* 代码内容 */}
  279. <div
  280. style={{
  281. ...codeThemeStyles.content,
  282. paddingTop: contentPadding,
  283. whiteSpace: 'pre-wrap',
  284. wordBreak: 'break-word',
  285. }}
  286. className='model-settings-scroll'
  287. >
  288. {isProcessing ? (
  289. <div
  290. style={{
  291. display: 'flex',
  292. alignItems: 'center',
  293. justifyContent: 'center',
  294. height: '200px',
  295. color: '#888',
  296. }}
  297. >
  298. <div
  299. style={{
  300. width: '20px',
  301. height: '20px',
  302. border: '2px solid #444',
  303. borderTop: '2px solid #888',
  304. borderRadius: '50%',
  305. animation: 'spin 1s linear infinite',
  306. marginRight: '8px',
  307. }}
  308. />
  309. {t('正在处理大内容...')}
  310. </div>
  311. ) : (
  312. <div dangerouslySetInnerHTML={{ __html: renderedContent }} />
  313. )}
  314. </div>
  315. {/* 展开/收起按钮 */}
  316. {contentMetrics.isLarge && !isProcessing && (
  317. <div
  318. style={{
  319. ...codeThemeStyles.actionButton,
  320. bottom: '12px',
  321. left: '50%',
  322. transform: 'translateX(-50%)',
  323. }}
  324. >
  325. <Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
  326. <Button
  327. icon={
  328. isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />
  329. }
  330. onClick={handleToggleExpand}
  331. size='small'
  332. theme='borderless'
  333. style={{
  334. backgroundColor: 'transparent',
  335. border: 'none',
  336. color: '#d4d4d4',
  337. padding: '6px 12px',
  338. }}
  339. >
  340. {isExpanded ? t('收起') : t('展开')}
  341. {!isExpanded && (
  342. <span
  343. style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}
  344. >
  345. (+
  346. {Math.round(
  347. (contentMetrics.length -
  348. PERFORMANCE_CONFIG.PREVIEW_LENGTH) /
  349. 1000,
  350. )}
  351. K)
  352. </span>
  353. )}
  354. </Button>
  355. </Tooltip>
  356. </div>
  357. )}
  358. </div>
  359. );
  360. };
  361. export default CodeViewer;