feat: 实现服装搭配智能搜索系统
新功能: - 完整的服装搭配智能搜索系统 - AI图像分析和服装识别 - 智能搜索和过滤功能 - LLM搭配顾问聊天功能 - HSV颜色匹配算法 - 响应式UI界面 技术实现: - 统一的GeminiService架构 - 完整的数据模型和类型定义 - Tauri命令接口层 - React前端组件库 - Zustand状态管理 - 数据库扩展支持 UI/UX: - 现代化的搭配搜索界面 - 直观的颜色选择器 - 多级筛选面板 - 图像上传和分析 - 搜索结果展示 - AI聊天界面 测试: - 核心功能单元测试 - 颜色匹配算法测试 - API集成测试 文档: - 完整的系统设计文档 - API接口文档 - 开发指南
This commit is contained in:
@@ -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"
|
||||
|
||||
261
apps/desktop/src-tauri/src/data/models/gemini_analysis.rs
Normal file
261
apps/desktop/src-tauri/src/data/models/gemini_analysis.rs
Normal file
@@ -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<Self, String> {
|
||||
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<String>,
|
||||
/// 与整体搭配的颜色匹配度 (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<String>,
|
||||
/// 环境主色调
|
||||
pub environment_color_pattern: ColorHSV,
|
||||
/// 整体搭配主色调
|
||||
pub dress_color_pattern: ColorHSV,
|
||||
/// 风格描述
|
||||
pub style_description: String,
|
||||
/// 识别的服装产品列表
|
||||
pub products: Vec<ProductAnalysis>,
|
||||
}
|
||||
|
||||
/// 图像分析请求
|
||||
#[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<Utc>,
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
510
apps/desktop/src-tauri/src/data/models/outfit_search.rs
Normal file
510
apps/desktop/src-tauri/src/data/models/outfit_search.rs
Normal file
@@ -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<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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<String> {
|
||||
// 读取图像文件
|
||||
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<String> {
|
||||
// 构建服装搭配顾问提示词
|
||||
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<String> {
|
||||
// 获取访问令牌
|
||||
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<String> {
|
||||
println!("🔍 开始提取JSON,响应长度: {}", response.len());
|
||||
|
||||
// 尝试直接解析为JSON
|
||||
if let Ok(_) = serde_json::from_str::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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::<String>(),
|
||||
"products": []
|
||||
});
|
||||
|
||||
Ok(default_response.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AnalyzeImageResponse, String> {
|
||||
// 创建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<SearchResponse, String> {
|
||||
// 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<LLMQueryResponse, String> {
|
||||
// 创建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<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::{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<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 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"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有服装搜索相关的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",
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user