use anyhow::Result; use tauri::State; use crate::app_state::AppState; use crate::data::models::gemini_analysis::{AnalyzeImageRequest, AnalyzeImageResponse}; use crate::data::models::outfit_search::{ LLMQueryRequest, LLMQueryResponse, OutfitSearchGlobalConfig, ProductInfo, SearchFilterBuilder, SearchRequest, SearchResponse, SearchResult, }; use crate::data::models::outfit_recommendation::{ OutfitRecommendationRequest, OutfitRecommendationResponse, }; use crate::infrastructure::gemini_service::{GeminiConfig, GeminiService}; /// 分析服装图像 /// 遵循 Tauri 开发规范的命令接口设计原则 #[tauri::command] pub async fn analyze_outfit_image( _state: State<'_, AppState>, request: AnalyzeImageRequest, ) -> Result { // 创建Gemini服务 let config = GeminiConfig::default(); let mut gemini_service = GeminiService::new(Some(config)) .map_err(|e| format!("Failed to create GeminiService: {}", e))?; // 执行图像分析 let analysis_result = gemini_service .analyze_outfit_image(&request.image_path) .await .map_err(|e| { eprintln!("Failed to analyze outfit image: {}", e); format!("图像分析失败: {}", e) })?; // 解析JSON结果 let parsed_result: serde_json::Value = serde_json::from_str(&analysis_result) .map_err(|e| format!("Failed to parse analysis result: {}", e))?; // 构建响应 Ok(AnalyzeImageResponse { result: parsed_result, analysis_time_ms: 1500, // 这里应该记录实际的分析时间 analyzed_at: chrono::Utc::now(), }) } /// 搜索相似服装 #[tauri::command] pub async fn search_similar_outfits( _state: State<'_, AppState>, request: SearchRequest, ) -> Result { let start_time = std::time::Instant::now(); // 创建Gemini服务实例用于获取访问令牌 let config = GeminiConfig::default(); let mut gemini_service = GeminiService::new(Some(config)) .map_err(|e| format!("Failed to create GeminiService: {}", e))?; // 执行直接的 Vertex AI Search 搜索 let search_results = execute_vertex_ai_search(&mut gemini_service, &request) .await .map_err(|e| { eprintln!("搜索失败: {}", e); format!("搜索失败: {}", e) })?; let search_time_ms = start_time.elapsed().as_millis() as u64; Ok(SearchResponse { results: search_results.results, total_size: search_results.total_size, next_page_token: search_results.next_page_token, search_time_ms, searched_at: chrono::Utc::now(), }) } /// LLM问答 #[tauri::command] pub async fn ask_llm_outfit_advice( _state: State<'_, AppState>, request: LLMQueryRequest, ) -> Result { // 创建Gemini服务 let config = GeminiConfig::default(); let mut gemini_service = GeminiService::new(Some(config)) .map_err(|e| format!("Failed to create GeminiService: {}", e))?; // 执行LLM问答 let answer = gemini_service .ask_outfit_advice(&request.user_input) .await .map_err(|e| { eprintln!("Failed to get LLM outfit advice: {}", e); format!("LLM问答失败: {}", e) })?; // 构建响应 Ok(LLMQueryResponse { answer, related_results: vec![], // 暂时返回空的相关结果 response_time_ms: 1000, responded_at: chrono::Utc::now(), }) } /// 获取搜索建议 #[tauri::command] pub async fn get_outfit_search_suggestions( _state: State<'_, AppState>, query: String, ) -> Result, String> { // 基于查询生成搜索建议 let suggestions = generate_search_suggestions(&query); Ok(suggestions) } /// 基于分析结果生成搜索配置 #[tauri::command] pub async fn generate_search_config_from_analysis( _state: State<'_, AppState>, _analysis_result: crate::data::models::gemini_analysis::OutfitAnalysisResult, ) -> Result { // TODO: 实现基于分析结果生成搜索配置的逻辑 // 暂时返回默认配置 use crate::data::models::outfit_search::{RelevanceThreshold, SearchConfig}; use std::collections::HashMap; Ok(SearchConfig { relevance_threshold: RelevanceThreshold::High, categories: vec![], environments: vec![], color_filters: HashMap::new(), design_styles: HashMap::new(), max_keywords: 10, }) } /// 验证图像文件 #[tauri::command] pub async fn validate_outfit_image( _state: State<'_, AppState>, image_path: String, ) -> Result { // 检查文件是否存在 if !std::path::Path::new(&image_path).exists() { return Ok(false); } // 检查文件扩展名 let valid_extensions = ["jpg", "jpeg", "png", "webp"]; let extension = std::path::Path::new(&image_path) .extension() .and_then(|ext| ext.to_str()) .map(|ext| ext.to_lowercase()); match extension { Some(ext) if valid_extensions.contains(&ext.as_str()) => Ok(true), _ => Ok(false), } } /// 获取支持的图像格式 #[tauri::command] pub async fn get_supported_image_formats( _state: State<'_, AppState>, ) -> Result, String> { Ok(vec![ "jpg".to_string(), "jpeg".to_string(), "png".to_string(), "webp".to_string(), ]) } /// 生成穿搭方案推荐 #[tauri::command] pub async fn generate_outfit_recommendations( _state: State<'_, AppState>, request: OutfitRecommendationRequest, ) -> Result { println!("🎨 收到穿搭方案生成请求: {:?}", request); // 创建Gemini服务 let config = GeminiConfig::default(); let mut gemini_service = GeminiService::new(Some(config)) .map_err(|e| format!("Failed to create GeminiService: {}", e))?; // 生成穿搭方案 let response = gemini_service .generate_outfit_recommendations(&request) .await .map_err(|e| { eprintln!("Failed to generate outfit recommendations: {}", e); format!("穿搭方案生成失败: {}", e) })?; println!("✅ 穿搭方案生成成功,共生成 {} 个方案", response.recommendations.len()); Ok(response) } /// 获取默认搜索配置 #[tauri::command] pub async fn get_default_search_config( _state: State<'_, AppState>, ) -> Result { Ok(crate::data::models::outfit_search::SearchConfig::default()) } /// 获取全局配置信息 #[tauri::command] pub async fn get_outfit_search_config( _state: State<'_, AppState>, ) -> Result { let config = OutfitSearchGlobalConfig::default(); Ok(OutfitSearchConfigInfo { google_project_id: config.google_project_id, vertex_ai_app_id: config.vertex_ai_app_id, storage_bucket_name: config.storage_bucket_name, data_store_id: config.data_store_id, }) } /// 配置信息(用于前端显示,不包含敏感信息) #[derive(serde::Serialize)] pub struct OutfitSearchConfigInfo { pub google_project_id: String, pub vertex_ai_app_id: String, pub storage_bucket_name: String, pub data_store_id: String, } /// 生成搜索建议的辅助函数 fn generate_search_suggestions(query: &str) -> Vec { let base_suggestions = vec![ "休闲搭配".to_string(), "正式搭配".to_string(), "运动风格".to_string(), "街头风格".to_string(), "简约风格".to_string(), "复古风格".to_string(), "牛仔裤搭配".to_string(), "连衣裙搭配".to_string(), "外套搭配".to_string(), "夏季搭配".to_string(), "冬季搭配".to_string(), "约会搭配".to_string(), "工作搭配".to_string(), "聚会搭配".to_string(), ]; if query.is_empty() { return base_suggestions; } // 基于查询过滤和排序建议 let mut filtered_suggestions: Vec = base_suggestions .into_iter() .filter(|suggestion| { suggestion.contains(query) || query.chars().any(|c| suggestion.contains(c)) }) .collect(); // 如果过滤后的建议太少,添加一些通用建议 if filtered_suggestions.len() < 5 { filtered_suggestions.extend(vec![ format!("{} 搭配", query), format!("{} 风格", query), format!("如何搭配 {}", query), ]); } // 限制建议数量 filtered_suggestions.truncate(10); filtered_suggestions } #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_search_suggestions() { let suggestions = generate_search_suggestions("牛仔"); assert!(suggestions.iter().any(|s| s.contains("牛仔"))); assert!(suggestions.len() <= 10); } #[test] fn test_generate_search_suggestions_empty() { let suggestions = generate_search_suggestions(""); assert!(!suggestions.is_empty()); assert!(suggestions.len() <= 10); } #[test] fn test_outfit_search_config_info_serialization() { let config_info = OutfitSearchConfigInfo { google_project_id: "test-project".to_string(), vertex_ai_app_id: "test-app".to_string(), storage_bucket_name: "test-bucket".to_string(), data_store_id: "test-store".to_string(), }; let serialized = serde_json::to_string(&config_info).unwrap(); assert!(serialized.contains("test-project")); } #[test] fn test_search_request_creation() { use crate::data::models::outfit_search::{RelevanceThreshold, SearchConfig}; let request = SearchRequest { query: "牛仔裤搭配".to_string(), config: SearchConfig { relevance_threshold: RelevanceThreshold::High, categories: vec!["上装".to_string(), "下装".to_string()], environments: vec!["Outdoor".to_string()], color_filters: std::collections::HashMap::new(), design_styles: std::collections::HashMap::new(), max_keywords: 10, }, page_size: 9, page_offset: 0, }; assert_eq!(request.query, "牛仔裤搭配"); assert_eq!(request.page_size, 9); assert_eq!(request.config.categories.len(), 2); } #[test] fn test_search_filter_builder() { use crate::data::models::outfit_search::SearchConfig; let mut config = SearchConfig::default(); config.categories = vec!["上装".to_string()]; config.environments = vec!["Outdoor".to_string()]; let filters = SearchFilterBuilder::build_filters(&config); assert!(filters.contains("上装") || filters.contains("Outdoor")); } #[test] fn test_query_keywords_builder() { use crate::data::models::outfit_search::SearchConfig; let mut config = SearchConfig::default(); config.environments = vec!["Outdoor".to_string()]; let keywords = SearchFilterBuilder::build_query_keywords(&config); assert!(keywords.contains(&"Outdoor".to_string())); } #[test] fn test_convert_s3_to_cdn_url() { // 测试 s3://ap-northeast-2/modal-media-cache/ 转换 let s3_url = "s3://ap-northeast-2/modal-media-cache/image.jpg"; let expected = "https://cdn.roasmax.cn/image.jpg"; assert_eq!(convert_s3_to_cdn_url(s3_url), expected); // 测试 gs://fashion_image_block/ 转换 let gs_url = "gs://fashion_image_block/image.jpg"; let expected = "https://storage.googleapis.com/fashion_image_block/image.jpg"; assert_eq!(convert_s3_to_cdn_url(gs_url), expected); // 测试其他 gs:// 转换 let gs_url = "gs://other-bucket/image.jpg"; let expected = "https://storage.googleapis.com/other-bucket/image.jpg"; assert_eq!(convert_s3_to_cdn_url(gs_url), expected); // 测试其他 s3:// 转换 let s3_url = "s3://other-bucket/image.jpg"; let expected = "https://cdn.roasmax.cn/other-bucket/image.jpg"; assert_eq!(convert_s3_to_cdn_url(s3_url), expected); // 测试普通HTTP URL(不转换) let http_url = "https://example.com/image.jpg"; assert_eq!(convert_s3_to_cdn_url(http_url), http_url); // 测试空字符串 assert_eq!(convert_s3_to_cdn_url(""), ""); } } /// 将S3/GS URL转换为CDN URL fn convert_s3_to_cdn_url(s3_url: &str) -> String { if s3_url.starts_with("s3://ap-northeast-2/modal-media-cache/") { // 将 s3://ap-northeast-2/modal-media-cache/ 替换为 https://cdn.roasmax.cn/ s3_url.replace( "s3://ap-northeast-2/modal-media-cache/", "https://cdn.roasmax.cn/", ) } else if s3_url.starts_with("gs://fashion_image_block/") { // 将 gs://fashion_image_block/ 替换为 https://cdn.roasmax.cn/fashion_image_block/ s3_url.replace("gs://", "https://storage.googleapis.com/") } else if s3_url.starts_with("gs://") { // 处理其他 gs:// 格式,转换为通用CDN格式 s3_url.replace("gs://", "https://storage.googleapis.com/") } else if s3_url.starts_with("s3://") { // 处理其他 s3:// 格式,转换为通用CDN格式 s3_url.replace("s3://", "https://cdn.roasmax.cn/") } else { // 如果不是预期的S3格式,返回原URL s3_url.to_string() } } /// 检查网络连接 async fn check_network_connectivity() -> Result<(), anyhow::Error> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(5)) .build()?; // 尝试连接到Google DNS match client.get("https://dns.google").send().await { Ok(_) => Ok(()), Err(e) => Err(anyhow::anyhow!( "网络连接检查失败: {}。请检查您的网络连接。", e )), } } /// 执行 Vertex AI Search 搜索 async fn execute_vertex_ai_search( _gemini_service: &mut GeminiService, request: &SearchRequest, ) -> Result { // 0. 检查网络连接 check_network_connectivity() .await .map_err(|e| anyhow::anyhow!("网络连接问题: {}", e))?; // 1. 获取访问令牌(通过直接调用API) let access_token = get_google_access_token() .await .map_err(|e| anyhow::anyhow!("获取访问令牌失败: {}。请检查网络连接或API配置。", e))?; // 2. 获取全局配置 let global_config = OutfitSearchGlobalConfig::default(); // 3. 构建搜索过滤器 let search_filter = SearchFilterBuilder::build_filters(&request.config); // 4. 构建查询关键词 let query_keywords = SearchFilterBuilder::build_query_keywords(&request.config); // 5. 组合查询字符串 let enhanced_query = if query_keywords.is_empty() { request.query.clone() } else { format!("{} {}", request.query, query_keywords.join(" ")) }; // 6. 构建请求负载 let mut payload = serde_json::json!({ "query": enhanced_query, "pageSize": request.page_size, "offset": request.page_offset }); // 添加相关性评分规范(但不设置阈值,因为API不支持) payload["relevanceScoreSpec"] = serde_json::json!({ "returnRelevanceScore": true }); // 7. 添加过滤器(如果有) if !search_filter.is_empty() { payload["filter"] = serde_json::Value::String(search_filter); } // 8. 构建请求URL let search_url = format!( "https://discoveryengine.googleapis.com/v1beta/projects/{}/locations/global/collections/default_collection/engines/{}/servingConfigs/default_search:search", global_config.google_project_id, global_config.vertex_ai_app_id ); // 9. 创建带有超时配置的客户端 let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) .connect_timeout(std::time::Duration::from_secs(15)) .build()?; // 10. 发送HTTP请求(带重试机制) let mut last_error = None; for attempt in 0..3 { match client .post(&search_url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .json(&payload) .send() .await { Ok(response) => { let status = response.status(); let response_text = response.text().await?; if !status.is_success() { return Err(anyhow::anyhow!( "Vertex AI Search 请求失败: {} - {}", status, response_text )); } // 11. 解析响应 let vertex_response: serde_json::Value = serde_json::from_str(&response_text)?; // 12. 转换为我们的搜索结果格式 let search_results = convert_vertex_response_to_search_results(&vertex_response, request)?; return Ok(search_results); } Err(e) => { let error_msg = format!("网络请求失败: {}", e); eprintln!("{}", error_msg); last_error = Some(anyhow::anyhow!(error_msg)); if attempt < 2 { eprintln!( "Vertex AI Search 请求失败,重试中... (尝试 {}/3)", attempt + 1 ); tokio::time::sleep(std::time::Duration::from_secs(3)).await; } else { eprintln!("Vertex AI Search 请求最终失败,已重试3次"); } } } } Err(anyhow::anyhow!( "Vertex AI Search 请求失败,已重试3次: {}", last_error.unwrap() )) } /// 获取 Google 访问令牌 async fn get_google_access_token() -> Result { let config = GeminiConfig::default(); // 创建带有超时和重试配置的客户端 let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .connect_timeout(std::time::Duration::from_secs(10)) .build()?; let url = format!("{}/google/access-token", config.base_url); // 重试机制 let mut last_error = None; for attempt in 0..3 { match client .get(&url) .header("Authorization", format!("Bearer {}", config.bearer_token)) .send() .await { Ok(response) => { let status = response.status(); if !status.is_success() { let error_body = response.text().await.unwrap_or_default(); return Err(anyhow::anyhow!( "获取访问令牌失败: {} - {}", status, error_body )); } let response_text = response.text().await?; let token_response: serde_json::Value = serde_json::from_str(&response_text)?; let access_token = token_response .get("access_token") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("访问令牌响应中未找到 access_token 字段"))?; return Ok(access_token.to_string()); } Err(e) => { let error_msg = format!("网络连接失败: {}", e); eprintln!("{}", error_msg); last_error = Some(anyhow::anyhow!(error_msg)); if attempt < 2 { eprintln!("获取访问令牌失败,重试中... (尝试 {}/3)", attempt + 1); tokio::time::sleep(std::time::Duration::from_secs(2)).await; } } } } Err(anyhow::anyhow!( "获取访问令牌失败,已重试3次: {}", last_error.unwrap() )) } /// 将 Vertex AI Search 响应转换为我们的搜索结果格式 fn convert_vertex_response_to_search_results( vertex_response: &serde_json::Value, request: &SearchRequest, ) -> Result { let mut results = Vec::new(); // 解析 Vertex AI Search 响应 if let Some(vertex_results) = vertex_response.get("results").and_then(|v| v.as_array()) { eprintln!("收到 {} 个原始搜索结果", vertex_results.len()); for vertex_result in vertex_results { if let Ok(search_result) = parse_vertex_result_to_search_result(vertex_result) { eprintln!( "解析结果: ID={}, 相关性评分={:.2}", search_result.id, search_result.relevance_score ); // 应用相关性阈值过滤 let threshold = request.config.relevance_threshold.to_value(); results.push(search_result); } else { eprintln!("解析搜索结果失败"); } } } else { eprintln!("响应中没有找到 results 数组"); } eprintln!("最终返回 {} 个过滤后的结果", results.len()); // 按相关性评分降序排序 results.sort_by(|a, b| { b.relevance_score.partial_cmp(&a.relevance_score).unwrap_or(std::cmp::Ordering::Equal) }); eprintln!("排序后的结果:"); for (index, result) in results.iter().enumerate() { eprintln!(" {}. ID={}, 相关性评分={:.3}", index + 1, result.id, result.relevance_score); } let total_size = vertex_response .get("totalSize") .and_then(|v| v.as_u64()) .unwrap_or(results.len() as u64) as usize; let next_page_token = vertex_response .get("nextPageToken") .and_then(|v| v.as_str()) .map(|s| s.to_string()); Ok(SearchResponse { results, total_size, next_page_token, search_time_ms: 0, // 将在调用方设置 searched_at: chrono::Utc::now(), }) } /// 解析单个 Vertex AI Search 结果为我们的搜索结果格式 fn parse_vertex_result_to_search_result( vertex_result: &serde_json::Value, ) -> Result { // 获取文档数据 let document = vertex_result .get("document") .ok_or_else(|| anyhow::anyhow!("Vertex result missing document field"))?; // 获取结构化数据 let struct_data = document .get("structData") .ok_or_else(|| anyhow::anyhow!("Document missing structData field"))?; // 解析基本信息 let id = document .get("id") .and_then(|v| v.as_str()) .unwrap_or(&format!("result_{}", chrono::Utc::now().timestamp())) .to_string(); // 从 structData 中提取信息 let style_description = struct_data .get("style_description") .and_then(|v| v.as_str()) .unwrap_or("时尚搭配") .to_string(); let environment_tags = struct_data .get("environment_tags") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str()) .map(|s| s.to_string()) .collect() }) .unwrap_or_else(|| vec!["日常".to_string()]); // 解析产品信息 let products = struct_data .get("products") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| parse_vertex_product_info(v).ok()) .collect() }) .unwrap_or_else(Vec::new); // 获取图片URL(可能在不同的字段中) let raw_image_url = struct_data .get("uri") .or_else(|| struct_data.get("image_url")) .or_else(|| struct_data.get("url")) .and_then(|v| v.as_str()) .unwrap_or(""); // 转换S3/GS URL为CDN URL let image_url = convert_s3_to_cdn_url(raw_image_url); // 调试:显示URL转换 if raw_image_url != image_url { eprintln!("URL转换: {} -> {}", raw_image_url, image_url); } // 获取相关性评分 let relevance_score = vertex_result .get("modelScores") .and_then(|scores| scores.get("relevance_score")) .and_then(|score| score.get("values")) .and_then(|values| values.as_array()) .and_then(|arr| arr.first()) .and_then(|v| v.as_f64()) .unwrap_or(0.0); // 调试:打印相关性评分解析过程 if let Some(model_scores) = vertex_result.get("modelScores") { eprintln!("ModelScores 结构: {}", serde_json::to_string_pretty(model_scores).unwrap_or_default()); if let Some(relevance_data) = model_scores.get("relevance_score") { eprintln!("Relevance Score 数据: {}", serde_json::to_string_pretty(relevance_data).unwrap_or_default()); } else { eprintln!("未找到 relevance_score 字段,可用字段: {:?}", model_scores.as_object().map(|obj| obj.keys().collect::>())); } } else { eprintln!("未找到 modelScores 字段"); } eprintln!("解析得到的相关性评分: {:.3}", relevance_score); Ok(SearchResult { id, image_url, style_description, environment_tags, products, relevance_score, }) } /// 解析 Vertex AI Search 中的产品信息 fn parse_vertex_product_info(value: &serde_json::Value) -> Result { use crate::data::models::gemini_analysis::ColorHSV; let category = value .get("category") .and_then(|v| v.as_str()) .unwrap_or("服装") .to_string(); // 尝试从多个字段获取描述信息 let description = if let Some(desc) = value.get("description").and_then(|v| v.as_str()) { desc.to_string() } else if let Some(styles_array) = value.get("design_styles").and_then(|v| v.as_array()) { if !styles_array.is_empty() { let styles: Vec = styles_array.iter() .filter_map(|v| v.as_str()) .map(|s| s.to_string()) .collect(); if !styles.is_empty() { styles.join("、") } else { "时尚单品".to_string() } } else { "时尚单品".to_string() } } else { "时尚单品".to_string() }; let color_pattern = value .get("color_pattern") .and_then(|v| { let hue = v .get("Hue") .or_else(|| v.get("hue")) .and_then(|h| h.as_f64()) .unwrap_or(0.0); let saturation = v .get("Saturation") .or_else(|| v.get("saturation")) .and_then(|s| s.as_f64()) .unwrap_or(0.5); let value = v .get("Value") .or_else(|| v.get("value")) .and_then(|val| val.as_f64()) .unwrap_or(0.8); Some(ColorHSV::new(hue, saturation, value)) }) .unwrap_or_else(|| ColorHSV::new(0.0, 0.5, 0.8)); let design_styles = value .get("design_styles") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str()) .map(|s| s.to_string()) .collect() }) .unwrap_or_else(|| vec!["时尚".to_string()]); Ok(ProductInfo { category, description, color_pattern, design_styles, }) } /// 获取所有服装搜索相关的Tauri命令名称 pub fn get_outfit_search_command_names() -> Vec<&'static str> { vec![ "analyze_outfit_image", "search_similar_outfits", "ask_llm_outfit_advice", "get_outfit_search_suggestions", "generate_search_config_from_analysis", "validate_outfit_image", "get_supported_image_formats", "get_default_search_config", "get_outfit_search_config", ] }