fix: 添加文字图片关联关系

This commit is contained in:
imeepos
2025-07-22 12:21:39 +08:00
parent e6d54e44aa
commit cb5a137ec9
13 changed files with 1800 additions and 387 deletions

View File

@@ -209,6 +209,7 @@ pub struct RagGroundingResponse {
pub struct GroundingMetadata {
pub sources: Vec<GroundingSource>,
pub search_queries: Vec<String>,
pub grounding_supports: Option<Vec<GroundingSupport>>,
}
/// Grounding 来源
@@ -219,6 +220,25 @@ pub struct GroundingSource {
pub content: Option<serde_json::Value>,
}
/// Grounding 支持信息
/// 用于关联文字片段与来源信息
#[derive(Debug, Serialize, Deserialize)]
pub struct GroundingSupport {
#[serde(rename = "groundingChunkIndices")]
pub grounding_chunk_indices: Vec<usize>,
pub segment: GroundingSegment,
}
/// Grounding 文字片段
#[derive(Debug, Serialize, Deserialize)]
pub struct GroundingSegment {
#[serde(rename = "startIndex")]
pub start_index: usize,
#[serde(rename = "endIndex")]
pub end_index: usize,
pub text: String,
}
/// 对话上下文
#[derive(Debug, Serialize, Deserialize)]
pub struct ConversationContext {
@@ -1503,9 +1523,49 @@ impl GeminiService {
})
.unwrap_or_default();
// 提取grounding supports
let grounding_supports = grounding_metadata
.get("groundingSupports")
.and_then(|supports| supports.as_array())
.map(|supports_array| {
supports_array
.iter()
.filter_map(|support| {
let grounding_chunk_indices = support
.get("groundingChunkIndices")
.and_then(|indices| indices.as_array())
.map(|indices_array| {
indices_array
.iter()
.filter_map(|idx| idx.as_u64().map(|i| i as usize))
.collect()
})
.unwrap_or_default();
let segment = support.get("segment").and_then(|seg| {
let start_index = seg.get("startIndex")?.as_u64()? as usize;
let end_index = seg.get("endIndex")?.as_u64()? as usize;
let text = seg.get("text")?.as_str()?.to_string();
Some(GroundingSegment {
start_index,
end_index,
text,
})
})?;
Some(GroundingSupport {
grounding_chunk_indices,
segment,
})
})
.collect()
});
Some(GroundingMetadata {
sources,
search_queries,
grounding_supports,
})
}

View File

@@ -322,7 +322,11 @@ pub fn run() {
commands::conversation_commands::get_conversation_stats,
commands::conversation_commands::cleanup_expired_sessions,
commands::conversation_commands::update_session_title,
commands::conversation_commands::generate_session_summary
commands::conversation_commands::generate_session_summary,
commands::image_download_commands::download_image_from_uri,
commands::image_download_commands::get_default_download_directory,
commands::image_download_commands::validate_image_uri,
commands::image_download_commands::get_image_info
])
.setup(|app| {
// 初始化日志系统

View File

@@ -0,0 +1,193 @@
use tauri::command;
use std::path::Path;
use anyhow::{Result, anyhow};
use reqwest;
use tokio::fs;
use tokio::io::AsyncWriteExt;
/// 从URI下载图片到本地文件
#[command]
pub async fn download_image_from_uri(uri: String, file_path: String) -> Result<(), String> {
println!("🔽 开始下载图片: {} -> {}", uri, file_path);
// 验证URI格式
if !uri.starts_with("http://") && !uri.starts_with("https://") {
return Err("无效的图片URI格式".to_string());
}
// 验证文件路径
let path = Path::new(&file_path);
if let Some(parent) = path.parent() {
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
return Err(format!("创建目录失败: {}", e));
}
}
}
// 下载图片
match download_image_internal(&uri, &file_path).await {
Ok(_) => {
println!("✅ 图片下载成功: {}", file_path);
Ok(())
}
Err(e) => {
eprintln!("❌ 图片下载失败: {}", e);
Err(format!("下载失败: {}", e))
}
}
}
/// 获取默认下载目录
#[command]
pub async fn get_default_download_directory() -> Result<String, String> {
// 尝试获取用户下载目录
if let Some(download_dir) = dirs::download_dir() {
Ok(download_dir.to_string_lossy().to_string())
} else {
// 如果获取失败,使用当前目录
match std::env::current_dir() {
Ok(current_dir) => Ok(current_dir.to_string_lossy().to_string()),
Err(e) => Err(format!("获取目录失败: {}", e))
}
}
}
/// 检查图片URI是否有效
#[command]
pub async fn validate_image_uri(uri: String) -> Result<bool, String> {
if !uri.starts_with("http://") && !uri.starts_with("https://") {
return Ok(false);
}
// 发送HEAD请求检查资源是否存在
match reqwest::Client::new().head(&uri).send().await {
Ok(response) => {
let is_valid = response.status().is_success();
let content_type = response.headers()
.get("content-type")
.and_then(|ct| ct.to_str().ok())
.unwrap_or("");
// 检查是否为图片类型
let is_image = content_type.starts_with("image/");
Ok(is_valid && is_image)
}
Err(_) => Ok(false)
}
}
/// 获取图片信息
#[command]
pub async fn get_image_info(uri: String) -> Result<ImageInfo, String> {
if !uri.starts_with("http://") && !uri.starts_with("https://") {
return Err("无效的图片URI格式".to_string());
}
match get_image_info_internal(&uri).await {
Ok(info) => Ok(info),
Err(e) => Err(format!("获取图片信息失败: {}", e))
}
}
/// 图片信息结构
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ImageInfo {
pub content_type: String,
pub content_length: Option<u64>,
pub last_modified: Option<String>,
pub is_valid: bool,
}
/// 内部下载实现
async fn download_image_internal(uri: &str, file_path: &str) -> Result<()> {
// 创建HTTP客户端
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| anyhow!("创建HTTP客户端失败: {}", e))?;
// 发送GET请求
let response = client
.get(uri)
.send()
.await
.map_err(|e| anyhow!("请求失败: {}", e))?;
// 检查响应状态
if !response.status().is_success() {
return Err(anyhow!("HTTP错误: {}", response.status()));
}
// 检查内容类型
let content_type = response.headers()
.get("content-type")
.and_then(|ct| ct.to_str().ok())
.unwrap_or("");
if !content_type.starts_with("image/") {
return Err(anyhow!("不是有效的图片类型: {}", content_type));
}
// 获取响应字节
let bytes = response
.bytes()
.await
.map_err(|e| anyhow!("读取响应数据失败: {}", e))?;
// 写入文件
let mut file = fs::File::create(file_path)
.await
.map_err(|e| anyhow!("创建文件失败: {}", e))?;
file.write_all(&bytes)
.await
.map_err(|e| anyhow!("写入文件失败: {}", e))?;
file.flush()
.await
.map_err(|e| anyhow!("刷新文件失败: {}", e))?;
println!("📁 文件已保存: {} ({} bytes)", file_path, bytes.len());
Ok(())
}
/// 内部获取图片信息实现
async fn get_image_info_internal(uri: &str) -> Result<ImageInfo> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| anyhow!("创建HTTP客户端失败: {}", e))?;
let response = client
.head(uri)
.send()
.await
.map_err(|e| anyhow!("请求失败: {}", e))?;
let is_valid = response.status().is_success();
let content_type = response.headers()
.get("content-type")
.and_then(|ct| ct.to_str().ok())
.unwrap_or("unknown")
.to_string();
let content_length = response.headers()
.get("content-length")
.and_then(|cl| cl.to_str().ok())
.and_then(|cl| cl.parse().ok());
let last_modified = response.headers()
.get("last-modified")
.and_then(|lm| lm.to_str().ok())
.map(|lm| lm.to_string());
Ok(ImageInfo {
content_type: content_type.clone(),
content_length,
last_modified,
is_valid: is_valid && content_type.starts_with("image/"),
})
}

View File

@@ -22,4 +22,5 @@ pub mod outfit_search_commands;
pub mod custom_tag_commands;
pub mod tolerant_json_commands;
pub mod rag_grounding_commands;
pub mod image_download_commands;
pub mod conversation_commands;

View File

@@ -1,23 +1,16 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
MessageCircle,
Send,
Bot,
User,
AlertCircle,
Sparkles,
Clock,
Tag,
CheckCircle,
XCircle,
Copy,
ExternalLink,
Tag,
Palette,
Calendar,
MapPin
} from 'lucide-react';
import { queryRagGrounding } from '../services/ragGroundingService';
import { RagGroundingQueryOptions } from '../types/ragGrounding';
import { RagGroundingQueryOptions, GroundingMetadata } from '../types/ragGrounding';
import EnhancedChatMessage from './EnhancedChatMessage';
/**
* 聊天消息接口
@@ -36,6 +29,7 @@ interface ChatMessage {
uri?: string;
content?: any;
}>;
grounding_metadata?: GroundingMetadata;
};
}
@@ -49,6 +43,8 @@ interface ChatInterfaceProps {
maxMessages?: number;
/** 是否显示来源信息 */
showSources?: boolean;
/** 是否启用图片卡片 */
enableImageCards?: boolean;
/** 自定义样式类名 */
className?: string;
/** 占位符文本 */
@@ -64,6 +60,7 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
sessionId = 'default-session',
maxMessages = 3,
showSources = true,
enableImageCards = true,
className = '',
placeholder = '描述您的穿搭需求,比如场合、风格、身材特点等...'
}) => {
@@ -71,14 +68,13 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedSources, setExpandedSources] = useState<Record<string, boolean>>({});
const [showAnswers, setShowAnswers] = useState<Record<string, boolean>>({});
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [allTags, setAllTags] = useState<string[]>([]);
const [isTagsExpanded, setIsTagsExpanded] = useState(false);
const [showTagBubble, setShowTagBubble] = useState(false);
const [bubbleTags, setBubbleTags] = useState<{categories: string[], environment: string[]}>({categories: [], environment: []});
const [bubblePosition, setBubblePosition] = useState<{x: number, y: number}>({x: 0, y: 0});
const [bubbleTags, _setBubbleTags] = useState<{categories: string[], environment: string[]}>({categories: [], environment: []});
const [bubblePosition, _setBubblePosition] = useState<{x: number, y: number}>({x: 0, y: 0});
const inputRef = useRef<HTMLTextAreaElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
@@ -114,180 +110,7 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
}
}, []);
// 渲染图片卡片
const renderImageCard = useCallback((source: any, index: number) => {
const imageData = parseImageContent(source.content);
if (!imageData || !source.uri) return null;
return (
<div key={index} className="bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm hover:shadow-md transition-all duration-200 group">
{/* 图片 */}
<div className="relative aspect-square bg-gray-100">
<img
src={source.uri}
alt={source.title || `图片 ${index + 1}`}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
onLoad={(e) => {
const target = e.target as HTMLImageElement;
target.parentElement?.querySelector('.loading-placeholder')?.classList.add('hidden');
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.parentElement?.querySelector('.error-placeholder')?.classList.remove('hidden');
target.parentElement?.querySelector('.loading-placeholder')?.classList.add('hidden');
}}
/>
{/* 加载占位符 */}
<div className="loading-placeholder absolute inset-0 bg-gray-100 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
</div>
{/* 错误占位符 */}
<div className="error-placeholder hidden absolute inset-0 bg-gray-100 flex items-center justify-center">
<div className="text-center">
<AlertCircle className="w-6 h-6 text-gray-400 mx-auto mb-1" />
<span className="text-gray-400 text-xs"></span>
</div>
</div>
{/* 运行时间标签 */}
{imageData.runtime && (
<div className="absolute top-2 right-2 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded backdrop-blur-sm">
<Clock className="w-3 h-3 inline mr-1" />
{imageData.runtime}
</div>
)}
{/* 悬停时显示的查看按钮 */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 flex items-center justify-center">
<button
onClick={() => window.open(source.uri, '_blank')}
className="opacity-0 group-hover:opacity-100 bg-white bg-opacity-90 hover:bg-opacity-100 text-gray-800 px-3 py-1 rounded-full text-xs font-medium transition-all duration-200 flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
{/* 内容信息 */}
<div className="p-3 space-y-2">
{/* 描述 */}
{imageData.description && (
<p
className="text-xs text-gray-700 leading-relaxed"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}
title={imageData.description}
>
{imageData.description}
</p>
)}
{/* 分类标签 */}
{imageData.categories.length > 0 && (
<div className="flex flex-wrap gap-1">
{imageData.categories.slice(0, 4).map((category: string, catIndex: number) => {
const isSelected = selectedTags.includes(category);
return (
<button
key={catIndex}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleTag(category);
}}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full transition-all duration-200 border ${
isSelected
? 'bg-pink-100 text-pink-700 border-pink-300 shadow-sm'
: 'bg-blue-50 text-blue-700 hover:bg-pink-100 hover:text-pink-700 border-blue-200 hover:border-pink-300'
}`}
>
<Tag className="w-2 h-2" />
{category}
</button>
);
})}
{imageData.categories.length > 4 && (
<button
onClick={(e) => {
e.stopPropagation();
showTagDetails(imageData, e);
}}
className="text-xs text-gray-500 hover:text-pink-600 hover:bg-pink-50 px-2 py-1 rounded-full transition-all duration-200 border border-gray-300 hover:border-pink-300"
>
+{imageData.categories.length - 4}
</button>
)}
</div>
)}
{/* 环境标签 */}
{imageData.environment_tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{imageData.environment_tags.slice(0, 3).map((tag: string, tagIndex: number) => {
const isSelected = selectedTags.includes(tag);
return (
<button
key={tagIndex}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleTag(tag);
}}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full transition-all duration-200 border ${
isSelected
? 'bg-pink-100 text-pink-700 border-pink-300 shadow-sm'
: 'bg-green-50 text-green-700 hover:bg-pink-100 hover:text-pink-700 border-green-200 hover:border-pink-300'
}`}
>
<MapPin className="w-2 h-2" />
{tag}
</button>
);
})}
</div>
)}
{/* 底部信息栏 */}
<div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-100">
{imageData.environment_color && (
<div className="flex items-center gap-1">
<Palette className="w-3 h-3" />
<div
className="w-3 h-3 rounded-full border border-gray-300 shadow-sm"
style={{ backgroundColor: imageData.environment_color }}
title={`环境色彩: ${imageData.environment_color}`}
/>
</div>
)}
{imageData.releaseDate && (
<div className="flex items-center gap-1" title={`发布时间: ${imageData.releaseDate}`}>
<Calendar className="w-3 h-3" />
<span>{new Date(imageData.releaseDate).toLocaleDateString('zh-CN')}</span>
</div>
)}
{/* 模特数量 */}
{imageData.models.length > 0 && (
<div className="flex items-center gap-1" title={`模特数量: ${imageData.models.length}`}>
<User className="w-3 h-3" />
<span>{imageData.models.length}</span>
</div>
)}
</div>
</div>
</div>
);
}, [parseImageContent, selectedTags]);
// 生成消息ID
const generateMessageId = useCallback(() => {
@@ -403,7 +226,8 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
metadata: {
responseTime: result.data!.response_time_ms,
modelUsed: result.data!.model_used,
sources: result.data!.grounding_metadata?.sources
sources: result.data!.grounding_metadata?.sources,
grounding_metadata: result.data!.grounding_metadata
}
}
: msg
@@ -453,21 +277,7 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
}
}, [messages]);
// 切换图片展开状态
const toggleSourcesExpanded = useCallback((messageId: string) => {
setExpandedSources(prev => ({
...prev,
[messageId]: !prev[messageId]
}));
}, []);
// 切换AI回答显示状态
const toggleAnswerVisibility = useCallback((messageId: string) => {
setShowAnswers(prev => ({
...prev,
[messageId]: !prev[messageId]
}));
}, []);
// 提取所有图片的标签
const extractAllTags = useCallback(() => {
@@ -523,20 +333,6 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
setIsTagsExpanded(prev => !prev);
}, []);
// 显示标签详情气泡
const showTagDetails = useCallback((imageData: any, event: React.MouseEvent) => {
const rect = event.currentTarget.getBoundingClientRect();
setBubblePosition({
x: rect.left + rect.width / 2,
y: rect.top - 10
});
setBubbleTags({
categories: imageData.categories || [],
environment: imageData.environment_tags || []
});
setShowTagBubble(true);
}, []);
// 关闭标签气泡
const closeTagBubble = useCallback(() => {
setShowTagBubble(false);
@@ -588,18 +384,6 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
return searchText;
}, [selectedTags]);
// 复制消息内容到剪贴板
const handleCopyMessage = useCallback(async (message: ChatMessage) => {
try {
await navigator.clipboard.writeText(message.content);
console.log('✅ 已复制到剪贴板');
} catch (err) {
console.error('❌ 复制失败:', err);
}
}, []);
return (
<div className={`flex flex-col h-full bg-white rounded-xl shadow-lg border border-gray-200 ${className} relative`}>
{/* 聊天消息区域 */}
@@ -640,166 +424,13 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
) : (
<>
{messages.map((message) => (
<div
<EnhancedChatMessage
key={message.id}
className={`flex gap-3 ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}
>
{message.type === 'assistant' && (
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4 text-white" />
</div>
)}
<div className={`max-w-[80%] ${message.type === 'user' ? 'order-1' : ''}`}>
{/* 用户消息或AI消息的文字回答可选显示 */}
{(message.type === 'user' || message.status === 'sending' || showAnswers[message.id]) && (
<div
className={`px-4 py-3 rounded-2xl ${
message.type === 'user'
? 'bg-pink-500 text-white'
: 'bg-white border border-gray-200 text-gray-900'
}`}
>
{message.status === 'sending' ? (
<div className="flex items-center gap-2">
<div className="animate-spin w-4 h-4 border-2 border-pink-300 border-t-pink-500 rounded-full"></div>
<span className="text-gray-600">...</span>
</div>
) : (
<div className="whitespace-pre-wrap">{message.content}</div>
)}
</div>
)}
{/* AI消息的查看详情按钮当文字回答隐藏且不在加载状态时显示 */}
{message.type === 'assistant' && message.status !== 'sending' && !showAnswers[message.id] && (
<div className="mb-3">
<button
onClick={() => toggleAnswerVisibility(message.id)}
className="inline-flex items-center gap-2 px-3 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 text-sm text-gray-600 hover:text-gray-800 transition-all duration-200"
>
<MessageCircle className="w-4 h-4" />
AI详细回答
</button>
</div>
)}
{/* AI消息的隐藏回答按钮当文字回答显示且不在加载状态时显示 */}
{message.type === 'assistant' && message.status !== 'sending' && showAnswers[message.id] && (
<div className="mt-2">
<button
onClick={() => toggleAnswerVisibility(message.id)}
className="inline-flex items-center gap-2 px-3 py-1 bg-gray-100 hover:bg-gray-200 text-xs text-gray-600 hover:text-gray-800 rounded-full transition-all duration-200"
>
<XCircle className="w-3 h-3" />
</button>
</div>
)}
{/* 消息状态和元数据 */}
<div className={`flex items-center gap-2 mt-1 text-xs ${
message.type === 'user' ? 'justify-end' : 'justify-start'
}`}>
<span className="text-gray-500">
{message.timestamp.toLocaleTimeString()}
</span>
{message.status === 'sent' && (
<CheckCircle className="w-3 h-3 text-green-500" />
)}
{message.status === 'error' && (
<XCircle className="w-3 h-3 text-red-500" />
)}
{message.metadata?.responseTime && (
<span className="text-gray-400">
<Clock className="w-3 h-3 inline mr-1" />
{message.metadata.responseTime}ms
</span>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-1 ml-2">
<button
onClick={() => handleCopyMessage(message)}
className="p-1 text-gray-400 hover:text-pink-500 hover:bg-pink-50 rounded transition-colors duration-200"
title="复制消息"
>
<Copy className="w-3 h-3" />
</button>
</div>
</div>
{/* 来源信息 - 图片卡片展示 */}
{showSources && message.metadata?.sources && message.metadata.sources.length > 0 && (
<div className="mt-3 p-4 bg-gradient-to-br from-pink-50 to-purple-50 rounded-lg border border-pink-200">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-pink-500 rounded-full"></div>
<div className="text-base font-semibold text-gray-800">
穿 ({message.metadata.sources.length} )
</div>
</div>
<div className="text-xs text-gray-500 bg-white px-2 py-1 rounded-full">
</div>
</div>
{/* 图片展示 - 响应式网格 */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{(() => {
const isExpanded = expandedSources[message.id];
const defaultDisplayCount = 12; // 默认显示12张图片
const displaySources = isExpanded
? message.metadata.sources
: message.metadata.sources.slice(0, defaultDisplayCount);
return displaySources.map((source, index) =>
<div key={`${index}-${selectedTags.length}-${selectedTags.join(',')}`}>
{renderImageCard(source, index)}
</div>
);
})()}
</div>
{/* 展开/收起按钮 */}
{message.metadata.sources.length > 12 && (
<div className="mt-3 text-center">
<button
onClick={() => toggleSourcesExpanded(message.id)}
className="inline-flex items-center gap-2 text-sm text-pink-600 hover:text-pink-800 bg-white hover:bg-pink-50 px-4 py-2 rounded-full border border-pink-200 hover:border-pink-300 transition-all duration-200 shadow-sm hover:shadow-md"
>
{expandedSources[message.id] ? (
<>
<span></span>
<svg className="w-4 h-4 transform rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</>
) : (
<>
<span> {message.metadata.sources.length} </span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</>
)}
</button>
</div>
)}
</div>
)}
</div>
{message.type === 'user' && (
<div className="w-8 h-8 bg-gray-500 rounded-full flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4 text-white" />
</div>
)}
</div>
message={message}
showSources={showSources}
enableImageCards={enableImageCards}
/>
))}
<div ref={messagesEndRef} />
</>
)}
</div>

View File

@@ -0,0 +1,264 @@
import React, { useState, useCallback } from 'react';
import {
Bot,
User,
Copy,
CheckCircle,
Clock,
Sparkles,
Image as ImageIcon,
ExternalLink
} from 'lucide-react';
import GroundedText from './GroundedText';
import { GroundingMetadata } 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?: GroundingMetadata;
};
}
/**
* 增强聊天消息属性接口
*/
interface EnhancedChatMessageProps {
/** 消息数据 */
message: ChatMessage;
/** 是否显示来源信息 */
showSources?: boolean;
/** 是否启用图片卡片 */
enableImageCards?: boolean;
/** 自定义样式类名 */
className?: string;
}
/**
* 增强聊天消息组件
* 支持grounding文本高亮和图片卡片展示
*/
export const EnhancedChatMessage: React.FC<EnhancedChatMessageProps> = ({
message,
showSources = true,
enableImageCards = true,
className = ''
}) => {
const [copied, setCopied] = useState(false);
const [expandedSources, setExpandedSources] = useState(false);
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 toggleSources = useCallback(() => {
setExpandedSources(prev => !prev);
}, []);
// 格式化时间
const formatTime = useCallback((date: Date) => {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
}, []);
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4 ${className}`}>
<div className={`flex max-w-[85%] ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
{/* 头像 */}
<div className={`flex-shrink-0 ${isUser ? 'ml-3' : 'mr-3'}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
isUser
? 'bg-gradient-to-br from-blue-500 to-blue-600 text-white'
: 'bg-gradient-to-br from-pink-500 to-purple-600 text-white'
}`}>
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</div>
</div>
{/* 消息内容 */}
<div className={`flex flex-col ${isUser ? 'items-end' : 'items-start'}`}>
{/* 消息气泡 */}
<div className={`relative rounded-2xl px-4 py-3 shadow-sm ${
isUser
? 'bg-gradient-to-br from-blue-500 to-blue-600 text-white'
: 'bg-white border border-gray-200 text-gray-900'
}`}>
{/* 消息内容 */}
<div className="space-y-2">
{isAssistant && groundingMetadata ? (
<GroundedText
text={message.content}
groundingMetadata={groundingMetadata}
enableImageCards={enableImageCards}
highlightStyle="underline"
className="whitespace-pre-wrap"
/>
) : (
<div className="whitespace-pre-wrap">{message.content}</div>
)}
{/* 状态指示器 */}
{message.status === 'sending' && (
<div className="flex items-center space-x-2 text-sm opacity-70">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
<span>...</span>
</div>
)}
{message.status === 'error' && (
<div className="text-red-500 text-sm">
</div>
)}
</div>
{/* 操作按钮 */}
<div className={`flex items-center justify-between mt-2 pt-2 border-t ${
isUser ? 'border-blue-400' : 'border-gray-100'
}`}>
<div className="flex items-center space-x-2 text-xs opacity-70">
<Clock className="w-3 h-3" />
<span>{formatTime(message.timestamp)}</span>
{message.metadata?.responseTime && (
<>
<span></span>
<span>{message.metadata.responseTime}ms</span>
</>
)}
</div>
<div className="flex items-center space-x-1">
{/* 复制按钮 */}
<button
onClick={handleCopy}
className={`p-1 rounded transition-colors ${
isUser
? 'hover:bg-blue-400 text-blue-100'
: 'hover:bg-gray-100 text-gray-500'
}`}
title="复制消息"
>
{copied ? (
<CheckCircle className="w-3 h-3" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
{/* 来源按钮 */}
{isAssistant && sources.length > 0 && showSources && (
<button
onClick={toggleSources}
className="p-1 rounded transition-colors hover:bg-gray-100 text-gray-500"
title={`查看来源 (${sources.length})`}
>
<ImageIcon className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
{/* 来源信息 */}
{isAssistant && sources.length > 0 && showSources && expandedSources && (
<div className="mt-2 w-full max-w-md">
<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">
<Sparkles className="w-4 h-4 mr-1 text-pink-500" />
({sources.length})
</h4>
<button
onClick={toggleSources}
className="text-xs text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="space-y-2">
{sources.slice(0, 3).map((source, index) => (
<div
key={index}
className="flex items-center justify-between p-2 bg-white rounded border border-gray-100"
>
<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="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>
</div>
))}
{sources.length > 3 && (
<div className="text-center">
<span className="text-xs text-gray-500">
{sources.length - 3} ...
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* 模型信息 */}
{isAssistant && message.metadata?.modelUsed && (
<div className="mt-1 text-xs text-gray-400">
{message.metadata.modelUsed}
</div>
)}
</div>
</div>
</div>
);
};
export default EnhancedChatMessage;

View File

@@ -0,0 +1,244 @@
import React, { useState, useCallback, useRef } from 'react';
import { GroundingMetadata, GroundingSupport, GroundingSource } from '../types/ragGrounding';
import ImageCard from './ImageCard';
import ImagePreviewModal from './ImagePreviewModal';
import useImageDownload from '../hooks/useImageDownload';
/**
* 文字片段信息
*/
interface TextSegment {
/** 片段文本 */
text: string;
/** 开始位置 */
startIndex: number;
/** 结束位置 */
endIndex: number;
/** 关联的grounding支持 */
groundingSupport?: GroundingSupport;
/** 关联的来源 */
sources?: GroundingSource[];
}
/**
* Grounded文本属性接口
*/
interface GroundedTextProps {
/** 原始文本内容 */
text: string;
/** Grounding元数据 */
groundingMetadata?: GroundingMetadata;
/** 自定义样式类名 */
className?: string;
/** 是否启用图片卡片 */
enableImageCards?: boolean;
/** 高亮样式 */
highlightStyle?: 'underline' | 'background' | 'border';
}
/**
* Grounded文本组件
* 支持文字片段高亮和相关图片卡片展示
*/
export const GroundedText: React.FC<GroundedTextProps> = ({
text,
groundingMetadata,
className = '',
enableImageCards = true,
highlightStyle = 'underline'
}) => {
const [hoveredSegment, setHoveredSegment] = useState<TextSegment | null>(null);
const [cardPosition, setCardPosition] = useState<{ top: number; left: number } | null>(null);
const [previewSource, setPreviewSource] = useState<GroundingSource | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { downloadImage, isDownloadable } = useImageDownload();
// 解析文本片段
const textSegments = React.useMemo(() => {
if (!groundingMetadata?.grounding_supports) {
return [{ text, startIndex: 0, endIndex: text.length }];
}
const segments: TextSegment[] = [];
const supports = groundingMetadata.grounding_supports;
const sources = groundingMetadata.sources;
// 按开始位置排序
const sortedSupports = [...supports].sort((a, b) => a.segment.start_index - b.segment.start_index);
let currentIndex = 0;
for (const support of sortedSupports) {
const { start_index, end_index, text: segmentText } = support.segment;
// 添加前面的普通文本
if (currentIndex < start_index) {
segments.push({
text: text.slice(currentIndex, start_index),
startIndex: currentIndex,
endIndex: start_index
});
}
// 获取关联的来源
const relatedSources = (support.groundingChunkIndices || [])
.map((index: number) => sources[index])
.filter(Boolean);
// 添加高亮片段
segments.push({
text: segmentText,
startIndex: start_index,
endIndex: end_index,
groundingSupport: support,
sources: relatedSources
});
currentIndex = end_index;
}
// 添加剩余的普通文本
if (currentIndex < text.length) {
segments.push({
text: text.slice(currentIndex),
startIndex: currentIndex,
endIndex: text.length
});
}
return segments;
}, [text, groundingMetadata]);
// 处理鼠标悬停
const handleMouseEnter = useCallback((segment: TextSegment, event: React.MouseEvent) => {
if (!segment.groundingSupport || !segment.sources?.length || !enableImageCards) return;
const rect = event.currentTarget.getBoundingClientRect();
const containerRect = containerRef.current?.getBoundingClientRect();
if (containerRect) {
setCardPosition({
top: rect.bottom - containerRect.top + 8,
left: rect.left - containerRect.left
});
}
setHoveredSegment(segment);
}, [enableImageCards]);
// 处理鼠标离开
const handleMouseLeave = useCallback(() => {
setHoveredSegment(null);
setCardPosition(null);
}, []);
// 处理图片下载
const handleDownload = useCallback(async (source: GroundingSource) => {
try {
const result = await downloadImage(source, {
showSaveDialog: true
});
if (result.success) {
console.log('图片下载成功:', result.filePath);
} else {
console.error('图片下载失败:', result.error);
}
} catch (error) {
console.error('下载过程中出错:', error);
}
}, [downloadImage]);
// 处理查看大图
const handleViewLarge = useCallback((source: GroundingSource) => {
setPreviewSource(source);
}, []);
// 关闭预览
const handleClosePreview = useCallback(() => {
setPreviewSource(null);
}, []);
// 获取高亮样式
const getHighlightClass = useCallback((hasGrounding: boolean) => {
if (!hasGrounding) return '';
switch (highlightStyle) {
case 'underline':
return 'border-b-2 border-pink-300 hover:border-pink-500 cursor-pointer';
case 'background':
return 'bg-pink-50 hover:bg-pink-100 cursor-pointer rounded px-1';
case 'border':
return 'border border-pink-200 hover:border-pink-400 cursor-pointer rounded px-1';
default:
return 'border-b-2 border-pink-300 hover:border-pink-500 cursor-pointer';
}
}, [highlightStyle]);
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>
{/* 图片卡片 */}
{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"
/>
)}
{/* 如果有多张图片,显示指示器 */}
{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}
</div>
</div>
)}
</div>
)}
{/* 图片预览模态框 */}
<ImagePreviewModal
isOpen={!!previewSource}
source={previewSource}
onClose={handleClosePreview}
onDownload={previewSource && isDownloadable(previewSource) ? handleDownload : undefined}
/>
</div>
);
};
export default GroundedText;

View File

@@ -0,0 +1,268 @@
import React, { useState, useCallback } from 'react';
import {
Download,
ExternalLink,
Eye,
X,
Calendar,
MapPin,
User,
Shirt,
Image as ImageIcon,
AlertCircle
} from 'lucide-react';
import { GroundingSource } from '../types/ragGrounding';
/**
* 图片卡片属性接口
*/
interface ImageCardProps {
/** Grounding 来源数据 */
source: GroundingSource;
/** 是否显示详细信息 */
showDetails?: boolean;
/** 卡片位置样式 */
position?: {
top?: number;
left?: number;
right?: number;
bottom?: number;
};
/** 关闭回调 */
onClose?: () => void;
/** 下载回调 */
onDownload?: (source: GroundingSource) => void;
/** 查看大图回调 */
onViewLarge?: (source: GroundingSource) => void;
/** 自定义样式类名 */
className?: string;
}
/**
* 图片卡片组件
* 遵循 ag-ui 设计标准,支持图片展示、信息显示和下载功能
*/
export const ImageCard: React.FC<ImageCardProps> = ({
source,
showDetails = true,
position,
onClose,
onDownload,
onViewLarge,
className = ''
}) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
// 解析图片内容
const imageData = source.content?.text || source.content;
const imageUri = source.uri;
const title = source.title || '时尚图片';
// 提取图片信息
const description = imageData?.description || '';
const models = imageData?.models || [];
const environmentTags = imageData?.environment_tags || [];
const categories = imageData?.categories || [];
const releaseDate = imageData?.releaseDate;
// 处理图片加载
const handleImageLoad = useCallback(() => {
setImageLoaded(true);
setImageError(false);
}, []);
const handleImageError = useCallback(() => {
setImageError(true);
setImageLoaded(false);
}, []);
// 处理下载
const handleDownload = useCallback(async () => {
if (!onDownload || isDownloading) return;
setIsDownloading(true);
try {
await onDownload(source);
} catch (error) {
console.error('下载失败:', error);
} finally {
setIsDownloading(false);
}
}, [onDownload, source, isDownloading]);
// 处理查看大图
const handleViewLarge = useCallback(() => {
if (onViewLarge) {
onViewLarge(source);
}
}, [onViewLarge, source]);
// 构建位置样式
const positionStyle = position ? {
position: 'absolute' as const,
top: position.top,
left: position.left,
right: position.right,
bottom: position.bottom,
zIndex: 1000
} : {};
return (
<div
className={`bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden max-w-sm ${className}`}
style={positionStyle}
>
{/* 卡片头部 */}
<div className="flex items-center justify-between p-3 border-b border-gray-100">
<div className="flex items-center space-x-2">
<ImageIcon className="w-4 h-4 text-pink-500" />
<h3 className="text-sm font-medium text-gray-900 truncate">{title}</h3>
</div>
<div className="flex items-center space-x-1">
{onViewLarge && (
<button
onClick={handleViewLarge}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
title="查看大图"
>
<Eye className="w-4 h-4" />
</button>
)}
{onDownload && (
<button
onClick={handleDownload}
disabled={isDownloading}
className="p-1 text-gray-400 hover:text-pink-500 transition-colors disabled:opacity-50"
title="下载到本地"
>
<Download className={`w-4 h-4 ${isDownloading ? 'animate-pulse' : ''}`} />
</button>
)}
{imageUri && (
<a
href={imageUri}
target="_blank"
rel="noopener noreferrer"
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
title="在新窗口打开"
>
<ExternalLink className="w-4 h-4" />
</a>
)}
{onClose && (
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
title="关闭"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* 图片展示区域 */}
<div className="relative bg-gray-50">
{imageUri && !imageError ? (
<div className="relative">
<img
src={imageUri}
alt={description || title}
className={`w-full h-48 object-cover transition-opacity duration-300 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div>
</div>
)}
</div>
) : (
<div className="h-48 flex items-center justify-center text-gray-400">
<div className="text-center">
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
<p className="text-sm"></p>
</div>
</div>
)}
</div>
{/* 详细信息区域 */}
{showDetails && (
<div className="p-3 space-y-3">
{/* 描述 */}
{description && (
<p className="text-sm text-gray-600 line-clamp-2">{description}</p>
)}
{/* 环境标签 */}
{environmentTags.length > 0 && (
<div className="flex items-center space-x-1">
<MapPin className="w-3 h-3 text-gray-400" />
<div className="flex flex-wrap gap-1">
{environmentTags.slice(0, 3).map((tag: string, index: number) => (
<span
key={index}
className="px-2 py-0.5 bg-blue-50 text-blue-600 text-xs rounded-full"
>
{tag}
</span>
))}
{environmentTags.length > 3 && (
<span className="text-xs text-gray-400">+{environmentTags.length - 3}</span>
)}
</div>
</div>
)}
{/* 服装类别 */}
{categories.length > 0 && (
<div className="flex items-center space-x-1">
<Shirt className="w-3 h-3 text-gray-400" />
<div className="flex flex-wrap gap-1">
{categories.slice(0, 4).map((category: string, index: number) => (
<span
key={index}
className="px-2 py-0.5 bg-pink-50 text-pink-600 text-xs rounded-full"
>
{category}
</span>
))}
{categories.length > 4 && (
<span className="text-xs text-gray-400">+{categories.length - 4}</span>
)}
</div>
</div>
)}
{/* 模特信息 */}
{models.length > 0 && (
<div className="flex items-center space-x-1">
<User className="w-3 h-3 text-gray-400" />
<span className="text-xs text-gray-500">
{models.length}
</span>
</div>
)}
{/* 发布时间 */}
{releaseDate && (
<div className="flex items-center space-x-1">
<Calendar className="w-3 h-3 text-gray-400" />
<span className="text-xs text-gray-500">
{new Date(releaseDate).toLocaleDateString('zh-CN')}
</span>
</div>
)}
</div>
)}
</div>
);
};
export default ImageCard;

View File

@@ -0,0 +1,341 @@
import React, { useState, useCallback, useEffect } from 'react';
import {
X,
Download,
ExternalLink,
ZoomIn,
ZoomOut,
RotateCw,
Calendar,
MapPin,
User,
Shirt,
AlertCircle
} from 'lucide-react';
import { GroundingSource } from '../types/ragGrounding';
/**
* 图片预览模态框属性接口
*/
interface ImagePreviewModalProps {
/** 是否显示模态框 */
isOpen: boolean;
/** Grounding 来源数据 */
source: GroundingSource | null;
/** 关闭回调 */
onClose: () => void;
/** 下载回调 */
onDownload?: (source: GroundingSource) => void;
}
/**
* 图片预览模态框组件
* 支持图片缩放、旋转和详细信息展示
*/
export const ImagePreviewModal: React.FC<ImagePreviewModalProps> = ({
isOpen,
source,
onClose,
onDownload
}) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [isDownloading, setIsDownloading] = useState(false);
// 重置状态当模态框打开时
useEffect(() => {
if (isOpen) {
setImageLoaded(false);
setImageError(false);
setZoom(1);
setRotation(0);
}
}, [isOpen]);
// 处理键盘事件
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) return;
switch (event.key) {
case 'Escape':
onClose();
break;
case '+':
case '=':
setZoom(prev => Math.min(prev + 0.25, 3));
break;
case '-':
setZoom(prev => Math.max(prev - 0.25, 0.25));
break;
case 'r':
case 'R':
setRotation(prev => (prev + 90) % 360);
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// 处理图片加载
const handleImageLoad = useCallback(() => {
setImageLoaded(true);
setImageError(false);
}, []);
const handleImageError = useCallback(() => {
setImageError(true);
setImageLoaded(false);
}, []);
// 处理下载
const handleDownload = useCallback(async () => {
if (!onDownload || !source || isDownloading) return;
setIsDownloading(true);
try {
await onDownload(source);
} catch (error) {
console.error('下载失败:', error);
} finally {
setIsDownloading(false);
}
}, [onDownload, source, isDownloading]);
// 缩放控制
const handleZoomIn = useCallback(() => {
setZoom(prev => Math.min(prev + 0.25, 3));
}, []);
const handleZoomOut = useCallback(() => {
setZoom(prev => Math.max(prev - 0.25, 0.25));
}, []);
const handleRotate = useCallback(() => {
setRotation(prev => (prev + 90) % 360);
}, []);
if (!isOpen || !source) return null;
const imageData = source.content?.text || source.content;
const imageUri = source.uri;
const title = source.title || '时尚图片';
const description = imageData?.description || '';
const models = imageData?.models || [];
const environmentTags = imageData?.environment_tags || [];
const categories = imageData?.categories || [];
const releaseDate = imageData?.releaseDate;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
{/* 模态框背景 */}
<div
className="absolute inset-0 cursor-pointer"
onClick={onClose}
/>
{/* 模态框内容 */}
<div className="relative bg-white rounded-xl shadow-2xl max-w-6xl max-h-[90vh] w-full mx-4 overflow-hidden">
{/* 头部工具栏 */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50">
<div className="flex items-center space-x-3">
<h2 className="text-lg font-semibold text-gray-900 truncate">{title}</h2>
{description && (
<p className="text-sm text-gray-600 truncate max-w-md">{description}</p>
)}
</div>
<div className="flex items-center space-x-2">
{/* 缩放控制 */}
<div className="flex items-center space-x-1 bg-white rounded-lg border border-gray-200 p-1">
<button
onClick={handleZoomOut}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="缩小 (-)"
>
<ZoomOut className="w-4 h-4" />
</button>
<span className="text-xs text-gray-600 px-2 min-w-[3rem] text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="放大 (+)"
>
<ZoomIn className="w-4 h-4" />
</button>
</div>
{/* 旋转按钮 */}
<button
onClick={handleRotate}
className="p-2 text-gray-500 hover:text-gray-700 transition-colors bg-white rounded-lg border border-gray-200"
title="旋转 (R)"
>
<RotateCw className="w-4 h-4" />
</button>
{/* 下载按钮 */}
{onDownload && (
<button
onClick={handleDownload}
disabled={isDownloading}
className="p-2 text-gray-500 hover:text-pink-500 transition-colors bg-white rounded-lg border border-gray-200 disabled:opacity-50"
title="下载到本地"
>
<Download className={`w-4 h-4 ${isDownloading ? 'animate-pulse' : ''}`} />
</button>
)}
{/* 外部链接 */}
{imageUri && (
<a
href={imageUri}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-gray-500 hover:text-blue-500 transition-colors bg-white rounded-lg border border-gray-200"
title="在新窗口打开"
>
<ExternalLink className="w-4 h-4" />
</a>
)}
{/* 关闭按钮 */}
<button
onClick={onClose}
className="p-2 text-gray-500 hover:text-gray-700 transition-colors bg-white rounded-lg border border-gray-200"
title="关闭 (Esc)"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* 主要内容区域 */}
<div className="flex h-[calc(90vh-5rem)]">
{/* 图片展示区域 */}
<div className="flex-1 flex items-center justify-center bg-gray-100 overflow-hidden">
{imageUri && !imageError ? (
<div className="relative">
<img
src={imageUri}
alt={description || title}
className={`max-w-full max-h-full object-contain transition-all duration-300 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
style={{
transform: `scale(${zoom}) rotate(${rotation}deg)`,
transformOrigin: 'center'
}}
onLoad={handleImageLoad}
onError={handleImageError}
/>
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pink-500"></div>
</div>
)}
</div>
) : (
<div className="text-center text-gray-400">
<AlertCircle className="w-16 h-16 mx-auto mb-4" />
<p className="text-lg"></p>
</div>
)}
</div>
{/* 信息侧边栏 */}
<div className="w-80 bg-gray-50 border-l border-gray-200 overflow-y-auto">
<div className="p-4 space-y-4">
{/* 基本信息 */}
<div>
<h3 className="text-sm font-medium text-gray-900 mb-2"></h3>
<div className="space-y-2">
{description && (
<p className="text-sm text-gray-600">{description}</p>
)}
{releaseDate && (
<div className="flex items-center space-x-2">
<Calendar className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-600">
{new Date(releaseDate).toLocaleDateString('zh-CN')}
</span>
</div>
)}
</div>
</div>
{/* 环境标签 */}
{environmentTags.length > 0 && (
<div>
<h3 className="text-sm font-medium text-gray-900 mb-2 flex items-center">
<MapPin className="w-4 h-4 mr-1" />
</h3>
<div className="flex flex-wrap gap-1">
{environmentTags.map((tag: string, index: number) => (
<span
key={index}
className="px-2 py-1 bg-blue-50 text-blue-600 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* 服装类别 */}
{categories.length > 0 && (
<div>
<h3 className="text-sm font-medium text-gray-900 mb-2 flex items-center">
<Shirt className="w-4 h-4 mr-1" />
</h3>
<div className="flex flex-wrap gap-1">
{categories.map((category: string, index: number) => (
<span
key={index}
className="px-2 py-1 bg-pink-50 text-pink-600 text-xs rounded-full"
>
{category}
</span>
))}
</div>
</div>
)}
{/* 模特信息 */}
{models.length > 0 && (
<div>
<h3 className="text-sm font-medium text-gray-900 mb-2 flex items-center">
<User className="w-4 h-4 mr-1" />
({models.length})
</h3>
<div className="space-y-2">
{models.slice(0, 3).map((model: any, index: number) => (
<div key={index} className="p-2 bg-white rounded border border-gray-200">
<p className="text-xs text-gray-600 mb-1">{model.position}</p>
<p className="text-xs text-gray-800">{model.style_description}</p>
</div>
))}
{models.length > 3 && (
<p className="text-xs text-gray-500"> {models.length - 3} ...</p>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default ImagePreviewModal;

View File

@@ -0,0 +1,190 @@
import { useState, useCallback } from 'react';
import { GroundingSource } from '../types/ragGrounding';
import ImageDownloadService, { ImageDownloadOptions, ImageDownloadResult } from '../services/imageDownloadService';
/**
* 图片下载状态
*/
export interface ImageDownloadState {
/** 是否正在下载 */
isDownloading: boolean;
/** 下载进度 (0-100) */
progress: number;
/** 错误信息 */
error: string | null;
/** 最后下载的文件路径 */
lastDownloadPath: string | null;
}
/**
* 图片下载钩子返回值
*/
export interface UseImageDownloadReturn {
/** 下载状态 */
downloadState: ImageDownloadState;
/** 下载单个图片 */
downloadImage: (source: GroundingSource, options?: ImageDownloadOptions) => Promise<ImageDownloadResult>;
/** 批量下载图片 */
downloadImages: (sources: GroundingSource[], options?: ImageDownloadOptions) => Promise<ImageDownloadResult[]>;
/** 检查图片是否可下载 */
isDownloadable: (source: GroundingSource) => boolean;
/** 重置状态 */
resetState: () => void;
}
/**
* 图片下载自定义钩子
* 提供图片下载功能和状态管理
*/
export const useImageDownload = (): UseImageDownloadReturn => {
const [downloadState, setDownloadState] = useState<ImageDownloadState>({
isDownloading: false,
progress: 0,
error: null,
lastDownloadPath: null
});
// 下载单个图片
const downloadImage = useCallback(async (
source: GroundingSource,
options: ImageDownloadOptions = {}
): Promise<ImageDownloadResult> => {
setDownloadState(prev => ({
...prev,
isDownloading: true,
progress: 0,
error: null
}));
try {
// 检查图片是否可下载
if (!ImageDownloadService.isDownloadable(source)) {
throw new Error('图片不可下载或URI无效');
}
// 模拟进度更新
setDownloadState(prev => ({ ...prev, progress: 25 }));
const result = await ImageDownloadService.downloadImage(source, options);
setDownloadState(prev => ({ ...prev, progress: 100 }));
if (result.success) {
setDownloadState(prev => ({
...prev,
isDownloading: false,
lastDownloadPath: result.filePath || null,
error: null
}));
} else {
setDownloadState(prev => ({
...prev,
isDownloading: false,
error: result.error || '下载失败'
}));
}
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '下载失败';
setDownloadState(prev => ({
...prev,
isDownloading: false,
error: errorMessage
}));
return {
success: false,
error: errorMessage
};
}
}, []);
// 批量下载图片
const downloadImages = useCallback(async (
sources: GroundingSource[],
options: ImageDownloadOptions = {}
): Promise<ImageDownloadResult[]> => {
setDownloadState(prev => ({
...prev,
isDownloading: true,
progress: 0,
error: null
}));
try {
const results: ImageDownloadResult[] = [];
const total = sources.length;
for (let i = 0; i < sources.length; i++) {
const source = sources[i];
// 更新进度
const progress = Math.round((i / total) * 100);
setDownloadState(prev => ({ ...prev, progress }));
// 下载单个图片
const result = await ImageDownloadService.downloadImage(source, {
...options,
showSaveDialog: false // 批量下载时不显示对话框
});
results.push(result);
// 如果有失败的,记录错误但继续下载其他图片
if (!result.success && !downloadState.error) {
setDownloadState(prev => ({
...prev,
error: `部分图片下载失败: ${result.error}`
}));
}
}
setDownloadState(prev => ({
...prev,
isDownloading: false,
progress: 100
}));
return results;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '批量下载失败';
setDownloadState(prev => ({
...prev,
isDownloading: false,
error: errorMessage
}));
return [];
}
}, [downloadState.error]);
// 检查图片是否可下载
const isDownloadable = useCallback((source: GroundingSource): boolean => {
return ImageDownloadService.isDownloadable(source);
}, []);
// 重置状态
const resetState = useCallback(() => {
setDownloadState({
isDownloading: false,
progress: 0,
error: null,
lastDownloadPath: null
});
}, []);
return {
downloadState,
downloadImage,
downloadImages,
isDownloadable,
resetState
};
};
export default useImageDownload;

View File

@@ -145,6 +145,7 @@ const ChatTool: React.FC = () => {
sessionId={`chat-tool-${Date.now()}`}
maxMessages={3}
showSources={true}
enableImageCards={true}
className="h-full"
placeholder="请输入您的问题,我会基于知识库为您提供准确的答案..."
/>

View File

@@ -0,0 +1,192 @@
import { invoke } from '@tauri-apps/api/core';
import { save } from '@tauri-apps/plugin-dialog';
import { GroundingSource } from '../types/ragGrounding';
/**
* 图片下载选项
*/
export interface ImageDownloadOptions {
/** 建议的文件名 */
suggestedName?: string;
/** 文件扩展名 */
extension?: string;
/** 是否显示保存对话框 */
showSaveDialog?: boolean;
}
/**
* 图片下载结果
*/
export interface ImageDownloadResult {
/** 是否成功 */
success: boolean;
/** 保存的文件路径 */
filePath?: string;
/** 错误信息 */
error?: string;
}
/**
* 图片下载服务
* 支持从URI下载图片到本地
*/
export class ImageDownloadService {
/**
* 下载图片到本地
*/
static async downloadImage(
source: GroundingSource,
options: ImageDownloadOptions = {}
): Promise<ImageDownloadResult> {
try {
const { suggestedName, extension = 'jpg', showSaveDialog = true } = options;
// 检查图片URI
if (!source.uri) {
return {
success: false,
error: '图片URI不存在'
};
}
// 生成建议的文件名
const defaultName = suggestedName || this.generateFileName(source);
const fileName = `${defaultName}.${extension}`;
let filePath: string | null = null;
if (showSaveDialog) {
// 显示保存对话框
filePath = await save({
defaultPath: fileName,
filters: [
{
name: '图片文件',
extensions: ['jpg', 'jpeg', 'png', 'webp', 'gif']
},
{
name: '所有文件',
extensions: ['*']
}
]
});
if (!filePath) {
return {
success: false,
error: '用户取消了保存操作'
};
}
} else {
// 使用默认下载目录
filePath = await this.getDefaultDownloadPath(fileName);
}
// 调用后端下载命令
await invoke('download_image_from_uri', {
uri: source.uri,
filePath: filePath
});
return {
success: true,
filePath: filePath
};
} catch (error) {
console.error('图片下载失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : '下载失败'
};
}
}
/**
* 批量下载图片
*/
static async downloadImages(
sources: GroundingSource[],
options: ImageDownloadOptions = {}
): Promise<ImageDownloadResult[]> {
const results: ImageDownloadResult[] = [];
for (const source of sources) {
const result = await this.downloadImage(source, {
...options,
showSaveDialog: false // 批量下载时不显示对话框
});
results.push(result);
}
return results;
}
/**
* 生成文件名
*/
private static generateFileName(source: GroundingSource): string {
const title = source.title || 'fashion_image';
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, '');
// 清理文件名中的特殊字符
const cleanTitle = title
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '_')
.toLowerCase();
return `${cleanTitle}_${timestamp}`;
}
/**
* 获取默认下载路径
*/
private static async getDefaultDownloadPath(fileName: string): Promise<string> {
try {
// 调用后端获取默认下载目录
const downloadDir = await invoke<string>('get_default_download_directory');
return `${downloadDir}/${fileName}`;
} catch (error) {
// 如果获取失败,使用当前目录
return fileName;
}
}
/**
* 检查图片是否可下载
*/
static isDownloadable(source: GroundingSource): boolean {
return !!(source.uri && source.uri.startsWith('http'));
}
/**
* 获取图片信息
*/
static getImageInfo(source: GroundingSource): {
title: string;
description: string;
size?: string;
format?: string;
} {
const imageData = source.content?.text || source.content;
return {
title: source.title || '时尚图片',
description: imageData?.description || '',
size: imageData?.size,
format: this.extractImageFormat(source.uri)
};
}
/**
* 从URI提取图片格式
*/
private static extractImageFormat(uri?: string): string | undefined {
if (!uri) return undefined;
const match = uri.match(/\.([a-zA-Z0-9]+)(?:\?|$)/);
return match ? match[1].toLowerCase() : undefined;
}
}
export default ImageDownloadService;

View File

@@ -47,6 +47,7 @@ export interface RagGroundingResponse {
export interface GroundingMetadata {
sources: GroundingSource[];
search_queries: string[];
grounding_supports?: GroundingSupport[];
}
/**
@@ -58,6 +59,29 @@ export interface GroundingSource {
content?: any; // JSON 内容
}
/**
* Grounding 支持信息
* 用于关联文字片段与来源信息
*/
export interface GroundingSupport {
/** 关联的grounding chunk索引数组 */
groundingChunkIndices: number[];
/** 文字片段信息 */
segment: GroundingSegment;
}
/**
* Grounding 文字片段
*/
export interface GroundingSegment {
/** 片段开始位置 */
start_index: number;
/** 片段结束位置 */
end_index: number;
/** 片段文字内容 */
text: string;
}
/**
* 对话上下文
*/