mixvideo-v2/apps/desktop/src/components/EnhancedMarkdownRenderer.tsx

493 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;