From cb5a137ec9bdb23ef0ad5f62f82c55320c9c26ec Mon Sep 17 00:00:00 2001 From: imeepos Date: Tue, 22 Jul 2025 12:21:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E6=96=87=E5=AD=97?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=85=B3=E8=81=94=E5=85=B3=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/infrastructure/gemini_service.rs | 60 +++ apps/desktop/src-tauri/src/lib.rs | 6 +- .../commands/image_download_commands.rs | 193 +++++++++ .../src/presentation/commands/mod.rs | 1 + apps/desktop/src/components/ChatInterface.tsx | 403 +----------------- .../src/components/EnhancedChatMessage.tsx | 264 ++++++++++++ apps/desktop/src/components/GroundedText.tsx | 244 +++++++++++ apps/desktop/src/components/ImageCard.tsx | 268 ++++++++++++ .../src/components/ImagePreviewModal.tsx | 341 +++++++++++++++ apps/desktop/src/hooks/useImageDownload.ts | 190 +++++++++ apps/desktop/src/pages/tools/ChatTool.tsx | 1 + .../src/services/imageDownloadService.ts | 192 +++++++++ apps/desktop/src/types/ragGrounding.ts | 24 ++ 13 files changed, 1800 insertions(+), 387 deletions(-) create mode 100644 apps/desktop/src-tauri/src/presentation/commands/image_download_commands.rs create mode 100644 apps/desktop/src/components/EnhancedChatMessage.tsx create mode 100644 apps/desktop/src/components/GroundedText.tsx create mode 100644 apps/desktop/src/components/ImageCard.tsx create mode 100644 apps/desktop/src/components/ImagePreviewModal.tsx create mode 100644 apps/desktop/src/hooks/useImageDownload.ts create mode 100644 apps/desktop/src/services/imageDownloadService.ts diff --git a/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs b/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs index 6714669..46cccfa 100644 --- a/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs +++ b/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs @@ -209,6 +209,7 @@ pub struct RagGroundingResponse { pub struct GroundingMetadata { pub sources: Vec, pub search_queries: Vec, + pub grounding_supports: Option>, } /// Grounding 来源 @@ -219,6 +220,25 @@ pub struct GroundingSource { pub content: Option, } +/// Grounding 支持信息 +/// 用于关联文字片段与来源信息 +#[derive(Debug, Serialize, Deserialize)] +pub struct GroundingSupport { + #[serde(rename = "groundingChunkIndices")] + pub grounding_chunk_indices: Vec, + 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, }) } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 8cfb12b..838d7aa 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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| { // 初始化日志系统 diff --git a/apps/desktop/src-tauri/src/presentation/commands/image_download_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/image_download_commands.rs new file mode 100644 index 0000000..775be51 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/image_download_commands.rs @@ -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 { + // 尝试获取用户下载目录 + 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 { + 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 { + 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, + pub last_modified: Option, + 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 { + 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/"), + }) +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index c7e84ca..05a78b3 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -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; diff --git a/apps/desktop/src/components/ChatInterface.tsx b/apps/desktop/src/components/ChatInterface.tsx index 46c2570..925f5d6 100644 --- a/apps/desktop/src/components/ChatInterface.tsx +++ b/apps/desktop/src/components/ChatInterface.tsx @@ -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 = ({ sessionId = 'default-session', maxMessages = 3, showSources = true, + enableImageCards = true, className = '', placeholder = '描述您的穿搭需求,比如场合、风格、身材特点等...' }) => { @@ -71,14 +68,13 @@ export const ChatInterface: React.FC = ({ const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [expandedSources, setExpandedSources] = useState>({}); - const [showAnswers, setShowAnswers] = useState>({}); + const [selectedTags, setSelectedTags] = useState([]); const [allTags, setAllTags] = useState([]); 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(null); const chatContainerRef = useRef(null); @@ -114,180 +110,7 @@ export const ChatInterface: React.FC = ({ } }, []); - // 渲染图片卡片 - const renderImageCard = useCallback((source: any, index: number) => { - const imageData = parseImageContent(source.content); - if (!imageData || !source.uri) return null; - return ( -
- {/* 图片 */} -
- {source.title { - 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'); - }} - /> - - {/* 加载占位符 */} -
-
-
- - {/* 错误占位符 */} -
-
- - 加载失败 -
-
- - {/* 运行时间标签 */} - {imageData.runtime && ( -
- - {imageData.runtime} -
- )} - - {/* 悬停时显示的查看按钮 */} -
- -
-
- - {/* 内容信息 */} -
- {/* 描述 */} - {imageData.description && ( -

- {imageData.description} -

- )} - - {/* 分类标签 */} - {imageData.categories.length > 0 && ( -
- {imageData.categories.slice(0, 4).map((category: string, catIndex: number) => { - const isSelected = selectedTags.includes(category); - return ( - - ); - })} - {imageData.categories.length > 4 && ( - - )} -
- )} - - {/* 环境标签 */} - {imageData.environment_tags.length > 0 && ( -
- {imageData.environment_tags.slice(0, 3).map((tag: string, tagIndex: number) => { - const isSelected = selectedTags.includes(tag); - return ( - - ); - })} -
- )} - - {/* 底部信息栏 */} -
- {imageData.environment_color && ( -
- -
-
- )} - - {imageData.releaseDate && ( -
- - {new Date(imageData.releaseDate).toLocaleDateString('zh-CN')} -
- )} - - {/* 模特数量 */} - {imageData.models.length > 0 && ( -
- - {imageData.models.length} -
- )} -
-
-
- ); - }, [parseImageContent, selectedTags]); // 生成消息ID const generateMessageId = useCallback(() => { @@ -403,7 +226,8 @@ export const ChatInterface: React.FC = ({ 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 = ({ } }, [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 = ({ 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 = ({ return searchText; }, [selectedTags]); - // 复制消息内容到剪贴板 - const handleCopyMessage = useCallback(async (message: ChatMessage) => { - try { - await navigator.clipboard.writeText(message.content); - console.log('✅ 已复制到剪贴板'); - } catch (err) { - console.error('❌ 复制失败:', err); - } - }, []); - - - return (
{/* 聊天消息区域 */} @@ -640,166 +424,13 @@ export const ChatInterface: React.FC = ({ ) : ( <> {messages.map((message) => ( -
- {message.type === 'assistant' && ( -
- -
- )} - -
- {/* 用户消息或AI消息的文字回答(可选显示) */} - {(message.type === 'user' || message.status === 'sending' || showAnswers[message.id]) && ( -
- {message.status === 'sending' ? ( -
-
- 正在为您搭配中... -
- ) : ( -
{message.content}
- )} -
- )} - - {/* AI消息的查看详情按钮(当文字回答隐藏且不在加载状态时显示) */} - {message.type === 'assistant' && message.status !== 'sending' && !showAnswers[message.id] && ( -
- -
- )} - - {/* AI消息的隐藏回答按钮(当文字回答显示且不在加载状态时显示) */} - {message.type === 'assistant' && message.status !== 'sending' && showAnswers[message.id] && ( -
- -
- )} - - {/* 消息状态和元数据 */} -
- - {message.timestamp.toLocaleTimeString()} - - - {message.status === 'sent' && ( - - )} - {message.status === 'error' && ( - - )} - - {message.metadata?.responseTime && ( - - - {message.metadata.responseTime}ms - - )} - - {/* 操作按钮 */} -
- -
-
- - {/* 来源信息 - 图片卡片展示 */} - {showSources && message.metadata?.sources && message.metadata.sources.length > 0 && ( -
-
-
-
-
- 时尚穿搭参考 ({message.metadata.sources.length} 张图片) -
-
-
- 悬停查看原图 -
-
- - {/* 图片展示 - 响应式网格 */} -
- {(() => { - 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) => -
- {renderImageCard(source, index)} -
- ); - })()} -
- - {/* 展开/收起按钮 */} - {message.metadata.sources.length > 12 && ( -
- -
- )} -
- )} -
- - {message.type === 'user' && ( -
- -
- )} -
+ message={message} + showSources={showSources} + enableImageCards={enableImageCards} + /> ))} -
)}
diff --git a/apps/desktop/src/components/EnhancedChatMessage.tsx b/apps/desktop/src/components/EnhancedChatMessage.tsx new file mode 100644 index 0000000..f9c7f55 --- /dev/null +++ b/apps/desktop/src/components/EnhancedChatMessage.tsx @@ -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 = ({ + 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 ( +
+
+ {/* 头像 */} +
+
+ {isUser ? : } +
+
+ + {/* 消息内容 */} +
+ {/* 消息气泡 */} +
+ {/* 消息内容 */} +
+ {isAssistant && groundingMetadata ? ( + + ) : ( +
{message.content}
+ )} + + {/* 状态指示器 */} + {message.status === 'sending' && ( +
+
+ 发送中... +
+ )} + + {message.status === 'error' && ( +
+ 发送失败,请重试 +
+ )} +
+ + {/* 操作按钮 */} +
+
+ + {formatTime(message.timestamp)} + {message.metadata?.responseTime && ( + <> + + {message.metadata.responseTime}ms + + )} +
+ +
+ {/* 复制按钮 */} + + + {/* 来源按钮 */} + {isAssistant && sources.length > 0 && showSources && ( + + )} +
+
+
+ + {/* 来源信息 */} + {isAssistant && sources.length > 0 && showSources && expandedSources && ( +
+
+
+

+ + 参考来源 ({sources.length}) +

+ +
+ +
+ {sources.slice(0, 3).map((source, index) => ( +
+
+

+ {source.title} +

+ {source.content?.description && ( +

+ {source.content.description} +

+ )} +
+ +
+ {source.uri && ( + + + + )} +
+
+ ))} + + {sources.length > 3 && ( +
+ + 还有 {sources.length - 3} 个来源... + +
+ )} +
+
+
+ )} + + {/* 模型信息 */} + {isAssistant && message.metadata?.modelUsed && ( +
+ 由 {message.metadata.modelUsed} 生成 +
+ )} +
+
+
+ ); +}; + +export default EnhancedChatMessage; diff --git a/apps/desktop/src/components/GroundedText.tsx b/apps/desktop/src/components/GroundedText.tsx new file mode 100644 index 0000000..c837998 --- /dev/null +++ b/apps/desktop/src/components/GroundedText.tsx @@ -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 = ({ + text, + groundingMetadata, + className = '', + enableImageCards = true, + highlightStyle = 'underline' +}) => { + const [hoveredSegment, setHoveredSegment] = useState(null); + const [cardPosition, setCardPosition] = useState<{ top: number; left: number } | null>(null); + const [previewSource, setPreviewSource] = useState(null); + const containerRef = useRef(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 ( +
+ {/* 渲染文本片段 */} +
+ {textSegments.map((segment, index) => { + const hasGrounding = !!(segment.groundingSupport && segment.sources?.length); + + return ( + handleMouseEnter(segment, e) : undefined} + onMouseLeave={hasGrounding ? handleMouseLeave : undefined} + title={hasGrounding ? `查看相关图片 (${segment.sources?.length} 张)` : undefined} + > + {segment.text} + + ); + })} +
+ + {/* 图片卡片 */} + {hoveredSegment && hoveredSegment.sources && cardPosition && ( +
{}} // 保持卡片显示 + onMouseLeave={handleMouseLeave} + > + {/* 显示第一张相关图片 */} + {hoveredSegment.sources[0] && ( + + )} + + {/* 如果有多张图片,显示指示器 */} + {hoveredSegment.sources.length > 1 && ( +
+
+ +{hoveredSegment.sources.length - 1} 张图片 +
+
+ )} +
+ )} + + {/* 图片预览模态框 */} + +
+ ); +}; + +export default GroundedText; diff --git a/apps/desktop/src/components/ImageCard.tsx b/apps/desktop/src/components/ImageCard.tsx new file mode 100644 index 0000000..70df08c --- /dev/null +++ b/apps/desktop/src/components/ImageCard.tsx @@ -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 = ({ + 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 ( +
+ {/* 卡片头部 */} +
+
+ +

{title}

+
+
+ {onViewLarge && ( + + )} + {onDownload && ( + + )} + {imageUri && ( + + + + )} + {onClose && ( + + )} +
+
+ + {/* 图片展示区域 */} +
+ {imageUri && !imageError ? ( +
+ {description + {!imageLoaded && ( +
+
+
+ )} +
+ ) : ( +
+
+ +

图片无法加载

+
+
+ )} +
+ + {/* 详细信息区域 */} + {showDetails && ( +
+ {/* 描述 */} + {description && ( +

{description}

+ )} + + {/* 环境标签 */} + {environmentTags.length > 0 && ( +
+ +
+ {environmentTags.slice(0, 3).map((tag: string, index: number) => ( + + {tag} + + ))} + {environmentTags.length > 3 && ( + +{environmentTags.length - 3} + )} +
+
+ )} + + {/* 服装类别 */} + {categories.length > 0 && ( +
+ +
+ {categories.slice(0, 4).map((category: string, index: number) => ( + + {category} + + ))} + {categories.length > 4 && ( + +{categories.length - 4} + )} +
+
+ )} + + {/* 模特信息 */} + {models.length > 0 && ( +
+ + + {models.length} 位模特 + +
+ )} + + {/* 发布时间 */} + {releaseDate && ( +
+ + + {new Date(releaseDate).toLocaleDateString('zh-CN')} + +
+ )} +
+ )} +
+ ); +}; + +export default ImageCard; diff --git a/apps/desktop/src/components/ImagePreviewModal.tsx b/apps/desktop/src/components/ImagePreviewModal.tsx new file mode 100644 index 0000000..6345276 --- /dev/null +++ b/apps/desktop/src/components/ImagePreviewModal.tsx @@ -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 = ({ + 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 ( +
+ {/* 模态框背景 */} +
+ + {/* 模态框内容 */} +
+ {/* 头部工具栏 */} +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ +
+ {/* 缩放控制 */} +
+ + + {Math.round(zoom * 100)}% + + +
+ + {/* 旋转按钮 */} + + + {/* 下载按钮 */} + {onDownload && ( + + )} + + {/* 外部链接 */} + {imageUri && ( + + + + )} + + {/* 关闭按钮 */} + +
+
+ + {/* 主要内容区域 */} +
+ {/* 图片展示区域 */} +
+ {imageUri && !imageError ? ( +
+ {description + {!imageLoaded && ( +
+
+
+ )} +
+ ) : ( +
+ +

图片无法加载

+
+ )} +
+ + {/* 信息侧边栏 */} +
+
+ {/* 基本信息 */} +
+

基本信息

+
+ {description && ( +

{description}

+ )} + {releaseDate && ( +
+ + + {new Date(releaseDate).toLocaleDateString('zh-CN')} + +
+ )} +
+
+ + {/* 环境标签 */} + {environmentTags.length > 0 && ( +
+

+ + 环境场景 +

+
+ {environmentTags.map((tag: string, index: number) => ( + + {tag} + + ))} +
+
+ )} + + {/* 服装类别 */} + {categories.length > 0 && ( +
+

+ + 服装类别 +

+
+ {categories.map((category: string, index: number) => ( + + {category} + + ))} +
+
+ )} + + {/* 模特信息 */} + {models.length > 0 && ( +
+

+ + 模特信息 ({models.length}) +

+
+ {models.slice(0, 3).map((model: any, index: number) => ( +
+

{model.position}

+

{model.style_description}

+
+ ))} + {models.length > 3 && ( +

还有 {models.length - 3} 位模特...

+ )} +
+
+ )} +
+
+
+
+
+ ); +}; + +export default ImagePreviewModal; diff --git a/apps/desktop/src/hooks/useImageDownload.ts b/apps/desktop/src/hooks/useImageDownload.ts new file mode 100644 index 0000000..59cb09e --- /dev/null +++ b/apps/desktop/src/hooks/useImageDownload.ts @@ -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; + /** 批量下载图片 */ + downloadImages: (sources: GroundingSource[], options?: ImageDownloadOptions) => Promise; + /** 检查图片是否可下载 */ + isDownloadable: (source: GroundingSource) => boolean; + /** 重置状态 */ + resetState: () => void; +} + +/** + * 图片下载自定义钩子 + * 提供图片下载功能和状态管理 + */ +export const useImageDownload = (): UseImageDownloadReturn => { + const [downloadState, setDownloadState] = useState({ + isDownloading: false, + progress: 0, + error: null, + lastDownloadPath: null + }); + + // 下载单个图片 + const downloadImage = useCallback(async ( + source: GroundingSource, + options: ImageDownloadOptions = {} + ): Promise => { + 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 => { + 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; diff --git a/apps/desktop/src/pages/tools/ChatTool.tsx b/apps/desktop/src/pages/tools/ChatTool.tsx index 0d81049..aed45b7 100644 --- a/apps/desktop/src/pages/tools/ChatTool.tsx +++ b/apps/desktop/src/pages/tools/ChatTool.tsx @@ -145,6 +145,7 @@ const ChatTool: React.FC = () => { sessionId={`chat-tool-${Date.now()}`} maxMessages={3} showSources={true} + enableImageCards={true} className="h-full" placeholder="请输入您的问题,我会基于知识库为您提供准确的答案..." /> diff --git a/apps/desktop/src/services/imageDownloadService.ts b/apps/desktop/src/services/imageDownloadService.ts new file mode 100644 index 0000000..54b4430 --- /dev/null +++ b/apps/desktop/src/services/imageDownloadService.ts @@ -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 { + 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 { + 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 { + try { + // 调用后端获取默认下载目录 + const downloadDir = await invoke('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; diff --git a/apps/desktop/src/types/ragGrounding.ts b/apps/desktop/src/types/ragGrounding.ts index db83596..dd3c373 100644 --- a/apps/desktop/src/types/ragGrounding.ts +++ b/apps/desktop/src/types/ragGrounding.ts @@ -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; +} + /** * 对话上下文 */