mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-21 19:47:16 +01:00
Compare commits
1 Commits
user/yeela
...
dev/vanzue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38d460cc2b |
@@ -1,61 +0,0 @@
|
||||
---
|
||||
description: 'Guidelines for shared libraries including logging, IPC, settings, DPI, telemetry, and utilities consumed by multiple modules'
|
||||
applyTo: 'src/common/**'
|
||||
---
|
||||
|
||||
# Common Libraries – Shared Code Guidance
|
||||
|
||||
Guidelines for modifying shared code in `src/common/`. Changes here can have wide-reaching impact across the entire PowerToys codebase.
|
||||
|
||||
## Scope
|
||||
|
||||
- Logging infrastructure (`src/common/logger/`)
|
||||
- IPC primitives and named pipe utilities
|
||||
- Settings serialization and management
|
||||
- DPI awareness and scaling utilities
|
||||
- Telemetry helpers
|
||||
- General utilities (JSON parsing, string helpers, etc.)
|
||||
|
||||
## Guidelines
|
||||
|
||||
### API Stability
|
||||
|
||||
- Avoid breaking public headers/APIs; if changed, search & update all callers
|
||||
- Coordinate ABI-impacting struct/class layout changes; keep binary compatibility
|
||||
- When modifying public interfaces, grep the entire codebase for usages
|
||||
|
||||
### Performance
|
||||
|
||||
- Watch perf in hot paths (hooks, timers, serialization)
|
||||
- Avoid avoidable allocations in frequently called code
|
||||
- Profile changes that touch performance-sensitive areas
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Ask before adding third-party deps or changing serialization formats
|
||||
- New dependencies must be MIT-licensed or approved by PM team
|
||||
- Add any new external packages to `NOTICE.md`
|
||||
|
||||
### Logging
|
||||
|
||||
- C++ logging uses spdlog (`Logger::info`, `Logger::warn`, `Logger::error`, `Logger::debug`)
|
||||
- Initialize with `init_logger()` early in startup
|
||||
- Keep hot paths quiet – no logging in tight loops or hooks
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- No unintended ABI breaks
|
||||
- No noisy logs in hot paths
|
||||
- New non-obvious symbols briefly commented
|
||||
- All callers updated when interfaces change
|
||||
|
||||
## Code Style
|
||||
|
||||
- **C++**: Follow `.clang-format` in `src/`; use Modern C++ patterns per C++ Core Guidelines
|
||||
- **C#**: Follow `src/.editorconfig`; enforce StyleCop.Analyzers
|
||||
|
||||
## Validation
|
||||
|
||||
- Build: `tools\build\build.cmd` from `src/common/` folder
|
||||
- Verify no ABI breaks: grep for changed function/struct names across codebase
|
||||
- Check logs: ensure no new logging in performance-critical paths
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
description: 'Guidelines for Runner and Settings UI components that communicate via named pipes and manage module lifecycle'
|
||||
applyTo: 'src/runner/**,src/settings-ui/**'
|
||||
---
|
||||
|
||||
# Runner & Settings UI – Core Components Guidance
|
||||
|
||||
Guidelines for modifying the Runner (tray/module loader) and Settings UI (configuration app). These components communicate via Windows Named Pipes using JSON messages.
|
||||
|
||||
## Runner (`src/runner/`)
|
||||
|
||||
### Scope
|
||||
|
||||
- Module bootstrap, hotkey management, settings bridge, update/elevation handling
|
||||
|
||||
### Guidelines
|
||||
|
||||
- If IPC/JSON contracts change, mirror updates in `src/settings-ui/**`
|
||||
- Keep module discovery in `src/runner/main.cpp` in sync when adding/removing modules
|
||||
- Keep startup lean: avoid blocking/network calls in early init path
|
||||
- Preserve GPO & elevation behaviors; confirm no regression in policy handling
|
||||
- Ask before modifying update workflow or elevation logic
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Stable startup, consistent contracts, no unnecessary logging noise
|
||||
|
||||
## Settings UI (`src/settings-ui/`)
|
||||
|
||||
### Scope
|
||||
|
||||
- WinUI/WPF UI, communicates with Runner over named pipes; manages persisted settings schema
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Don't break settings schema silently; add migration when shape changes
|
||||
- If IPC/JSON contracts change, align with `src/runner/**` implementation
|
||||
- Keep UI responsive: marshal to UI thread for UI-bound operations
|
||||
- Reuse existing styles/resources; avoid duplicate theme keys
|
||||
- Add/adjust migration or serialization tests when changing persisted settings
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Schema integrity preserved, responsive UI, consistent contracts, no style duplication
|
||||
|
||||
## Shared Concerns
|
||||
|
||||
### IPC Contract Changes
|
||||
|
||||
When modifying the JSON message format between Runner and Settings UI:
|
||||
|
||||
1. Update both `src/runner/` and `src/settings-ui/` in the same PR
|
||||
2. Preserve backward compatibility where possible
|
||||
3. Add migration logic for settings schema changes
|
||||
4. Test both directions of communication
|
||||
|
||||
### Code Style
|
||||
|
||||
- **C++ (Runner)**: Follow `.clang-format` in `src/`
|
||||
- **C# (Settings UI)**: Follow `src/.editorconfig`, use StyleCop.Analyzers
|
||||
- **XAML**: Use XamlStyler or run `.\.pipelines\applyXamlStyling.ps1 -Main`
|
||||
|
||||
## Validation
|
||||
|
||||
- Build Runner: `tools\build\build.cmd` from `src/runner/`
|
||||
- Build Settings UI: `tools\build\build.cmd` from `src/settings-ui/`
|
||||
- Test IPC: Launch both Runner and Settings UI, verify communication works
|
||||
- Schema changes: Run serialization tests if settings shape changed
|
||||
165
AGENTS.md
165
AGENTS.md
@@ -1,165 +0,0 @@
|
||||
---
|
||||
description: 'Top-level AI contributor guidance for developing PowerToys - a collection of Windows productivity utilities'
|
||||
applyTo: '**'
|
||||
---
|
||||
|
||||
# PowerToys – AI Contributor Guide
|
||||
|
||||
This is the top-level guidance for AI contributions to PowerToys. Keep changes atomic, follow existing patterns, and cite exact paths in PRs.
|
||||
|
||||
## Overview
|
||||
|
||||
PowerToys is a set of utilities for power users to tune and streamline their Windows experience.
|
||||
|
||||
| Area | Location | Description |
|
||||
|------|----------|-------------|
|
||||
| Runner | `src/runner/` | Main executable, tray icon, module loader, hotkey management |
|
||||
| Settings UI | `src/settings-ui/` | WinUI/WPF configuration app communicating via named pipes |
|
||||
| Modules | `src/modules/` | Individual PowerToys utilities (each in its own subfolder) |
|
||||
| Common Libraries | `src/common/` | Shared code: logging, IPC, settings, DPI, telemetry, utilities |
|
||||
| Build Tools | `tools/build/` | Build scripts and automation |
|
||||
| Documentation | `doc/devdocs/` | Developer documentation |
|
||||
| Installer | `installer/` | WiX-based installer projects |
|
||||
|
||||
For architecture details and module types, see [Architecture Overview](doc/devdocs/core/architecture.md).
|
||||
|
||||
## Conventions
|
||||
|
||||
For detailed coding conventions, see:
|
||||
- [Coding Guidelines](doc/devdocs/development/guidelines.md) – Dependencies, testing, PR management
|
||||
- [Coding Style](doc/devdocs/development/style.md) – Formatting, C++/C#/XAML style rules
|
||||
- [Logging](doc/devdocs/development/logging.md) – C++ spdlog and C# Logger usage
|
||||
|
||||
### Component-Specific Instructions
|
||||
|
||||
These instruction files are automatically applied when working in their respective areas:
|
||||
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md) – IPC contracts, schema migrations
|
||||
- [Common Libraries](.github/instructions/common-libraries.instructions.md) – ABI stability, shared code guidelines
|
||||
|
||||
## Build
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Visual Studio 2022 17.4+
|
||||
- Windows 10 1803+ (April 2018 Update or newer)
|
||||
- Initialize submodules once: `git submodule update --init --recursive`
|
||||
|
||||
### Build Commands
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| First build / NuGet restore | `tools\build\build-essentials.cmd` |
|
||||
| Build current folder | `tools\build\build.cmd` |
|
||||
| Build with options | `build.ps1 -Platform x64 -Configuration Release` |
|
||||
|
||||
### Build Discipline
|
||||
|
||||
1. One terminal per operation (build → test). Do not switch or open new ones mid-flow
|
||||
2. After making changes, `cd` to the project folder that changed (`.csproj`/`.vcxproj`)
|
||||
3. Use scripts to build: `tools/build/build.ps1` or `tools/build/build.cmd`
|
||||
4. For first build or missing NuGet packages, run `build-essentials.cmd` first
|
||||
5. **Exit code 0 = success; non-zero = failure** – treat this as absolute
|
||||
6. On failure, read the errors log: `build.<config>.<platform>.errors.log`
|
||||
7. Do not start tests or launch Runner until the build succeeds
|
||||
|
||||
### Build Logs
|
||||
|
||||
Located next to the solution/project being built:
|
||||
- `build.<configuration>.<platform>.errors.log` – errors only (check this first)
|
||||
- `build.<configuration>.<platform>.all.log` – full log
|
||||
- `build.<configuration>.<platform>.trace.binlog` – for MSBuild Structured Log Viewer
|
||||
|
||||
For complete details, see [Build Guidelines](tools/build/BUILD-GUIDELINES.md).
|
||||
|
||||
## Tests
|
||||
|
||||
### Test Discovery
|
||||
|
||||
- Find test projects by product code prefix (e.g., `FancyZones`, `AdvancedPaste`)
|
||||
- Look for sibling folders or 1-2 levels up named `<Product>*UnitTests` or `<Product>*UITests`
|
||||
|
||||
### Running Tests
|
||||
|
||||
1. **Build the test project first**, wait for exit code 0
|
||||
2. Run via VS Test Explorer (`Ctrl+E, T`) or `vstest.console.exe` with filters
|
||||
3. **Avoid `dotnet test`** in this repo – use VS Test Explorer or vstest.console.exe
|
||||
|
||||
### Test Types
|
||||
|
||||
| Type | Requirements | Setup |
|
||||
|------|--------------|-------|
|
||||
| Unit Tests | Standard dev environment | None |
|
||||
| UI Tests | WinAppDriver v1.2.1, Developer Mode | Install from [WinAppDriver releases](https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1) |
|
||||
| Fuzz Tests | OneFuzz, .NET 8 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
|
||||
|
||||
### Test Discipline
|
||||
|
||||
1. Add or adjust tests when changing behavior
|
||||
2. If tests skipped, state why (e.g., comment-only change, string rename)
|
||||
3. New modules handling file I/O or user input **must** implement fuzzing tests
|
||||
|
||||
### Special Requirements
|
||||
|
||||
- **Mouse Without Borders**: Requires 2+ physical computers (not VMs)
|
||||
- **Multi-monitor utilities**: Test with 2+ monitors, different DPI settings
|
||||
|
||||
For UI test setup details, see [UI Tests](doc/devdocs/development/ui-tests.md).
|
||||
|
||||
## Boundaries
|
||||
|
||||
### Ask for Clarification When
|
||||
|
||||
- Ambiguous spec after scanning relevant docs
|
||||
- Cross-module impact (shared enum/struct) is unclear
|
||||
- Security, elevation, or installer changes involved
|
||||
- GPO or policy handling modifications needed
|
||||
|
||||
### Areas Requiring Extra Care
|
||||
|
||||
| Area | Concern | Reference |
|
||||
|------|---------|-----------|
|
||||
| `src/common/` | ABI breaks | [Common Libraries Instructions](.github/instructions/common-libraries.instructions.md) |
|
||||
| `src/runner/`, `src/settings-ui/` | IPC contracts, schema | [Runner & Settings UI Instructions](.github/instructions/runner-settings-ui.instructions.md) |
|
||||
| Installer files | Release impact | Careful review required |
|
||||
| Elevation/GPO logic | Security | Confirm no regression in policy handling |
|
||||
|
||||
### What NOT to Do
|
||||
|
||||
- Don't merge incomplete features into main (use feature branches)
|
||||
- Don't break IPC/JSON contracts without updating both runner and settings-ui
|
||||
- Don't add noisy logs in hot paths
|
||||
- Don't introduce third-party deps without PM approval and `NOTICE.md` update
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
Before finishing, verify:
|
||||
|
||||
- [ ] Build clean with exit code 0
|
||||
- [ ] Tests updated and passing locally
|
||||
- [ ] No unintended ABI breaks or schema changes
|
||||
- [ ] IPC contracts consistent between runner and settings-ui
|
||||
- [ ] New dependencies added to `NOTICE.md`
|
||||
- [ ] PR is atomic (one logical change), with issue linked
|
||||
|
||||
## Documentation Index
|
||||
|
||||
### Core Architecture
|
||||
- [Architecture Overview](doc/devdocs/core/architecture.md)
|
||||
- [Runner](doc/devdocs/core/runner.md)
|
||||
- [Settings System](doc/devdocs/core/settings/readme.md)
|
||||
- [Module Interface](doc/devdocs/modules/interface.md)
|
||||
|
||||
### Development
|
||||
- [Coding Guidelines](doc/devdocs/development/guidelines.md)
|
||||
- [Coding Style](doc/devdocs/development/style.md)
|
||||
- [Logging](doc/devdocs/development/logging.md)
|
||||
- [UI Tests](doc/devdocs/development/ui-tests.md)
|
||||
- [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md)
|
||||
|
||||
### Build & Tools
|
||||
- [Build Guidelines](tools/build/BUILD-GUIDELINES.md)
|
||||
- [Tools Overview](doc/devdocs/tools/readme.md)
|
||||
|
||||
### Instructions (Auto-Applied)
|
||||
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
|
||||
- [Common Libraries](.github/instructions/common-libraries.instructions.md)
|
||||
244
SEMANTIC_SEARCH_API_SUMMARY.md
Normal file
244
SEMANTIC_SEARCH_API_SUMMARY.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Windows App SDK Semantic Search API 总结
|
||||
|
||||
## 1. 环境与依赖
|
||||
|
||||
| 项目 | 版本/值 |
|
||||
|------|---------|
|
||||
| **Windows App SDK** | `2.0.0-experimental3` |
|
||||
| **.NET** | `net9.0-windows10.0.26100.0` |
|
||||
| **AI Search NuGet** | `Microsoft.WindowsAppSDK.AI` (2.0.57-experimental) |
|
||||
| **命名空间** | `Microsoft.Windows.AI.Search.Experimental.AppContentIndex` |
|
||||
| **应用类型** | WinUI 3 MSIX 打包应用 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心 API
|
||||
|
||||
### 2.1 索引管理
|
||||
```csharp
|
||||
// 创建/打开索引
|
||||
var result = AppContentIndexer.GetOrCreateIndex("indexName");
|
||||
if (result.Succeeded) {
|
||||
_indexer = result.Indexer;
|
||||
// result.Status: CreatedNew | OpenedExisting
|
||||
}
|
||||
|
||||
// 等待索引能力就绪
|
||||
await _indexer.WaitForIndexCapabilitiesAsync();
|
||||
|
||||
// 等待索引空闲(建索引完成)
|
||||
await _indexer.WaitForIndexingIdleAsync(TimeSpan.FromSeconds(120));
|
||||
|
||||
// 清理
|
||||
_indexer.RemoveAll(); // 删除所有索引
|
||||
_indexer.Remove(id); // 删除单个
|
||||
_indexer.Dispose();
|
||||
```
|
||||
|
||||
### 2.2 添加内容到索引
|
||||
```csharp
|
||||
// 索引文本 → 自动建立 TextLexical + TextSemantic 索引
|
||||
IndexableAppContent textContent = AppManagedIndexableAppContent.CreateFromString(id, text);
|
||||
_indexer.AddOrUpdate(textContent);
|
||||
|
||||
// 索引图片 → 自动建立 ImageSemantic + ImageOcr 索引
|
||||
IndexableAppContent imageContent = AppManagedIndexableAppContent.CreateFromBitmap(id, softwareBitmap);
|
||||
_indexer.AddOrUpdate(imageContent);
|
||||
```
|
||||
|
||||
### 2.3 查询
|
||||
```csharp
|
||||
// 文本查询
|
||||
TextQueryOptions options = new TextQueryOptions {
|
||||
Language = "en-US", // 可选
|
||||
MatchScope = QueryMatchScope.Unconstrained, // 匹配范围
|
||||
TextMatchType = TextLexicalMatchType.Fuzzy // Fuzzy | Exact
|
||||
};
|
||||
AppIndexTextQuery query = _indexer.CreateTextQuery(searchText, options);
|
||||
IReadOnlyList<TextQueryMatch> matches = query.GetNextMatches(5);
|
||||
|
||||
// 图片查询
|
||||
ImageQueryOptions imgOptions = new ImageQueryOptions {
|
||||
MatchScope = QueryMatchScope.Unconstrained,
|
||||
ImageOcrTextMatchType = TextLexicalMatchType.Fuzzy
|
||||
};
|
||||
AppIndexImageQuery imgQuery = _indexer.CreateImageQuery(searchText, imgOptions);
|
||||
IReadOnlyList<ImageQueryMatch> imgMatches = imgQuery.GetNextMatches(5);
|
||||
```
|
||||
|
||||
### 2.4 能力检查(只读)
|
||||
```csharp
|
||||
IndexCapabilities capabilities = _indexer.GetIndexCapabilities();
|
||||
|
||||
bool textLexicalOK = capabilities.GetCapabilityState(IndexCapability.TextLexical)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
bool textSemanticOK = capabilities.GetCapabilityState(IndexCapability.TextSemantic)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
bool imageSemanticOK = capabilities.GetCapabilityState(IndexCapability.ImageSemantic)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
bool imageOcrOK = capabilities.GetCapabilityState(IndexCapability.ImageOcr)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 四种索引能力
|
||||
|
||||
| 能力 | 说明 | 触发方式 |
|
||||
|------|------|----------|
|
||||
| `TextLexical` | 词法/关键词搜索 | CreateFromString() 自动 |
|
||||
| `TextSemantic` | AI 语义搜索 (Embedding) | CreateFromString() 自动 |
|
||||
| `ImageSemantic` | 图像语义搜索 | CreateFromBitmap() 自动 |
|
||||
| `ImageOcr` | 图片 OCR 文字搜索 | CreateFromBitmap() 自动 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 可控选项(有限)
|
||||
|
||||
### TextQueryOptions
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `Language` | string | 查询语言(可选,如 "en-US")|
|
||||
| `MatchScope` | QueryMatchScope | Unconstrained / Region / ContentItem |
|
||||
| `TextMatchType` | TextLexicalMatchType | **Fuzzy** / Exact(仅影响 Lexical)|
|
||||
|
||||
### ImageQueryOptions
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `MatchScope` | QueryMatchScope | Unconstrained / Region / ContentItem |
|
||||
| `ImageOcrTextMatchType` | TextLexicalMatchType | **Fuzzy** / Exact(仅影响 OCR)|
|
||||
|
||||
### 枚举值说明
|
||||
|
||||
**QueryMatchScope:**
|
||||
- `Unconstrained` - 无约束,同时使用 Lexical + Semantic
|
||||
- `Region` - 限制在特定区域
|
||||
- `ContentItem` - 限制在单个内容项
|
||||
|
||||
**TextLexicalMatchType:**
|
||||
- `Fuzzy` - 模糊匹配,允许拼写错误、近似词
|
||||
- `Exact` - 精确匹配,必须完全一致
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键限制 ⚠️
|
||||
|
||||
| 限制 | 说明 |
|
||||
|------|------|
|
||||
| **不能单独指定 Semantic/Lexical** | 系统自动同时使用所有可用能力 |
|
||||
| **Fuzzy/Exact 只影响 Lexical** | 对 Semantic 搜索无效 |
|
||||
| **能力检查是只读的** | `GetIndexCapabilities()` 只能查看,不能控制 |
|
||||
| **无相似度阈值** | 不能设置 Semantic 匹配的阈值 |
|
||||
| **无结果排序控制** | 无法指定按相关度或其他方式排序 |
|
||||
| **语言需手动传** | 不会自动检测,需开发者指定 |
|
||||
| **无相关度分数** | 查询结果不返回匹配分数 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 典型使用流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ App 启动时 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. GetOrCreateIndex("name") // 创建/打开索引 │
|
||||
│ 2. WaitForIndexCapabilitiesAsync() // 等待能力就绪 │
|
||||
│ 3. GetIndexCapabilities() // 检查可用能力 │
|
||||
│ 4. IndexAll() // 索引所有数据 │
|
||||
│ ├─ CreateFromString() × N │
|
||||
│ └─ CreateFromBitmap() × N │
|
||||
│ 5. WaitForIndexingIdleAsync() // 等待索引完成 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 运行时查询 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. CreateTextQuery(text, options) // 创建查询 │
|
||||
│ 2. query.GetNextMatches(N) // 获取结果 │
|
||||
│ 3. 处理 TextQueryMatch / ImageQueryMatch │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ App 退出时 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. _indexer.RemoveAll() // 清理索引 │
|
||||
│ 2. _indexer.Dispose() // 释放资源 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 查询结果处理
|
||||
|
||||
```csharp
|
||||
// 文本查询结果
|
||||
foreach (var match in textMatches)
|
||||
{
|
||||
if (match.ContentKind == QueryMatchContentKind.AppManagedText)
|
||||
{
|
||||
AppManagedTextQueryMatch textResult = (AppManagedTextQueryMatch)match;
|
||||
string contentId = match.ContentId; // 内容 ID
|
||||
int offset = textResult.TextOffset; // 匹配文本偏移
|
||||
int length = textResult.TextLength; // 匹配文本长度
|
||||
}
|
||||
}
|
||||
|
||||
// 图片查询结果
|
||||
foreach (var match in imageMatches)
|
||||
{
|
||||
if (match.ContentKind == QueryMatchContentKind.AppManagedImage)
|
||||
{
|
||||
AppManagedImageQueryMatch imageResult = (AppManagedImageQueryMatch)match;
|
||||
string contentId = imageResult.ContentId; // 图片 ID
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 能力变化监听
|
||||
|
||||
```csharp
|
||||
// 监听索引能力变化
|
||||
_indexer.Listener.IndexCapabilitiesChanged += (indexer, capabilities) =>
|
||||
{
|
||||
// 重新检查能力状态,更新 UI
|
||||
LoadAppIndexCapabilities();
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 结论
|
||||
|
||||
这是一个**高度封装的黑盒 API**:
|
||||
|
||||
### 优点 ✅
|
||||
- 简单易用,几行代码即可实现搜索
|
||||
- 自动处理 Lexical + Semantic
|
||||
- 支持文本和图片多模态搜索
|
||||
- 系统级集成,无需额外部署模型
|
||||
|
||||
### 缺点 ❌
|
||||
- 无法精细控制搜索类型
|
||||
- 不能只用 Semantic Search
|
||||
- 选项有限,缺乏高级配置
|
||||
- 实验性 API,可能变更
|
||||
|
||||
### 替代方案
|
||||
**如果需要纯 Semantic Search(向量搜索)**,建议:
|
||||
- 直接使用 Embedding 模型生成向量
|
||||
- 配合向量数据库(Azure Cosmos DB、FAISS、Qdrant 等)
|
||||
|
||||
---
|
||||
|
||||
## 10. 相关 NuGet 包
|
||||
|
||||
```xml
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental3" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK.AI" Version="2.0.57-experimental" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*文档生成日期: 2026-01-21*
|
||||
496
doc/specs/common-search-library.md
Normal file
496
doc/specs/common-search-library.md
Normal file
@@ -0,0 +1,496 @@
|
||||
# Common.Search Library Specification
|
||||
|
||||
## Overview
|
||||
|
||||
本文档描述 `Common.Search` 库的重构设计,目标是提供一个通用的、可插拔的搜索框架,支持多种搜索引擎实现(Fuzzy Match、Semantic Search 等)。
|
||||
|
||||
## Goals
|
||||
|
||||
1. **解耦** - 搜索引擎与数据源完全解耦
|
||||
2. **可插拔** - 支持替换不同的搜索引擎实现
|
||||
3. **泛型** - 不绑定特定业务类型(如 SettingEntry)
|
||||
4. **可组合** - 支持多引擎组合(即时 Fuzzy + 延迟 Semantic)
|
||||
5. **可复用** - 可被 PowerToys 多个模块使用
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Consumer (e.g., Settings.UI) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ SettingsDataProvider ← 业务特定的数据加载 │
|
||||
│ SettingsSearchService ← 业务特定的搜索服务 │
|
||||
│ SettingEntry : ISearchable ← 业务实体实现搜索契约 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ uses
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Common.Search (Library) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Core Abstractions │ │
|
||||
│ ├─────────────────────────────────────────────────────────┤ │
|
||||
│ │ ISearchable ← 可搜索内容契约 │ │
|
||||
│ │ ISearchEngine<T> ← 搜索引擎接口 │ │
|
||||
│ │ SearchResult<T> ← 统一结果模型 │ │
|
||||
│ │ SearchOptions ← 搜索选项 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Implementations │ │
|
||||
│ ├─────────────────────────────────────────────────────────┤ │
|
||||
│ │ FuzzSearch/ │ │
|
||||
│ │ ├── FuzzSearchEngine<T> ← 内存 Fuzzy 搜索 │ │
|
||||
│ │ ├── StringMatcher ← 现有的模糊匹配算法 │ │
|
||||
│ │ └── MatchResult ← Fuzzy 匹配结果 │ │
|
||||
│ │ │ │
|
||||
│ │ SemanticSearch/ │ │
|
||||
│ │ ├── SemanticSearchEngine ← Windows AI Search 封装 │ │
|
||||
│ │ └── SemanticSearchCapabilities │ │
|
||||
│ │ │ │
|
||||
│ │ CompositeSearch/ │ │
|
||||
│ │ └── CompositeSearchEngine<T> ← 多引擎组合 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Core Interfaces
|
||||
|
||||
### ISearchable
|
||||
|
||||
定义可搜索内容的最小契约。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a searchable item that can be indexed and searched.
|
||||
/// </summary>
|
||||
public interface ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this item.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary searchable text (e.g., title, header).
|
||||
/// </summary>
|
||||
string SearchableText { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional secondary searchable text (e.g., description).
|
||||
/// Returns null if not available.
|
||||
/// </summary>
|
||||
string? SecondarySearchableText { get; }
|
||||
}
|
||||
```
|
||||
|
||||
### ISearchEngine<T>
|
||||
|
||||
搜索引擎核心接口。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a pluggable search engine that can index and search items.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search, must implement ISearchable.</typeparam>
|
||||
public interface ISearchEngine<T> : IDisposable
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine is ready to search.
|
||||
/// </summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the engine capabilities.
|
||||
/// </summary>
|
||||
SearchEngineCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search engine.
|
||||
/// </summary>
|
||||
Task InitializeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes a single item.
|
||||
/// </summary>
|
||||
Task IndexAsync(T item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes multiple items in batch.
|
||||
/// </summary>
|
||||
Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the index by its ID.
|
||||
/// </summary>
|
||||
Task RemoveAsync(string id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all indexed items.
|
||||
/// </summary>
|
||||
Task ClearAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for items matching the query.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
### SearchResult<T>
|
||||
|
||||
统一的搜索结果模型。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a search result with the matched item and scoring information.
|
||||
/// </summary>
|
||||
public sealed class SearchResult<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matched item.
|
||||
/// </summary>
|
||||
public required T Item { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relevance score (higher is more relevant).
|
||||
/// </summary>
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of match that produced this result.
|
||||
/// </summary>
|
||||
public required SearchMatchKind MatchKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the match details for highlighting (optional).
|
||||
/// </summary>
|
||||
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a span of matched text for highlighting.
|
||||
/// </summary>
|
||||
public readonly record struct MatchSpan(int Start, int Length);
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the kind of match that produced a search result.
|
||||
/// </summary>
|
||||
public enum SearchMatchKind
|
||||
{
|
||||
/// <summary>Exact text match.</summary>
|
||||
Exact,
|
||||
|
||||
/// <summary>Fuzzy/approximate text match.</summary>
|
||||
Fuzzy,
|
||||
|
||||
/// <summary>Semantic/AI-based match.</summary>
|
||||
Semantic,
|
||||
|
||||
/// <summary>Combined match from multiple engines.</summary>
|
||||
Composite,
|
||||
}
|
||||
```
|
||||
|
||||
### SearchOptions
|
||||
|
||||
搜索配置选项。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring search behavior.
|
||||
/// </summary>
|
||||
public sealed class SearchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of results to return.
|
||||
/// Default is 20.
|
||||
/// </summary>
|
||||
public int MaxResults { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum score threshold (0.0 to 1.0).
|
||||
/// Results below this score are filtered out.
|
||||
/// Default is 0.0 (no filtering).
|
||||
/// </summary>
|
||||
public double MinScore { get; set; } = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the language hint for the search (e.g., "en-US").
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to include match spans for highlighting.
|
||||
/// Default is false.
|
||||
/// </summary>
|
||||
public bool IncludeMatchSpans { get; set; } = false;
|
||||
}
|
||||
```
|
||||
|
||||
### SearchEngineCapabilities
|
||||
|
||||
引擎能力描述。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the capabilities of a search engine.
|
||||
/// </summary>
|
||||
public sealed class SearchEngineCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports fuzzy matching.
|
||||
/// </summary>
|
||||
public bool SupportsFuzzyMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports semantic search.
|
||||
/// </summary>
|
||||
public bool SupportsSemanticSearch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine persists the index to disk.
|
||||
/// </summary>
|
||||
public bool PersistsIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports incremental indexing.
|
||||
/// </summary>
|
||||
public bool SupportsIncrementalIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports match span highlighting.
|
||||
/// </summary>
|
||||
public bool SupportsMatchSpans { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
## Implementations
|
||||
|
||||
### FuzzSearchEngine<T>
|
||||
|
||||
基于现有 StringMatcher 的内存搜索引擎。
|
||||
|
||||
**特点:**
|
||||
- 纯内存,无持久化
|
||||
- 即时响应(毫秒级)
|
||||
- 支持 match spans 高亮
|
||||
- 基于字符的模糊匹配
|
||||
|
||||
**Capabilities:**
|
||||
```csharp
|
||||
new SearchEngineCapabilities
|
||||
{
|
||||
SupportsFuzzyMatch = true,
|
||||
SupportsSemanticSearch = false,
|
||||
PersistsIndex = false,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = true,
|
||||
}
|
||||
```
|
||||
|
||||
### SemanticSearchEngine
|
||||
|
||||
基于 Windows App SDK AI Search API 的语义搜索引擎。
|
||||
|
||||
**特点:**
|
||||
- 系统管理的持久化索引
|
||||
- AI 驱动的语义理解
|
||||
- 需要模型初始化(可能较慢)
|
||||
- 可能不可用(依赖系统支持)
|
||||
|
||||
**Capabilities:**
|
||||
```csharp
|
||||
new SearchEngineCapabilities
|
||||
{
|
||||
SupportsFuzzyMatch = true, // API 同时提供 lexical + semantic
|
||||
SupportsSemanticSearch = true,
|
||||
PersistsIndex = true,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = false, // API 不返回详细位置
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** SemanticSearchEngine 不是泛型的,因为它需要将内容转换为字符串存入系统索引。实现时通过 `ISearchable` 接口提取文本。
|
||||
|
||||
### CompositeSearchEngine<T>
|
||||
|
||||
组合多个搜索引擎,支持 fallback 和结果合并。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// A search engine that combines results from multiple engines.
|
||||
/// </summary>
|
||||
public sealed class CompositeSearchEngine<T> : ISearchEngine<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Strategy for combining results from multiple engines.
|
||||
/// </summary>
|
||||
public enum CombineStrategy
|
||||
{
|
||||
/// <summary>Use first ready engine only.</summary>
|
||||
FirstReady,
|
||||
|
||||
/// <summary>Merge results from all ready engines.</summary>
|
||||
MergeAll,
|
||||
|
||||
/// <summary>Use primary, fallback to secondary if primary not ready.</summary>
|
||||
PrimaryWithFallback,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**典型用法:** Fuzzy 作为即时响应,Semantic 准备好后增强结果。
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/common/Common.Search/
|
||||
├── Common.Search.csproj
|
||||
├── GlobalSuppressions.cs
|
||||
├── ISearchable.cs
|
||||
├── ISearchEngine.cs
|
||||
├── SearchResult.cs
|
||||
├── SearchOptions.cs
|
||||
├── SearchEngineCapabilities.cs
|
||||
├── SearchMatchKind.cs
|
||||
├── MatchSpan.cs
|
||||
│
|
||||
├── FuzzSearch/
|
||||
│ ├── FuzzSearchEngine.cs
|
||||
│ ├── StringMatcher.cs (existing)
|
||||
│ ├── MatchOption.cs (existing)
|
||||
│ ├── MatchResult.cs (existing)
|
||||
│ └── SearchPrecisionScore.cs (existing)
|
||||
│
|
||||
├── SemanticSearch/
|
||||
│ ├── SemanticSearchEngine.cs
|
||||
│ ├── SemanticSearchCapabilities.cs
|
||||
│ └── SemanticSearchAdapter.cs (adapts ISearchable to Windows API)
|
||||
│
|
||||
└── CompositeSearch/
|
||||
└── CompositeSearchEngine.cs
|
||||
```
|
||||
|
||||
## Consumer Usage (Settings.UI)
|
||||
|
||||
### SettingEntry 实现 ISearchable
|
||||
|
||||
```csharp
|
||||
// Settings.UI.Library/SettingEntry.cs
|
||||
public struct SettingEntry : ISearchable
|
||||
{
|
||||
// Existing properties...
|
||||
|
||||
// ISearchable implementation
|
||||
public string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
|
||||
public string SearchableText => Header ?? string.Empty;
|
||||
public string? SecondarySearchableText => Description;
|
||||
}
|
||||
```
|
||||
|
||||
### SettingsSearchService
|
||||
|
||||
```csharp
|
||||
// Settings.UI/Services/SettingsSearchService.cs
|
||||
public sealed class SettingsSearchService : IDisposable
|
||||
{
|
||||
private readonly ISearchEngine<SettingEntry> _engine;
|
||||
|
||||
public SettingsSearchService(ISearchEngine<SettingEntry> engine)
|
||||
{
|
||||
_engine = engine;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(IEnumerable<SettingEntry> entries)
|
||||
{
|
||||
await _engine.InitializeAsync();
|
||||
await _engine.IndexBatchAsync(entries);
|
||||
}
|
||||
|
||||
public async Task<List<SettingEntry>> SearchAsync(string query, CancellationToken ct = default)
|
||||
{
|
||||
var results = await _engine.SearchAsync(query, cancellationToken: ct);
|
||||
return results.Select(r => r.Item).ToList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Startup Configuration
|
||||
|
||||
```csharp
|
||||
// Option 1: Fuzzy only (default, immediate)
|
||||
var engine = new FuzzSearchEngine<SettingEntry>();
|
||||
|
||||
// Option 2: Semantic only (requires Windows AI)
|
||||
var engine = new SemanticSearchAdapter<SettingEntry>("PowerToysSettings");
|
||||
|
||||
// Option 3: Composite (best of both worlds)
|
||||
var engine = new CompositeSearchEngine<SettingEntry>(
|
||||
primary: new SemanticSearchAdapter<SettingEntry>("PowerToysSettings"),
|
||||
fallback: new FuzzSearchEngine<SettingEntry>(),
|
||||
strategy: CombineStrategy.PrimaryWithFallback
|
||||
);
|
||||
|
||||
var searchService = new SettingsSearchService(engine);
|
||||
await searchService.InitializeAsync(settingEntries);
|
||||
```
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Core Abstractions
|
||||
1. 创建 `ISearchable`, `ISearchEngine<T>`, `SearchResult<T>` 等核心接口
|
||||
2. 保持现有 FuzzSearch 代码不变
|
||||
|
||||
### Phase 2: FuzzSearchEngine<T>
|
||||
1. 创建泛型 `FuzzSearchEngine<T>` 实现
|
||||
2. 内部复用现有 `StringMatcher`
|
||||
|
||||
### Phase 3: SemanticSearchEngine
|
||||
1. 完善现有 `SemanticSearchEngine` 实现
|
||||
2. 创建 `SemanticSearchAdapter<T>` 桥接泛型接口
|
||||
|
||||
### Phase 4: Settings.UI Migration
|
||||
1. `SettingEntry` 实现 `ISearchable`
|
||||
2. 创建 `SettingsSearchService`
|
||||
3. 迁移 `SearchIndexService` 到新架构
|
||||
4. 保持 API 兼容,逐步废弃旧方法
|
||||
|
||||
### Phase 5: CompositeSearchEngine (Optional)
|
||||
1. 实现组合引擎
|
||||
2. 支持 Fuzzy + Semantic 混合搜索
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **是否需要支持图片搜索?** 当前 SemanticSearchEngine 支持 `IndexImage`,但 `ISearchable` 只有文本。如果需要图片,可能需要 `IImageSearchable` 扩展。
|
||||
|
||||
2. **结果去重策略?** CompositeEngine 合并结果时,同一个 Item 可能被多个引擎匹配,如何去重和合并分数?
|
||||
|
||||
3. **异步 vs 同步?** FuzzSearch 完全可以同步执行,但接口统一用 `Task` 是否合适?考虑提供同步重载?
|
||||
|
||||
4. **索引更新策略?** 当 Settings 内容变化时(例如用户切换语言),如何高效更新索引?
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 1.0*
|
||||
*Last Updated: 2026-01-21*
|
||||
@@ -4,5 +4,11 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Exclude SemanticSearch until experimental SDK is available -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="SemanticSearch\**" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
306
src/common/Common.Search/FuzzSearch/FuzzSearchEngine`1.cs
Normal file
306
src/common/Common.Search/FuzzSearch/FuzzSearchEngine`1.cs
Normal file
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Common.Search.FuzzSearch;
|
||||
|
||||
/// <summary>
|
||||
/// A search engine that uses fuzzy string matching for search.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search.</typeparam>
|
||||
public sealed class FuzzSearchEngine<T> : ISearchEngine<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
private readonly object _lockObject = new();
|
||||
private readonly Dictionary<string, T> _itemsById = new();
|
||||
private readonly Dictionary<string, (string PrimaryNorm, string? SecondaryNorm)> _normalizedCache = new();
|
||||
private bool _isReady;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsReady
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _isReady;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SearchEngineCapabilities Capabilities { get; } = new()
|
||||
{
|
||||
SupportsFuzzyMatch = true,
|
||||
SupportsSemanticSearch = false,
|
||||
PersistsIndex = false,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = true,
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_isReady = true;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexAsync(T item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
_normalizedCache[item.Id] = (
|
||||
NormalizeString(item.SearchableText),
|
||||
item.SecondarySearchableText != null ? NormalizeString(item.SecondarySearchableText) : null
|
||||
);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_itemsById[item.Id] = item;
|
||||
_normalizedCache[item.Id] = (
|
||||
NormalizeString(item.SearchableText),
|
||||
item.SecondarySearchableText != null ? NormalizeString(item.SecondarySearchableText) : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Remove(id);
|
||||
_normalizedCache.Remove(id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ClearAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Clear();
|
||||
_normalizedCache.Clear();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
options ??= new SearchOptions();
|
||||
var normalizedQuery = NormalizeString(query);
|
||||
|
||||
List<KeyValuePair<string, T>> snapshot;
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_itemsById.Count == 0)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
snapshot = _itemsById.ToList();
|
||||
}
|
||||
|
||||
var bag = new ConcurrentBag<SearchResult<T>>();
|
||||
var po = new ParallelOptions
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Parallel.ForEach(snapshot, po, kvp =>
|
||||
{
|
||||
var (primaryNorm, secondaryNorm) = GetNormalizedTexts(kvp.Key);
|
||||
|
||||
var primaryResult = StringMatcher.FuzzyMatch(normalizedQuery, primaryNorm);
|
||||
double score = primaryResult.Score;
|
||||
List<int>? matchData = primaryResult.MatchData;
|
||||
|
||||
if (!string.IsNullOrEmpty(secondaryNorm))
|
||||
{
|
||||
var secondaryResult = StringMatcher.FuzzyMatch(normalizedQuery, secondaryNorm);
|
||||
if (secondaryResult.Success && secondaryResult.Score * 0.8 > score)
|
||||
{
|
||||
score = secondaryResult.Score * 0.8;
|
||||
matchData = null; // Secondary matches don't have primary text spans
|
||||
}
|
||||
}
|
||||
|
||||
if (score > options.MinScore)
|
||||
{
|
||||
var result = new SearchResult<T>
|
||||
{
|
||||
Item = kvp.Value,
|
||||
Score = score,
|
||||
MatchKind = SearchMatchKind.Fuzzy,
|
||||
MatchSpans = options.IncludeMatchSpans && matchData != null
|
||||
? ConvertToMatchSpans(matchData)
|
||||
: null,
|
||||
};
|
||||
|
||||
bag.Add(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
var results = bag
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Take(options.MaxResults)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Clear();
|
||||
_normalizedCache.Clear();
|
||||
_isReady = false;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private (string PrimaryNorm, string? SecondaryNorm) GetNormalizedTexts(string id)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_normalizedCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
return (string.Empty, null);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MatchSpan> ConvertToMatchSpans(List<int> matchData)
|
||||
{
|
||||
if (matchData == null || matchData.Count == 0)
|
||||
{
|
||||
return Array.Empty<MatchSpan>();
|
||||
}
|
||||
|
||||
// Convert individual match indices to spans
|
||||
var spans = new List<MatchSpan>();
|
||||
var sortedIndices = matchData.OrderBy(i => i).ToList();
|
||||
|
||||
int start = sortedIndices[0];
|
||||
int length = 1;
|
||||
|
||||
for (int i = 1; i < sortedIndices.Count; i++)
|
||||
{
|
||||
if (sortedIndices[i] == sortedIndices[i - 1] + 1)
|
||||
{
|
||||
// Consecutive index, extend the span
|
||||
length++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Gap found, save current span and start new one
|
||||
spans.Add(new MatchSpan(start, length));
|
||||
start = sortedIndices[i];
|
||||
length = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last span
|
||||
spans.Add(new MatchSpan(start, length));
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
private static string NormalizeString(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
|
||||
var sb = new StringBuilder(normalized.Length);
|
||||
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (category != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,9 @@ public class StringMatcher
|
||||
/// 6. Move onto the next substring's characters until all substrings are checked.
|
||||
/// 7. Consider success and move onto scoring if every char or substring without whitespaces matched
|
||||
/// </summary>
|
||||
public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt = null)
|
||||
public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption? opt = null)
|
||||
{
|
||||
opt = opt ?? new MatchOption();
|
||||
opt ??= new MatchOption();
|
||||
|
||||
if (string.IsNullOrEmpty(stringToCompare))
|
||||
{
|
||||
|
||||
@@ -11,6 +11,10 @@ using System.Diagnostics.CodeAnalysis;
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.MatchResult._rawScore")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._defaultMatchOption")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchEngine._indexName")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchEngine._indexer")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchEngine._disposed")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchEngine._capabilities")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore)")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore,System.Collections.Generic.List{System.Int32},System.Int32)")]
|
||||
[assembly: SuppressMessage("Compiler", "CS8618:Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.", Justification = "Coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")]
|
||||
|
||||
73
src/common/Common.Search/ISearchEngine`1.cs
Normal file
73
src/common/Common.Search/ISearchEngine`1.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a pluggable search engine that can index and search items.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search, must implement ISearchable.</typeparam>
|
||||
public interface ISearchEngine<T> : IDisposable
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine is ready to search.
|
||||
/// </summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the engine capabilities.
|
||||
/// </summary>
|
||||
SearchEngineCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search engine.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task InitializeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes a single item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task IndexAsync(T item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes multiple items in batch.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the index by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the item to remove.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task RemoveAsync(string id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all indexed items.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task ClearAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for items matching the query.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="options">Optional search options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A list of search results ordered by relevance.</returns>
|
||||
Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
27
src/common/Common.Search/ISearchable.cs
Normal file
27
src/common/Common.Search/ISearchable.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a searchable item that can be indexed and searched.
|
||||
/// </summary>
|
||||
public interface ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this item.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary searchable text (e.g., title, header).
|
||||
/// </summary>
|
||||
string SearchableText { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional secondary searchable text (e.g., description).
|
||||
/// Returns null if not available.
|
||||
/// </summary>
|
||||
string? SecondarySearchableText { get; }
|
||||
}
|
||||
12
src/common/Common.Search/MatchSpan.cs
Normal file
12
src/common/Common.Search/MatchSpan.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a span of matched text for highlighting.
|
||||
/// </summary>
|
||||
/// <param name="Start">The starting index of the match.</param>
|
||||
/// <param name="Length">The length of the match.</param>
|
||||
public readonly record struct MatchSpan(int Start, int Length);
|
||||
36
src/common/Common.Search/SearchEngineCapabilities.cs
Normal file
36
src/common/Common.Search/SearchEngineCapabilities.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the capabilities of a search engine.
|
||||
/// </summary>
|
||||
public sealed class SearchEngineCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports fuzzy matching.
|
||||
/// </summary>
|
||||
public bool SupportsFuzzyMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports semantic search.
|
||||
/// </summary>
|
||||
public bool SupportsSemanticSearch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine persists the index to disk.
|
||||
/// </summary>
|
||||
public bool PersistsIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports incremental indexing.
|
||||
/// </summary>
|
||||
public bool SupportsIncrementalIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports match span highlighting.
|
||||
/// </summary>
|
||||
public bool SupportsMatchSpans { get; init; }
|
||||
}
|
||||
31
src/common/Common.Search/SearchMatchKind.cs
Normal file
31
src/common/Common.Search/SearchMatchKind.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the kind of match that produced a search result.
|
||||
/// </summary>
|
||||
public enum SearchMatchKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Exact text match.
|
||||
/// </summary>
|
||||
Exact,
|
||||
|
||||
/// <summary>
|
||||
/// Fuzzy/approximate text match.
|
||||
/// </summary>
|
||||
Fuzzy,
|
||||
|
||||
/// <summary>
|
||||
/// Semantic/AI-based match.
|
||||
/// </summary>
|
||||
Semantic,
|
||||
|
||||
/// <summary>
|
||||
/// Combined match from multiple engines.
|
||||
/// </summary>
|
||||
Composite,
|
||||
}
|
||||
35
src/common/Common.Search/SearchOptions.cs
Normal file
35
src/common/Common.Search/SearchOptions.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring search behavior.
|
||||
/// </summary>
|
||||
public sealed class SearchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of results to return.
|
||||
/// Default is 20.
|
||||
/// </summary>
|
||||
public int MaxResults { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum score threshold.
|
||||
/// Results below this score are filtered out.
|
||||
/// Default is 0.0 (no filtering).
|
||||
/// </summary>
|
||||
public double MinScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the language hint for the search (e.g., "en-US").
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to include match spans for highlighting.
|
||||
/// Default is false.
|
||||
/// </summary>
|
||||
public bool IncludeMatchSpans { get; set; }
|
||||
}
|
||||
33
src/common/Common.Search/SearchResult`1.cs
Normal file
33
src/common/Common.Search/SearchResult`1.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a search result with the matched item and scoring information.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the matched item.</typeparam>
|
||||
public sealed class SearchResult<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matched item.
|
||||
/// </summary>
|
||||
public required T Item { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relevance score (higher is more relevant).
|
||||
/// </summary>
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of match that produced this result.
|
||||
/// </summary>
|
||||
public required SearchMatchKind MatchKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the match details for highlighting (optional).
|
||||
/// </summary>
|
||||
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
|
||||
}
|
||||
234
src/common/Common.Search/SemanticSearch/SemanticSearchAdapter.cs
Normal file
234
src/common/Common.Search/SemanticSearch/SemanticSearchAdapter.cs
Normal file
@@ -0,0 +1,234 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts the SemanticSearchEngine to the generic ISearchEngine interface.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search.</typeparam>
|
||||
public sealed class SemanticSearchAdapter<T> : ISearchEngine<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
private readonly SemanticSearchEngine _engine;
|
||||
private readonly Dictionary<string, T> _itemsById = new();
|
||||
private readonly object _lockObject = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SemanticSearchAdapter{T}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="indexName">The name of the search index.</param>
|
||||
public SemanticSearchAdapter(string indexName)
|
||||
{
|
||||
_engine = new SemanticSearchEngine(indexName);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsReady => _engine.IsInitialized;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SearchEngineCapabilities Capabilities { get; } = new()
|
||||
{
|
||||
SupportsFuzzyMatch = true,
|
||||
SupportsSemanticSearch = true,
|
||||
PersistsIndex = true,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying semantic search capabilities.
|
||||
/// </summary>
|
||||
public SemanticSearchCapabilities? SemanticCapabilities => _engine.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the semantic search capabilities change.
|
||||
/// </summary>
|
||||
public event EventHandler<SemanticSearchCapabilities>? CapabilitiesChanged
|
||||
{
|
||||
add => _engine.CapabilitiesChanged += value;
|
||||
remove => _engine.CapabilitiesChanged -= value;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
await _engine.InitializeAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexAsync(T item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var text = BuildSearchableText(item);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
}
|
||||
|
||||
_engine.IndexText(item.Id, text);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var batch = new List<(string Id, string Text)>();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var text = BuildSearchableText(item);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
batch.Add((item.Id, text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_engine.IndexTextBatch(batch);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Remove(id);
|
||||
}
|
||||
|
||||
_engine.Remove(id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ClearAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Clear();
|
||||
}
|
||||
|
||||
_engine.RemoveAll();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
options ??= new SearchOptions();
|
||||
|
||||
var semanticOptions = new SemanticSearchOptions
|
||||
{
|
||||
MaxResults = options.MaxResults,
|
||||
Language = options.Language,
|
||||
MatchScope = SemanticSearchMatchScope.Unconstrained,
|
||||
TextMatchType = SemanticSearchTextMatchType.Fuzzy,
|
||||
};
|
||||
|
||||
var matches = _engine.SearchText(query, semanticOptions);
|
||||
var results = new List<SearchResult<T>>();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var match in matches)
|
||||
{
|
||||
if (_itemsById.TryGetValue(match.ContentId, out var item))
|
||||
{
|
||||
results.Add(new SearchResult<T>
|
||||
{
|
||||
Item = item,
|
||||
Score = 100.0, // Semantic search doesn't return scores, use fixed value
|
||||
MatchKind = SearchMatchKind.Semantic,
|
||||
MatchSpans = null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the indexing process to complete.
|
||||
/// </summary>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
public async Task WaitForIndexingCompleteAsync(TimeSpan timeout)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
await _engine.WaitForIndexingCompleteAsync(timeout);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_engine.Dispose();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Clear();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private static string BuildSearchableText(T item)
|
||||
{
|
||||
var primary = item.SearchableText ?? string.Empty;
|
||||
var secondary = item.SecondarySearchableText;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secondary))
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
return $"{primary} {secondary}";
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the capabilities of the semantic search index.
|
||||
/// </summary>
|
||||
public class SemanticSearchCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether text lexical (keyword) search is available.
|
||||
/// </summary>
|
||||
public bool TextLexicalAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether text semantic (AI embedding) search is available.
|
||||
/// </summary>
|
||||
public bool TextSemanticAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether image semantic search is available.
|
||||
/// </summary>
|
||||
public bool ImageSemanticAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether image OCR search is available.
|
||||
/// </summary>
|
||||
public bool ImageOcrAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether any search capability is available.
|
||||
/// </summary>
|
||||
public bool AnyAvailable => TextLexicalAvailable || TextSemanticAvailable || ImageSemanticAvailable || ImageOcrAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether text search (lexical or semantic) is available.
|
||||
/// </summary>
|
||||
public bool TextSearchAvailable => TextLexicalAvailable || TextSemanticAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether image search (semantic or OCR) is available.
|
||||
/// </summary>
|
||||
public bool ImageSearchAvailable => ImageSemanticAvailable || ImageOcrAvailable;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the kind of content in a semantic search result.
|
||||
/// </summary>
|
||||
public enum SemanticSearchContentKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Text content.
|
||||
/// </summary>
|
||||
Text,
|
||||
|
||||
/// <summary>
|
||||
/// Image content.
|
||||
/// </summary>
|
||||
Image,
|
||||
}
|
||||
347
src/common/Common.Search/SemanticSearch/SemanticSearchEngine.cs
Normal file
347
src/common/Common.Search/SemanticSearch/SemanticSearchEngine.cs
Normal file
@@ -0,0 +1,347 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.Windows.AI.Search.Experimental;
|
||||
using Windows.Graphics.Imaging;
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// A semantic search engine powered by Windows App SDK AI Search APIs.
|
||||
/// Provides text and image indexing with lexical and semantic search capabilities.
|
||||
/// </summary>
|
||||
public sealed class SemanticSearchEngine : IDisposable
|
||||
{
|
||||
private readonly string _indexName;
|
||||
private AppContentIndexer? _indexer;
|
||||
private bool _disposed;
|
||||
private SemanticSearchCapabilities? _capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SemanticSearchEngine"/> class.
|
||||
/// </summary>
|
||||
/// <param name="indexName">The name of the search index.</param>
|
||||
public SemanticSearchEngine(string indexName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(indexName);
|
||||
_indexName = indexName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the index capabilities change.
|
||||
/// </summary>
|
||||
public event EventHandler<SemanticSearchCapabilities>? CapabilitiesChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the search engine is initialized.
|
||||
/// </summary>
|
||||
public bool IsInitialized => _indexer != null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current index capabilities, or null if not initialized.
|
||||
/// </summary>
|
||||
public SemanticSearchCapabilities? Capabilities => _capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search engine and creates or opens the index.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation. Returns true if initialization succeeded.</returns>
|
||||
public async Task<bool> InitializeAsync()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_indexer != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var result = AppContentIndexer.GetOrCreateIndex(_indexName);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_indexer = result.Indexer;
|
||||
|
||||
// Wait for index capabilities to be ready
|
||||
await _indexer.WaitForIndexCapabilitiesAsync();
|
||||
|
||||
// Load capabilities
|
||||
_capabilities = LoadCapabilities();
|
||||
|
||||
// Subscribe to capability changes
|
||||
_indexer.Listener.IndexCapabilitiesChanged += OnIndexCapabilitiesChanged;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the indexing process to complete.
|
||||
/// </summary>
|
||||
/// <param name="timeout">The maximum time to wait for indexing to complete.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WaitForIndexingCompleteAsync(TimeSpan timeout)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
|
||||
await _indexer!.WaitForIndexingIdleAsync(timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current index capabilities.
|
||||
/// </summary>
|
||||
/// <returns>The current capabilities of the search index.</returns>
|
||||
public SemanticSearchCapabilities GetCapabilities()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
|
||||
return _capabilities ?? LoadCapabilities();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates text content in the index.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier for the content.</param>
|
||||
/// <param name="text">The text content to index.</param>
|
||||
public void IndexText(string id, string text)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(text);
|
||||
|
||||
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
|
||||
_indexer!.AddOrUpdate(content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates multiple text contents in the index.
|
||||
/// </summary>
|
||||
/// <param name="items">A collection of id-text pairs to index.</param>
|
||||
public void IndexTextBatch(IEnumerable<(string Id, string Text)> items)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
foreach (var (id, text) in items)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
|
||||
_indexer!.AddOrUpdate(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates image content in the index.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier for the image.</param>
|
||||
/// <param name="bitmap">The image bitmap to index.</param>
|
||||
public void IndexImage(string id, SoftwareBitmap bitmap)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
var content = AppManagedIndexableAppContent.CreateFromBitmap(id, bitmap);
|
||||
_indexer!.AddOrUpdate(content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes content from the index by its identifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the content to remove.</param>
|
||||
public void Remove(string id)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
|
||||
_indexer!.Remove(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all content from the index.
|
||||
/// </summary>
|
||||
public void RemoveAll()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
|
||||
_indexer!.RemoveAll();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for text content in the index.
|
||||
/// </summary>
|
||||
/// <param name="searchText">The text to search for.</param>
|
||||
/// <param name="options">Optional search options.</param>
|
||||
/// <returns>A list of search results.</returns>
|
||||
public IReadOnlyList<SemanticSearchResult> SearchText(string searchText, SemanticSearchOptions? options = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
|
||||
|
||||
options ??= new SemanticSearchOptions();
|
||||
|
||||
var queryOptions = new TextQueryOptions
|
||||
{
|
||||
MatchScope = ConvertMatchScope(options.MatchScope),
|
||||
TextMatchType = ConvertTextMatchType(options.TextMatchType),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.Language))
|
||||
{
|
||||
queryOptions.Language = options.Language;
|
||||
}
|
||||
|
||||
var query = _indexer!.CreateTextQuery(searchText, queryOptions);
|
||||
var matches = query.GetNextMatches((uint)options.MaxResults);
|
||||
|
||||
return ConvertTextMatches(matches);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for image content in the index using text.
|
||||
/// </summary>
|
||||
/// <param name="searchText">The text to search for in images.</param>
|
||||
/// <param name="options">Optional search options.</param>
|
||||
/// <returns>A list of search results.</returns>
|
||||
public IReadOnlyList<SemanticSearchResult> SearchImages(string searchText, SemanticSearchOptions? options = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
|
||||
|
||||
options ??= new SemanticSearchOptions();
|
||||
|
||||
var queryOptions = new ImageQueryOptions
|
||||
{
|
||||
MatchScope = ConvertMatchScope(options.MatchScope),
|
||||
ImageOcrTextMatchType = ConvertTextMatchType(options.TextMatchType),
|
||||
};
|
||||
|
||||
var query = _indexer!.CreateImageQuery(searchText, queryOptions);
|
||||
var matches = query.GetNextMatches((uint)options.MaxResults);
|
||||
|
||||
return ConvertImageMatches(matches);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_indexer != null)
|
||||
{
|
||||
_indexer.Listener.IndexCapabilitiesChanged -= OnIndexCapabilitiesChanged;
|
||||
_indexer.Dispose();
|
||||
_indexer = null;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private SemanticSearchCapabilities LoadCapabilities()
|
||||
{
|
||||
var capabilities = _indexer!.GetIndexCapabilities();
|
||||
|
||||
return new SemanticSearchCapabilities
|
||||
{
|
||||
TextLexicalAvailable = IsCapabilityInitialized(capabilities, IndexCapability.TextLexical),
|
||||
TextSemanticAvailable = IsCapabilityInitialized(capabilities, IndexCapability.TextSemantic),
|
||||
ImageSemanticAvailable = IsCapabilityInitialized(capabilities, IndexCapability.ImageSemantic),
|
||||
ImageOcrAvailable = IsCapabilityInitialized(capabilities, IndexCapability.ImageOcr),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsCapabilityInitialized(IndexCapabilities capabilities, IndexCapability capability)
|
||||
{
|
||||
var state = capabilities.GetCapabilityState(capability);
|
||||
return state.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
}
|
||||
|
||||
private void OnIndexCapabilitiesChanged(AppContentIndexer indexer, IndexCapabilities capabilities)
|
||||
{
|
||||
_capabilities = LoadCapabilities();
|
||||
CapabilitiesChanged?.Invoke(this, _capabilities);
|
||||
}
|
||||
|
||||
private static QueryMatchScope ConvertMatchScope(SemanticSearchMatchScope scope)
|
||||
{
|
||||
return scope switch
|
||||
{
|
||||
SemanticSearchMatchScope.Unconstrained => QueryMatchScope.Unconstrained,
|
||||
SemanticSearchMatchScope.Region => QueryMatchScope.Region,
|
||||
SemanticSearchMatchScope.ContentItem => QueryMatchScope.ContentItem,
|
||||
_ => QueryMatchScope.Unconstrained,
|
||||
};
|
||||
}
|
||||
|
||||
private static TextLexicalMatchType ConvertTextMatchType(SemanticSearchTextMatchType matchType)
|
||||
{
|
||||
return matchType switch
|
||||
{
|
||||
SemanticSearchTextMatchType.Fuzzy => TextLexicalMatchType.Fuzzy,
|
||||
SemanticSearchTextMatchType.Exact => TextLexicalMatchType.Exact,
|
||||
_ => TextLexicalMatchType.Fuzzy,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SemanticSearchResult> ConvertTextMatches(IReadOnlyList<TextQueryMatch> matches)
|
||||
{
|
||||
var results = new List<SemanticSearchResult>();
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Text);
|
||||
|
||||
if (match.ContentKind == QueryMatchContentKind.AppManagedText &&
|
||||
match is AppManagedTextQueryMatch textMatch)
|
||||
{
|
||||
result.TextOffset = textMatch.TextOffset;
|
||||
result.TextLength = textMatch.TextLength;
|
||||
}
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SemanticSearchResult> ConvertImageMatches(IReadOnlyList<ImageQueryMatch> matches)
|
||||
{
|
||||
var results = new List<SemanticSearchResult>();
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Image);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
|
||||
private void ThrowIfNotInitialized()
|
||||
{
|
||||
if (_indexer == null)
|
||||
{
|
||||
throw new InvalidOperationException("Search engine is not initialized. Call InitializeAsync() first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the scope for semantic search matching.
|
||||
/// </summary>
|
||||
public enum SemanticSearchMatchScope
|
||||
{
|
||||
/// <summary>
|
||||
/// No constraints, uses both Lexical and Semantic matching.
|
||||
/// </summary>
|
||||
Unconstrained,
|
||||
|
||||
/// <summary>
|
||||
/// Restrict matching to a specific region.
|
||||
/// </summary>
|
||||
Region,
|
||||
|
||||
/// <summary>
|
||||
/// Restrict matching to a single content item.
|
||||
/// </summary>
|
||||
ContentItem,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring semantic search queries.
|
||||
/// </summary>
|
||||
public class SemanticSearchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the language for the search query (e.g., "en-US").
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the match scope for the search.
|
||||
/// </summary>
|
||||
public SemanticSearchMatchScope MatchScope { get; set; } = SemanticSearchMatchScope.Unconstrained;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text match type for lexical matching.
|
||||
/// </summary>
|
||||
public SemanticSearchTextMatchType TextMatchType { get; set; } = SemanticSearchTextMatchType.Fuzzy;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of results to return.
|
||||
/// </summary>
|
||||
public int MaxResults { get; set; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a search result from the semantic search engine.
|
||||
/// </summary>
|
||||
public class SemanticSearchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SemanticSearchResult"/> class.
|
||||
/// </summary>
|
||||
/// <param name="contentId">The unique identifier of the matched content.</param>
|
||||
/// <param name="contentKind">The kind of content matched (text or image).</param>
|
||||
public SemanticSearchResult(string contentId, SemanticSearchContentKind contentKind)
|
||||
{
|
||||
ContentId = contentId;
|
||||
ContentKind = contentKind;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique identifier of the matched content.
|
||||
/// </summary>
|
||||
public string ContentId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the kind of content that was matched.
|
||||
/// </summary>
|
||||
public SemanticSearchContentKind ContentKind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text offset where the match was found (for text matches only).
|
||||
/// </summary>
|
||||
public int TextOffset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the length of the matched text (for text matches only).
|
||||
/// </summary>
|
||||
public int TextLength { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the type of text matching for lexical searches.
|
||||
/// </summary>
|
||||
public enum SemanticSearchTextMatchType
|
||||
{
|
||||
/// <summary>
|
||||
/// Fuzzy matching allows spelling errors and approximate words.
|
||||
/// </summary>
|
||||
Fuzzy,
|
||||
|
||||
/// <summary>
|
||||
/// Exact matching requires exact text matches.
|
||||
/// </summary>
|
||||
Exact,
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using Common.Search;
|
||||
|
||||
namespace Settings.UI.Library
|
||||
{
|
||||
public enum EntryType
|
||||
@@ -11,7 +15,7 @@ namespace Settings.UI.Library
|
||||
SettingsExpander,
|
||||
}
|
||||
|
||||
public struct SettingEntry
|
||||
public struct SettingEntry : ISearchable
|
||||
{
|
||||
public EntryType Type { get; set; }
|
||||
|
||||
@@ -29,16 +33,23 @@ namespace Settings.UI.Library
|
||||
|
||||
public string Icon { get; set; }
|
||||
|
||||
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string parentElementName = null, string description = null, string icon = null)
|
||||
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string? parentElementName = null, string? description = null, string? icon = null)
|
||||
{
|
||||
Type = type;
|
||||
Header = header;
|
||||
PageTypeName = pageTypeName;
|
||||
ElementName = elementName;
|
||||
ElementUid = elementUid;
|
||||
ParentElementName = parentElementName;
|
||||
Description = description;
|
||||
Icon = icon;
|
||||
ParentElementName = parentElementName ?? string.Empty;
|
||||
Description = description ?? string.Empty;
|
||||
Icon = icon ?? string.Empty;
|
||||
}
|
||||
|
||||
// ISearchable implementation
|
||||
public readonly string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
|
||||
|
||||
public readonly string SearchableText => Header ?? string.Empty;
|
||||
|
||||
public readonly string? SecondarySearchableText => Description;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<ProjectReference Include="..\..\common\ManagedCommon\ManagedCommon.csproj" />
|
||||
<ProjectReference Include="..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
|
||||
<ProjectReference Include="..\..\modules\MouseUtils\MouseJump.Common\MouseJump.Common.csproj" />
|
||||
<ProjectReference Include="..\..\common\Common.Search\Common.Search.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Common.Search.FuzzSearch;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Services.Search
|
||||
{
|
||||
/// <summary>
|
||||
/// A search provider that uses fuzzy string matching for settings search.
|
||||
/// </summary>
|
||||
public sealed class FuzzSearchProvider : ISearchProvider
|
||||
{
|
||||
private readonly object _lockObject = new();
|
||||
private readonly Dictionary<string, (string HeaderNorm, string DescNorm)> _normalizedTextCache = new();
|
||||
private IReadOnlyList<SettingEntry> _entries = Array.Empty<SettingEntry>();
|
||||
private bool _isReady;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsReady
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _isReady;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task InitializeAsync(IReadOnlyList<SettingEntry> entries, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_normalizedTextCache.Clear();
|
||||
_entries = entries;
|
||||
_isReady = true;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public List<SettingEntry> Search(string query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
IReadOnlyList<SettingEntry> currentEntries;
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (!_isReady || _entries.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
currentEntries = _entries;
|
||||
}
|
||||
|
||||
var normalizedQuery = NormalizeString(query);
|
||||
var bag = new ConcurrentBag<(SettingEntry Hit, double Score)>();
|
||||
var po = new ParallelOptions
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Parallel.ForEach(currentEntries, po, entry =>
|
||||
{
|
||||
var (headerNorm, descNorm) = GetNormalizedTexts(entry);
|
||||
var captionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, headerNorm);
|
||||
double score = captionScoreResult.Score;
|
||||
|
||||
if (!string.IsNullOrEmpty(descNorm))
|
||||
{
|
||||
var descriptionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, descNorm);
|
||||
if (descriptionScoreResult.Success)
|
||||
{
|
||||
score = Math.Max(score, descriptionScoreResult.Score * 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
if (score > 0)
|
||||
{
|
||||
bag.Add((entry, score));
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return bag
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Select(r => r.Hit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_normalizedTextCache.Clear();
|
||||
_entries = Array.Empty<SettingEntry>();
|
||||
_isReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
private (string HeaderNorm, string DescNorm) GetNormalizedTexts(SettingEntry entry)
|
||||
{
|
||||
if (entry.ElementUid == null && entry.Header == null)
|
||||
{
|
||||
return (NormalizeString(entry.Header), NormalizeString(entry.Description));
|
||||
}
|
||||
|
||||
var key = entry.ElementUid ?? $"{entry.PageTypeName}|{entry.ElementName}";
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_normalizedTextCache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
var headerNorm = NormalizeString(entry.Header);
|
||||
var descNorm = NormalizeString(entry.Description);
|
||||
lock (_lockObject)
|
||||
{
|
||||
_normalizedTextCache[key] = (headerNorm, descNorm);
|
||||
}
|
||||
|
||||
return (headerNorm, descNorm);
|
||||
}
|
||||
|
||||
private static string NormalizeString(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
|
||||
var stringBuilder = new StringBuilder();
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Services.Search
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines a pluggable search provider interface for settings search.
|
||||
/// </summary>
|
||||
public interface ISearchProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the provider is ready to search.
|
||||
/// </summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search provider with the given index entries.
|
||||
/// </summary>
|
||||
/// <param name="entries">The setting entries to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task InitializeAsync(IReadOnlyList<SettingEntry> entries, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for settings matching the query.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A list of matching setting entries ordered by relevance.</returns>
|
||||
List<SettingEntry> Search(string query, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the index and releases resources.
|
||||
/// </summary>
|
||||
void Clear();
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,15 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Common.Search.FuzzSearch;
|
||||
using Microsoft.PowerToys.Settings.UI.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.Services.Search;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
using Microsoft.Windows.ApplicationModel.Resources;
|
||||
using Settings.UI.Library;
|
||||
@@ -27,14 +22,43 @@ namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
{
|
||||
private static readonly object _lockObject = new();
|
||||
private static readonly Dictionary<string, string> _pageNameCache = [];
|
||||
private static readonly Dictionary<string, (string HeaderNorm, string DescNorm)> _normalizedTextCache = new();
|
||||
private static readonly Dictionary<string, Type> _pageTypeCache = new();
|
||||
private static ImmutableArray<SettingEntry> _index = [];
|
||||
private static ISearchProvider _searchProvider;
|
||||
private static bool _isIndexBuilt;
|
||||
private static bool _isIndexBuilding;
|
||||
private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json";
|
||||
private static JsonSerializerOptions _serializerOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the search provider. Defaults to FuzzSearchProvider if not set.
|
||||
/// </summary>
|
||||
public static ISearchProvider SearchProvider
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_searchProvider ??= new FuzzSearchProvider();
|
||||
return _searchProvider;
|
||||
}
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_searchProvider = value;
|
||||
|
||||
// If index is already built, reinitialize the new provider
|
||||
if (_isIndexBuilt && _searchProvider != null)
|
||||
{
|
||||
_searchProvider.InitializeAsync(_index).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static ImmutableArray<SettingEntry> Index
|
||||
{
|
||||
get
|
||||
@@ -52,7 +76,7 @@ namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _isIndexBuilt;
|
||||
return _isIndexBuilt && SearchProvider.IsReady;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +93,6 @@ namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
_isIndexBuilding = true;
|
||||
|
||||
// Clear caches on rebuild
|
||||
_normalizedTextCache.Clear();
|
||||
_pageTypeCache.Clear();
|
||||
}
|
||||
|
||||
@@ -78,12 +101,17 @@ namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
var builder = ImmutableArray.CreateBuilder<SettingEntry>();
|
||||
LoadIndexFromPrebuiltData(builder);
|
||||
|
||||
ImmutableArray<SettingEntry> builtIndex;
|
||||
lock (_lockObject)
|
||||
{
|
||||
_index = builder.ToImmutable();
|
||||
builtIndex = _index;
|
||||
_isIndexBuilt = true;
|
||||
_isIndexBuilding = false;
|
||||
}
|
||||
|
||||
// Initialize the search provider with the index
|
||||
SearchProvider.InitializeAsync(builtIndex).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -209,57 +237,32 @@ namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
return [];
|
||||
}
|
||||
|
||||
var currentIndex = Index;
|
||||
if (currentIndex.IsEmpty)
|
||||
if (!IsIndexReady)
|
||||
{
|
||||
Debug.WriteLine("[SearchIndexService] Search called but index is empty.");
|
||||
Debug.WriteLine("[SearchIndexService] Search called but index is not ready.");
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalizedQuery = NormalizeString(query);
|
||||
var bag = new ConcurrentBag<(SettingEntry Hit, double Score)>();
|
||||
var po = new ParallelOptions
|
||||
{
|
||||
CancellationToken = token,
|
||||
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
|
||||
};
|
||||
// Delegate search to the pluggable search provider
|
||||
var results = SearchProvider.Search(query, token);
|
||||
|
||||
try
|
||||
// Filter results to ensure page types are valid
|
||||
return FilterValidPageTypes(results);
|
||||
}
|
||||
|
||||
private static List<SettingEntry> FilterValidPageTypes(List<SettingEntry> results)
|
||||
{
|
||||
var filtered = new List<SettingEntry>();
|
||||
foreach (var entry in results)
|
||||
{
|
||||
Parallel.ForEach(currentIndex, po, entry =>
|
||||
var pageType = GetPageTypeFromName(entry.PageTypeName);
|
||||
if (pageType != null)
|
||||
{
|
||||
var (headerNorm, descNorm) = GetNormalizedTexts(entry);
|
||||
var captionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, headerNorm);
|
||||
double score = captionScoreResult.Score;
|
||||
|
||||
if (!string.IsNullOrEmpty(descNorm))
|
||||
{
|
||||
var descriptionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, descNorm);
|
||||
if (descriptionScoreResult.Success)
|
||||
{
|
||||
score = Math.Max(score, descriptionScoreResult.Score * 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
if (score > 0)
|
||||
{
|
||||
var pageType = GetPageTypeFromName(entry.PageTypeName);
|
||||
if (pageType != null)
|
||||
{
|
||||
bag.Add((entry, score));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return [];
|
||||
filtered.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return bag
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Select(r => r.Hit)
|
||||
.ToList();
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private static Type GetPageTypeFromName(string pageTypeName)
|
||||
@@ -283,53 +286,6 @@ namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
}
|
||||
}
|
||||
|
||||
private static (string HeaderNorm, string DescNorm) GetNormalizedTexts(SettingEntry entry)
|
||||
{
|
||||
if (entry.ElementUid == null && entry.Header == null)
|
||||
{
|
||||
return (NormalizeString(entry.Header), NormalizeString(entry.Description));
|
||||
}
|
||||
|
||||
var key = entry.ElementUid ?? $"{entry.PageTypeName}|{entry.ElementName}";
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_normalizedTextCache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
var headerNorm = NormalizeString(entry.Header);
|
||||
var descNorm = NormalizeString(entry.Description);
|
||||
lock (_lockObject)
|
||||
{
|
||||
_normalizedTextCache[key] = (headerNorm, descNorm);
|
||||
}
|
||||
|
||||
return (headerNorm, descNorm);
|
||||
}
|
||||
|
||||
private static string NormalizeString(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
|
||||
var stringBuilder = new StringBuilder();
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
public static string GetLocalizedPageName(string pageTypeName)
|
||||
{
|
||||
return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty;
|
||||
|
||||
140
src/settings-ui/Settings.UI/Services/SettingsSearchService.cs
Normal file
140
src/settings-ui/Settings.UI/Services/SettingsSearchService.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Common.Search;
|
||||
using Common.Search.FuzzSearch;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// A service that provides search functionality for settings using pluggable search engines.
|
||||
/// </summary>
|
||||
public sealed class SettingsSearchService : IDisposable
|
||||
{
|
||||
private readonly ISearchEngine<SettingEntry> _searchEngine;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SettingsSearchService"/> class with the default FuzzSearchEngine.
|
||||
/// </summary>
|
||||
public SettingsSearchService()
|
||||
: this(new FuzzSearchEngine<SettingEntry>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SettingsSearchService"/> class with a custom search engine.
|
||||
/// </summary>
|
||||
/// <param name="searchEngine">The search engine to use.</param>
|
||||
public SettingsSearchService(ISearchEngine<SettingEntry> searchEngine)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(searchEngine);
|
||||
_searchEngine = searchEngine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the search service is ready.
|
||||
/// </summary>
|
||||
public bool IsReady => _searchEngine.IsReady;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the search engine capabilities.
|
||||
/// </summary>
|
||||
public SearchEngineCapabilities Capabilities => _searchEngine.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search service with the given setting entries.
|
||||
/// </summary>
|
||||
/// <param name="entries">The setting entries to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
public async Task InitializeAsync(IEnumerable<SettingEntry> entries, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ThrowIfDisposed();
|
||||
|
||||
await _searchEngine.InitializeAsync(cancellationToken);
|
||||
await _searchEngine.IndexBatchAsync(entries, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the index with new entries.
|
||||
/// </summary>
|
||||
/// <param name="entries">The new setting entries to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
public async Task RebuildIndexAsync(IEnumerable<SettingEntry> entries, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ThrowIfDisposed();
|
||||
|
||||
await _searchEngine.ClearAsync(cancellationToken);
|
||||
await _searchEngine.IndexBatchAsync(entries, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for settings matching the query.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A list of matching setting entries.</returns>
|
||||
public async Task<List<SettingEntry>> SearchAsync(string query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var results = await _searchEngine.SearchAsync(query, cancellationToken: cancellationToken);
|
||||
return results.Select(r => r.Item).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for settings matching the query with detailed results.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="options">Search options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A list of search results with scoring information.</returns>
|
||||
public async Task<IReadOnlyList<SearchResult<SettingEntry>>> SearchWithScoresAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Array.Empty<SearchResult<SettingEntry>>();
|
||||
}
|
||||
|
||||
return await _searchEngine.SearchAsync(query, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_searchEngine.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user