fix: 添加文字图片关联关系
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -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/"),
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
264
apps/desktop/src/components/EnhancedChatMessage.tsx
Normal file
264
apps/desktop/src/components/EnhancedChatMessage.tsx
Normal 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;
|
||||
244
apps/desktop/src/components/GroundedText.tsx
Normal file
244
apps/desktop/src/components/GroundedText.tsx
Normal 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;
|
||||
268
apps/desktop/src/components/ImageCard.tsx
Normal file
268
apps/desktop/src/components/ImageCard.tsx
Normal 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;
|
||||
341
apps/desktop/src/components/ImagePreviewModal.tsx
Normal file
341
apps/desktop/src/components/ImagePreviewModal.tsx
Normal 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;
|
||||
190
apps/desktop/src/hooks/useImageDownload.ts
Normal file
190
apps/desktop/src/hooks/useImageDownload.ts
Normal 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;
|
||||
@@ -145,6 +145,7 @@ const ChatTool: React.FC = () => {
|
||||
sessionId={`chat-tool-${Date.now()}`}
|
||||
maxMessages={3}
|
||||
showSources={true}
|
||||
enableImageCards={true}
|
||||
className="h-full"
|
||||
placeholder="请输入您的问题,我会基于知识库为您提供准确的答案..."
|
||||
/>
|
||||
|
||||
192
apps/desktop/src/services/imageDownloadService.ts
Normal file
192
apps/desktop/src/services/imageDownloadService.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话上下文
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user