fix: 优化界面展 及引用关系

This commit is contained in:
imeepos
2025-07-22 13:35:18 +08:00
parent cb5a137ec9
commit 98254ddc09
15 changed files with 2599 additions and 157 deletions

View File

@@ -28,20 +28,24 @@
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"lucide-react": "^0.294.0",
"marked": "^16.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.20.1",
"react-window": "^1.8.11",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react-window": "^1.8.8",
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.16",
"jsdom": "^23.0.1",

View File

@@ -405,4 +405,84 @@
.shadow-colored {
box-shadow: 0 10px 25px -5px rgba(59, 130, 246, 0.1), 0 4px 6px -2px rgba(59, 130, 246, 0.05);
}
/* Markdown 和代码高亮样式 */
.prose {
color: #374151;
max-width: none;
}
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
color: #111827;
font-weight: 600;
}
.prose p {
margin-bottom: 1rem;
line-height: 1.6;
}
.prose ul, .prose ol {
margin-bottom: 1rem;
}
.prose li {
margin-bottom: 0.25rem;
}
.prose blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
font-style: italic;
color: #6b7280;
margin: 1rem 0;
}
.prose code {
background-color: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-family: 'Courier New', monospace;
}
.prose pre {
background-color: #f3f4f6;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1rem 0;
}
.prose pre code {
background-color: transparent;
padding: 0;
}
/* 简化的代码高亮样式 */
.hljs {
background: #f3f4f6 !important;
color: #374151 !important;
}
.hljs-keyword, .hljs-selector-tag, .hljs-title {
color: #7c3aed !important;
}
.hljs-string, .hljs-attr {
color: #059669 !important;
}
.hljs-comment, .hljs-quote {
color: #6b7280 !important;
font-style: italic;
}
.hljs-number, .hljs-literal {
color: #dc2626 !important;
}
.hljs-function, .hljs-class {
color: #2563eb !important;
}
}

View File

@@ -10,7 +10,7 @@ import {
} from 'lucide-react';
import { queryRagGrounding } from '../services/ragGroundingService';
import { RagGroundingQueryOptions, GroundingMetadata } from '../types/ragGrounding';
import EnhancedChatMessage from './EnhancedChatMessage';
import { EnhancedChatMessageV2 } from './EnhancedChatMessageV2';
/**
* 聊天消息接口
@@ -188,34 +188,6 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
const result = await queryRagGrounding(userMessage.content, options);
if (result.success && result.data) {
// 在控制台打印详细的回复结果
console.group('🤖 AI 回复结果');
console.log('📝 用户问题:', userMessage.content);
console.log('💬 AI 回答:', result.data.answer);
console.log('⏱️ 响应时间:', result.data.response_time_ms + 'ms');
console.log('🤖 使用模型:', result.data.model_used);
console.log('📊 总耗时:', result.totalTime + 'ms');
if (result.data.grounding_metadata?.sources && result.data.grounding_metadata.sources.length > 0) {
console.log('📚 参考来源 (' + result.data.grounding_metadata.sources.length + ' 条):');
result.data.grounding_metadata.sources.forEach((source, index) => {
console.log(` ${index + 1}. ${source.title}`);
if (source.content) {
console.log(` 内容: ${JSON.stringify(source.content, null, 2)}`);
}
if (source.uri) {
console.log(` 链接: ${source.uri}`);
}
});
}
if (result.data.grounding_metadata?.search_queries && result.data.grounding_metadata.search_queries.length > 0) {
console.log('🔍 搜索查询:', result.data.grounding_metadata.search_queries);
}
console.log('🕐 查询时间:', new Date().toLocaleString());
console.groupEnd();
// 更新助手消息
setMessages(prev => prev.map(msg =>
msg.id === assistantMessage.id
@@ -385,11 +357,11 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
}, [selectedTags]);
return (
<div className={`flex flex-col h-full bg-white rounded-xl shadow-lg border border-gray-200 ${className} relative`}>
<div className={`flex flex-col h-full enhanced-chat-interface rounded-xl shadow-lg border border-gray-200 ${className} relative`}>
{/* 聊天消息区域 */}
<div
ref={chatContainerRef}
className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50 pb-32"
className="flex-1 overflow-y-auto p-4 space-y-6 bg-transparent pb-32 chat-scroll"
style={{ marginBottom: allTags.length > 0 ? '200px' : '120px' }}
>
{messages.length === 0 ? (
@@ -424,11 +396,12 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
) : (
<>
{messages.map((message) => (
<EnhancedChatMessage
<EnhancedChatMessageV2
key={message.id}
message={message}
showSources={showSources}
enableImageCards={enableImageCards}
enableMaterialCards={enableImageCards}
enableReferences={true}
/>
))}
</>

View File

@@ -0,0 +1,222 @@
import React, { useState } from 'react';
import {
FileVideo,
FileAudio,
FileImage,
Clock,
ExternalLink,
Eye,
Play,
Volume2,
Image as ImageIcon,
Calendar,
Hash,
HardDrive
} from 'lucide-react';
import { GroundingSource } from '../types/ragGrounding';
import { MaterialThumbnail } from './MaterialThumbnail';
/**
* 聊天素材卡片属性接口
*/
interface ChatMaterialCardProps {
/** 素材来源信息 */
source: GroundingSource;
/** 卡片大小 */
size?: 'small' | 'medium' | 'large';
/** 是否显示详细信息 */
showDetails?: boolean;
/** 自定义样式类名 */
className?: string;
/** 点击回调 */
onClick?: (source: GroundingSource) => void;
}
/**
* 格式化文件大小
*/
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
/**
* 格式化时长
*/
const formatDuration = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
} else {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
};
/**
* 获取文件类型图标
*/
const getFileTypeIcon = (type: string) => {
switch (type.toLowerCase()) {
case 'video':
return <FileVideo className="w-4 h-4 text-blue-500" />;
case 'audio':
return <FileAudio className="w-4 h-4 text-green-500" />;
case 'image':
return <FileImage className="w-4 h-4 text-purple-500" />;
default:
return <FileImage className="w-4 h-4 text-gray-500" />;
}
};
/**
* 聊天素材卡片组件
* 专为聊天界面优化的素材展示卡片
*/
export const ChatMaterialCard: React.FC<ChatMaterialCardProps> = ({
source,
size = 'medium',
showDetails = true,
className = '',
onClick
}) => {
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
// 获取卡片尺寸样式
const getSizeClasses = () => {
switch (size) {
case 'small':
return 'w-32 h-24';
case 'large':
return 'w-64 h-48';
default:
return 'w-48 h-36';
}
};
// 获取内容类型
const contentType = source.content?.type || 'unknown';
const isImage = contentType === 'image';
const isVideo = contentType === 'video';
const isAudio = contentType === 'audio';
// 处理卡片点击
const handleClick = () => {
onClick?.(source);
};
return (
<div
className={`
relative chat-material-card rounded-lg
cursor-pointer overflow-hidden group
${getSizeClasses()}
${className}
`}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 缩略图/预览区域 */}
<div className="relative w-full h-2/3 bg-gray-100 overflow-hidden">
{isImage && source.content?.image_url && !imageError ? (
<img
src={source.content.image_url}
alt={source.title || '素材图片'}
className="w-full h-full object-cover"
onError={() => setImageError(true)}
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200">
{getFileTypeIcon(contentType)}
</div>
)}
{/* 悬停播放按钮 */}
{(isVideo || isAudio) && isHovered && (
<div className="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
<div className="bg-white bg-opacity-90 rounded-full p-2">
{isVideo ? (
<Play className="w-6 h-6 text-gray-700" />
) : (
<Volume2 className="w-6 h-6 text-gray-700" />
)}
</div>
</div>
)}
{/* 文件类型标识 */}
<div className="absolute top-2 left-2">
<div className="bg-black bg-opacity-60 rounded px-2 py-1 flex items-center space-x-1">
{getFileTypeIcon(contentType)}
<span className="text-white text-xs font-medium">
{contentType.toUpperCase()}
</span>
</div>
</div>
{/* 时长标识 */}
{(isVideo || isAudio) && source.content?.duration && (
<div className="absolute bottom-2 right-2">
<div className="bg-black bg-opacity-60 rounded px-2 py-1 flex items-center space-x-1">
<Clock className="w-3 h-3 text-white" />
<span className="text-white text-xs">
{formatDuration(source.content.duration)}
</span>
</div>
</div>
)}
</div>
{/* 信息区域 */}
<div className="p-3 h-1/3 flex flex-col justify-between">
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-900 truncate">
{source.title || '未知素材'}
</h4>
{showDetails && source.content?.description && (
<p className="text-xs text-gray-600 mt-1 line-clamp-2">
{source.content.description}
</p>
)}
</div>
{/* 底部信息 */}
{showDetails && (
<div className="flex items-center justify-between mt-2 text-xs text-gray-500">
<div className="flex items-center space-x-2">
{source.content?.file_size && (
<div className="flex items-center space-x-1">
<HardDrive className="w-3 h-3" />
<span>{formatFileSize(source.content.file_size)}</span>
</div>
)}
</div>
{source.uri && (
<div className="flex items-center space-x-1">
<ExternalLink className="w-3 h-3" />
<span className="truncate max-w-16">
{source.uri.split('/').pop()}
</span>
</div>
)}
</div>
)}
</div>
{/* 悬停效果 */}
{isHovered && (
<div className="absolute inset-0 bg-blue-500 bg-opacity-5 pointer-events-none" />
)}
</div>
);
};
export default ChatMaterialCard;

View File

@@ -65,6 +65,11 @@ export const EnhancedChatMessage: React.FC<EnhancedChatMessageProps> = ({
const groundingMetadata = message.metadata?.grounding_metadata;
const sources = message.metadata?.sources || [];
// 调试信息
console.log('💬 EnhancedChatMessage - message:', message);
console.log('💬 EnhancedChatMessage - groundingMetadata:', groundingMetadata);
console.log('💬 EnhancedChatMessage - grounding_supports:', groundingMetadata?.grounding_supports);
// 处理复制消息
const handleCopy = useCallback(async () => {
try {
@@ -118,8 +123,9 @@ export const EnhancedChatMessage: React.FC<EnhancedChatMessageProps> = ({
text={message.content}
groundingMetadata={groundingMetadata}
enableImageCards={enableImageCards}
enableMarkdown={true}
highlightStyle="underline"
className="whitespace-pre-wrap"
className=""
/>
) : (
<div className="whitespace-pre-wrap">{message.content}</div>
@@ -189,7 +195,7 @@ export const EnhancedChatMessage: React.FC<EnhancedChatMessageProps> = ({
{/* 来源信息 */}
{isAssistant && sources.length > 0 && showSources && expandedSources && (
<div className="mt-2 w-full max-w-md">
<div className="mt-2 w-full">
<div className="bg-gray-50 rounded-lg border border-gray-200 p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-gray-700 flex items-center">
@@ -204,46 +210,78 @@ export const EnhancedChatMessage: React.FC<EnhancedChatMessageProps> = ({
</button>
</div>
<div className="space-y-2">
{sources.slice(0, 3).map((source, index) => (
{/* 图片卡片网格布局 */}
<div className="grid grid-cols-4 sm:grid-cols-6 gap-3">
{sources.map((source, index) => (
<div
key={index}
className="flex items-center justify-between p-2 bg-white rounded border border-gray-100"
className="dynamic-card group cursor-pointer aspect-square overflow-hidden"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{source.title}
</p>
{source.content?.description && (
<p className="text-xs text-gray-500 truncate">
{source.content.description}
</p>
)}
</div>
{/* 图片展示 */}
<div className="relative w-full h-full bg-gray-100">
{source.uri ? (
<div className="relative w-full h-full">
<img
src={source.uri}
alt={source.title || `图片 ${index + 1}`}
className="w-full h-full object-cover transition-all duration-500 group-hover:scale-105"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.parentElement?.querySelector('.error-placeholder')?.classList.remove('hidden');
}}
/>
<div className="flex items-center space-x-1 ml-2">
{source.uri && (
<a
href={source.uri}
target="_blank"
rel="noopener noreferrer"
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
title="查看原图"
>
<ExternalLink className="w-3 h-3" />
</a>
{/* 悬停时显示的操作按钮 */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
<a
href={source.uri}
target="_blank"
rel="noopener noreferrer"
className="opacity-0 group-hover:opacity-100 p-2 bg-white bg-opacity-90 hover:bg-opacity-100 text-gray-800 rounded-full transition-all duration-200"
title="查看原图"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
{/* 错误占位符 */}
<div className="error-placeholder hidden absolute inset-0 bg-gray-100 flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 bg-gray-300 rounded-full flex items-center justify-center mx-auto mb-1">
<span className="text-gray-500 text-xs">!</span>
</div>
<span className="text-gray-400 text-xs"></span>
</div>
</div>
</div>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-100">
<div className="text-center">
<div className="w-8 h-8 bg-gray-300 rounded-full flex items-center justify-center mx-auto mb-1">
<ImageIcon className="w-4 h-4 text-gray-500" />
</div>
<span className="text-gray-400 text-xs"></span>
</div>
</div>
)}
{/* 图片标题覆盖层 */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs font-medium truncate">
{source.title || `图片 ${index + 1}`}
</p>
{source.content?.description && (
<p className="text-white/80 text-xs truncate">
{source.content.description}
</p>
)}
</div>
</div>
</div>
))}
{sources.length > 3 && (
<div className="text-center">
<span className="text-xs text-gray-500">
{sources.length - 3} ...
</span>
</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,253 @@
import React, { useState, useCallback } from 'react';
import {
Copy,
Check,
User,
Bot,
Clock,
ChevronDown,
ChevronUp,
ExternalLink,
Grid3X3,
List
} from 'lucide-react';
import { EnhancedMarkdownRenderer } from './EnhancedMarkdownRenderer';
import { ChatMaterialCard } from './ChatMaterialCard';
import { GroundingSource } from '../types/ragGrounding';
/**
* 聊天消息接口
*/
interface ChatMessage {
id: string;
type: 'user' | 'assistant';
content: string;
timestamp: Date;
status?: 'sending' | 'sent' | 'error';
metadata?: {
responseTime?: number;
modelUsed?: string;
sources?: Array<{
title: string;
uri?: string;
content?: any;
}>;
grounding_metadata?: any;
};
}
/**
* 增强聊天消息组件属性接口
*/
interface EnhancedChatMessageV2Props {
/** 消息数据 */
message: ChatMessage;
/** 是否显示来源信息 */
showSources?: boolean;
/** 是否启用素材卡片 */
enableMaterialCards?: boolean;
/** 是否启用角标引用 */
enableReferences?: boolean;
/** 自定义样式类名 */
className?: string;
}
/**
* 增强聊天消息组件 V2
* 支持角标引用、优化素材展示和Markdown渲染
*/
export const EnhancedChatMessageV2: React.FC<EnhancedChatMessageV2Props> = ({
message,
showSources = true,
enableMaterialCards = true,
enableReferences = true,
className = ''
}) => {
const [copied, setCopied] = useState(false);
const [expandedSources, setExpandedSources] = useState(false);
const [materialViewMode, setMaterialViewMode] = useState<'grid' | 'list'>('grid');
const [selectedReference, setSelectedReference] = useState<number | null>(null);
const isUser = message.type === 'user';
const isAssistant = message.type === 'assistant';
const groundingMetadata = message.metadata?.grounding_metadata;
const sources = message.metadata?.sources || [];
// 复制消息内容
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('复制失败:', error);
}
}, [message.content]);
// 处理引用点击
const handleReferenceClick = useCallback((sources: GroundingSource[], index: number) => {
setSelectedReference(selectedReference === index ? null : index);
console.log('引用点击:', { index, sources });
}, [selectedReference]);
// 处理素材卡片点击
const handleMaterialClick = useCallback((source: GroundingSource) => {
console.log('素材点击:', source);
// TODO: 实现素材详情查看逻辑
}, []);
// 格式化时间
const formatTime = (date: Date) => {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className={`flex gap-3 p-4 chat-message-enter ${isUser ? 'flex-row-reverse' : 'flex-row'} ${className}`}>
{/* 头像 */}
<div className="flex-shrink-0">
<div className={`
w-8 h-8 rounded-full flex items-center justify-center
${isUser
? 'bg-blue-500 text-white'
: 'bg-gradient-to-br from-purple-500 to-pink-500 text-white'
}
`}>
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</div>
</div>
{/* 消息内容 */}
<div className={`flex-1 max-w-3xl ${isUser ? 'text-right' : 'text-left'}`}>
{/* 消息气泡 */}
<div className={`
inline-block p-4 rounded-2xl chat-message-bubble
${isUser
? 'user text-white rounded-br-md'
: 'assistant rounded-bl-md'
}
${message.status === 'sending' ? 'opacity-70' : ''}
`}>
{/* 消息内容渲染 */}
<div className={`${isUser ? 'text-white' : 'text-gray-900'}`}>
{isAssistant && enableReferences ? (
<EnhancedMarkdownRenderer
content={message.content}
groundingMetadata={groundingMetadata}
enableReferences={enableReferences}
enableMarkdown={true}
onReferenceClick={handleReferenceClick}
/>
) : (
<div className="whitespace-pre-wrap leading-relaxed">
{message.content}
</div>
)}
</div>
{/* 消息状态 */}
{message.status === 'sending' && (
<div className="flex items-center mt-2 text-xs opacity-70">
<div className="animate-spin rounded-full h-3 w-3 border border-current border-t-transparent mr-2" />
...
</div>
)}
</div>
{/* 消息元信息 */}
<div className={`flex items-center gap-2 mt-2 text-xs text-gray-500 ${isUser ? 'justify-end' : 'justify-start'}`}>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{formatTime(message.timestamp)}</span>
</div>
{isAssistant && message.metadata?.responseTime && (
<span> : {message.metadata.responseTime}ms</span>
)}
{isAssistant && message.metadata?.modelUsed && (
<span> {message.metadata.modelUsed}</span>
)}
{/* 复制按钮 */}
<button
onClick={handleCopy}
className="ml-2 p-1 hover:bg-gray-100 rounded transition-colors"
title="复制消息"
>
{copied ? (
<Check className="w-3 h-3 text-green-500" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</div>
{/* 素材来源展示 */}
{isAssistant && showSources && sources.length > 0 && enableMaterialCards && (
<div className="mt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-gray-700">
({sources.length})
</h4>
<button
onClick={() => setExpandedSources(!expandedSources)}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
{expandedSources ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
</div>
{expandedSources && (
<div className="flex items-center gap-1">
<button
onClick={() => setMaterialViewMode('grid')}
className={`p-1 rounded ${materialViewMode === 'grid' ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
title="网格视图"
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setMaterialViewMode('list')}
className={`p-1 rounded ${materialViewMode === 'list' ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
title="列表视图"
>
<List className="w-4 h-4" />
</button>
</div>
)}
</div>
{expandedSources && (
<div className={`
${materialViewMode === 'grid'
? 'material-grid compact'
: 'space-y-2'
}
`}>
{sources.map((source, index) => (
<ChatMaterialCard
key={index}
source={source as GroundingSource}
size={materialViewMode === 'grid' ? 'small' : 'medium'}
showDetails={materialViewMode === 'list'}
onClick={handleMaterialClick}
/>
))}
</div>
)}
</div>
)}
</div>
</div>
);
};
export default EnhancedChatMessageV2;

View File

@@ -0,0 +1,177 @@
import React, { useMemo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { GroundingMetadata, GroundingSupport, GroundingSource } from '../types/ragGrounding';
import { ReferenceFootnote } from './ReferenceFootnote';
/**
* 文本片段与引用信息
*/
interface TextSegmentWithReference {
text: string;
startIndex: number;
endIndex: number;
groundingSupport?: GroundingSupport;
sources?: GroundingSource[];
referenceIndex?: number;
}
/**
* 增强Markdown渲染器属性接口
*/
interface EnhancedMarkdownRendererProps {
/** 要渲染的文本内容 */
content: string;
/** Grounding元数据 */
groundingMetadata?: GroundingMetadata;
/** 是否启用角标引用 */
enableReferences?: boolean;
/** 是否启用Markdown渲染 */
enableMarkdown?: boolean;
/** 自定义样式类名 */
className?: string;
/** 引用点击回调 */
onReferenceClick?: (sources: GroundingSource[], index: number) => void;
}
/**
* 增强Markdown渲染器组件
* 支持角标引用和grounding高亮
*/
export const EnhancedMarkdownRenderer: React.FC<EnhancedMarkdownRendererProps> = ({
content,
groundingMetadata,
enableReferences = true,
enableMarkdown = true,
className = '',
onReferenceClick
}) => {
const [selectedReference, setSelectedReference] = useState<number | null>(null);
// 解析文本片段和引用信息
const textSegments = useMemo(() => {
if (!groundingMetadata?.grounding_supports?.length) {
return [{ text: content, startIndex: 0, endIndex: content.length }];
}
const segments: TextSegmentWithReference[] = [];
const supports = [...groundingMetadata.grounding_supports].sort(
(a, b) => a.start_index - b.start_index
);
let currentIndex = 0;
let referenceCounter = 1;
supports.forEach((support) => {
// 添加支持前的文本
if (currentIndex < support.start_index) {
segments.push({
text: content.slice(currentIndex, support.start_index),
startIndex: currentIndex,
endIndex: support.start_index
});
}
// 获取相关来源
const sources = groundingMetadata.sources?.filter(source =>
support.grounding_chunk_indices?.includes(source.chunk_index || 0)
) || [];
// 添加支持的文本片段
segments.push({
text: content.slice(support.start_index, support.end_index),
startIndex: support.start_index,
endIndex: support.end_index,
groundingSupport: support,
sources,
referenceIndex: sources.length > 0 ? referenceCounter++ : undefined
});
currentIndex = support.end_index;
});
// 添加剩余文本
if (currentIndex < content.length) {
segments.push({
text: content.slice(currentIndex),
startIndex: currentIndex,
endIndex: content.length
});
}
return segments;
}, [content, groundingMetadata]);
// 处理引用点击
const handleReferenceClick = (sources: GroundingSource[], index: number) => {
setSelectedReference(selectedReference === index ? null : index);
onReferenceClick?.(sources, index);
};
// 渲染文本片段
const renderSegment = (segment: TextSegmentWithReference, index: number) => {
const hasReference = segment.referenceIndex && segment.sources?.length;
const isHighlighted = hasReference && selectedReference === segment.referenceIndex;
return (
<span
key={index}
className={`
${hasReference ? 'relative text-highlight' : ''}
${isHighlighted ? 'bg-blue-100 rounded px-1' : ''}
`}
>
{enableMarkdown ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <>{children}</>,
h1: ({ children }) => <strong className="text-lg font-bold">{children}</strong>,
h2: ({ children }) => <strong className="text-base font-semibold">{children}</strong>,
h3: ({ children }) => <strong className="font-medium">{children}</strong>,
ul: ({ children }) => <div className="ml-4">{children}</div>,
ol: ({ children }) => <div className="ml-4">{children}</div>,
li: ({ children }) => <div className="flex items-start"><span className="mr-2"></span><span>{children}</span></div>,
strong: ({ children }) => <strong>{children}</strong>,
em: ({ children }) => <em>{children}</em>,
code: ({ children }) => (
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">
{children}
</code>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 pl-4 italic text-gray-600">
{children}
</blockquote>
),
}}
>
{segment.text}
</ReactMarkdown>
) : (
segment.text
)}
{/* 角标引用 */}
{enableReferences && hasReference && (
<ReferenceFootnote
index={segment.referenceIndex!}
sources={segment.sources!}
className="ml-1 align-super"
onClick={(sources) => handleReferenceClick(sources, segment.referenceIndex!)}
/>
)}
</span>
);
};
return (
<div className={`enhanced-markdown-renderer ${className}`}>
<div className="prose prose-sm max-w-none leading-relaxed">
{textSegments.map(renderSegment)}
</div>
</div>
);
};
export default EnhancedMarkdownRenderer;

View File

@@ -1,8 +1,11 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { GroundingMetadata, GroundingSupport, GroundingSource } from '../types/ragGrounding';
import ImageCard from './ImageCard';
import ImagePreviewModal from './ImagePreviewModal';
import useImageDownload from '../hooks/useImageDownload';
import { ReferenceFootnote } from './ReferenceFootnote';
/**
* 文字片段信息
@@ -34,6 +37,8 @@ interface GroundedTextProps {
enableImageCards?: boolean;
/** 高亮样式 */
highlightStyle?: 'underline' | 'background' | 'border';
/** 是否启用 Markdown 渲染 */
enableMarkdown?: boolean;
}
/**
@@ -45,17 +50,35 @@ export const GroundedText: React.FC<GroundedTextProps> = ({
groundingMetadata,
className = '',
enableImageCards = true,
highlightStyle = 'underline'
highlightStyle = 'underline',
enableMarkdown = true
}) => {
const [hoveredSegment, setHoveredSegment] = useState<TextSegment | null>(null);
const [clickedSegment, setClickedSegment] = useState<TextSegment | null>(null);
const [cardPosition, setCardPosition] = useState<{ top: number; left: number } | null>(null);
const [previewSource, setPreviewSource] = useState<GroundingSource | null>(null);
const [isCardHovered, setIsCardHovered] = useState(false);
const [isPersistent, setIsPersistent] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { downloadImage, isDownloadable } = useImageDownload();
// 清理定时器
useEffect(() => {
return () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, []);
// 解析文本片段
const textSegments = React.useMemo(() => {
console.log('🔍 GroundedText - groundingMetadata:', groundingMetadata);
console.log('🔍 GroundedText - grounding_supports:', groundingMetadata?.grounding_supports);
if (!groundingMetadata?.grounding_supports) {
console.log('❌ 没有 grounding_supports 数据');
return [{ text, startIndex: 0, endIndex: text.length }];
}
@@ -85,6 +108,13 @@ export const GroundedText: React.FC<GroundedTextProps> = ({
.map((index: number) => sources[index])
.filter(Boolean);
console.log('📝 处理片段:', {
text: segmentText,
groundingChunkIndices: support.groundingChunkIndices,
relatedSources: relatedSources.length,
sources: relatedSources
});
// 添加高亮片段
segments.push({
text: segmentText,
@@ -109,9 +139,50 @@ export const GroundedText: React.FC<GroundedTextProps> = ({
return segments;
}, [text, groundingMetadata]);
// 处理鼠标悬停
const handleMouseEnter = useCallback((segment: TextSegment, event: React.MouseEvent) => {
if (!segment.groundingSupport || !segment.sources?.length || !enableImageCards) return;
// 清除隐藏定时器
const clearHideTimeout = useCallback(() => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
}, []);
// 隐藏卡片
const hideCard = useCallback(() => {
if (!isPersistent) {
setHoveredSegment(null);
setCardPosition(null);
setIsCardHovered(false);
}
}, [isPersistent]);
// 延迟隐藏卡片
const scheduleHideCard = useCallback(() => {
if (isPersistent) return; // 如果是持久显示,不隐藏
clearHideTimeout();
hideTimeoutRef.current = setTimeout(() => {
if (!isCardHovered) {
hideCard();
}
}, 150); // 150ms 延迟,给用户时间移动到卡片上
}, [clearHideTimeout, hideCard, isCardHovered, isPersistent]);
// 显示卡片
const showCard = useCallback((segment: TextSegment, event: React.MouseEvent, persistent = false) => {
console.log('🎯 showCard 调用:', {
hasGrounding: !!segment.groundingSupport,
sourcesLength: segment.sources?.length,
enableImageCards,
persistent
});
if (!segment.groundingSupport || !segment.sources?.length || !enableImageCards) {
console.log('❌ showCard 条件不满足');
return;
}
clearHideTimeout();
const rect = event.currentTarget.getBoundingClientRect();
const containerRect = containerRef.current?.getBoundingClientRect();
@@ -123,14 +194,59 @@ export const GroundedText: React.FC<GroundedTextProps> = ({
});
}
setHoveredSegment(segment);
}, [enableImageCards]);
if (persistent) {
setClickedSegment(segment);
setIsPersistent(true);
} else {
setHoveredSegment(segment);
}
}, [enableImageCards, clearHideTimeout]);
// 处理鼠标离开
// 处理鼠标悬停文字
const handleMouseEnter = useCallback((segment: TextSegment, event: React.MouseEvent) => {
console.log('🖱️ 鼠标悬停:', {
text: segment.text,
hasGrounding: !!segment.groundingSupport,
sourcesCount: segment.sources?.length || 0,
isPersistent
});
if (isPersistent) return; // 如果已经是持久显示,悬停不生效
showCard(segment, event, false);
}, [isPersistent, showCard]);
// 处理鼠标离开文字
const handleMouseLeave = useCallback(() => {
setHoveredSegment(null);
setCardPosition(null);
}, []);
scheduleHideCard();
}, [scheduleHideCard]);
// 处理点击文字
const handleClick = useCallback((segment: TextSegment, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
// 如果点击的是同一个片段,切换显示状态
if (clickedSegment === segment && isPersistent) {
setIsPersistent(false);
setClickedSegment(null);
hideCard();
} else {
// 显示新的片段
showCard(segment, event, true);
}
}, [clickedSegment, isPersistent, showCard, hideCard]);
// 处理鼠标进入卡片
const handleCardMouseEnter = useCallback(() => {
clearHideTimeout();
setIsCardHovered(true);
}, [clearHideTimeout]);
// 处理鼠标离开卡片
const handleCardMouseLeave = useCallback(() => {
setIsCardHovered(false);
scheduleHideCard();
}, [scheduleHideCard]);
// 处理图片下载
const handleDownload = useCallback(async (source: GroundingSource) => {
@@ -159,6 +275,15 @@ export const GroundedText: React.FC<GroundedTextProps> = ({
setPreviewSource(null);
}, []);
// 关闭卡片
const handleCloseCard = useCallback(() => {
setIsPersistent(false);
setClickedSegment(null);
setHoveredSegment(null);
setCardPosition(null);
setIsCardHovered(false);
}, []);
// 获取高亮样式
const getHighlightClass = useCallback((hasGrounding: boolean) => {
if (!hasGrounding) return '';
@@ -175,60 +300,154 @@ export const GroundedText: React.FC<GroundedTextProps> = ({
}
}, [highlightStyle]);
// 渲染内容(支持 Markdown 或纯文本)
const renderContent = useCallback(() => {
if (!enableMarkdown) {
// 纯文本模式
return (
<div className="leading-relaxed">
{textSegments.map((segment, index) => {
const hasGrounding = !!(segment.groundingSupport && segment.sources?.length);
const isActive = clickedSegment === segment && isPersistent;
return (
<span
key={index}
className={`transition-all duration-200 ${getHighlightClass(hasGrounding)} ${
isActive ? 'bg-pink-100 border-pink-400' : ''
}`}
onMouseEnter={hasGrounding ? (e) => handleMouseEnter(segment, e) : undefined}
onMouseLeave={hasGrounding ? handleMouseLeave : undefined}
onClick={hasGrounding ? (e) => handleClick(segment, e) : undefined}
title={hasGrounding ? `点击查看相关图片 (${segment.sources?.length} 张)` : undefined}
>
{segment.text}
</span>
);
})}
</div>
);
}
// Markdown 模式 - 暂时使用纯文本渲染以保持 grounding 功能
// TODO: 未来需要实现更复杂的 Markdown + Grounding 集成
return (
<div className="prose prose-sm max-w-none">
<div className="leading-relaxed whitespace-pre-wrap">
{textSegments.map((segment, index) => {
const hasGrounding = !!(segment.groundingSupport && segment.sources?.length);
const isActive = clickedSegment === segment && isPersistent;
return (
<span
key={index}
className={`transition-all duration-200 ${getHighlightClass(hasGrounding)} ${
isActive ? 'bg-pink-100 border-pink-400' : ''
}`}
onMouseEnter={hasGrounding ? (e) => handleMouseEnter(segment, e) : undefined}
onMouseLeave={hasGrounding ? handleMouseLeave : undefined}
onClick={hasGrounding ? (e) => handleClick(segment, e) : undefined}
title={hasGrounding ? `点击查看相关图片 (${segment.sources?.length} 张)` : undefined}
>
<>
{enableMarkdown ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <>{children}</>,
h1: ({ children }) => <strong className="text-lg font-bold">{children}</strong>,
h2: ({ children }) => <strong className="text-base font-semibold">{children}</strong>,
h3: ({ children }) => <strong className="font-medium">{children}</strong>,
ul: ({ children }) => <span className="inline">{children}</span>,
ol: ({ children }) => <span className="inline">{children}</span>,
li: ({ children }) => <span className="inline"> {children} </span>,
strong: ({ children }) => <strong>{children}</strong>,
em: ({ children }) => <em>{children}</em>,
code: ({ children }) => <code className="bg-gray-100 px-1 py-0.5 rounded text-sm">{children}</code>,
blockquote: ({ children }) => <span className="italic text-gray-600">{children}</span>,
}}
>
{segment.text}
</ReactMarkdown>
) : (
segment.text
)}
{/* 素材引用数量标注 - 使用CSS伪元素样式 */}
{hasGrounding && segment.sources && segment.sources.length > 0 && (
<span
className="text-xs text-gray-400 font-normal align-super leading-none"
style={{
fontSize: '0.6em',
verticalAlign: 'super',
lineHeight: 0,
marginLeft: '1px'
}}
>
{segment.sources.length}
</span>
)}
</>
</span>
);
})}
</div>
</div>
);
}, [enableMarkdown, textSegments, clickedSegment, isPersistent, getHighlightClass, handleMouseEnter, handleMouseLeave, handleClick, text]);
return (
<div ref={containerRef} className={`relative ${className}`}>
{/* 渲染文本片段 */}
<div className="leading-relaxed">
{textSegments.map((segment, index) => {
const hasGrounding = !!(segment.groundingSupport && segment.sources?.length);
return (
<span
key={index}
className={`transition-all duration-200 ${getHighlightClass(hasGrounding)}`}
onMouseEnter={hasGrounding ? (e) => handleMouseEnter(segment, e) : undefined}
onMouseLeave={hasGrounding ? handleMouseLeave : undefined}
title={hasGrounding ? `查看相关图片 (${segment.sources?.length} 张)` : undefined}
>
{segment.text}
</span>
);
})}
</div>
{/* 渲染内容 */}
{renderContent()}
{/* 图片卡片 */}
{hoveredSegment && hoveredSegment.sources && cardPosition && (
<div
className="absolute z-50"
style={{
top: cardPosition.top,
left: cardPosition.left
}}
onMouseEnter={() => {}} // 保持卡片显示
onMouseLeave={handleMouseLeave}
>
{/* 显示第一张相关图片 */}
{hoveredSegment.sources[0] && (
<ImageCard
source={hoveredSegment.sources[0]}
showDetails={true}
onClose={handleMouseLeave}
onDownload={isDownloadable(hoveredSegment.sources[0]) ? handleDownload : undefined}
onViewLarge={handleViewLarge}
className="shadow-xl border-2 border-pink-200"
/>
)}
{(() => {
const displaySegment = isPersistent ? clickedSegment : hoveredSegment;
return displaySegment && displaySegment.sources && cardPosition && (
<div
className="absolute z-50"
style={{
top: cardPosition.top,
left: cardPosition.left
}}
onMouseEnter={handleCardMouseEnter}
onMouseLeave={handleCardMouseLeave}
>
{/* 显示第一张相关图片 */}
{displaySegment.sources[0] && (
<ImageCard
source={displaySegment.sources[0]}
showDetails={true}
onClose={isPersistent ? handleCloseCard : hideCard}
onDownload={isDownloadable(displaySegment.sources[0]) ? handleDownload : undefined}
onViewLarge={handleViewLarge}
className={`shadow-xl border-2 ${
isPersistent ? 'border-pink-400' : 'border-pink-200'
}`}
/>
)}
{/* 如果有多张图片,显示指示器 */}
{hoveredSegment.sources.length > 1 && (
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2">
<div className="bg-pink-500 text-white text-xs px-2 py-1 rounded-full">
+{hoveredSegment.sources.length - 1}
{/* 如果有多张图片,显示指示器 */}
{displaySegment.sources.length > 1 && (
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2">
<div className={`text-white text-xs px-2 py-1 rounded-full ${
isPersistent ? 'bg-pink-600' : 'bg-pink-500'
}`}>
+{displaySegment.sources.length - 1}
</div>
</div>
</div>
)}
</div>
)}
)}
{/* 持久显示时的固定指示器 */}
{isPersistent && (
<div className="absolute -top-2 -right-2">
<div className="w-3 h-3 bg-pink-500 rounded-full border-2 border-white shadow-sm"></div>
</div>
)}
</div>
);
})()}
{/* 图片预览模态框 */}
<ImagePreviewModal

View File

@@ -0,0 +1,214 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { ExternalLink, Image as ImageIcon, FileText } from 'lucide-react';
import { GroundingSource } from '../types/ragGrounding';
/**
* 引用角标属性接口
*/
interface ReferenceFootnoteProps {
/** 角标编号 */
index: number;
/** 引用来源列表 */
sources: GroundingSource[];
/** 自定义样式类名 */
className?: string;
/** 是否显示详细信息 */
showDetails?: boolean;
/** 点击回调 */
onClick?: (sources: GroundingSource[]) => void;
}
/**
* 引用来源详情弹窗属性接口
*/
interface ReferenceTooltipProps {
sources: GroundingSource[];
isVisible: boolean;
position: { top: number; left: number };
onClose: () => void;
}
/**
* 引用来源详情弹窗组件
*/
const ReferenceTooltip: React.FC<ReferenceTooltipProps> = ({
sources,
isVisible,
position,
onClose
}) => {
const tooltipRef = useRef<HTMLDivElement>(null);
// 点击外部关闭弹窗
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (tooltipRef.current && !tooltipRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isVisible) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isVisible, onClose]);
if (!isVisible) return null;
return (
<div
ref={tooltipRef}
className="fixed z-50 reference-tooltip rounded-lg p-4 max-w-sm animate-fade-in"
style={{
top: position.top,
left: position.left,
transform: 'translateY(-100%)'
}}
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-900"></h4>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
×
</button>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{sources.map((source, index) => (
<div key={index} className="border-l-2 border-blue-200 pl-3 py-2">
<div className="flex items-start space-x-2">
{/* 来源类型图标 */}
<div className="flex-shrink-0 mt-0.5">
{source.content?.type === 'image' ? (
<ImageIcon className="w-4 h-4 text-blue-500" />
) : (
<FileText className="w-4 h-4 text-gray-500" />
)}
</div>
{/* 来源信息 */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{source.title || '未知来源'}
</p>
{source.content?.description && (
<p className="text-xs text-gray-600 mt-1 line-clamp-2">
{source.content.description}
</p>
)}
{source.uri && (
<div className="flex items-center mt-1">
<ExternalLink className="w-3 h-3 text-gray-400 mr-1" />
<span className="text-xs text-gray-500 truncate">
{source.uri}
</span>
</div>
)}
</div>
</div>
{/* 图片预览 */}
{source.content?.type === 'image' && source.content?.image_url && (
<div className="mt-2">
<img
src={source.content.image_url}
alt={source.title || '引用图片'}
className="w-full h-20 object-cover rounded border"
loading="lazy"
/>
</div>
)}
</div>
))}
</div>
</div>
</div>
);
};
/**
* 引用角标组件
* 遵循 frontend-developer 规范的设计标准
*/
export const ReferenceFootnote: React.FC<ReferenceFootnoteProps> = ({
index,
sources,
className = '',
showDetails = true,
onClick
}) => {
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
const footnoteRef = useRef<HTMLButtonElement>(null);
// 处理角标点击
const handleClick = useCallback((event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (onClick) {
onClick(sources);
}
if (showDetails && footnoteRef.current) {
const rect = footnoteRef.current.getBoundingClientRect();
setTooltipPosition({
top: rect.top + window.scrollY - 8,
left: rect.left + window.scrollX + rect.width / 2
});
setShowTooltip(!showTooltip);
}
}, [sources, onClick, showDetails, showTooltip]);
// 处理悬停
const handleMouseEnter = useCallback(() => {
if (!showTooltip && showDetails && footnoteRef.current) {
const rect = footnoteRef.current.getBoundingClientRect();
setTooltipPosition({
top: rect.top + window.scrollY - 8,
left: rect.left + window.scrollX + rect.width / 2
});
}
}, [showTooltip, showDetails]);
return (
<>
<button
ref={footnoteRef}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
className={`
inline-flex items-center justify-center
w-5 h-5 text-xs font-medium
text-white rounded-full
reference-footnote
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
cursor-pointer
${className}
`}
title={`查看引用来源 (${sources.length} 个)`}
aria-label={`引用角标 ${index}${sources.length} 个来源`}
>
{index}
</button>
{/* 引用详情弹窗 */}
<ReferenceTooltip
sources={sources}
isVisible={showTooltip}
position={tooltipPosition}
onClose={() => setShowTooltip(false)}
/>
</>
);
};
export default ReferenceFootnote;

View File

@@ -0,0 +1,141 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { describe, test, expect, vi } from 'vitest';
import { EnhancedChatMessageV2 } from '../EnhancedChatMessageV2';
// Mock 数据
const mockUserMessage = {
id: '1',
type: 'user' as const,
content: '请推荐一些夏日穿搭',
timestamp: new Date('2024-01-01T10:00:00Z'),
status: 'sent' as const
};
const mockAssistantMessage = {
id: '2',
type: 'assistant' as const,
content: '夏日穿搭建议:选择轻薄透气的面料,如棉麻材质。推荐搭配清爽的色彩,如白色、浅蓝色等。',
timestamp: new Date('2024-01-01T10:01:00Z'),
status: 'sent' as const,
metadata: {
responseTime: 1500,
modelUsed: 'gemini-pro',
sources: [
{
title: '夏日穿搭指南',
uri: 'https://example.com/summer-style.jpg',
content: {
type: 'image',
image_url: 'https://example.com/summer-style.jpg',
description: '清爽夏日穿搭示例'
}
}
],
grounding_metadata: {
grounding_supports: [
{
start_index: 0,
end_index: 20,
grounding_chunk_indices: [0]
}
],
sources: [
{
chunk_index: 0,
title: '夏日穿搭指南',
uri: 'https://example.com/summer-style.jpg',
content: {
type: 'image',
image_url: 'https://example.com/summer-style.jpg',
description: '清爽夏日穿搭示例'
}
}
]
}
}
};
describe('EnhancedChatMessageV2', () => {
test('渲染用户消息', () => {
render(<EnhancedChatMessageV2 message={mockUserMessage} />);
expect(screen.getByText('请推荐一些夏日穿搭')).toBeInTheDocument();
expect(screen.getByText('10:00')).toBeInTheDocument();
});
test('渲染助手消息', () => {
render(<EnhancedChatMessageV2 message={mockAssistantMessage} />);
expect(screen.getByText(/夏日穿搭建议/)).toBeInTheDocument();
expect(screen.getByText('10:01')).toBeInTheDocument();
expect(screen.getByText('• 响应时间: 1500ms')).toBeInTheDocument();
expect(screen.getByText('• gemini-pro')).toBeInTheDocument();
});
test('显示素材来源', () => {
render(
<EnhancedChatMessageV2
message={mockAssistantMessage}
showSources={true}
enableMaterialCards={true}
/>
);
expect(screen.getByText('相关素材 (1)')).toBeInTheDocument();
});
test('复制消息功能', async () => {
// Mock clipboard API
const mockWriteText = vi.fn().mockImplementation(() => Promise.resolve());
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
render(<EnhancedChatMessageV2 message={mockUserMessage} />);
const copyButton = screen.getByTitle('复制消息');
fireEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledWith('请推荐一些夏日穿搭');
});
test('展开/收起素材来源', () => {
render(
<EnhancedChatMessageV2
message={mockAssistantMessage}
showSources={true}
enableMaterialCards={true}
/>
);
// 检查是否显示了素材来源标题
expect(screen.getByText('相关素材 (1)')).toBeInTheDocument();
});
test('角标引用功能', () => {
render(
<EnhancedChatMessageV2
message={mockAssistantMessage}
enableReferences={true}
/>
);
// 检查是否渲染了消息内容
expect(screen.getByText(/夏日穿搭建议/)).toBeInTheDocument();
});
test('发送中状态显示', () => {
const sendingMessage = {
...mockUserMessage,
status: 'sending' as const
};
render(<EnhancedChatMessageV2 message={sendingMessage} />);
expect(screen.getByText('发送中...')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,177 @@
# AI智能聊天功能重新设计
根据 `promptx/frontend-developer` 规范重新设计的AI智能聊天功能包含以下核心特性
## 🎯 核心功能
### 1. 信息引用角标系统
- **角标显示**: 每句话的信息引用以角标形式展示
- **点击查看**: 点击角标可查看详细来源信息
- **悬停预览**: 鼠标悬停显示来源预览
- **智能关联**: 与 grounding metadata 自动关联
### 2. 优化素材卡片列表
- **多种视图**: 支持网格和列表两种展示模式
- **响应式设计**: 适配不同屏幕尺寸
- **缩略图预览**: 图片/视频素材缩略图展示
- **详细信息**: 文件大小、时长等元数据显示
### 3. 增强Markdown渲染
- **语法支持**: 完整的Markdown语法支持
- **角标集成**: 角标与Markdown内容无缝集成
- **高亮效果**: 引用文本智能高亮显示
- **自定义组件**: 支持自定义Markdown组件
## 🏗️ 组件架构
### 核心组件
#### `EnhancedChatMessageV2`
主要的聊天消息组件,整合所有新功能:
```tsx
<EnhancedChatMessageV2
message={message}
showSources={true}
enableMaterialCards={true}
enableReferences={true}
/>
```
#### `ReferenceFootnote`
引用角标组件:
```tsx
<ReferenceFootnote
index={1}
sources={sources}
onClick={handleReferenceClick}
/>
```
#### `EnhancedMarkdownRenderer`
增强Markdown渲染器
```tsx
<EnhancedMarkdownRenderer
content={content}
groundingMetadata={metadata}
enableReferences={true}
enableMarkdown={true}
/>
```
#### `ChatMaterialCard`
聊天专用素材卡片:
```tsx
<ChatMaterialCard
source={source}
size="medium"
showDetails={true}
onClick={handleMaterialClick}
/>
```
## 🎨 设计规范
### 视觉设计
- **渐变背景**: 使用现代渐变背景提升视觉效果
- **毛玻璃效果**: 消息气泡采用毛玻璃效果
- **动画过渡**: 流畅的动画过渡效果
- **响应式布局**: 完美适配移动端和桌面端
### 交互设计
- **直观操作**: 点击、悬停等交互符合用户习惯
- **即时反馈**: 所有操作都有即时视觉反馈
- **无障碍访问**: 支持键盘导航和屏幕阅读器
- **性能优化**: 懒加载和虚拟化提升性能
## 🔧 技术实现
### 依赖库
- `react-markdown`: Markdown渲染
- `remark-gfm`: GitHub风格Markdown支持
- `lucide-react`: 图标库
- `@testing-library/react`: 单元测试
### 样式系统
- **CSS类**: 使用语义化CSS类名
- **设计系统**: 遵循统一的设计系统
- **响应式**: 移动优先的响应式设计
- **动画**: CSS动画和过渡效果
### 状态管理
- **本地状态**: 使用React Hooks管理组件状态
- **全局状态**: 与现有状态管理系统集成
- **缓存策略**: 智能缓存提升性能
## 📱 使用示例
### 基础用法
```tsx
import { EnhancedChatMessageV2 } from './components/EnhancedChatMessageV2';
function ChatInterface() {
return (
<div className="chat-container">
{messages.map((message) => (
<EnhancedChatMessageV2
key={message.id}
message={message}
showSources={true}
enableMaterialCards={true}
enableReferences={true}
/>
))}
</div>
);
}
```
### 自定义配置
```tsx
<EnhancedChatMessageV2
message={message}
showSources={false} // 隐藏素材来源
enableMaterialCards={false} // 禁用素材卡片
enableReferences={false} // 禁用角标引用
className="custom-message" // 自定义样式
/>
```
## 🧪 测试
### 单元测试
```bash
npm test -- EnhancedChatMessageV2.test.tsx
```
### 功能测试
- 消息渲染测试
- 角标引用测试
- 素材卡片测试
- 交互功能测试
## 🚀 性能优化
### 渲染优化
- **虚拟化**: 长列表虚拟化
- **懒加载**: 图片和素材懒加载
- **缓存**: 智能缓存策略
- **防抖**: 用户输入防抖处理
### 内存管理
- **清理**: 及时清理事件监听器
- **优化**: 避免内存泄漏
- **回收**: 自动垃圾回收
## 📋 待办事项
- [ ] 添加更多Markdown组件支持
- [ ] 实现素材详情查看功能
- [ ] 添加更多动画效果
- [ ] 优化移动端体验
- [ ] 添加国际化支持
## 🔗 相关文档
- [Frontend Developer 规范](../../promptx/frontend-developer/)
- [设计系统文档](../../styles/design-system.css)
- [组件测试指南](../__tests__/README.md)

View File

@@ -2285,6 +2285,155 @@ main::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
}
/* 增强聊天界面样式 */
.enhanced-chat-interface {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
}
.chat-message-bubble {
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.chat-message-bubble.user {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
}
.chat-message-bubble.assistant {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
/* 引用角标样式 */
.reference-footnote {
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.reference-footnote:hover {
transform: scale(1.1);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);
}
/* 素材卡片样式 */
.chat-material-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(229, 231, 235, 0.8);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-material-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border-color: rgba(59, 130, 246, 0.3);
}
/* 引用提示框样式 */
.reference-tooltip {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px);
border: 1px solid rgba(229, 231, 235, 0.8);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* 动画效果 */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
/* 文本高亮效果 */
.text-highlight {
background: linear-gradient(120deg, rgba(59, 130, 246, 0.1) 0%, rgba(147, 197, 253, 0.1) 100%);
border-bottom: 1px dotted rgba(59, 130, 246, 0.5);
transition: all 0.2s ease;
border-radius: 2px;
padding: 1px 2px;
}
.text-highlight:hover {
background: linear-gradient(120deg, rgba(59, 130, 246, 0.15) 0%, rgba(147, 197, 253, 0.15) 100%);
border-bottom-color: rgba(59, 130, 246, 0.8);
}
/* 聊天消息动画 */
@keyframes message-slide-in {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.chat-message-enter {
animation: message-slide-in 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 素材网格布局优化 */
.material-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.material-grid.compact {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
}
/* 响应式优化 */
@media (max-width: 768px) {
.material-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.chat-message-bubble {
max-width: 85%;
}
.reference-footnote {
width: 1.25rem;
height: 1.25rem;
font-size: 0.625rem;
}
}
/* 滚动条样式优化 */
.chat-scroll::-webkit-scrollbar {
width: 6px;
}
.chat-scroll::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
}
.chat-scroll::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.chat-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
.thumbnail-container::before {
content: '';
position: absolute;

View File

@@ -92,7 +92,6 @@ export async function testConfig(): Promise<boolean> {
*/
export async function testSimpleQuery(): Promise<boolean> {
try {
console.log('🔍 测试简单查询...');
const result = await queryRagGrounding('你好,请介绍一下自己', {
sessionId: 'test-session',
includeMetadata: true,
@@ -100,26 +99,6 @@ export async function testSimpleQuery(): Promise<boolean> {
});
if (result.success && result.data) {
console.group('✅ 简单查询测试成功');
console.log('💬 AI 回答:', result.data.answer);
console.log('⏱️ 响应时间:', result.data.response_time_ms + 'ms');
console.log('🤖 使用模型:', result.data.model_used);
console.log('🕐 总耗时:', result.totalTime + 'ms');
if (result.data.grounding_metadata?.sources) {
console.log('📚 参考来源数量:', result.data.grounding_metadata.sources.length);
result.data.grounding_metadata.sources.forEach((source, index) => {
const contentPreview = source.content
? (typeof source.content === 'string' ? source.content.substring(0, 50) : JSON.stringify(source.content).substring(0, 50))
: '无内容';
console.log(` ${index + 1}. ${source.title}: ${contentPreview}...`);
});
}
if (result.data.grounding_metadata?.search_queries) {
console.log('🔍 搜索查询:', result.data.grounding_metadata.search_queries);
}
console.groupEnd();
return true;
} else {
console.error('❌ 查询失败:', result.error);

View File

@@ -39,10 +39,6 @@ async function basicQueryExample() {
console.log('参考来源:');
result.data.grounding_metadata.sources.forEach((source, index) => {
console.log(` ${index + 1}. ${source.title}`);
console.log(` 片段: ${source.snippet}`);
if (source.relevance_score) {
console.log(` 相关性: ${(source.relevance_score * 100).toFixed(1)}%`);
}
});
}
} else {

830
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff