fix: 优化界面展 及引用关系
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
222
apps/desktop/src/components/ChatMaterialCard.tsx
Normal file
222
apps/desktop/src/components/ChatMaterialCard.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
253
apps/desktop/src/components/EnhancedChatMessageV2.tsx
Normal file
253
apps/desktop/src/components/EnhancedChatMessageV2.tsx
Normal 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;
|
||||
177
apps/desktop/src/components/EnhancedMarkdownRenderer.tsx
Normal file
177
apps/desktop/src/components/EnhancedMarkdownRenderer.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
214
apps/desktop/src/components/ReferenceFootnote.tsx
Normal file
214
apps/desktop/src/components/ReferenceFootnote.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
177
apps/desktop/src/components/chat/README.md
Normal file
177
apps/desktop/src/components/chat/README.md
Normal 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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
830
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user