493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
||
import { GroundingMetadata } from '../types/ragGrounding';
|
||
import { markdownService } from '../services/markdownService';
|
||
import {
|
||
MarkdownParseResult,
|
||
MarkdownNode,
|
||
MarkdownNodeType,
|
||
ValidationResult
|
||
} from '../types/markdown';
|
||
import ImageCard from './ImageCard';
|
||
import ImagePreviewModal from './ImagePreviewModal';
|
||
|
||
/**
|
||
* 增强Markdown渲染器属性接口
|
||
*/
|
||
interface EnhancedMarkdownRendererProps {
|
||
/** 要渲染的文本内容 */
|
||
content: string;
|
||
/** Grounding元数据 */
|
||
groundingMetadata?: GroundingMetadata;
|
||
/** 是否启用角标引用 */
|
||
enableReferences?: boolean;
|
||
/** 是否启用Markdown渲染 */
|
||
enableMarkdown?: boolean;
|
||
/** 自定义样式类名 */
|
||
className?: string;
|
||
/** 是否显示解析统计信息 */
|
||
showStatistics?: boolean;
|
||
/** 是否启用实时解析 */
|
||
enableRealTimeParsing?: boolean;
|
||
}
|
||
|
||
/**
|
||
* 增强Markdown渲染器组件
|
||
* 使用基于Tree-sitter的MarkdownService替代ReactMarkdown
|
||
*/
|
||
export const EnhancedMarkdownRenderer: React.FC<EnhancedMarkdownRendererProps> = ({
|
||
content,
|
||
groundingMetadata,
|
||
enableMarkdown = true,
|
||
className = '',
|
||
showStatistics = false,
|
||
enableRealTimeParsing = false
|
||
}) => {
|
||
const [parseResult, setParseResult] = useState<MarkdownParseResult | null>(null);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [validation, setValidation] = useState<ValidationResult | null>(null);
|
||
const [expandedGrounding, setExpandedGrounding] = useState<any>(null);
|
||
const [previewSource, setPreviewSource] = useState<any>(null);
|
||
|
||
// 解析Markdown内容
|
||
const parseContent = useCallback(async () => {
|
||
if (!content.trim() || !enableMarkdown) {
|
||
setParseResult(null);
|
||
setValidation(null);
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
// 解析Markdown
|
||
const result = await markdownService.parseMarkdown(content);
|
||
console.log({
|
||
content: content.length,
|
||
result: result.source_text.length
|
||
})
|
||
setParseResult(result);
|
||
|
||
// 验证文档结构
|
||
const validationResult = await markdownService.validateMarkdown(content);
|
||
setValidation(validationResult);
|
||
} catch (err) {
|
||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||
setError(errorMessage);
|
||
console.error('📝 Markdown解析失败:', err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [content, enableMarkdown]);
|
||
|
||
// 实时解析效果
|
||
useEffect(() => {
|
||
if (enableRealTimeParsing) {
|
||
const timeoutId = setTimeout(parseContent, 300); // 防抖
|
||
return () => clearTimeout(timeoutId);
|
||
}
|
||
}, [content, enableRealTimeParsing, parseContent]);
|
||
|
||
// 初始解析
|
||
useEffect(() => {
|
||
if (!enableRealTimeParsing) {
|
||
parseContent();
|
||
}
|
||
}, [parseContent, enableRealTimeParsing]);
|
||
|
||
// 切换grounding详情展开状态
|
||
const toggleGroundingDetails = useCallback((groundingAnalysis: any) => {
|
||
if (expandedGrounding && expandedGrounding === groundingAnalysis) {
|
||
// 如果当前已展开相同的内容,则收起
|
||
setExpandedGrounding(null);
|
||
} else {
|
||
// 展开新的内容
|
||
setExpandedGrounding(groundingAnalysis);
|
||
}
|
||
}, [expandedGrounding]);
|
||
|
||
// 查看大图
|
||
const handleViewLarge = useCallback((source: any) => {
|
||
setPreviewSource(source);
|
||
}, []);
|
||
|
||
// 关闭图片预览
|
||
const handleClosePreview = useCallback(() => {
|
||
setPreviewSource(null);
|
||
}, []);
|
||
|
||
// 分析节点与引用资源的关联
|
||
const analyzeNodeGrounding = useCallback((node: MarkdownNode) => {
|
||
if (!groundingMetadata?.grounding_supports || !parseResult) {
|
||
return null;
|
||
}
|
||
|
||
// 计算节点在原始文本中的字节偏移位置(与grounding数据的字节偏移匹配)
|
||
const nodeStartOffset = node.range?.start?.byte_offset || 0;
|
||
const nodeEndOffset = node.range?.end?.byte_offset || 0;
|
||
// 查找与当前节点位置重叠的grounding支持信息
|
||
const relatedSupports = groundingMetadata.grounding_supports.filter(support => {
|
||
// grounding数据使用字节偏移
|
||
const segmentStart = support.segment.startIndex;
|
||
const segmentEnd = support.segment.endIndex;
|
||
const hasOverlap = (nodeStartOffset <= segmentEnd && nodeEndOffset >= segmentStart);
|
||
// 检查节点范围与grounding片段是否有重叠
|
||
return hasOverlap;
|
||
});
|
||
if (relatedSupports.length > 0) {
|
||
// 获取相关的来源信息
|
||
console.log('🔍 Related supports:', relatedSupports);
|
||
const relatedSources = relatedSupports.flatMap(support =>
|
||
support.groundingChunkIndices.map(index => groundingMetadata.sources[index])
|
||
).filter(Boolean);
|
||
|
||
console.log('📚 Related sources:', relatedSources);
|
||
console.log('🖼️ Source content examples:', relatedSources.map(s => ({
|
||
title: s.title,
|
||
uri: s.uri,
|
||
contentType: typeof s.content,
|
||
contentKeys: s.content ? Object.keys(s.content) : null,
|
||
contentSample: s.content
|
||
})));
|
||
|
||
const analysisResult = {
|
||
node: {
|
||
type: node.node_type,
|
||
content: node.content.substring(0, 100) + (node.content.length > 100 ? '...' : ''),
|
||
position: {
|
||
start: nodeStartOffset,
|
||
end: nodeEndOffset,
|
||
line: node.range?.start?.line,
|
||
column: node.range?.start?.column
|
||
}
|
||
},
|
||
groundingInfo: {
|
||
supportCount: relatedSupports.length,
|
||
sourceCount: relatedSources.length,
|
||
sources: relatedSources.map(source => {
|
||
// 解析content字段中的图片信息
|
||
let imageData = null;
|
||
if (source.content) {
|
||
// 如果content是字符串,尝试解析为JSON
|
||
if (typeof source.content === 'string') {
|
||
try {
|
||
imageData = JSON.parse(source.content);
|
||
} catch {
|
||
imageData = { description: source.content };
|
||
}
|
||
} else if (source.content.text) {
|
||
// 如果content有text字段,使用text字段
|
||
try {
|
||
imageData = typeof source.content.text === 'string'
|
||
? JSON.parse(source.content.text)
|
||
: source.content.text;
|
||
} catch {
|
||
imageData = { description: source.content.text };
|
||
}
|
||
} else {
|
||
// 直接使用content对象
|
||
imageData = source.content;
|
||
}
|
||
}
|
||
|
||
return {
|
||
title: source.title,
|
||
uri: source.uri,
|
||
content: imageData,
|
||
snippet: imageData?.description || imageData?.snippet || 'No description available'
|
||
};
|
||
}),
|
||
segments: relatedSupports.map(support => ({
|
||
start: support.segment.startIndex,
|
||
end: support.segment.endIndex,
|
||
chunkIndices: support.groundingChunkIndices
|
||
}))
|
||
}
|
||
};
|
||
|
||
console.log('🔗 节点引用分析:', analysisResult);
|
||
return analysisResult;
|
||
}
|
||
return null;
|
||
}, [groundingMetadata, parseResult]);
|
||
|
||
// 渲染单个Markdown节点
|
||
const renderNode = useCallback((node: MarkdownNode, depth: number = 0, index: number = 0, showGroundingIndicator: boolean = true): React.ReactNode => {
|
||
const key = `${node.range?.start?.line || 0}-${node.range?.start?.column || 0}-${node.range?.start?.offset || 0}-${depth}-${index}`;
|
||
// 分析当前节点的引用关联
|
||
const groundingAnalysis = analyzeNodeGrounding(node);
|
||
// 创建引用指示器组件
|
||
const isExpanded = expandedGrounding === groundingAnalysis;
|
||
const GroundingIndicator = (groundingAnalysis && showGroundingIndicator) ? (
|
||
<span
|
||
className={`inline-flex items-center ml-1 px-2 py-1 text-xs rounded cursor-pointer transition-all duration-200 ${
|
||
isExpanded
|
||
? 'bg-blue-500 text-white shadow-md'
|
||
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||
}`}
|
||
title={`${isExpanded ? '收起' : '查看'} ${groundingAnalysis.groundingInfo.sourceCount} 个相关素材`}
|
||
onClick={() => {
|
||
console.log('📚 点击查看引用详情:', groundingAnalysis);
|
||
// 切换详细的引用信息展示
|
||
toggleGroundingDetails(groundingAnalysis);
|
||
}}
|
||
>
|
||
<span className="mr-1">🖼️</span>
|
||
<span>{groundingAnalysis.groundingInfo.sourceCount}</span>
|
||
<svg
|
||
className={`w-3 h-3 ml-1 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</span>
|
||
) : null;
|
||
|
||
switch (node.node_type) {
|
||
case MarkdownNodeType.Document:
|
||
return (
|
||
<div key={key} className="markdown-document">
|
||
{node.children.map((child, childIndex) => renderNode(child, depth + 1, childIndex))}
|
||
</div>
|
||
);
|
||
|
||
case MarkdownNodeType.Heading:
|
||
const level = parseInt(node.attributes.level || '1');
|
||
const HeadingTag = `h${Math.min(level, 6)}` as keyof JSX.IntrinsicElements;
|
||
const headingClasses = {
|
||
1: 'text-lg font-bold mb-2',
|
||
2: 'text-base font-semibold mb-2',
|
||
3: 'font-medium mb-1',
|
||
4: 'font-medium mb-1 text-sm',
|
||
5: 'font-medium mb-1 text-sm',
|
||
6: 'font-medium mb-1 text-xs'
|
||
};
|
||
return (
|
||
<HeadingTag key={key} className={headingClasses[level as keyof typeof headingClasses] || headingClasses[3]}>
|
||
{node.children.map((child, childIndex) => renderNode(child, depth + 1, childIndex))}
|
||
</HeadingTag>
|
||
);
|
||
|
||
case MarkdownNodeType.Paragraph:
|
||
return (
|
||
<p key={key} className="mb-2">
|
||
{node.children.map((child, childIndex) => renderNode(child, depth + 1, childIndex))}
|
||
</p>
|
||
);
|
||
|
||
case MarkdownNodeType.List:
|
||
const isOrdered = node.content.trim().match(/^\d+\./);
|
||
const ListTag = isOrdered ? 'ol' : 'ul';
|
||
const listClass = isOrdered ? 'list-decimal ml-4 mb-2' : 'list-disc ml-4 mb-2';
|
||
return (
|
||
<ListTag key={key} className={listClass}>
|
||
{node.children.map((child, childIndex) => renderNode(child, depth + 1, childIndex))}
|
||
</ListTag>
|
||
);
|
||
|
||
case MarkdownNodeType.ListItem:
|
||
return (
|
||
<li key={key} className="mb-1">
|
||
{node.children.map((child, childIndex) => renderNode(child, depth + 1, childIndex))}
|
||
</li>
|
||
);
|
||
|
||
case MarkdownNodeType.CodeBlock:
|
||
const language = node.attributes.language || '';
|
||
return (
|
||
<pre key={key} className="bg-gray-100 p-3 rounded mb-2 overflow-x-auto">
|
||
<code className={`language-${language} text-sm font-mono`}>
|
||
{markdownService.extractTextContent(node)}
|
||
</code>
|
||
</pre>
|
||
);
|
||
|
||
case MarkdownNodeType.InlineCode:
|
||
return (
|
||
<code key={key} className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">
|
||
{markdownService.extractTextContent(node)}
|
||
</code>
|
||
);
|
||
|
||
case MarkdownNodeType.Link:
|
||
const url = node.attributes.url || '#';
|
||
const title = node.attributes.title;
|
||
return (
|
||
<a
|
||
key={key}
|
||
href={url}
|
||
title={title}
|
||
className="text-blue-600 hover:text-blue-800 underline"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{node.children.map((child, childIndex) => renderNode(child, depth + 1, childIndex))}
|
||
</a>
|
||
);
|
||
|
||
case MarkdownNodeType.Image:
|
||
const src = node.attributes.src || '';
|
||
const alt = node.attributes.alt || '';
|
||
return (
|
||
<img
|
||
key={key}
|
||
src={src}
|
||
alt={alt}
|
||
className="max-w-full h-auto mb-2"
|
||
/>
|
||
);
|
||
|
||
case MarkdownNodeType.Strong:
|
||
return (
|
||
<strong key={key}>
|
||
{node.children.map((child, childIndex) => renderNode(child, depth + 1, childIndex, false))}
|
||
</strong>
|
||
);
|
||
|
||
case MarkdownNodeType.Emphasis:
|
||
return (
|
||
<em key={key}>
|
||
{node.children.map((child, childIndex) => renderNode(child, depth + 1, childIndex))}
|
||
</em>
|
||
);
|
||
|
||
case MarkdownNodeType.Blockquote:
|
||
return (
|
||
<blockquote key={key} className="border-l-4 border-gray-300 pl-4 italic text-gray-600 mb-2">
|
||
{node.children.map((child, childIndex) => renderNode(child, depth + 1, childIndex))}
|
||
</blockquote>
|
||
);
|
||
|
||
case MarkdownNodeType.Text:
|
||
return <span key={key}>{node.content} {GroundingIndicator}</span>;
|
||
|
||
case MarkdownNodeType.LineBreak:
|
||
return <br key={key} />;
|
||
|
||
default:
|
||
return (
|
||
<div key={key} className="unknown-node">
|
||
{node.children.map((child, childIndex) => renderNode(child, depth + 1, childIndex))}
|
||
</div>
|
||
);
|
||
}
|
||
}, [analyzeNodeGrounding]);
|
||
|
||
|
||
return (
|
||
<div className={`enhanced-markdown-renderer ${className}`}>
|
||
{/* 加载状态 */}
|
||
{isLoading && (
|
||
<div className="flex items-center justify-center p-4">
|
||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||
<span className="ml-2 text-sm text-gray-600">解析中...</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 错误状态 */}
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-200 rounded p-3 mb-4">
|
||
<div className="text-red-800 font-medium text-sm">解析错误</div>
|
||
<div className="text-red-600 text-sm mt-1">{error}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Markdown内容渲染 */}
|
||
{enableMarkdown && parseResult && !isLoading && !error ? (
|
||
<div className="prose prose-sm max-w-none leading-relaxed">
|
||
{renderNode(parseResult.root)}
|
||
</div>
|
||
) : !enableMarkdown ? (
|
||
<div className="leading-relaxed whitespace-pre-wrap">{content}</div>
|
||
) : null}
|
||
|
||
{/* 解析统计信息 */}
|
||
{showStatistics && parseResult && (
|
||
<div className="mt-4 p-3 bg-gray-50 rounded text-xs">
|
||
<div className="font-medium mb-1">解析统计</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>节点数: {parseResult.statistics.total_nodes}</div>
|
||
<div>错误数: {parseResult.statistics.error_nodes}</div>
|
||
<div>解析时间: {parseResult.statistics.parse_time_ms}ms</div>
|
||
<div>最大深度: {parseResult.statistics.max_depth}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 验证结果 */}
|
||
{validation && !validation.is_valid && (
|
||
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||
<div className="text-yellow-800 font-medium text-sm mb-2">文档验证警告</div>
|
||
<div className="space-y-1">
|
||
{validation.issues.slice(0, 3).map((issue, index) => (
|
||
<div key={index} className="text-yellow-700 text-xs">
|
||
• {issue.message}
|
||
</div>
|
||
))}
|
||
{validation.issues.length > 3 && (
|
||
<div className="text-yellow-600 text-xs">
|
||
还有 {validation.issues.length - 3} 个问题...
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Grounding详情内联展开 */}
|
||
{expandedGrounding && (
|
||
<div className="mt-6 border-t border-gray-200 pt-4 animate-in slide-in-from-top-2 duration-300">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-base font-semibold text-gray-900 flex items-center">
|
||
<span className="mr-2">🖼️</span>
|
||
相关素材详情 ({expandedGrounding.groundingInfo?.sourceCount || 0})
|
||
</h3>
|
||
<button
|
||
onClick={() => setExpandedGrounding(null)}
|
||
className="text-gray-400 hover:text-gray-600 transition-colors p-2 rounded-full hover:bg-gray-100"
|
||
title="收起"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 shadow-inner">
|
||
{expandedGrounding.groundingInfo?.sources && expandedGrounding.groundingInfo.sources.length > 0 ? (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6">
|
||
{expandedGrounding.groundingInfo.sources.map((source: any, index: number) => (
|
||
<div key={index} className="relative transform hover:scale-105 transition-transform duration-200">
|
||
<ImageCard
|
||
source={source}
|
||
showDetails={true}
|
||
onViewLarge={handleViewLarge}
|
||
className="shadow-lg border border-gray-200 hover:shadow-xl transition-all duration-300 bg-white"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-12 text-gray-500">
|
||
<div className="text-6xl mb-4 opacity-50">🖼️</div>
|
||
<p className="text-base font-medium mb-2">暂无相关图片素材</p>
|
||
<p className="text-sm text-gray-400">该内容段落没有关联的图片资源</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 图片预览模态框 */}
|
||
<ImagePreviewModal
|
||
isOpen={!!previewSource}
|
||
source={previewSource}
|
||
onClose={handleClosePreview}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default EnhancedMarkdownRenderer;
|