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

403 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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';
/**
* 增强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,
enableReferences = true,
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);
// 解析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]);
// 分析节点与引用资源的关联
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;
const nodeStartOffset1 = node.range?.start?.offset || 0;
const nodeEndOffset1 = node.range?.end?.offset || 0;
// 查找与当前节点位置重叠的grounding支持信息
const relatedSupports = groundingMetadata.grounding_supports.filter(support => {
// grounding数据使用字节偏移
const segmentStart = support.segment.startIndex;
const segmentEnd = support.segment.endIndex;
const hasOverlap = (nodeEndOffset <= segmentEnd && nodeStartOffset >= segmentStart);
// 检查节点范围与grounding片段是否有重叠
return hasOverlap;
});
console.log({
relatedSupports,
nodeStartOffset,
nodeEndOffset,
nodeStartOffset1,
nodeEndOffset1
})
if (relatedSupports.length > 0) {
// 获取相关的来源信息
const relatedSources = relatedSupports.flatMap(support =>
support.groundingChunkIndices.map(index => groundingMetadata.sources[index])
).filter(Boolean);
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 => ({
title: source.title,
uri: source.uri,
snippet: source.content?.snippet || 'No snippet 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): 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 GroundingIndicator = groundingAnalysis ? (
<span
className="inline-flex items-center ml-1 px-1 py-0.5 text-xs bg-blue-100 text-blue-700 rounded cursor-help"
title={`引用了 ${groundingAnalysis.groundingInfo.sourceCount} 个来源`}
onClick={() => {
console.log('📚 点击查看引用详情:', groundingAnalysis);
// 这里可以添加弹窗或侧边栏显示详细引用信息
}}
>
📚 {groundingAnalysis.groundingInfo.sourceCount}
</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))}
{GroundingIndicator}
</HeadingTag>
);
case MarkdownNodeType.Paragraph:
return (
<p key={key} className="mb-2">
{node.children.map((child, childIndex) => renderNode(child, depth + 1, childIndex))}
{GroundingIndicator}
</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))}
</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}</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>
)}
{/* 显示引用来源信息(如果有的话) */}
{enableReferences && groundingMetadata?.sources && groundingMetadata.sources.length > 0 && (
<div className="mt-4 pt-3 border-t border-gray-200">
<div className="text-xs text-gray-500 mb-2">
{groundingMetadata.sources.length}
</div>
<div className="flex flex-wrap gap-1">
{groundingMetadata.sources.slice(0, 5).map((source, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 text-xs bg-blue-50 text-blue-700 rounded-full"
title={source.title || `来源 ${index + 1}`}
>
{index + 1}
</span>
))}
{groundingMetadata.sources.length > 5 && (
<span className="text-xs text-gray-400">
+{groundingMetadata.sources.length - 5}
</span>
)}
</div>
</div>
)}
</div>
);
};
export default EnhancedMarkdownRenderer;