Files
mixvideo-v2/apps/desktop/src-tauri/src/data/models/outfit_search.rs
imeepos 504b1a6577 feat: 实现服装搭配智能搜索系统
新功能:
- 完整的服装搭配智能搜索系统
- AI图像分析和服装识别
- 智能搜索和过滤功能
- LLM搭配顾问聊天功能
- HSV颜色匹配算法
- 响应式UI界面

 技术实现:
- 统一的GeminiService架构
- 完整的数据模型和类型定义
- Tauri命令接口层
- React前端组件库
- Zustand状态管理
- 数据库扩展支持

 UI/UX:
- 现代化的搭配搜索界面
- 直观的颜色选择器
- 多级筛选面板
- 图像上传和分析
- 搜索结果展示
- AI聊天界面

 测试:
- 核心功能单元测试
- 颜色匹配算法测试
- API集成测试

 文档:
- 完整的系统设计文档
- API接口文档
- 开发指南
2025-07-17 22:35:36 +08:00

511 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
/// 类别过滤
pub categories: Vec<String>,
/// 颜色过滤器(按类别)
pub color_filters: HashMap<String, ColorFilter>,
/// 设计风格过滤(按类别)
pub design_styles: HashMap<String, Vec<String>>,
/// 最大关键词数量
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<String>,
}
/// 单个搜索结果
#[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<String>,
/// 产品信息列表
pub products: Vec<ProductInfo>,
/// 相关性评分
pub relevance_score: f64,
}
/// 搜索响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResponse {
/// 搜索结果列表
pub results: Vec<SearchResult>,
/// 总结果数量
pub total_size: usize,
/// 下一页令牌
pub next_page_token: Option<String>,
/// 搜索耗时(毫秒)
pub search_time_ms: u64,
/// 搜索时间戳
pub searched_at: DateTime<Utc>,
}
/// 搜索历史记录
#[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<Utc>,
}
/// LLM问答请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LLMQueryRequest {
/// 用户输入的情景描述
pub user_input: String,
/// 会话ID可选
pub session_id: Option<String>,
}
/// LLM问答响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LLMQueryResponse {
/// LLM回答内容
pub answer: String,
/// 相关的搜索结果
pub related_results: Vec<SearchResult>,
/// 响应时间(毫秒)
pub response_time_ms: u64,
/// 响应时间戳
pub responded_at: DateTime<Utc>,
}
/// 全局配置
#[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::<Vec<_>>()
.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::<Vec<_>>()
.join(",");
filters.push(format!("environment_tags: ANY({})", env_str));
}
filters.join(" AND ")
}
/// 构建颜色过滤器
fn build_color_filters(color_filter: &ColorFilter) -> Vec<String> {
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<String> {
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");
}
}