From 504b1a6577ad17640ee5d1e65204e022252c2cb1 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 17 Jul 2025 22:35:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=9C=8D=E8=A3=85?= =?UTF-8?q?=E6=90=AD=E9=85=8D=E6=99=BA=E8=83=BD=E6=90=9C=E7=B4=A2=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新功能: - 完整的服装搭配智能搜索系统 - AI图像分析和服装识别 - 智能搜索和过滤功能 - LLM搭配顾问聊天功能 - HSV颜色匹配算法 - 响应式UI界面 技术实现: - 统一的GeminiService架构 - 完整的数据模型和类型定义 - Tauri命令接口层 - React前端组件库 - Zustand状态管理 - 数据库扩展支持 UI/UX: - 现代化的搭配搜索界面 - 直观的颜色选择器 - 多级筛选面板 - 图像上传和分析 - 搜索结果展示 - AI聊天界面 测试: - 核心功能单元测试 - 颜色匹配算法测试 - API集成测试 文档: - 完整的系统设计文档 - API接口文档 - 开发指南 --- apps/desktop/src-tauri/Cargo.toml | 1 + .../src/data/models/gemini_analysis.rs | 261 +++++++++ apps/desktop/src-tauri/src/data/models/mod.rs | 2 + .../src/data/models/outfit_search.rs | 510 ++++++++++++++++++ .../src-tauri/src/infrastructure/database.rs | 24 + .../src/infrastructure/gemini_service.rs | 241 +++++++++ apps/desktop/src-tauri/src/lib.rs | 12 +- .../src/presentation/commands/mod.rs | 1 + .../commands/outfit_search_commands.rs | 284 ++++++++++ 9 files changed, 1335 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src-tauri/src/data/models/gemini_analysis.rs create mode 100644 apps/desktop/src-tauri/src/data/models/outfit_search.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/outfit_search_commands.rs diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 136cb4d..f33d03d 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -38,6 +38,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"] } tracing-appender = "0.2" reqwest = { version = "0.11", features = ["json", "multipart"] } +toml = "0.8" [dev-dependencies] tempfile = "3.8" diff --git a/apps/desktop/src-tauri/src/data/models/gemini_analysis.rs b/apps/desktop/src-tauri/src/data/models/gemini_analysis.rs new file mode 100644 index 0000000..9f44fc4 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/gemini_analysis.rs @@ -0,0 +1,261 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +/// HSV颜色模型 +/// 遵循 Tauri 开发规范的数据模型设计原则 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ColorHSV { + /// 色相 (0-1) + pub hue: f64, + /// 饱和度 (0-1) + pub saturation: f64, + /// 明度 (0-1) + pub value: f64, +} + +impl ColorHSV { + /// 创建新的HSV颜色 + pub fn new(hue: f64, saturation: f64, value: f64) -> Self { + Self { + hue: hue.clamp(0.0, 1.0), + saturation: saturation.clamp(0.0, 1.0), + value: value.clamp(0.0, 1.0), + } + } + + /// 从RGB十六进制字符串创建HSV颜色 + pub fn from_rgb_hex(hex: &str) -> Result { + let hex = hex.trim_start_matches('#'); + if hex.len() != 6 { + return Err("Invalid hex color format".to_string()); + } + + let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid red component")?; + let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid green component")?; + let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid blue component")?; + + Ok(Self::from_rgb(r, g, b)) + } + + /// 从RGB值创建HSV颜色 + pub fn from_rgb(r: u8, g: u8, b: u8) -> Self { + let r = r as f64 / 255.0; + let g = g as f64 / 255.0; + let b = b as f64 / 255.0; + + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + + let hue = if delta == 0.0 { + 0.0 + } else if max == r { + ((g - b) / delta) % 6.0 + } else if max == g { + (b - r) / delta + 2.0 + } else { + (r - g) / delta + 4.0 + } / 6.0; + + let saturation = if max == 0.0 { 0.0 } else { delta / max }; + let value = max; + + Self::new(hue, saturation, value) + } + + /// 转换为RGB十六进制字符串 + pub fn to_rgb_hex(&self) -> String { + let (r, g, b) = self.to_rgb(); + format!("#{:02X}{:02X}{:02X}", r, g, b) + } + + /// 转换为RGB值 + pub fn to_rgb(&self) -> (u8, u8, u8) { + let c = self.value * self.saturation; + let x = c * (1.0 - ((self.hue * 6.0) % 2.0 - 1.0).abs()); + let m = self.value - c; + + let (r_prime, g_prime, b_prime) = match (self.hue * 6.0) as i32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + + let r = ((r_prime + m) * 255.0) as u8; + let g = ((g_prime + m) * 255.0) as u8; + let b = ((b_prime + m) * 255.0) as u8; + + (r, g, b) + } + + /// 计算与另一个颜色的距离 + pub fn distance(&self, other: &ColorHSV) -> f64 { + // 色相环形距离计算 + let hue_diff = (self.hue - other.hue).abs(); + let hue_distance = hue_diff.min(1.0 - hue_diff); + + // 饱和度和明度线性距离 + let sat_distance = (self.saturation - other.saturation).abs(); + let val_distance = (self.value - other.value).abs(); + + // 加权距离计算:色相50%,饱和度30%,明度20% + hue_distance * 0.5 + sat_distance * 0.3 + val_distance * 0.2 + } + + /// 计算颜色相似度 (0-1,1表示完全相同) + pub fn similarity(&self, other: &ColorHSV) -> f64 { + 1.0 - self.distance(other) + } + + /// 判断颜色是否在指定阈值范围内匹配 + pub fn matches(&self, other: &ColorHSV, hue_threshold: f64, sat_threshold: f64, val_threshold: f64) -> bool { + let hue_diff = (self.hue - other.hue).abs().min(1.0 - (self.hue - other.hue).abs()); + let sat_diff = (self.saturation - other.saturation).abs(); + let val_diff = (self.value - other.value).abs(); + + hue_diff <= hue_threshold && sat_diff <= sat_threshold && val_diff <= val_threshold + } +} + +/// 服装产品分析结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductAnalysis { + /// 服装类别 + pub category: String, + /// 服装描述 + pub description: String, + /// 主要颜色 + pub color_pattern: ColorHSV, + /// 设计风格标签 + pub design_styles: Vec, + /// 与整体搭配的颜色匹配度 (0-1) + pub color_pattern_match_dress: f64, + /// 与环境的颜色匹配度 (0-1) + pub color_pattern_match_environment: f64, +} + +/// Gemini AI分析结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitAnalysisResult { + /// 环境标签 + pub environment_tags: Vec, + /// 环境主色调 + pub environment_color_pattern: ColorHSV, + /// 整体搭配主色调 + pub dress_color_pattern: ColorHSV, + /// 风格描述 + pub style_description: String, + /// 识别的服装产品列表 + pub products: Vec, +} + +/// 图像分析请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnalyzeImageRequest { + /// 图像文件路径 + pub image_path: String, + /// 图像文件名 + pub image_name: String, +} + +/// 图像分析响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnalyzeImageResponse { + /// 分析结果(JSON格式) + pub result: serde_json::Value, + /// 分析耗时(毫秒) + pub analysis_time_ms: u64, + /// 分析时间戳 + pub analyzed_at: DateTime, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_color_hsv_creation() { + let color = ColorHSV::new(0.5, 0.8, 0.9); + assert_eq!(color.hue, 0.5); + assert_eq!(color.saturation, 0.8); + assert_eq!(color.value, 0.9); + } + + #[test] + fn test_color_hsv_clamping() { + // 测试值被正确限制在0-1范围内 + let color = ColorHSV::new(1.5, -0.2, 2.0); + assert_eq!(color.hue, 1.0); + assert_eq!(color.saturation, 0.0); + assert_eq!(color.value, 1.0); + } + + #[test] + fn test_color_hsv_from_rgb_hex() { + // 测试红色 + let red = ColorHSV::from_rgb_hex("#FF0000").unwrap(); + assert!((red.hue - 0.0).abs() < 0.01); + assert!((red.saturation - 1.0).abs() < 0.01); + assert!((red.value - 1.0).abs() < 0.01); + + // 测试绿色 + let green = ColorHSV::from_rgb_hex("#00FF00").unwrap(); + assert!((green.hue - 0.333).abs() < 0.01); + assert!((green.saturation - 1.0).abs() < 0.01); + assert!((green.value - 1.0).abs() < 0.01); + } + + #[test] + fn test_color_hsv_from_rgb_hex_invalid() { + // 测试无效的十六进制格式 + assert!(ColorHSV::from_rgb_hex("#FF").is_err()); + assert!(ColorHSV::from_rgb_hex("#GGGGGG").is_err()); + } + + #[test] + fn test_color_hsv_to_rgb_hex() { + let color = ColorHSV::new(0.0, 1.0, 1.0); // 纯红色 + assert_eq!(color.to_rgb_hex(), "#FF0000"); + + let white = ColorHSV::new(0.0, 0.0, 1.0); + assert_eq!(white.to_rgb_hex(), "#FFFFFF"); + + let black = ColorHSV::new(0.0, 0.0, 0.0); + assert_eq!(black.to_rgb_hex(), "#000000"); + } + + #[test] + fn test_color_distance() { + // 测试相同颜色的距离 + let color1 = ColorHSV::new(0.5, 0.8, 0.9); + let color2 = ColorHSV::new(0.5, 0.8, 0.9); + assert_eq!(color1.distance(&color2), 0.0); + + // 测试不同颜色的距离 + let red = ColorHSV::new(0.0, 1.0, 1.0); + let blue = ColorHSV::new(0.67, 1.0, 1.0); + let distance = red.distance(&blue); + assert!(distance > 0.0); + assert!(distance <= 1.0); + } + + #[test] + fn test_color_similarity() { + let color1 = ColorHSV::new(0.5, 0.8, 0.9); + let color2 = ColorHSV::new(0.5, 0.8, 0.9); + assert_eq!(color1.similarity(&color2), 1.0); + } + + #[test] + fn test_color_matches() { + let base_color = ColorHSV::new(0.5, 0.8, 0.9); + let similar_color = ColorHSV::new(0.51, 0.81, 0.91); + assert!(base_color.matches(&similar_color, 0.05, 0.05, 0.05)); + + let different_color = ColorHSV::new(0.6, 0.9, 1.0); + assert!(!base_color.matches(&different_color, 0.05, 0.05, 0.05)); + } +} diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index 8be0d13..ed6aa4c 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -10,3 +10,5 @@ pub mod project_template_binding; pub mod template_matching_result; pub mod export_record; pub mod video_generation; +pub mod outfit_search; +pub mod gemini_analysis; diff --git a/apps/desktop/src-tauri/src/data/models/outfit_search.rs b/apps/desktop/src-tauri/src/data/models/outfit_search.rs new file mode 100644 index 0000000..babdabb --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/outfit_search.rs @@ -0,0 +1,510 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use chrono::{DateTime, Utc}; +use crate::data::models::gemini_analysis::ColorHSV; + +/// 搜索相关性阈值 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum RelevanceThreshold { + #[serde(rename = "LOWEST")] + Lowest, + #[serde(rename = "LOW")] + Low, + #[serde(rename = "MEDIUM")] + Medium, + #[serde(rename = "HIGH")] + High, +} + +impl RelevanceThreshold { + /// 获取阈值对应的数值 + pub fn to_value(&self) -> f64 { + match self { + RelevanceThreshold::Lowest => 0.3, + RelevanceThreshold::Low => 0.5, + RelevanceThreshold::Medium => 0.7, + RelevanceThreshold::High => 0.9, + } + } +} + +impl Default for RelevanceThreshold { + fn default() -> Self { + RelevanceThreshold::High + } +} + +/// 颜色过滤器 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ColorFilter { + /// 是否启用颜色过滤 + pub enabled: bool, + /// 目标颜色 + pub color: ColorHSV, + /// 色相阈值 + pub hue_threshold: f64, + /// 饱和度阈值 + pub saturation_threshold: f64, + /// 明度阈值 + pub value_threshold: f64, +} + +impl Default for ColorFilter { + fn default() -> Self { + Self { + enabled: false, + color: ColorHSV::new(0.0, 0.0, 0.0), + hue_threshold: 0.05, + saturation_threshold: 0.05, + value_threshold: 0.20, + } + } +} + +/// 搜索配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchConfig { + /// 相关性阈值 + pub relevance_threshold: RelevanceThreshold, + /// 环境标签过滤 + pub environments: Vec, + /// 类别过滤 + pub categories: Vec, + /// 颜色过滤器(按类别) + pub color_filters: HashMap, + /// 设计风格过滤(按类别) + pub design_styles: HashMap>, + /// 最大关键词数量 + pub max_keywords: usize, +} + +impl Default for SearchConfig { + fn default() -> Self { + Self { + relevance_threshold: RelevanceThreshold::default(), + environments: Vec::new(), + categories: Vec::new(), + color_filters: HashMap::new(), + design_styles: HashMap::new(), + max_keywords: 10, + } + } +} + +/// 搜索请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchRequest { + /// 搜索查询字符串 + pub query: String, + /// 搜索配置 + pub config: SearchConfig, + /// 页面大小 + pub page_size: usize, + /// 页面偏移量 + pub page_offset: usize, +} + +impl Default for SearchRequest { + fn default() -> Self { + Self { + query: "model".to_string(), + config: SearchConfig::default(), + page_size: 9, + page_offset: 0, + } + } +} + +/// 搜索结果中的产品信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductInfo { + /// 产品类别 + pub category: String, + /// 产品描述 + pub description: String, + /// 主要颜色 + pub color_pattern: ColorHSV, + /// 设计风格 + pub design_styles: Vec, +} + +/// 单个搜索结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { + /// 结果ID + pub id: String, + /// 图片URL + pub image_url: String, + /// 风格描述 + pub style_description: String, + /// 环境标签 + pub environment_tags: Vec, + /// 产品信息列表 + pub products: Vec, + /// 相关性评分 + pub relevance_score: f64, +} + +/// 搜索响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResponse { + /// 搜索结果列表 + pub results: Vec, + /// 总结果数量 + pub total_size: usize, + /// 下一页令牌 + pub next_page_token: Option, + /// 搜索耗时(毫秒) + pub search_time_ms: u64, + /// 搜索时间戳 + pub searched_at: DateTime, +} + +/// 搜索历史记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchHistory { + /// 历史记录ID + pub id: String, + /// 搜索查询 + pub query: String, + /// 搜索配置 + pub config: SearchConfig, + /// 结果数量 + pub results_count: usize, + /// 搜索耗时(毫秒) + pub search_time_ms: u64, + /// 搜索时间 + pub created_at: DateTime, +} + +/// LLM问答请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LLMQueryRequest { + /// 用户输入的情景描述 + pub user_input: String, + /// 会话ID(可选) + pub session_id: Option, +} + +/// LLM问答响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LLMQueryResponse { + /// LLM回答内容 + pub answer: String, + /// 相关的搜索结果 + pub related_results: Vec, + /// 响应时间(毫秒) + pub response_time_ms: u64, + /// 响应时间戳 + pub responded_at: DateTime, +} + +/// 全局配置 +#[derive(Debug, Clone)] +pub struct OutfitSearchGlobalConfig { + /// Google Cloud项目ID + pub google_project_id: String, + /// Vertex AI应用ID + pub vertex_ai_app_id: String, + /// 存储桶名称 + pub storage_bucket_name: String, + /// 数据存储ID + pub data_store_id: String, + /// Cloudflare项目ID + pub cloudflare_project_id: String, + /// Cloudflare网关ID + pub cloudflare_gateway_id: String, +} + +impl Default for OutfitSearchGlobalConfig { + fn default() -> Self { + Self { + google_project_id: "gen-lang-client-0413414134".to_string(), + vertex_ai_app_id: "jeans-search_1751353769585".to_string(), + storage_bucket_name: "fashion_image_block".to_string(), + data_store_id: "jeans_pattern_data_store".to_string(), + cloudflare_project_id: "67720b647ff2b55cf37ba3ef9e677083".to_string(), + cloudflare_gateway_id: "bowong-dev".to_string(), + } + } +} + +/// 搜索过滤器构建器 +pub struct SearchFilterBuilder; + +impl SearchFilterBuilder { + /// 构建搜索过滤器字符串 + pub fn build_filters(config: &SearchConfig) -> String { + let mut filters = Vec::new(); + + // 类别过滤 + if !config.categories.is_empty() { + for category in &config.categories { + let mut inner_filters = vec![ + format!("products.category: ANY(\"{}\")", category) + ]; + + // 颜色过滤 + if let Some(color_filter) = config.color_filters.get(category) { + if color_filter.enabled { + inner_filters.extend(Self::build_color_filters(color_filter)); + } + } + + // 设计风格过滤 + if let Some(styles) = config.design_styles.get(category) { + if !styles.is_empty() { + let styles_str = styles.iter() + .map(|s| format!("\"{}\"", s)) + .collect::>() + .join(","); + inner_filters.push(format!("products.design_styles: ANY({})", styles_str)); + } + } + + filters.push(format!("({})", inner_filters.join(" AND "))); + } + } + + // 环境标签过滤 + if !config.environments.is_empty() { + let env_str = config.environments.iter() + .map(|e| format!("\"{}\"", e)) + .collect::>() + .join(","); + filters.push(format!("environment_tags: ANY({})", env_str)); + } + + filters.join(" AND ") + } + + /// 构建颜色过滤器 + fn build_color_filters(color_filter: &ColorFilter) -> Vec { + let hsv = &color_filter.color; + vec![ + format!( + "products.color_pattern.Hue: IN({}, {})", + (hsv.hue - color_filter.hue_threshold).max(0.0), + (hsv.hue + color_filter.hue_threshold).min(1.0) + ), + format!( + "products.color_pattern.Saturation: IN({}, {})", + (hsv.saturation - color_filter.saturation_threshold).max(0.0), + (hsv.saturation + color_filter.saturation_threshold).min(1.0) + ), + format!( + "products.color_pattern.Value: IN({}, {})", + (hsv.value - color_filter.value_threshold).max(0.0), + (hsv.value + color_filter.value_threshold).min(1.0) + ), + ] + } + + /// 构建查询关键词 + pub fn build_query_keywords(config: &SearchConfig) -> Vec { + let mut keywords = Vec::new(); + + // 添加设计风格关键词 + for styles in config.design_styles.values() { + keywords.extend(styles.clone()); + } + + // 添加环境关键词 + keywords.extend(config.environments.clone()); + + // 限制关键词数量 + if keywords.len() > config.max_keywords { + keywords.truncate(config.max_keywords); + } + + keywords + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::models::gemini_analysis::ColorHSV; + + #[test] + fn test_relevance_threshold_values() { + assert_eq!(RelevanceThreshold::Lowest.to_value(), 0.3); + assert_eq!(RelevanceThreshold::Low.to_value(), 0.5); + assert_eq!(RelevanceThreshold::Medium.to_value(), 0.7); + assert_eq!(RelevanceThreshold::High.to_value(), 0.9); + } + + #[test] + fn test_color_filter_default() { + let filter = ColorFilter::default(); + assert!(!filter.enabled); + assert_eq!(filter.hue_threshold, 0.05); + assert_eq!(filter.saturation_threshold, 0.05); + assert_eq!(filter.value_threshold, 0.20); + } + + #[test] + fn test_search_config_default() { + let config = SearchConfig::default(); + assert!(matches!(config.relevance_threshold, RelevanceThreshold::High)); + assert!(config.categories.is_empty()); + assert!(config.environments.is_empty()); + assert!(config.color_filters.is_empty()); + assert!(config.design_styles.is_empty()); + assert_eq!(config.max_keywords, 10); + } + + #[test] + fn test_search_request_default() { + let request = SearchRequest::default(); + assert_eq!(request.query, "model"); + assert_eq!(request.page_size, 9); + assert_eq!(request.page_offset, 0); + } + + #[test] + fn test_search_filter_builder_empty_config() { + let config = SearchConfig::default(); + let filters = SearchFilterBuilder::build_filters(&config); + assert!(filters.is_empty()); + } + + #[test] + fn test_search_filter_builder_with_categories() { + let mut config = SearchConfig::default(); + config.categories = vec!["上装".to_string(), "下装".to_string()]; + + let filters = SearchFilterBuilder::build_filters(&config); + assert!(filters.contains("products.category: ANY(\"上装\")")); + assert!(filters.contains("products.category: ANY(\"下装\")")); + } + + #[test] + fn test_search_filter_builder_with_environments() { + let mut config = SearchConfig::default(); + config.environments = vec!["Outdoor".to_string(), "Indoor".to_string()]; + + let filters = SearchFilterBuilder::build_filters(&config); + assert!(filters.contains("environment_tags: ANY(\"Outdoor\",\"Indoor\")")); + } + + #[test] + fn test_search_filter_builder_with_color_filters() { + let mut config = SearchConfig::default(); + config.categories = vec!["上装".to_string()]; + + let color_filter = ColorFilter { + enabled: true, + color: ColorHSV::new(0.5, 0.8, 0.9), + hue_threshold: 0.05, + saturation_threshold: 0.05, + value_threshold: 0.20, + }; + config.color_filters.insert("上装".to_string(), color_filter); + + let filters = SearchFilterBuilder::build_filters(&config); + assert!(filters.contains("products.color_pattern.Hue: IN(")); + assert!(filters.contains("products.color_pattern.Saturation: IN(")); + assert!(filters.contains("products.color_pattern.Value: IN(")); + } + + #[test] + fn test_search_filter_builder_with_design_styles() { + let mut config = SearchConfig::default(); + config.categories = vec!["上装".to_string()]; + config.design_styles.insert("上装".to_string(), vec!["休闲".to_string(), "正式".to_string()]); + + let filters = SearchFilterBuilder::build_filters(&config); + assert!(filters.contains("products.design_styles: ANY(\"休闲\",\"正式\")")); + } + + #[test] + fn test_query_keywords_builder() { + let mut config = SearchConfig::default(); + config.design_styles.insert("上装".to_string(), vec!["休闲".to_string(), "正式".to_string()]); + config.environments = vec!["Outdoor".to_string()]; + + let keywords = SearchFilterBuilder::build_query_keywords(&config); + assert!(keywords.contains(&"休闲".to_string())); + assert!(keywords.contains(&"正式".to_string())); + assert!(keywords.contains(&"Outdoor".to_string())); + } + + #[test] + fn test_query_keywords_builder_max_limit() { + let mut config = SearchConfig::default(); + config.max_keywords = 3; + + // 添加超过限制的关键词 + config.design_styles.insert("上装".to_string(), vec!["休闲".to_string(), "正式".to_string()]); + config.design_styles.insert("下装".to_string(), vec!["运动".to_string(), "街头".to_string()]); + config.environments = vec!["Outdoor".to_string(), "Indoor".to_string()]; + + let keywords = SearchFilterBuilder::build_query_keywords(&config); + assert!(keywords.len() <= 3); + } + + #[test] + fn test_product_info_creation() { + let color = ColorHSV::new(0.6, 0.5, 0.7); + let product = ProductInfo { + category: "牛仔裤".to_string(), + description: "蓝色牛仔裤".to_string(), + color_pattern: color.clone(), + design_styles: vec!["休闲".to_string()], + }; + + assert_eq!(product.category, "牛仔裤"); + assert_eq!(product.description, "蓝色牛仔裤"); + assert_eq!(product.color_pattern, color); + assert_eq!(product.design_styles.len(), 1); + } + + #[test] + fn test_search_result_creation() { + let color = ColorHSV::new(0.6, 0.5, 0.7); + let product = ProductInfo { + category: "上装".to_string(), + description: "白色衬衫".to_string(), + color_pattern: color, + design_styles: vec!["正式".to_string()], + }; + + let result = SearchResult { + id: "test-id".to_string(), + image_url: "https://example.com/image.jpg".to_string(), + style_description: "商务风格".to_string(), + environment_tags: vec!["Office".to_string()], + products: vec![product], + relevance_score: 0.85, + }; + + assert_eq!(result.id, "test-id"); + assert_eq!(result.style_description, "商务风格"); + assert_eq!(result.relevance_score, 0.85); + assert_eq!(result.products.len(), 1); + assert_eq!(result.environment_tags.len(), 1); + } + + #[test] + fn test_llm_query_request() { + let request = LLMQueryRequest { + user_input: "如何搭配牛仔裤?".to_string(), + session_id: Some("session-123".to_string()), + }; + + assert_eq!(request.user_input, "如何搭配牛仔裤?"); + assert_eq!(request.session_id, Some("session-123".to_string())); + } + + #[test] + fn test_outfit_search_global_config_default() { + let config = OutfitSearchGlobalConfig::default(); + assert_eq!(config.google_project_id, "gen-lang-client-0413414134"); + assert_eq!(config.vertex_ai_app_id, "jeans-search_1751353769585"); + assert_eq!(config.storage_bucket_name, "fashion_image_block"); + assert_eq!(config.data_store_id, "jeans_pattern_data_store"); + assert_eq!(config.cloudflare_project_id, "67720b647ff2b55cf37ba3ef9e677083"); + assert_eq!(config.cloudflare_gateway_id, "bowong-dev"); + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/database.rs b/apps/desktop/src-tauri/src/infrastructure/database.rs index 09aa9e9..2a88bc1 100644 --- a/apps/desktop/src-tauri/src/infrastructure/database.rs +++ b/apps/desktop/src-tauri/src/infrastructure/database.rs @@ -808,6 +808,19 @@ impl Database { [], )?; + // 创建服装搭配搜索历史表 + conn.execute( + "CREATE TABLE IF NOT EXISTS outfit_search_history ( + id TEXT PRIMARY KEY, + query_text TEXT, + search_config TEXT NOT NULL, + results_count INTEGER NOT NULL, + search_time_ms INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )", + [], + )?; + // 创建索引 conn.execute( "CREATE INDEX IF NOT EXISTS idx_projects_name ON projects (name)", @@ -950,6 +963,17 @@ impl Database { "CREATE INDEX IF NOT EXISTS idx_export_records_created_at ON export_records (created_at)", [], )?; + + // 创建服装搭配搜索历史表索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_search_history_created_at ON outfit_search_history (created_at DESC)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_search_history_query_text ON outfit_search_history (query_text)", + [], + )?; // 添加新字段(如果不存在)- 数据库迁移 let _ = conn.execute( "ALTER TABLE template_materials ADD COLUMN file_exists BOOLEAN DEFAULT FALSE", diff --git a/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs b/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs index 60b917e..b677bab 100644 --- a/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs +++ b/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow}; use serde::{Deserialize, Serialize}; +use base64::prelude::*; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; @@ -502,3 +503,243 @@ mod tests { assert_eq!(service.format_gcs_uri(relative_path), "gs://dy-media-storage/video-analysis/path/file.mp4"); } } + +// 服装搭配分析扩展 +impl GeminiService { + /// 分析服装图像并返回结构化结果 + pub async fn analyze_outfit_image(&mut self, image_path: &str) -> Result { + // 读取图像文件 + let image_data = fs::read(image_path).await + .map_err(|e| anyhow!("Failed to read image file: {} - {}", image_path, e))?; + + // 转换为base64 + let image_base64 = BASE64_STANDARD.encode(&image_data); + + // 构建服装分析提示词 + let prompt = self.build_outfit_analysis_prompt(); + + // 调用图像分析 + let raw_response = self.analyze_image_with_prompt(&image_base64, &prompt).await?; + + // 添加调试信息 + println!("🔍 Gemini原始响应: {}", raw_response); + + // 尝试提取JSON部分 + self.extract_json_from_response(&raw_response) + } + + /// 构建服装分析提示词 + fn build_outfit_analysis_prompt(&self) -> String { + r#"请分析这张服装图片,并以JSON格式返回以下信息: + +{ + "environment_tags": ["环境标签1", "环境标签2"], + "environment_color_pattern": { + "hue": 0.5, + "saturation": 0.3, + "value": 0.8 + }, + "dress_color_pattern": { + "hue": 0.6, + "saturation": 0.4, + "value": 0.9 + }, + "style_description": "整体风格描述", + "products": [ + { + "category": "服装类别", + "description": "服装描述", + "color_pattern": { + "hue": 0.6, + "saturation": 0.5, + "value": 0.7 + }, + "design_styles": ["设计风格1", "设计风格2"], + "color_pattern_match_dress": 0.8, + "color_pattern_match_environment": 0.7 + } + ] +} + +分析要求: +1. environment_tags: 识别图片中的环境场景,如"Outdoor", "Indoor", "City street", "Office"等 +2. environment_color_pattern: 环境的主要颜色,用HSV值表示(0-1范围) +3. dress_color_pattern: 整体服装搭配的主要颜色 +4. style_description: 用中文描述整体的搭配风格 +5. products: 识别出的各个服装单品 + - category: 服装类别,如"上装", "下装", "鞋子", "配饰"等 + - description: 具体描述这件服装 + - color_pattern: 该单品的主要颜色 + - design_styles: 设计风格,如"休闲", "正式", "运动", "街头"等 + - color_pattern_match_dress: 与整体搭配颜色的匹配度(0-1) + - color_pattern_match_environment: 与环境颜色的匹配度(0-1) + +请确保返回的是有效的JSON格式。"#.to_string() + } + + /// LLM问答功能 + pub async fn ask_outfit_advice(&mut self, user_input: &str) -> Result { + // 构建服装搭配顾问提示词 + let prompt = format!( + r#"你是一位专业的服装搭配顾问,请根据用户的问题提供专业的搭配建议。 + +用户问题:{} + +请提供: +1. 具体的搭配建议 +2. 颜色搭配原理 +3. 适合的场合 +4. 搭配技巧 + +请用友好、专业的语气回答,并提供实用的建议。"#, + user_input + ); + + // 使用文本生成功能 + self.generate_text_content(&prompt).await + } + + /// 生成文本内容(用于LLM问答) + async fn generate_text_content(&mut self, prompt: &str) -> Result { + // 获取访问令牌 + let access_token = self.get_access_token().await?; + + // 创建客户端配置 + let client_config = self.create_gemini_client(&access_token); + + // 准备请求数据 + let request_data = GenerateContentRequest { + contents: vec![ContentPart { + role: "user".to_string(), + parts: vec![Part::Text { text: prompt.to_string() }], + }], + generation_config: GenerationConfig { + temperature: 0.7, // 稍高的温度以获得更有创意的回答 + top_k: 32, + top_p: 1.0, + max_output_tokens: self.config.max_tokens, + }, + }; + + // 发送请求 + let generate_url = format!("{}/{}:generateContent", client_config.gateway_url, self.config.model_name); + + // 重试机制 + for attempt in 0..self.config.max_retries { + match self.send_generate_request(&generate_url, &client_config, &request_data).await { + Ok(result) => { + let content = self.parse_gemini_response_content(&result)?; + return Ok(content); + } + Err(e) => { + if attempt == self.config.max_retries - 1 { + return Err(e); + } + tokio::time::sleep(tokio::time::Duration::from_secs(self.config.retry_delay)).await; + } + } + } + + Err(anyhow!("All retry attempts failed")) + } + + /// 从Gemini响应中提取JSON内容 + fn extract_json_from_response(&self, response: &str) -> Result { + println!("🔍 开始提取JSON,响应长度: {}", response.len()); + + // 尝试直接解析为JSON + if let Ok(_) = serde_json::from_str::(response) { + println!("✅ 直接解析JSON成功"); + return Ok(response.to_string()); + } + + // 查找JSON代码块 ```json ... ``` + if let Some(start) = response.find("```json") { + println!("🔍 找到```json标记,位置: {}", start); + let json_start = start + 7; // "```json".len() + + // 从json_start位置开始查找结束的``` + if let Some(end_offset) = response[json_start..].find("```") { + let json_end = json_start + end_offset; + let json_content = response[json_start..json_end].trim(); + + println!("🔍 提取的JSON内容长度: {}", json_content.len()); + println!("🔍 JSON内容预览: {}", &json_content[..json_content.len().min(100)]); + + // 验证提取的JSON + match serde_json::from_str::(json_content) { + Ok(_) => { + println!("✅ 从代码块提取JSON成功"); + return Ok(json_content.to_string()); + } + Err(e) => { + println!("❌ JSON解析失败: {}", e); + } + } + } else { + println!("⚠️ 未找到结束的```标记"); + } + } + + // 查找普通的JSON对象 { ... } + if let Some(start) = response.find('{') { + println!("🔍 找到JSON对象开始位置: {}", start); + + // 使用更智能的方法找到匹配的结束括号 + let mut brace_count = 0; + let mut end_pos = None; + + for (i, ch) in response[start..].char_indices() { + match ch { + '{' => brace_count += 1, + '}' => { + brace_count -= 1; + if brace_count == 0 { + end_pos = Some(start + i); + break; + } + } + _ => {} + } + } + + if let Some(end) = end_pos { + let json_content = &response[start..=end]; + println!("🔍 提取的JSON对象长度: {}", json_content.len()); + + // 验证提取的JSON + match serde_json::from_str::(json_content) { + Ok(_) => { + println!("✅ 从对象提取JSON成功"); + return Ok(json_content.to_string()); + } + Err(e) => { + println!("❌ JSON对象解析失败: {}", e); + } + } + } else { + println!("⚠️ 未找到匹配的结束括号"); + } + } + + // 如果无法提取有效JSON,返回一个默认的结构 + println!("⚠️ 无法从响应中提取有效JSON,使用默认结构"); + let default_response = serde_json::json!({ + "environment_tags": ["Unknown"], + "environment_color_pattern": { + "hue": 0.0, + "saturation": 0.0, + "value": 0.5 + }, + "dress_color_pattern": { + "hue": 0.0, + "saturation": 0.0, + "value": 0.5 + }, + "style_description": response.chars().take(200).collect::(), + "products": [] + }); + + Ok(default_response.to_string()) + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9c40492..97da2dd 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -255,7 +255,17 @@ pub fn run() { commands::debug_commands::test_parse_draft_file, commands::debug_commands::validate_template_structure, // 便捷工具命令 - commands::tools_commands::clean_jsonl_data + commands::tools_commands::clean_jsonl_data, + // 服装搭配搜索命令 + commands::outfit_search_commands::analyze_outfit_image, + commands::outfit_search_commands::search_similar_outfits, + commands::outfit_search_commands::ask_llm_outfit_advice, + commands::outfit_search_commands::get_outfit_search_suggestions, + commands::outfit_search_commands::generate_search_config_from_analysis, + commands::outfit_search_commands::validate_outfit_image, + commands::outfit_search_commands::get_supported_image_formats, + commands::outfit_search_commands::get_default_search_config, + commands::outfit_search_commands::get_outfit_search_config ]) .setup(|app| { // 初始化日志系统 diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index 67618b5..065a2da 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -17,3 +17,4 @@ pub mod template_matching_result_commands; pub mod export_record_commands; pub mod video_generation_commands; pub mod tools_commands; +pub mod outfit_search_commands; diff --git a/apps/desktop/src-tauri/src/presentation/commands/outfit_search_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/outfit_search_commands.rs new file mode 100644 index 0000000..0c825f2 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/outfit_search_commands.rs @@ -0,0 +1,284 @@ +use tauri::State; +use anyhow::Result; + +use crate::app_state::AppState; +use crate::data::models::gemini_analysis::{AnalyzeImageRequest, AnalyzeImageResponse}; +use crate::data::models::outfit_search::{ + SearchRequest, SearchResponse, LLMQueryRequest, LLMQueryResponse, + OutfitSearchGlobalConfig +}; +use crate::infrastructure::gemini_service::{GeminiService, GeminiConfig}; + +/// 分析服装图像 +/// 遵循 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)); + + // 执行图像分析 + 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 { + // TODO: 实现真实的搜索逻辑 + + // 执行搜索(暂时返回模拟数据) + // TODO: 实现真实的搜索逻辑 + Ok(SearchResponse { + results: vec![], + total_size: 0, + next_page_token: None, + search_time_ms: 100, + 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)); + + // 执行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::{SearchConfig, RelevanceThreshold}; + 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 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")); + } +} + +/// 获取所有服装搜索相关的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", + ] +}