fix: 添加文字图片关联关系
This commit is contained in:
@@ -209,6 +209,7 @@ pub struct RagGroundingResponse {
|
|||||||
pub struct GroundingMetadata {
|
pub struct GroundingMetadata {
|
||||||
pub sources: Vec<GroundingSource>,
|
pub sources: Vec<GroundingSource>,
|
||||||
pub search_queries: Vec<String>,
|
pub search_queries: Vec<String>,
|
||||||
|
pub grounding_supports: Option<Vec<GroundingSupport>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Grounding 来源
|
/// Grounding 来源
|
||||||
@@ -219,6 +220,25 @@ pub struct GroundingSource {
|
|||||||
pub content: Option<serde_json::Value>,
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct ConversationContext {
|
pub struct ConversationContext {
|
||||||
@@ -1503,9 +1523,49 @@ impl GeminiService {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.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 {
|
Some(GroundingMetadata {
|
||||||
sources,
|
sources,
|
||||||
search_queries,
|
search_queries,
|
||||||
|
grounding_supports,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -322,7 +322,11 @@ pub fn run() {
|
|||||||
commands::conversation_commands::get_conversation_stats,
|
commands::conversation_commands::get_conversation_stats,
|
||||||
commands::conversation_commands::cleanup_expired_sessions,
|
commands::conversation_commands::cleanup_expired_sessions,
|
||||||
commands::conversation_commands::update_session_title,
|
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| {
|
.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 custom_tag_commands;
|
||||||
pub mod tolerant_json_commands;
|
pub mod tolerant_json_commands;
|
||||||
pub mod rag_grounding_commands;
|
pub mod rag_grounding_commands;
|
||||||
|
pub mod image_download_commands;
|
||||||
pub mod conversation_commands;
|
pub mod conversation_commands;
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
MessageCircle,
|
|
||||||
Send,
|
Send,
|
||||||
Bot,
|
|
||||||
User,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Clock,
|
Tag,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
Copy,
|
|
||||||
ExternalLink,
|
|
||||||
Tag,
|
|
||||||
Palette,
|
|
||||||
Calendar,
|
|
||||||
MapPin
|
MapPin
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { queryRagGrounding } from '../services/ragGroundingService';
|
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;
|
uri?: string;
|
||||||
content?: any;
|
content?: any;
|
||||||
}>;
|
}>;
|
||||||
|
grounding_metadata?: GroundingMetadata;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +43,8 @@ interface ChatInterfaceProps {
|
|||||||
maxMessages?: number;
|
maxMessages?: number;
|
||||||
/** 是否显示来源信息 */
|
/** 是否显示来源信息 */
|
||||||
showSources?: boolean;
|
showSources?: boolean;
|
||||||
|
/** 是否启用图片卡片 */
|
||||||
|
enableImageCards?: boolean;
|
||||||
/** 自定义样式类名 */
|
/** 自定义样式类名 */
|
||||||
className?: string;
|
className?: string;
|
||||||
/** 占位符文本 */
|
/** 占位符文本 */
|
||||||
@@ -64,6 +60,7 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
sessionId = 'default-session',
|
sessionId = 'default-session',
|
||||||
maxMessages = 3,
|
maxMessages = 3,
|
||||||
showSources = true,
|
showSources = true,
|
||||||
|
enableImageCards = true,
|
||||||
className = '',
|
className = '',
|
||||||
placeholder = '描述您的穿搭需求,比如场合、风格、身材特点等...'
|
placeholder = '描述您的穿搭需求,比如场合、风格、身材特点等...'
|
||||||
}) => {
|
}) => {
|
||||||
@@ -71,14 +68,13 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [allTags, setAllTags] = useState<string[]>([]);
|
const [allTags, setAllTags] = useState<string[]>([]);
|
||||||
const [isTagsExpanded, setIsTagsExpanded] = useState(false);
|
const [isTagsExpanded, setIsTagsExpanded] = useState(false);
|
||||||
const [showTagBubble, setShowTagBubble] = useState(false);
|
const [showTagBubble, setShowTagBubble] = useState(false);
|
||||||
const [bubbleTags, setBubbleTags] = useState<{categories: string[], environment: string[]}>({categories: [], environment: []});
|
const [bubbleTags, _setBubbleTags] = useState<{categories: string[], environment: string[]}>({categories: [], environment: []});
|
||||||
const [bubblePosition, setBubblePosition] = useState<{x: number, y: number}>({x: 0, y: 0});
|
const [bubblePosition, _setBubblePosition] = useState<{x: number, y: number}>({x: 0, y: 0});
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const chatContainerRef = useRef<HTMLDivElement>(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
|
// 生成消息ID
|
||||||
const generateMessageId = useCallback(() => {
|
const generateMessageId = useCallback(() => {
|
||||||
@@ -403,7 +226,8 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
metadata: {
|
metadata: {
|
||||||
responseTime: result.data!.response_time_ms,
|
responseTime: result.data!.response_time_ms,
|
||||||
modelUsed: result.data!.model_used,
|
modelUsed: result.data!.model_used,
|
||||||
sources: result.data!.grounding_metadata?.sources
|
sources: result.data!.grounding_metadata?.sources,
|
||||||
|
grounding_metadata: result.data!.grounding_metadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: msg
|
: msg
|
||||||
@@ -453,21 +277,7 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
}
|
}
|
||||||
}, [messages]);
|
}, [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(() => {
|
const extractAllTags = useCallback(() => {
|
||||||
@@ -523,20 +333,6 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
setIsTagsExpanded(prev => !prev);
|
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(() => {
|
const closeTagBubble = useCallback(() => {
|
||||||
setShowTagBubble(false);
|
setShowTagBubble(false);
|
||||||
@@ -588,18 +384,6 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|||||||
return searchText;
|
return searchText;
|
||||||
}, [selectedTags]);
|
}, [selectedTags]);
|
||||||
|
|
||||||
// 复制消息内容到剪贴板
|
|
||||||
const handleCopyMessage = useCallback(async (message: ChatMessage) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(message.content);
|
|
||||||
console.log('✅ 已复制到剪贴板');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('❌ 复制失败:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col h-full bg-white rounded-xl shadow-lg border border-gray-200 ${className} relative`}>
|
<div className={`flex flex-col h-full 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) => (
|
{messages.map((message) => (
|
||||||
<div
|
<EnhancedChatMessage
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`flex gap-3 ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}
|
message={message}
|
||||||
>
|
showSources={showSources}
|
||||||
{message.type === 'assistant' && (
|
enableImageCards={enableImageCards}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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()}`}
|
sessionId={`chat-tool-${Date.now()}`}
|
||||||
maxMessages={3}
|
maxMessages={3}
|
||||||
showSources={true}
|
showSources={true}
|
||||||
|
enableImageCards={true}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
placeholder="请输入您的问题,我会基于知识库为您提供准确的答案..."
|
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 {
|
export interface GroundingMetadata {
|
||||||
sources: GroundingSource[];
|
sources: GroundingSource[];
|
||||||
search_queries: string[];
|
search_queries: string[];
|
||||||
|
grounding_supports?: GroundingSupport[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +59,29 @@ export interface GroundingSource {
|
|||||||
content?: any; // JSON 内容
|
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