| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 |
- /*
- 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 } from '@douyinfe/semi-ui';
- import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
- import { useTranslation } from 'react-i18next';
- import { copy } from '../../helpers';
- const PERFORMANCE_CONFIG = {
- MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数
- PREVIEW_LENGTH: 5000, // 预览长度
- VERY_LARGE_MULTIPLIER: 2, // 超大内容倍数
- };
- const codeThemeStyles = {
- container: {
- backgroundColor: '#1e1e1e',
- color: '#d4d4d4',
- fontFamily: 'Consolas, "Courier New", Monaco, "SF Mono", monospace',
- fontSize: '13px',
- lineHeight: '1.4',
- borderRadius: '8px',
- border: '1px solid #3c3c3c',
- position: 'relative',
- overflow: 'hidden',
- boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
- },
- content: {
- height: '100%',
- overflowY: 'auto',
- overflowX: 'auto',
- padding: '16px',
- margin: 0,
- whiteSpace: 'pre',
- wordBreak: 'normal',
- background: '#1e1e1e',
- },
- actionButton: {
- position: 'absolute',
- zIndex: 10,
- backgroundColor: 'rgba(45, 45, 45, 0.9)',
- border: '1px solid rgba(255, 255, 255, 0.1)',
- color: '#d4d4d4',
- borderRadius: '6px',
- transition: 'all 0.2s ease',
- },
- actionButtonHover: {
- backgroundColor: 'rgba(60, 60, 60, 0.95)',
- borderColor: 'rgba(255, 255, 255, 0.2)',
- transform: 'scale(1.05)',
- },
- noContent: {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- height: '100%',
- color: '#666',
- fontSize: '14px',
- fontStyle: 'italic',
- backgroundColor: 'var(--semi-color-fill-0)',
- borderRadius: '8px',
- },
- performanceWarning: {
- padding: '8px 12px',
- backgroundColor: 'rgba(255, 193, 7, 0.1)',
- border: '1px solid rgba(255, 193, 7, 0.3)',
- borderRadius: '6px',
- color: '#ffc107',
- fontSize: '12px',
- marginBottom: '8px',
- display: 'flex',
- alignItems: 'center',
- gap: '8px',
- },
- };
- const escapeHtml = (str) => {
- return str
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- };
- const highlightJson = (str) => {
- const tokenRegex =
- /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g;
- let result = '';
- let lastIndex = 0;
- let match;
- while ((match = tokenRegex.exec(str)) !== null) {
- // Escape non-token text (structural chars like {, }, [, ], :, comma, whitespace)
- result += escapeHtml(str.slice(lastIndex, match.index));
- const token = match[0];
- let color = '#b5cea8';
- if (/^"/.test(token)) {
- color = /:$/.test(token) ? '#9cdcfe' : '#ce9178';
- } else if (/true|false|null/.test(token)) {
- color = '#569cd6';
- }
- // Escape token content before wrapping in span
- result += `<span style="color: ${color}">${escapeHtml(token)}</span>`;
- lastIndex = tokenRegex.lastIndex;
- }
- // Escape remaining text
- result += escapeHtml(str.slice(lastIndex));
- return result;
- };
- const linkRegex = /(https?:\/\/(?:[^\s<"'\]),;&}]|&)+)/g;
- const linkifyHtml = (html) => {
- const parts = html.split(/(<[^>]+>)/g);
- return parts
- .map((part) => {
- if (part.startsWith('<')) return part;
- return part.replace(
- linkRegex,
- (url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
- );
- })
- .join('');
- };
- const isJsonLike = (content, language) => {
- if (language === 'json') return true;
- const trimmed = content.trim();
- return (
- (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
- (trimmed.startsWith('[') && trimmed.endsWith(']'))
- );
- };
- const formatContent = (content) => {
- if (!content) return '';
- if (typeof content === 'object') {
- try {
- return JSON.stringify(content, null, 2);
- } catch (e) {
- return String(content);
- }
- }
- if (typeof content === 'string') {
- try {
- const parsed = JSON.parse(content);
- return JSON.stringify(parsed, null, 2);
- } catch (e) {
- return content;
- }
- }
- return String(content);
- };
- const CodeViewer = ({ content, title, language = 'json' }) => {
- const { t } = useTranslation();
- const [copied, setCopied] = useState(false);
- const [isHoveringCopy, setIsHoveringCopy] = useState(false);
- const [isExpanded, setIsExpanded] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
- const formattedContent = useMemo(() => formatContent(content), [content]);
- const contentMetrics = useMemo(() => {
- const length = formattedContent.length;
- const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH;
- const isVeryLarge =
- length >
- PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH *
- PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
- return { length, isLarge, isVeryLarge };
- }, [formattedContent.length]);
- const displayContent = useMemo(() => {
- if (!contentMetrics.isLarge || isExpanded) {
- return formattedContent;
- }
- return (
- formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
- '\n\n// ... 内容被截断以提升性能 ...'
- );
- }, [formattedContent, contentMetrics.isLarge, isExpanded]);
- const highlightedContent = useMemo(() => {
- if (contentMetrics.isVeryLarge && !isExpanded) {
- return escapeHtml(displayContent);
- }
- if (isJsonLike(displayContent, language)) {
- return highlightJson(displayContent);
- }
- return escapeHtml(displayContent);
- }, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
- const renderedContent = useMemo(() => {
- return linkifyHtml(highlightedContent);
- }, [highlightedContent]);
- const handleCopy = useCallback(async () => {
- try {
- const textToCopy =
- typeof content === 'object' && content !== null
- ? JSON.stringify(content, null, 2)
- : content;
- const success = await copy(textToCopy);
- setCopied(true);
- Toast.success(t('已复制到剪贴板'));
- setTimeout(() => setCopied(false), 2000);
- if (!success) {
- throw new Error('Copy operation failed');
- }
- } catch (err) {
- Toast.error(t('复制失败'));
- console.error('Copy failed:', err);
- }
- }, [content, t]);
- const handleToggleExpand = useCallback(() => {
- if (contentMetrics.isVeryLarge && !isExpanded) {
- setIsProcessing(true);
- setTimeout(() => {
- setIsExpanded(true);
- setIsProcessing(false);
- }, 100);
- } else {
- setIsExpanded(!isExpanded);
- }
- }, [isExpanded, contentMetrics.isVeryLarge]);
- if (!content) {
- const placeholderText =
- {
- preview: t('正在构造请求体预览...'),
- request: t('暂无请求数据'),
- response: t('暂无响应数据'),
- }[title] || t('暂无数据');
- return (
- <div style={codeThemeStyles.noContent}>
- <span>{placeholderText}</span>
- </div>
- );
- }
- const warningTop = contentMetrics.isLarge ? '52px' : '12px';
- const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
- return (
- <div style={codeThemeStyles.container} className='h-full'>
- {/* 性能警告 */}
- {contentMetrics.isLarge && (
- <div style={codeThemeStyles.performanceWarning}>
- <span>⚡</span>
- <span>
- {contentMetrics.isVeryLarge
- ? t('内容较大,已启用性能优化模式')
- : t('内容较大,部分功能可能受限')}
- </span>
- </div>
- )}
- {/* 复制按钮 */}
- <div
- style={{
- ...codeThemeStyles.actionButton,
- ...(isHoveringCopy ? codeThemeStyles.actionButtonHover : {}),
- top: warningTop,
- right: '12px',
- }}
- onMouseEnter={() => setIsHoveringCopy(true)}
- onMouseLeave={() => setIsHoveringCopy(false)}
- >
- <Tooltip content={copied ? t('已复制') : t('复制代码')}>
- <Button
- icon={<Copy size={14} />}
- onClick={handleCopy}
- size='small'
- theme='borderless'
- style={{
- backgroundColor: 'transparent',
- border: 'none',
- color: copied ? '#4ade80' : '#d4d4d4',
- padding: '6px',
- }}
- />
- </Tooltip>
- </div>
- {/* 代码内容 */}
- <div
- style={{
- ...codeThemeStyles.content,
- paddingTop: contentPadding,
- whiteSpace: 'pre-wrap',
- wordBreak: 'break-word',
- }}
- className='model-settings-scroll'
- >
- {isProcessing ? (
- <div
- style={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- height: '200px',
- color: '#888',
- }}
- >
- <div
- style={{
- width: '20px',
- height: '20px',
- border: '2px solid #444',
- borderTop: '2px solid #888',
- borderRadius: '50%',
- animation: 'spin 1s linear infinite',
- marginRight: '8px',
- }}
- />
- {t('正在处理大内容...')}
- </div>
- ) : (
- <div dangerouslySetInnerHTML={{ __html: renderedContent }} />
- )}
- </div>
- {/* 展开/收起按钮 */}
- {contentMetrics.isLarge && !isProcessing && (
- <div
- style={{
- ...codeThemeStyles.actionButton,
- bottom: '12px',
- left: '50%',
- transform: 'translateX(-50%)',
- }}
- >
- <Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
- <Button
- icon={
- isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />
- }
- onClick={handleToggleExpand}
- size='small'
- theme='borderless'
- style={{
- backgroundColor: 'transparent',
- border: 'none',
- color: '#d4d4d4',
- padding: '6px 12px',
- }}
- >
- {isExpanded ? t('收起') : t('展开')}
- {!isExpanded && (
- <span
- style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}
- >
- (+
- {Math.round(
- (contentMetrics.length -
- PERFORMANCE_CONFIG.PREVIEW_LENGTH) /
- 1000,
- )}
- K)
- </span>
- )}
- </Button>
- </Tooltip>
- </div>
- )}
- </div>
- );
- };
- export default CodeViewer;
|