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 index 2bbcb99..a0381a1 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/outfit_search_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/outfit_search_commands.rs @@ -326,6 +326,70 @@ mod tests { let keywords = SearchFilterBuilder::build_query_keywords(&config); assert!(keywords.contains(&"Outdoor".to_string())); } + + #[test] + fn test_convert_s3_to_cdn_url() { + // 测试 s3://ap-northeast-2/modal-media-cache/ 转换 + let s3_url = "s3://ap-northeast-2/modal-media-cache/image.jpg"; + let expected = "https://cdn.roasmax.cn/image.jpg"; + assert_eq!(convert_s3_to_cdn_url(s3_url), expected); + + // 测试 gs://fashion_image_block/ 转换 + let gs_url = "gs://fashion_image_block/image.jpg"; + let expected = "https://storage.googleapis.com/fashion_image_block/image.jpg"; + assert_eq!(convert_s3_to_cdn_url(gs_url), expected); + + // 测试其他 gs:// 转换 + let gs_url = "gs://other-bucket/image.jpg"; + let expected = "https://storage.googleapis.com/other-bucket/image.jpg"; + assert_eq!(convert_s3_to_cdn_url(gs_url), expected); + + // 测试其他 s3:// 转换 + let s3_url = "s3://other-bucket/image.jpg"; + let expected = "https://cdn.roasmax.cn/other-bucket/image.jpg"; + assert_eq!(convert_s3_to_cdn_url(s3_url), expected); + + // 测试普通HTTP URL(不转换) + let http_url = "https://example.com/image.jpg"; + assert_eq!(convert_s3_to_cdn_url(http_url), http_url); + + // 测试空字符串 + assert_eq!(convert_s3_to_cdn_url(""), ""); + } +} + +/// 将S3/GS URL转换为CDN URL +fn convert_s3_to_cdn_url(s3_url: &str) -> String { + if s3_url.starts_with("s3://ap-northeast-2/modal-media-cache/") { + // 将 s3://ap-northeast-2/modal-media-cache/ 替换为 https://cdn.roasmax.cn/ + s3_url.replace("s3://ap-northeast-2/modal-media-cache/", "https://cdn.roasmax.cn/") + } else if s3_url.starts_with("gs://fashion_image_block/") { + // 将 gs://fashion_image_block/ 替换为 https://cdn.roasmax.cn/fashion_image_block/ + s3_url.replace("gs://", "https://storage.googleapis.com/") + } else if s3_url.starts_with("gs://") { + // 处理其他 gs:// 格式,转换为通用CDN格式 + s3_url.replace("gs://", "https://storage.googleapis.com/") + } else if s3_url.starts_with("s3://") { + // 处理其他 s3:// 格式,转换为通用CDN格式 + s3_url.replace("s3://", "https://cdn.roasmax.cn/") + } else { + // 如果不是预期的S3格式,返回原URL + s3_url.to_string() + } +} + +/// 检查网络连接 +async fn check_network_connectivity() -> Result<(), anyhow::Error> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build()?; + + // 尝试连接到Google DNS + match client.get("https://dns.google").send().await { + Ok(_) => Ok(()), + Err(e) => Err(anyhow::anyhow!("网络连接检查失败: {}。请检查您的网络连接。", e)) + } } /// 执行 Vertex AI Search 搜索 @@ -333,8 +397,13 @@ async fn execute_vertex_ai_search( _gemini_service: &mut GeminiService, request: &SearchRequest, ) -> Result { + // 0. 检查网络连接 + check_network_connectivity().await + .map_err(|e| anyhow::anyhow!("网络连接问题: {}", e))?; + // 1. 获取访问令牌(通过直接调用API) - let access_token = get_google_access_token().await?; + let access_token = get_google_access_token().await + .map_err(|e| anyhow::anyhow!("获取访问令牌失败: {}。请检查网络连接或API配置。", e))?; // 2. 获取全局配置 let global_config = OutfitSearchGlobalConfig::default(); @@ -355,14 +424,15 @@ async fn execute_vertex_ai_search( // 6. 构建请求负载 let mut payload = serde_json::json!({ "query": enhanced_query, - "relevanceThreshold": request.config.relevance_threshold.to_value().to_string(), - "relevanceScoreSpec": { - "returnRelevanceScore": true - }, "pageSize": request.page_size, "offset": request.page_offset }); + // 添加相关性评分规范(但不设置阈值,因为API不支持) + payload["relevanceScoreSpec"] = serde_json::json!({ + "returnRelevanceScore": true + }); + // 7. 添加过滤器(如果有) if !search_filter.is_empty() { payload["filter"] = serde_json::Value::String(search_filter); @@ -375,60 +445,109 @@ async fn execute_vertex_ai_search( global_config.vertex_ai_app_id ); - // 9. 发送HTTP请求 - let client = reqwest::Client::new(); - let response = client - .post(&search_url) - .header("Authorization", format!("Bearer {}", access_token)) - .header("Content-Type", "application/json") - .json(&payload) - .send() - .await?; + // 9. 创建带有超时配置的客户端 + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .connect_timeout(std::time::Duration::from_secs(15)) + .build()?; - let status = response.status(); - let response_text = response.text().await?; + // 10. 发送HTTP请求(带重试机制) + let mut last_error = None; + for attempt in 0..3 { + match client + .post(&search_url) + .header("Authorization", format!("Bearer {}", access_token)) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + { + Ok(response) => { + let status = response.status(); + let response_text = response.text().await?; - if !status.is_success() { - return Err(anyhow::anyhow!("Vertex AI Search 请求失败: {} - {}", status, response_text)); + if !status.is_success() { + return Err(anyhow::anyhow!("Vertex AI Search 请求失败: {} - {}", status, response_text)); + } + + // 11. 解析响应 + let vertex_response: serde_json::Value = serde_json::from_str(&response_text)?; + + // 12. 转换为我们的搜索结果格式 + let search_results = convert_vertex_response_to_search_results(&vertex_response, request)?; + + return Ok(search_results); + } + Err(e) => { + let error_msg = format!("网络请求失败: {}", e); + eprintln!("{}", error_msg); + last_error = Some(anyhow::anyhow!(error_msg)); + + if attempt < 2 { + eprintln!("Vertex AI Search 请求失败,重试中... (尝试 {}/3)", attempt + 1); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } else { + eprintln!("Vertex AI Search 请求最终失败,已重试3次"); + } + } + } } - // 10. 解析响应 - let vertex_response: serde_json::Value = serde_json::from_str(&response_text)?; - - // 11. 转换为我们的搜索结果格式 - let search_results = convert_vertex_response_to_search_results(&vertex_response, request)?; - - Ok(search_results) + Err(anyhow::anyhow!("Vertex AI Search 请求失败,已重试3次: {}", last_error.unwrap())) } /// 获取 Google 访问令牌 async fn get_google_access_token() -> Result { let config = GeminiConfig::default(); - let client = reqwest::Client::new(); + + // 创建带有超时和重试配置的客户端 + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build()?; let url = format!("{}/google/access-token", config.base_url); - let response = client - .get(&url) - .header("Authorization", format!("Bearer {}", config.bearer_token)) - .send() - .await?; + // 重试机制 + let mut last_error = None; + for attempt in 0..3 { + match client + .get(&url) + .header("Authorization", format!("Bearer {}", config.bearer_token)) + .send() + .await + { + Ok(response) => { + let status = response.status(); + if !status.is_success() { + let error_body = response.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("获取访问令牌失败: {} - {}", status, error_body)); + } - let status = response.status(); - if !status.is_success() { - let error_body = response.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("获取访问令牌失败: {} - {}", status, error_body)); + let response_text = response.text().await?; + let token_response: serde_json::Value = serde_json::from_str(&response_text)?; + + let access_token = token_response + .get("access_token") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("访问令牌响应中未找到 access_token 字段"))?; + + return Ok(access_token.to_string()); + } + Err(e) => { + let error_msg = format!("网络连接失败: {}", e); + eprintln!("{}", error_msg); + last_error = Some(anyhow::anyhow!(error_msg)); + + if attempt < 2 { + eprintln!("获取访问令牌失败,重试中... (尝试 {}/3)", attempt + 1); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } + } } - let response_text = response.text().await?; - let token_response: serde_json::Value = serde_json::from_str(&response_text)?; - - let access_token = token_response - .get("access_token") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("访问令牌响应中未找到 access_token 字段"))?; - - Ok(access_token.to_string()) + Err(anyhow::anyhow!("获取访问令牌失败,已重试3次: {}", last_error.unwrap())) } /// 将 Vertex AI Search 响应转换为我们的搜索结果格式 @@ -441,16 +560,29 @@ fn convert_vertex_response_to_search_results( // 解析 Vertex AI Search 响应 if let Some(vertex_results) = vertex_response.get("results").and_then(|v| v.as_array()) { + eprintln!("收到 {} 个原始搜索结果", vertex_results.len()); + for vertex_result in vertex_results { if let Ok(search_result) = parse_vertex_result_to_search_result(vertex_result) { + eprintln!("解析结果: ID={}, 相关性评分={:.2}", search_result.id, search_result.relevance_score); + // 应用相关性阈值过滤 - if search_result.relevance_score >= request.config.relevance_threshold.to_value() { + let threshold = request.config.relevance_threshold.to_value(); + if search_result.relevance_score >= threshold { results.push(search_result); + } else { + eprintln!("结果被过滤: 评分 {:.2} < 阈值 {:.2}", search_result.relevance_score, threshold); } + } else { + eprintln!("解析搜索结果失败"); } } + } else { + eprintln!("响应中没有找到 results 数组"); } + eprintln!("最终返回 {} 个过滤后的结果", results.len()); + let total_size = vertex_response .get("totalSize") .and_then(|v| v.as_u64()) @@ -472,7 +604,6 @@ fn convert_vertex_response_to_search_results( /// 解析单个 Vertex AI Search 结果为我们的搜索结果格式 fn parse_vertex_result_to_search_result(vertex_result: &serde_json::Value) -> Result { - // 获取文档数据 let document = vertex_result .get("document") @@ -520,13 +651,20 @@ fn parse_vertex_result_to_search_result(vertex_result: &serde_json::Value) -> Re .unwrap_or_else(Vec::new); // 获取图片URL(可能在不同的字段中) - let image_url = struct_data + let raw_image_url = struct_data .get("uri") .or_else(|| struct_data.get("image_url")) .or_else(|| struct_data.get("url")) .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + .unwrap_or(""); + + // 转换S3/GS URL为CDN URL + let image_url = convert_s3_to_cdn_url(raw_image_url); + + // 调试:显示URL转换 + if raw_image_url != image_url { + eprintln!("URL转换: {} -> {}", raw_image_url, image_url); + } // 获取相关性评分 let relevance_score = vertex_result diff --git a/apps/desktop/src-tauri/src/presentation/commands/similarity_search_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/similarity_search_commands.rs index aececd5..8e34111 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/similarity_search_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/similarity_search_commands.rs @@ -14,6 +14,8 @@ pub async fn quick_similarity_search( state: State<'_, AppState>, query: String, relevance_threshold: Option, + page_size: Option, + page_offset: Option, ) -> Result { // 构建默认搜索配置 let threshold = match relevance_threshold.as_deref() { @@ -34,12 +36,16 @@ pub async fn quick_similarity_search( }; let request = SearchRequest { - query, + query: query.clone(), config, - page_size: 12, // 工具页面显示更多结果 - page_offset: 0, + page_size: page_size.unwrap_or(12), // 工具页面显示更多结果,支持自定义 + page_offset: page_offset.unwrap_or(0), // 支持分页偏移 }; + // 调试信息 + eprintln!("相似度搜索请求: query='{}', page_size={}, page_offset={}", + query, request.page_size, request.page_offset); + // 复用现有的搜索功能 search_similar_outfits(state, request).await } diff --git a/apps/desktop/src/components/similarity/SimilaritySearchCard.tsx b/apps/desktop/src/components/similarity/SimilaritySearchCard.tsx index e8c33cb..1e418d8 100644 --- a/apps/desktop/src/components/similarity/SimilaritySearchCard.tsx +++ b/apps/desktop/src/components/similarity/SimilaritySearchCard.tsx @@ -124,11 +124,6 @@ export const SimilaritySearchCard: React.FC = ({ }`}> {result.style_description || '未知风格'} - {result.id && ( -

- ID: {result.id} -

- )} {/* 环境标签 */} diff --git a/apps/desktop/src/components/similarity/SimilaritySearchPanel.tsx b/apps/desktop/src/components/similarity/SimilaritySearchPanel.tsx index 54eb3ec..2283042 100644 --- a/apps/desktop/src/components/similarity/SimilaritySearchPanel.tsx +++ b/apps/desktop/src/components/similarity/SimilaritySearchPanel.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useRef, useEffect } from 'react'; import { Search, Sparkles, - Settings, Loader2 } from 'lucide-react'; import { @@ -18,12 +17,10 @@ import SimilaritySearchService from '../../services/similaritySearchService'; export const SimilaritySearchPanel: React.FC = ({ query, selectedThreshold, - config, suggestions, showSuggestions, isSearching, onQueryChange, - onThresholdChange, onSearch, onSuggestionSelect, onSuggestionsToggle, @@ -157,69 +154,7 @@ export const SimilaritySearchPanel: React.FC = ({ - {/* 相关性阈值设置 */} -
-
-
- -
-
-

相关性阈值

-

调整搜索结果的相关性要求

-
-
- {config?.available_thresholds && ( -
- {config.available_thresholds.map((threshold) => ( - - ))} -
- )} -
{/* 搜索提示 */}
@@ -240,7 +175,7 @@ export const SimilaritySearchPanel: React.FC = ({
  • - 调整相关性阈值来控制结果数量 + 使用顶部的相关性阈值来控制结果数量
  • diff --git a/apps/desktop/src/components/similarity/SimilaritySearchResults.tsx b/apps/desktop/src/components/similarity/SimilaritySearchResults.tsx index 5f156c6..d7e9b8d 100644 --- a/apps/desktop/src/components/similarity/SimilaritySearchResults.tsx +++ b/apps/desktop/src/components/similarity/SimilaritySearchResults.tsx @@ -1,12 +1,16 @@ import React, { useCallback } from 'react'; +import { createPortal } from 'react-dom'; import { Grid, Clock, TrendingUp, - ExternalLink + ExternalLink, + ChevronRight, + ChevronLeft, + Loader2 } from 'lucide-react'; import { SimilaritySearchResultsProps } from '../../types/similaritySearch'; -import { SimilaritySearchCard } from './SimilaritySearchCard'; +import { SimilaritySearchCard } from './SimilaritySearchCard' import SimilaritySearchService from '../../services/similaritySearchService'; /** @@ -20,12 +24,54 @@ export const SimilaritySearchResults: React.FC = ( maxResultsPerPage, isLoading, onPageChange, + onPageSizeChange, onResultSelect, }) => { // 计算分页信息 const totalPages = SimilaritySearchService.getTotalPages(totalResults, maxResultsPerPage); const pageInfo = SimilaritySearchService.getPageRangeInfo(currentPage, maxResultsPerPage, totalResults); + // 生成智能分页数字 + const generatePageNumbers = useCallback(() => { + const pages: (number | string)[] = []; + const maxVisible = 7; // 最多显示7个页码按钮 + + if (totalPages <= maxVisible) { + // 总页数少,显示所有页码 + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // 总页数多,智能显示 + pages.push(1); // 始终显示第一页 + + if (currentPage <= 4) { + // 当前页在前面 + for (let i = 2; i <= 5; i++) { + pages.push(i); + } + pages.push('...'); + pages.push(totalPages); + } else if (currentPage >= totalPages - 3) { + // 当前页在后面 + pages.push('...'); + for (let i = totalPages - 4; i <= totalPages; i++) { + pages.push(i); + } + } else { + // 当前页在中间 + pages.push('...'); + for (let i = currentPage - 1; i <= currentPage + 1; i++) { + pages.push(i); + } + pages.push('...'); + pages.push(totalPages); + } + } + + return pages; + }, [currentPage, totalPages]); + // 处理结果选择 const handleResultSelect = useCallback((result: any) => { if (onResultSelect) { @@ -33,6 +79,16 @@ export const SimilaritySearchResults: React.FC = ( } }, [onResultSelect]); + // 处理每页显示数量变化 + const handlePageSizeChange = useCallback((newPageSize: number) => { + if (onPageSizeChange) { + onPageSizeChange(newPageSize); + } + }, [onPageSizeChange]); + + // 每页显示数量选项 + const pageSizeOptions = [6, 12, 24, 48, 96]; + if (isLoading) { @@ -78,7 +134,20 @@ export const SimilaritySearchResults: React.FC = ( } return ( -
    +
    + {/* 加载遮罩 */} + {isLoading && ( +
    +
    + +
    +

    正在搜索...

    +

    请稍候,正在为您查找相似内容

    +
    +
    +
    + )} + {/* 结果统计 */}
    @@ -119,45 +188,94 @@ export const SimilaritySearchResults: React.FC = ( {/* 分页控制 */} {totalPages > 1 && (
    -
    -
    - 第 {currentPage} 页,共 {totalPages} 页 +
    + {/* 分页信息和每页显示数量 */} +
    +
    + 第 {currentPage} 页,共 {totalPages} 页 (总计 {totalResults} 条) +
    + + {onPageSizeChange && ( +
    + 每页显示: + + {isLoading && ( +
    + + 更新中... +
    + )} +
    + )}
    - + + {/* 分页按钮 */}
    - +
    - {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - const page = i + 1; + {generatePageNumbers().map((page, index) => { + if (page === '...') { + return ( + + ... + + ); + } + + const pageNum = page as number; return ( ); })}
    - +
    @@ -178,6 +296,46 @@ export const SimilaritySearchResults: React.FC = (
    + + {/* 悬浮分页按钮组 - 使用 Portal 确保真正固定在屏幕上 */} + {results.length > 0 && totalPages > 1 && createPortal( +
    + {/* 上一页按钮 - 左侧 */} + {currentPage > 1 && ( + + )} + + {/* 下一页按钮 - 右侧 */} + {currentPage < totalPages && ( + + )} +
    , + document.body + )}
    ); }; diff --git a/apps/desktop/src/pages/tools/SimilaritySearchTool.tsx b/apps/desktop/src/pages/tools/SimilaritySearchTool.tsx index 85c93f2..8c8a94c 100644 --- a/apps/desktop/src/pages/tools/SimilaritySearchTool.tsx +++ b/apps/desktop/src/pages/tools/SimilaritySearchTool.tsx @@ -1,21 +1,23 @@ import React, { useEffect, useCallback } from 'react'; -import { - Search, - Sparkles, - RotateCcw, +import { + Search, + Sparkles, + RotateCcw, TrendingUp, Zap, - ArrowLeft + ArrowLeft, + Loader2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { - useSimilaritySearchStore, - useSimilaritySearchSelectors, - useSimilaritySearchActions +import { + useSimilaritySearchStore, + useSimilaritySearchSelectors, + useSimilaritySearchActions } from '../../store/similaritySearchStore'; import { SimilaritySearchPanel } from '../../components/similarity/SimilaritySearchPanel'; -import { SimilaritySearchResults } from '../../components/similarity/SimilaritySearchResults'; +import SimilaritySearchResults from '../../components/similarity/SimilaritySearchResults'; import { SimilaritySearchRequest } from '../../types/similaritySearch'; +import { CustomSelect } from '../../components/CustomSelect'; /** * 相似度检索工具页面 @@ -31,6 +33,8 @@ const SimilaritySearchTool: React.FC = () => { setQuery, setThreshold, executeSearch, + searchWithPagination, + changePageSize, loadConfig, clearError, setShowSuggestions, @@ -51,6 +55,24 @@ const SimilaritySearchTool: React.FC = () => { clearError(); }, [loadConfig, clearError]); + // 恢复持久化的查询内容 + useEffect(() => { + const persistedData = localStorage.getItem('similarity-search-storage'); + if (persistedData) { + try { + const parsed = JSON.parse(persistedData); + const persistedQuery = parsed.state?.query; + + if (persistedQuery && persistedQuery.trim() && persistedQuery !== query) { + console.log('恢复持久化的查询内容:', persistedQuery); + setQuery(persistedQuery); + } + } catch (e) { + console.warn('无法解析持久化的查询内容:', e); + } + } + }, []); // 只在组件挂载时执行一次 + // 处理搜索 const handleSearch = useCallback((request: SimilaritySearchRequest) => { executeSearch(request); @@ -68,9 +90,33 @@ const SimilaritySearchTool: React.FC = () => { // 处理重置 const handleReset = useCallback(() => { + // 清除持久化数据 + localStorage.removeItem('similarity-search-storage'); + // 重置所有状态 resetAll(); }, [resetAll]); + // 处理分页 + const handlePageChange = useCallback((page: number) => { + searchWithPagination(page); + }, [searchWithPagination]); + + // 处理每页显示数量变化 + const handlePageSizeChange = useCallback((pageSize: number) => { + changePageSize(pageSize); + }, [changePageSize]); + + // 监听相关性阈值变化,立即重新搜索 + useEffect(() => { + const request: SimilaritySearchRequest = { + query: query.trim(), + relevance_threshold: selectedThreshold, + page_size: configState.config?.max_results_per_page || 12, + page_offset: (searchState.currentPage - 1) * (configState.config?.max_results_per_page || 12), + }; + executeSearch(request); + }, [selectedThreshold]); // 只监听阈值变化 + // 返回工具列表 const handleBackToTools = useCallback(() => { navigate('/tools'); @@ -90,9 +136,9 @@ const SimilaritySearchTool: React.FC = () => { 返回工具 - +
    - +
    @@ -105,6 +151,40 @@ const SimilaritySearchTool: React.FC = () => {
    + {/* 相关性阈值选择器 */} +
    + 相关性阈值: +
    + ({ + value: threshold.value, + label: threshold.label, + description: threshold.description + })) || [ + { value: "LOWEST", label: "最低 (0.3)", description: "显示更多相关结果" }, + { value: "LOW", label: "较低 (0.5)", description: "包含较多相关结果" }, + { value: "MEDIUM", label: "中等 (0.7)", description: "平衡相关性和数量" }, + { value: "HIGH", label: "较高 (0.9)", description: "只显示高度相关结果" } + ] + } + placeholder="选择相关性阈值" + className="min-w-[160px]" + disabled={searchState.isSearching} + /> + {searchState.isSearching && ( +
    + + 搜索中... +
    + )} +
    +
    + +
    +
    + {/* 全局搜索状态指示器 */} + {searchState.isSearching && ( +
    +
    +
    + + 正在搜索相似内容... + 请稍候,AI正在为您分析匹配结果 +
    +
    +
    + )} + {/* 主要内容 */} -
    +
    {/* 快速开始提示 */} {!searchState.results.length && !searchState.isSearching && (
    @@ -161,12 +254,10 @@ const SimilaritySearchTool: React.FC = () => { { currentPage={searchState.currentPage} maxResultsPerPage={configState.config?.max_results_per_page || 12} isLoading={searchState.isSearching} - onPageChange={() => {}} // 暂时不实现分页 - onResultSelect={(result) => { + onPageChange={handlePageChange} + onPageSizeChange={handlePageSizeChange} + onResultSelect={(result: any) => { console.log('Selected result:', result); }} /> diff --git a/apps/desktop/src/services/similaritySearchService.ts b/apps/desktop/src/services/similaritySearchService.ts index 883bb75..a3267f7 100644 --- a/apps/desktop/src/services/similaritySearchService.ts +++ b/apps/desktop/src/services/similaritySearchService.ts @@ -18,6 +18,8 @@ export class SimilaritySearchService { const response = await invoke('quick_similarity_search', { query: request.query, relevanceThreshold: request.relevance_threshold, + pageSize: request.page_size, + pageOffset: request.page_offset, }); return response; } catch (error) { @@ -94,7 +96,7 @@ export class SimilaritySearchService { } const lowercaseQuery = query.toLowerCase(); - const filtered = baseList.filter(item => + const filtered = baseList.filter(item => item.toLowerCase().includes(lowercaseQuery) || lowercaseQuery.split('').some(char => item.includes(char)) ); diff --git a/apps/desktop/src/store/similaritySearchStore.ts b/apps/desktop/src/store/similaritySearchStore.ts index 54572e5..9de99f3 100644 --- a/apps/desktop/src/store/similaritySearchStore.ts +++ b/apps/desktop/src/store/similaritySearchStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; import { SimilaritySearchState, SimilaritySearchRequest, @@ -6,11 +7,15 @@ import { } from '../types/similaritySearch'; import SimilaritySearchService from '../services/similaritySearchService'; + + /** * 相似度检索工具状态管理 * 遵循 Tauri 开发规范的状态管理模式 */ -export const useSimilaritySearchStore = create((set, get) => ({ +export const useSimilaritySearchStore = create()( + persist( + (set, get) => ({ // 搜索状态 query: '', selectedThreshold: DEFAULT_SIMILARITY_SEARCH_CONFIG.default_threshold || 'MEDIUM', @@ -48,15 +53,15 @@ export const useSimilaritySearchStore = create((set, get) executeSearch: async (request: SimilaritySearchRequest) => { const { query } = request; - + // 验证查询 if (!SimilaritySearchService.validateQuery(query)) { set({ searchError: '请输入有效的搜索关键词' }); return; } - set({ - isSearching: true, + set({ + isSearching: true, searchError: null, showSuggestions: false, currentPage: 1, @@ -64,7 +69,7 @@ export const useSimilaritySearchStore = create((set, get) try { const response = await SimilaritySearchService.quickSimilaritySearch(request); - + set({ searchResults: response.results, totalResults: response.total_size, @@ -80,19 +85,151 @@ export const useSimilaritySearchStore = create((set, get) } }, - loadConfig: async () => { - set({ isLoadingConfig: true }); - + // 分页搜索 + searchWithPagination: async (page: number) => { + const state = get(); + const maxResultsPerPage = state.config?.max_results_per_page || 12; + + // 构建分页请求 + const request: SimilaritySearchRequest = { + query: state.query, + relevance_threshold: state.selectedThreshold, + page_size: maxResultsPerPage, + page_offset: (page - 1) * maxResultsPerPage, + }; + + console.log('分页搜索请求:', { + page, + query: state.query, + page_size: maxResultsPerPage, + page_offset: (page - 1) * maxResultsPerPage, + }); + + set({ + isSearching: true, + searchError: null, + currentPage: page, + }); + try { - const config = await SimilaritySearchService.getConfig(); - set({ - config, - selectedThreshold: config.default_threshold, - isLoadingConfig: false, + const response = await SimilaritySearchService.quickSimilaritySearch(request); + + console.log('分页搜索响应:', { + results_count: response.results.length, + total_size: response.total_size, + current_page: page, + }); + + set({ + searchResults: response.results, + totalResults: response.total_size, + isSearching: false, }); + } catch (error) { + console.error('分页搜索失败:', error); + set({ + searchError: error instanceof Error ? error.message : '搜索失败', + isSearching: false, + }); + } + }, + + // 更改每页显示数量 + changePageSize: async (pageSize: number) => { + const state = get(); + + console.log('更改每页显示数量:', { + oldPageSize: state.config?.max_results_per_page, + newPageSize: pageSize, + currentPage: state.currentPage, + query: state.query + }); + + // 更新配置中的每页显示数量 + if (state.config) { + set({ + config: { + ...state.config, + max_results_per_page: pageSize, + }, + currentPage: 1, // 重置到第一页 + }); + } + + // 如果有查询,重新执行搜索 + if (state.query.trim()) { + const request: SimilaritySearchRequest = { + query: state.query, + relevance_threshold: state.selectedThreshold, + page_size: pageSize, + page_offset: 0, + }; + + set({ + isSearching: true, + searchError: null, + }); + + try { + const response = await SimilaritySearchService.quickSimilaritySearch(request); + + set({ + searchResults: response.results, + totalResults: response.total_size, + isSearching: false, + }); + } catch (error) { + console.error('更改每页显示数量后搜索失败:', error); + set({ + searchError: error instanceof Error ? error.message : '搜索失败', + isSearching: false, + }); + } + } + }, + + loadConfig: async () => { + set({ isLoadingConfig: true }); + + try { + const config = await SimilaritySearchService.getConfig(); + + // 获取当前状态,保持持久化的设置 + const currentState = get(); + + // 检查是否有持久化的设置 + const persistedData = localStorage.getItem('similarity-search-storage'); + let persistedSettings = null; + + if (persistedData) { + try { + const parsed = JSON.parse(persistedData); + persistedSettings = parsed.state; + } catch (e) { + console.warn('无法解析持久化的搜索设置:', e); + } + } + + set({ + config: { + ...config, + // 如果有持久化的每页显示数量,使用它,否则使用配置默认值 + max_results_per_page: persistedSettings?.maxResultsPerPage || config.max_results_per_page, + }, + // 如果有持久化的阈值,使用它,否则使用配置默认值 + selectedThreshold: persistedSettings?.selectedThreshold || config.default_threshold, + isLoadingConfig: false, + }); + + console.log('配置加载完成,已恢复持久化设置:', { + threshold: persistedSettings?.selectedThreshold || config.default_threshold, + pageSize: persistedSettings?.maxResultsPerPage || config.max_results_per_page, + query: persistedSettings?.query || currentState.query, + }); + } catch (error) { console.error('Failed to load config:', error); - set({ + set({ isLoadingConfig: false, // 使用默认配置 config: { @@ -139,7 +276,26 @@ export const useSimilaritySearchStore = create((set, get) setShowSuggestions: (show: boolean) => { set({ showSuggestions: show }); }, -})); +}), +{ + name: 'similarity-search-storage', // 本地存储的键名 + partialize: (state) => ({ + query: state.query, + selectedThreshold: state.selectedThreshold, + maxResultsPerPage: state.config?.max_results_per_page || 12, + }), + // 从存储中恢复状态时的处理 + onRehydrateStorage: () => (state) => { + if (state) { + console.log('相似度搜索参数已从本地存储恢复:', { + query: state.query, + selectedThreshold: state.selectedThreshold, + maxResultsPerPage: state.config?.max_results_per_page + }); + } + }, +} +)); // 选择器 hooks export const useSimilaritySearchSelectors = () => { diff --git a/apps/desktop/src/types/similaritySearch.ts b/apps/desktop/src/types/similaritySearch.ts index 77c3279..ea89e13 100644 --- a/apps/desktop/src/types/similaritySearch.ts +++ b/apps/desktop/src/types/similaritySearch.ts @@ -26,6 +26,8 @@ export interface SimilaritySearchConfig { export interface SimilaritySearchRequest { query: string; relevance_threshold?: string; + page_size?: number; + page_offset?: number; } // 搜索状态 @@ -53,6 +55,8 @@ export interface SimilaritySearchState { setQuery: (query: string) => void; setThreshold: (threshold: string) => void; executeSearch: (request: SimilaritySearchRequest) => Promise; + searchWithPagination: (page: number) => Promise; + changePageSize: (pageSize: number) => Promise; loadConfig: () => Promise; loadSuggestions: (query: string) => Promise; clearResults: () => void; @@ -64,12 +68,12 @@ export interface SimilaritySearchState { export interface SimilaritySearchPanelProps { query: string; selectedThreshold: string; - config: SimilaritySearchConfig | null; + config?: SimilaritySearchConfig | null; // 现在可选,因为阈值选择器已移到顶部 suggestions: string[]; showSuggestions: boolean; isSearching: boolean; onQueryChange: (query: string) => void; - onThresholdChange: (threshold: string) => void; + onThresholdChange?: (threshold: string) => void; // 现在可选,因为阈值选择器已移到顶部 onSearch: (request: SimilaritySearchRequest) => void; onSuggestionSelect: (suggestion: string) => void; onSuggestionsToggle: (show: boolean) => void; @@ -82,6 +86,7 @@ export interface SimilaritySearchResultsProps { maxResultsPerPage: number; isLoading: boolean; onPageChange: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; onResultSelect?: (result: SearchResult) => void; }