- 新增穿搭方案推荐数据模型和类型定义 - 实现基于TikTok视觉趋势的Gemini AI穿搭方案生成 - 创建穿搭方案卡片和列表展示组件 - 集成Sparkles图标点击触发穿搭方案生成 - 实现穿搭方案到场景检索的无缝集成 - 添加完整的前后端API和服务层 - 遵循promptx开发规范和设计系统标准 功能特点: - 基于用户输入关键词生成个性化穿搭推荐 - 包含色彩搭配、风格标签、场景建议等详细信息 - 提供TikTok优化建议和拍摄技巧 - 支持一键场景检索功能 - 美观的卡片式展示界面 - 完整的加载状态和错误处理
853 lines
28 KiB
Rust
853 lines
28 KiB
Rust
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<AnalyzeImageResponse, String> {
|
||
// 创建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<SearchResponse, String> {
|
||
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<LLMQueryResponse, String> {
|
||
// 创建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<Vec<String>, 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<crate::data::models::outfit_search::SearchConfig, String> {
|
||
// 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<bool, String> {
|
||
// 检查文件是否存在
|
||
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<Vec<String>, 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<OutfitRecommendationResponse, String> {
|
||
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<crate::data::models::outfit_search::SearchConfig, String> {
|
||
Ok(crate::data::models::outfit_search::SearchConfig::default())
|
||
}
|
||
|
||
/// 获取全局配置信息
|
||
#[tauri::command]
|
||
pub async fn get_outfit_search_config(
|
||
_state: State<'_, AppState>,
|
||
) -> Result<OutfitSearchConfigInfo, String> {
|
||
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<String> {
|
||
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<String> = 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<SearchResponse, anyhow::Error> {
|
||
// 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<String, anyhow::Error> {
|
||
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<SearchResponse, anyhow::Error> {
|
||
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<SearchResult, anyhow::Error> {
|
||
// 获取文档数据
|
||
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::<Vec<_>>()));
|
||
}
|
||
} 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<ProductInfo, anyhow::Error> {
|
||
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<String> = 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",
|
||
]
|
||
}
|