From a3b8dc6cb8f9391899192f87aecd469a9b91f5dc Mon Sep 17 00:00:00 2001 From: Shawn Yuan <128874481+shuaiyuanxx@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:13:55 +0800 Subject: [PATCH 001/100] Advanced Paste: AI pasting enhancement (#42374) ## Summary of the Pull Request * Add multiple endpoint support for paste with AI * Add Local AI support for paste AI * Advanced AI implementation ## PR Checklist - [x] Closes: #32960 - [x] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **Tests:** Added/updated and all pass - [x] **Localization:** All end-user-facing strings can be localized - [x] **Dev docs:** Added/updated - [x] **New binaries:** Added on the required places - [x] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [x] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [x] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [x] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed ### GPO - [x] Paste with AI should not be available if the original GPO for paste AI is set to false - [x] Paste with AI should be controlled within endpoint granularity - [x] Advanced Paste UI should disable AI ability if GPO is set to disable for any llm ### Paste AI - [x] Every AI endpoint should work as expected - [x] Default prompt should be able to give a reasonable result - [x] Local AI should work as expected ### Advanced AI - [x] Open AI and Azure OPENAI should be able to configure as advanced AI endpoint - [x] Advanced AI should be able to pick up functions correctly to do the transformation and give reasonable result --------- Signed-off-by: Shawn Yuan Signed-off-by: Shuai Yuan Signed-off-by: Shawn Yuan (from Dev Box) Co-authored-by: Leilei Zhang Co-authored-by: Niels Laute Co-authored-by: Kai Tao Co-authored-by: Kai Tao <69313318+vanzue@users.noreply.github.com> Co-authored-by: vanzue Co-authored-by: Gordon Lam (SH) --- .github/actions/spell-check/expect.txt | 21 + .pipelines/ESRPSigning_core.json | 4 +- .pipelines/v2/ci.yml | 2 +- .pipelines/v2/templates/job-build-project.yml | 20 + .vscode/launch.json | 13 +- Directory.Packages.props | 28 +- NOTICE.md | 1 - PowerToys.sln | 11 + .../generateDscResourcesWxs.ps1 | 87 -- installer/PowerToysSetupVNext/Settings.wxs | 13 +- .../generateAllFileComponents.ps1 | 2 + src/common/GPOWrapper/GPOWrapper.cpp | 32 + src/common/GPOWrapper/GPOWrapper.h | 8 + src/common/GPOWrapper/GPOWrapper.idl | 8 + .../FoundryLocal/FoundryCachedModel.cs | 7 + .../FoundryLocal/FoundryCatalogModel.cs | 61 + .../FoundryLocal/FoundryClient.cs | 208 ++++ .../FoundryLocal/FoundryJsonContext.cs | 17 + .../FoundryLocal/ModelSettings.cs | 16 + .../FoundryLocal/PromptTemplate.cs | 16 + .../FoundryLocal/Runtime.cs | 16 + .../FoundryLocalModelProvider.cs | 185 +++ .../HardwareAccelerator.cs | 22 + .../ILanguageModelProvider.cs | 22 + .../LanguageModelProvider.csproj | 20 + .../LanguageModelService.cs | 106 ++ .../LanguageModelProvider/ModelDetails.cs | 32 + src/common/utils/gpo.h | 48 + ...rToys.Settings.DSC.Schema.Generator.csproj | 5 - ...SettingsResourceAdvancedPasteModuleTest.cs | 3 +- src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj | 6 +- src/gpo/assets/PowerToys.admx | 85 +- src/gpo/assets/en-US/PowerToys.adml | 51 +- .../Mocks/IntegrationTestUserSettings.cs | 66 + .../AIServiceBatchIntegrationTests.cs | 36 +- .../KernelServiceIntegrationTests.cs | 11 +- .../AdvancedPaste/AdvancedPaste.csproj | 41 +- .../AdvancedPaste/AdvancedPasteXAML/App.xaml | 1 + .../AdvancedPasteXAML/App.xaml.cs | 17 +- .../AnimatedContentControl.xaml | 2 +- .../ClipboardHistoryItemPreviewControl.xaml | 60 + ...ClipboardHistoryItemPreviewControl.xaml.cs | 127 ++ .../AdvancedPasteXAML/Controls/PromptBox.xaml | 206 +++- .../Controls/PromptBox.xaml.cs | 28 + .../CountToInvertedVisibilityConverter.cs | 24 + .../DateTimeToFriendlyStringConverter.cs | 103 ++ .../Converters/ServiceTypeToIconConverter.cs | 42 + .../AdvancedPasteXAML/MainWindow.xaml | 4 +- .../AdvancedPasteXAML/Pages/MainPage.xaml | 295 +++-- .../AdvancedPasteXAML/Pages/MainPage.xaml.cs | 20 +- .../AdvancedPasteXAML/Styles/Button.xaml | 135 ++ .../Assets/AdvancedPaste/Gradient.png | Bin 3545 -> 26499 bytes .../Helpers/AIServiceUsageHelper.cs | 54 + .../Helpers/ClipboardItemHelper.cs | 84 ++ .../Helpers/DataPackageHelpers.cs | 60 +- .../AdvancedPaste/Helpers/IUserSettings.cs | 7 +- .../AdvancedPaste/Helpers/UserSettings.cs | 126 +- .../AdvancedPaste/Models/ClipboardItem.cs | 10 +- .../Services/AdvancedAIKernelService.cs | 227 ++++ .../CustomActionTransformResult.cs | 22 + .../CustomActionTransformService.cs | 200 +++ .../FoundryLocalPasteProvider.cs | 194 +++ .../ICustomActionTransformService.cs | 17 + .../CustomActions/IPasteAIProvider.cs | 19 + .../CustomActions/IPasteAIProviderFactory.cs | 11 + .../CustomActions/LocalModelPasteProvider.cs | 43 + .../Services/CustomActions/PasteAIConfig.cs | 32 + .../CustomActions/PasteAIProviderFactory.cs | 61 + .../PasteAIProviderRegistration.cs | 22 + .../Services/CustomActions/PasteAIRequest.cs | 19 + .../SemanticKernelPasteProvider.cs | 203 ++++ .../EnhancedVaultCredentialsProvider.cs | 188 +++ .../Services/IAICredentialsProvider.cs | 19 +- .../Services/ICustomTextTransformService.cs | 14 - .../Services/IKernelRuntimeConfiguration.cs | 27 + .../Services/KernelServiceBase.cs | 158 ++- .../OpenAI/CustomTextTransformService.cs | 113 -- .../Services/OpenAI/KernelService.cs | 34 - .../OpenAI/PromptModerationService.cs | 12 +- .../OpenAI/VaultCredentialsProvider.cs | 37 - .../Services/PasteFormatExecutor.cs | 7 +- .../Strings/en-us/Resources.resw | 86 +- .../AdvancedPasteEndpointUsageEvent.cs | 28 + .../ViewModels/OptionsViewModel.cs | 342 +++++- .../AdvancedPasteModuleInterface.vcxproj | 2 +- .../AdvancedPasteModuleInterface/dllmain.cpp | 117 +- .../TestFiles/settings.json | 2 +- .../TemplateCmdPalExtension/nuget.config | 12 - .../AIProviderConfigurationSnapshot.cs | 35 + .../Settings.UI.Library/AIServiceType.cs | 26 + .../AIServiceTypeExtensions.cs | 88 ++ .../AIServiceTypeMetadata.cs | 44 + .../AIServiceTypeRegistry.cs | 222 ++++ .../AdvancedPasteCustomAction.cs | 9 + .../AdvancedPasteProperties.cs | 34 +- .../PasteAIConfiguration.cs | 103 ++ .../PasteAIProviderDefinition.cs | 175 +++ .../Settings/Icons/Models/Anthropic.svg | 10 + .../Assets/Settings/Icons/Models/Azure.svg | 23 + .../Assets/Settings/Icons/Models/AzureAI.svg | 9 + .../Assets/Settings/Icons/Models/Bedrock.svg | 10 + .../Settings/Icons/Models/FoundryLocal.svg | 59 + .../Assets/Settings/Icons/Models/Gemini.svg | 20 + .../Settings/Icons/Models/HuggingFace.svg | 8 + .../Assets/Settings/Icons/Models/Mistral.svg | 24 + .../Assets/Settings/Icons/Models/Ollama.svg | 3 + .../Assets/Settings/Icons/Models/Onnx.svg | 18 + .../Settings/Icons/Models/OpenAI.dark.svg | 15 + .../Settings/Icons/Models/OpenAI.light.svg | 10 + .../Settings/Icons/Models/WindowsML.svg | 74 ++ .../Converters/ServiceTypeToIconConverter.cs | 30 + .../Settings.UI/PowerToys.Settings.csproj | 16 +- .../ModelPicker/FoundryLocalModelPicker.xaml | 192 +++ .../FoundryLocalModelPicker.xaml.cs | 457 +++++++ .../SettingsXAML/Views/AdvancedPastePage.xaml | 583 ++++++--- .../Views/AdvancedPastePage.xaml.cs | 1081 ++++++++++++++++- .../Settings.UI/Strings/en-us/Resources.resw | 126 +- .../ViewModels/AdvancedPasteViewModel.cs | 884 +++++++++++++- .../BugReportTool/ReportGPOValues.cpp | 8 + 119 files changed, 8441 insertions(+), 958 deletions(-) delete mode 100644 installer/PowerToysSetup/generateDscResourcesWxs.ps1 create mode 100644 src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs create mode 100644 src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs create mode 100644 src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs create mode 100644 src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs create mode 100644 src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs create mode 100644 src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs create mode 100644 src/common/LanguageModelProvider/FoundryLocal/Runtime.cs create mode 100644 src/common/LanguageModelProvider/FoundryLocalModelProvider.cs create mode 100644 src/common/LanguageModelProvider/HardwareAccelerator.cs create mode 100644 src/common/LanguageModelProvider/ILanguageModelProvider.cs create mode 100644 src/common/LanguageModelProvider/LanguageModelProvider.csproj create mode 100644 src/common/LanguageModelProvider/LanguageModelService.cs create mode 100644 src/common/LanguageModelProvider/ModelDetails.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/CountToInvertedVisibilityConverter.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/DateTimeToFriendlyStringConverter.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/ServiceTypeToIconConverter.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Styles/Button.xaml create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs delete mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs delete mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs delete mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs delete mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs create mode 100644 src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs delete mode 100644 src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/nuget.config create mode 100644 src/settings-ui/Settings.UI.Library/AIProviderConfigurationSnapshot.cs create mode 100644 src/settings-ui/Settings.UI.Library/AIServiceType.cs create mode 100644 src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs create mode 100644 src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs create mode 100644 src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs create mode 100644 src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs create mode 100644 src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Anthropic.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Bedrock.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/HuggingFace.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg create mode 100644 src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs create mode 100644 src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml create mode 100644 src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 63469ee8ee..c50b54b1d9 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -34,6 +34,7 @@ AFX AGGREGATABLE AHK AHybrid +AIUI akv ALarger ALIGNRIGHT @@ -45,6 +46,7 @@ Allmodule ALLOWUNDO ALLVIEW ALPHATYPE +amazonbedrock AModifier amr ANDSCANS @@ -112,6 +114,9 @@ AValid AWAYMODE azcliversion azman +azureaiinference +azureinference +azureopenai bbwe BCIE bck @@ -204,6 +209,7 @@ CIBUILD cidl CIELCh cim +claude CImage cla CLASSDC @@ -236,6 +242,7 @@ CODENAME codereview Codespaces Coen +cognitiveservices COINIT colid colorconv @@ -420,6 +427,7 @@ DROPFILES DSTINVERT DString DSVG +dto DTo DUMMYUNIONNAME dutil @@ -551,6 +559,7 @@ FIXEDSYS flac flyouts FMask +foundrylocal fmtid FOF FOFX @@ -615,6 +624,8 @@ GValue gwl GWLP GWLSTYLE +googleai +googlegemini hangeul Hanzi Hardlines @@ -677,6 +688,7 @@ hmonitor homies homljgmgpmcbpjbnjpfijnhipfkiclkd HOOKPROC +huggingface HORZRES HORZSIZE Hostbackdropbrush @@ -1140,6 +1152,7 @@ nullrefs NOOWNERZORDER NOPARENTNOTIFY NOPREFIX +NPU NOREDIRECTIONBITMAP NOREDRAW NOREMOVE @@ -1199,6 +1212,9 @@ opencode OPENFILENAME opensource openxmlformats +ollama +Olllama +onnx OPTIMIZEFORINVOKE ORPHANEDDIALOGTITLE ORSCANS @@ -1388,6 +1404,8 @@ pwsz pwtd QDC qit +QNN +Qualcomm QITAB QITABENT qoi @@ -1936,6 +1954,7 @@ wcsicmp wcsncpy wcsnicmp WCT +WCRAPI WDA wdm wdp @@ -1979,6 +1998,8 @@ WINL winlogon winmd WINNT +windowsml +winml winres winrt winsdk diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index d99fabf0eb..5b6dd50bb5 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -5,7 +5,6 @@ { "MatchedPath": [ "*.resources.dll", - "WinUI3Apps\\Assets\\Settings\\Scripts\\*.ps1", "PowerToys.ActionRunner.exe", @@ -27,6 +26,7 @@ "PowerToys.GPOWrapper.dll", "PowerToys.GPOWrapperProjection.dll", "PowerToys.AllExperiments.dll", + "LanguageModelProvider.dll", "Common.Search.dll", @@ -346,6 +346,8 @@ "Testably.Abstractions.FileSystem.Interface.dll", "WinUI3Apps\\Testably.Abstractions.FileSystem.Interface.dll", "ColorCode.Core.dll", + "Microsoft.SemanticKernel.Connectors.Ollama.dll", + "OllamaSharp.dll", "UnitsNet.dll", "UtfUnknown.dll", diff --git a/.pipelines/v2/ci.yml b/.pipelines/v2/ci.yml index 6b0105a38a..297c268757 100644 --- a/.pipelines/v2/ci.yml +++ b/.pipelines/v2/ci.yml @@ -32,7 +32,7 @@ parameters: - name: enableMsBuildCaching type: boolean displayName: "Enable MSBuild Caching" - default: true + default: false - name: runTests type: boolean displayName: "Run Tests" diff --git a/.pipelines/v2/templates/job-build-project.yml b/.pipelines/v2/templates/job-build-project.yml index 5c2f74e9e0..34e4ce4340 100644 --- a/.pipelines/v2/templates/job-build-project.yml +++ b/.pipelines/v2/templates/job-build-project.yml @@ -266,6 +266,26 @@ jobs: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) + - task: VSBuild@1 + displayName: Generate DSC artifacts for ARM64 + condition: and(succeeded(), eq(variables['BuildPlatform'], 'arm64')) + inputs: + solution: PowerToys.sln + vsVersion: 17.0 + msbuildArgs: >- + -restore + /p:Configuration=$(BuildConfiguration) + /p:Platform=x64 + /t:DSC\PowerToys_Settings_DSC_Schema_Generator + /bl:$(LogOutputDirectory)\build-dsc-generator.binlog + ${{ parameters.additionalBuildOptions }} + $(MSBuildCacheParameters) + $(RestoreAdditionalProjectSourcesArg) + platform: x64 + configuration: $(BuildConfiguration) + msbuildArchitecture: x64 + maximumCpuCount: true + # Build PowerToys.DSC.exe for ARM64 (x64 uses existing binary from previous build) - task: VSBuild@1 displayName: Build PowerToys.DSC.exe (x64 for generating manifests) diff --git a/.vscode/launch.json b/.vscode/launch.json index b2d2bca9ac..940ff302de 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -38,6 +38,17 @@ "env": {}, "console": "internalConsole", "stopAtEntry": false - } + }, + { + "name": "Run AdvancedPaste (managed, no build, ARCH configurable)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}\\${input:arch}\\Debug\\WinUI3Apps\\PowerToys.AdvancedPaste.exe", + "args": [], + "cwd": "${workspaceFolder}", + "env": {}, + "console": "internalConsole", + "stopAtEntry": false + }, ] } \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 129abadb00..38d587dfee 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,4 +1,4 @@ - + true true @@ -9,7 +9,6 @@ - @@ -40,12 +39,24 @@ - - - + + + + + + + - + + + + + + + + + @@ -74,7 +85,7 @@ - + @@ -95,6 +106,7 @@ + @@ -127,4 +139,4 @@ - + \ No newline at end of file diff --git a/NOTICE.md b/NOTICE.md index 1998ea805a..a0d87d429c 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1495,7 +1495,6 @@ SOFTWARE. - AdaptiveCards.Rendering.WinUI3 - AdaptiveCards.Templating - Appium.WebDriver -- Azure.AI.OpenAI - CoenM.ImageSharp.ImageHash - CommunityToolkit.Common - CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock diff --git a/PowerToys.sln b/PowerToys.sln index f4f2e1bd0f..b343993b68 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -828,6 +828,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightSwitch.UITests", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj", "{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanguageModelProvider", "src\common\LanguageModelProvider\LanguageModelProvider.csproj", "{45354F4F-1414-45CE-B600-51CD1209FD19}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.UI.ViewModels.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.UI.ViewModels.UnitTests\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj", "{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}" EndProject Global @@ -3008,6 +3010,14 @@ Global {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|ARM64.Build.0 = Release|ARM64 {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.ActiveCfg = Release|x64 {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.Build.0 = Release|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|ARM64.Build.0 = Debug|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|x64.ActiveCfg = Debug|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Debug|x64.Build.0 = Debug|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|ARM64.ActiveCfg = Release|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|ARM64.Build.0 = Release|ARM64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|x64.ActiveCfg = Release|x64 + {45354F4F-1414-45CE-B600-51CD1209FD19}.Release|x64.Build.0 = Release|x64 {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.ActiveCfg = Debug|ARM64 {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.Build.0 = Debug|ARM64 {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|x64.ActiveCfg = Debug|x64 @@ -3344,6 +3354,7 @@ Global {3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477} {F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94} {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {45354F4F-1414-45CE-B600-51CD1209FD19} = {1AFB6476-670D-4E80-A464-657E01DFF482} {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/installer/PowerToysSetup/generateDscResourcesWxs.ps1 b/installer/PowerToysSetup/generateDscResourcesWxs.ps1 deleted file mode 100644 index f76fd71953..0000000000 --- a/installer/PowerToysSetup/generateDscResourcesWxs.ps1 +++ /dev/null @@ -1,87 +0,0 @@ -[CmdletBinding()] -Param( - [Parameter(Mandatory = $True)] - [string]$dscWxsFile, - [Parameter(Mandatory = $True)] - [string]$Platform, - [Parameter(Mandatory = $True)] - [string]$Configuration -) - -$ErrorActionPreference = 'Stop' -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$buildOutputDir = Join-Path $scriptDir "..\..\$Platform\$Configuration" - -if (-not (Test-Path $buildOutputDir)) { - Write-Error "Build output directory not found: '$buildOutputDir'" - exit 1 -} - -$dscFiles = Get-ChildItem -Path $buildOutputDir -Filter "microsoft.powertoys.*.settings.dsc.resource.json" -File - -if (-not $dscFiles) { - Write-Warning "No DSC manifest files found in '$buildOutputDir'" - $wxsContent = @" - - - - - - - -"@ - Set-Content -Path $dscWxsFile -Value $wxsContent - exit 0 -} - -Write-Host "Found $($dscFiles.Count) DSC manifest file(s)" - -$wxsContent = @" - - - - -"@ - -$componentRefs = @() -foreach ($file in $dscFiles) { - $componentId = "DscResource_" + ($file.BaseName -replace '[^A-Za-z0-9_]', '_') - $fileId = $componentId + "_File" - $guid = [System.Guid]::NewGuid().ToString().ToUpper() - $componentRefs += $componentId - - $wxsContent += @" - - - - - - - -"@ -} - -$wxsContent += @" - - - - - -"@ - -foreach ($componentId in $componentRefs) { - $wxsContent += @" - - -"@ -} - -$wxsContent += @" - - - - -"@ - -Set-Content -Path $dscWxsFile -Value $wxsContent -Write-Host "Generated DSC resources WiX fragment: '$dscWxsFile'" diff --git a/installer/PowerToysSetupVNext/Settings.wxs b/installer/PowerToysSetupVNext/Settings.wxs index f9e5312ea7..cf7cf7f727 100644 --- a/installer/PowerToysSetupVNext/Settings.wxs +++ b/installer/PowerToysSetupVNext/Settings.wxs @@ -14,11 +14,16 @@ + + + - + + + @@ -45,6 +50,11 @@ + + + + + @@ -67,6 +77,7 @@ + diff --git a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 index 440cf38418..84616af567 100644 --- a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 +++ b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 @@ -307,10 +307,12 @@ Generate-FileList -fileDepsJson "" -fileListName SettingsV2AssetsFiles -wxsFileP Generate-FileList -fileDepsJson "" -fileListName SettingsV2AssetsModulesFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Modules\" Generate-FileList -fileDepsJson "" -fileListName SettingsV2OOBEAssetsModulesFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Modules\OOBE\" Generate-FileList -fileDepsJson "" -fileListName SettingsV2OOBEAssetsFluentIconsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Icons\" +Generate-FileList -fileDepsJson "" -fileListName SettingsV2IconsModelsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Icons\Models\" Generate-FileComponents -fileListName "SettingsV2AssetsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs Generate-FileComponents -fileListName "SettingsV2AssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs Generate-FileComponents -fileListName "SettingsV2OOBEAssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs Generate-FileComponents -fileListName "SettingsV2OOBEAssetsFluentIconsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs +Generate-FileComponents -fileListName "SettingsV2IconsModelsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs #Workspaces Generate-FileList -fileDepsJson "" -fileListName WorkspacesImagesComponentFiles -wxsFilePath $PSScriptRoot\Workspaces.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Workspaces\" diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp index 361255f66f..b8df132fe4 100644 --- a/src/common/GPOWrapper/GPOWrapper.cpp +++ b/src/common/GPOWrapper/GPOWrapper.cpp @@ -192,6 +192,38 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getAllowedAdvancedPasteOnlineAIModelsValue()); } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteOpenAIValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteOpenAIValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteAzureOpenAIValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteAzureOpenAIValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteAzureAIInferenceValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteAzureAIInferenceValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteMistralValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteMistralValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteGoogleValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteGoogleValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteAnthropicValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteAnthropicValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteOllamaValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteOllamaValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteFoundryLocalValue() + { + return static_cast(powertoys_gpo::getAllowedAdvancedPasteFoundryLocalValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredNewPlusEnabledValue() { return static_cast(powertoys_gpo::getConfiguredNewPlusEnabledValue()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index c0fff9f542..4f60a989db 100644 --- a/src/common/GPOWrapper/GPOWrapper.h +++ b/src/common/GPOWrapper/GPOWrapper.h @@ -54,6 +54,14 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredQoiPreviewEnabledValue(); static GpoRuleConfigured GetConfiguredQoiThumbnailsEnabledValue(); static GpoRuleConfigured GetAllowedAdvancedPasteOnlineAIModelsValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureAIInferenceValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteMistralValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAnthropicValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue(); static GpoRuleConfigured GetConfiguredNewPlusEnabledValue(); static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue(); static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue(); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index 630beab9c9..d1af719998 100644 --- a/src/common/GPOWrapper/GPOWrapper.idl +++ b/src/common/GPOWrapper/GPOWrapper.idl @@ -58,6 +58,14 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredQoiPreviewEnabledValue(); static GpoRuleConfigured GetConfiguredQoiThumbnailsEnabledValue(); static GpoRuleConfigured GetAllowedAdvancedPasteOnlineAIModelsValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureAIInferenceValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteMistralValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAnthropicValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue(); static GpoRuleConfigured GetConfiguredNewPlusEnabledValue(); static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue(); static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue(); diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs new file mode 100644 index 0000000000..489a779179 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs @@ -0,0 +1,7 @@ +// 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 LanguageModelProvider.FoundryLocal; + +internal sealed record FoundryCachedModel(string Name, string? Id); diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs new file mode 100644 index 0000000000..413bb47316 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs @@ -0,0 +1,61 @@ +// 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.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record FoundryCatalogModel +{ + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; init; } = string.Empty; + + [JsonPropertyName("providerType")] + public string ProviderType { get; init; } = string.Empty; + + [JsonPropertyName("uri")] + public string Uri { get; init; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; init; } = string.Empty; + + [JsonPropertyName("modelType")] + public string ModelType { get; init; } = string.Empty; + + [JsonPropertyName("promptTemplate")] + public PromptTemplate PromptTemplate { get; init; } = default!; + + [JsonPropertyName("publisher")] + public string Publisher { get; init; } = string.Empty; + + [JsonPropertyName("task")] + public string Task { get; init; } = string.Empty; + + [JsonPropertyName("runtime")] + public Runtime Runtime { get; init; } = default!; + + [JsonPropertyName("fileSizeMb")] + public long FileSizeMb { get; init; } + + [JsonPropertyName("modelSettings")] + public ModelSettings ModelSettings { get; init; } = default!; + + [JsonPropertyName("alias")] + public string Alias { get; init; } = string.Empty; + + [JsonPropertyName("supportsToolCalling")] + public bool SupportsToolCalling { get; init; } + + [JsonPropertyName("license")] + public string License { get; init; } = string.Empty; + + [JsonPropertyName("licenseDescription")] + public string LicenseDescription { get; init; } = string.Empty; + + [JsonPropertyName("parentModelUri")] + public string ParentModelUri { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs new file mode 100644 index 0000000000..84c38c49f2 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs @@ -0,0 +1,208 @@ +// 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 ManagedCommon; +using Microsoft.AI.Foundry.Local; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed class FoundryClient +{ + public static async Task CreateAsync() + { + try + { + Logger.LogInfo("[FoundryClient] Creating Foundry Local client"); + + var manager = new FoundryLocalManager(); + + // Check if service is already running + if (manager.IsServiceRunning) + { + Logger.LogInfo("[FoundryClient] Foundry service is already running"); + return new FoundryClient(manager); + } + + // Start the service using SDK's method + Logger.LogInfo("[FoundryClient] Starting Foundry service using manager.StartServiceAsync()"); + await manager.StartServiceAsync().ConfigureAwait(false); + + Logger.LogInfo("[FoundryClient] Foundry service started successfully"); + return new FoundryClient(manager); + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error creating client: {ex.Message}"); + if (ex.InnerException != null) + { + Logger.LogError($"[FoundryClient] Inner exception: {ex.InnerException.Message}"); + } + + return null; + } + } + + private readonly FoundryLocalManager _foundryManager; + private readonly List _catalogModels = []; + + private FoundryClient(FoundryLocalManager foundryManager) + { + _foundryManager = foundryManager; + } + + public Task GetServiceUrl() + { + try + { + return Task.FromResult(_foundryManager.Endpoint?.ToString()); + } + catch + { + return Task.FromResult(null); + } + } + + public Uri? GetServiceUri() + { + try + { + return _foundryManager.ServiceUri; + } + catch + { + return null; + } + } + + public async Task> ListCatalogModels() + { + if (_catalogModels.Count > 0) + { + return _catalogModels; + } + + try + { + Logger.LogInfo("[FoundryClient] Listing catalog models"); + var models = await _foundryManager.ListCatalogModelsAsync().ConfigureAwait(false); + + if (models != null) + { + foreach (var model in models) + { + _catalogModels.Add(new FoundryCatalogModel + { + Name = model.ModelId ?? string.Empty, + DisplayName = model.DisplayName ?? string.Empty, + ProviderType = model.ProviderType ?? string.Empty, + Uri = model.Uri ?? string.Empty, + Version = model.Version ?? string.Empty, + ModelType = model.ModelType ?? string.Empty, + Publisher = model.Publisher ?? string.Empty, + Task = model.Task ?? string.Empty, + FileSizeMb = model.FileSizeMb, + Alias = model.Alias ?? string.Empty, + License = model.License ?? string.Empty, + LicenseDescription = model.LicenseDescription ?? string.Empty, + ParentModelUri = model.ParentModelUri ?? string.Empty, + SupportsToolCalling = model.SupportsToolCalling, + }); + } + + Logger.LogInfo($"[FoundryClient] Found {_catalogModels.Count} catalog models"); + } + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error listing catalog models: {ex.Message}"); + + // Surfacing errors here prevents listing other providers; swallow and return cached list instead. + } + + return _catalogModels; + } + + public async Task> ListCachedModels() + { + try + { + Logger.LogInfo("[FoundryClient] Listing cached models"); + var cachedModels = await _foundryManager.ListCachedModelsAsync().ConfigureAwait(false); + var catalogModels = await ListCatalogModels().ConfigureAwait(false); + + List models = []; + + foreach (var model in cachedModels) + { + var catalogModel = catalogModels.FirstOrDefault(m => m.Name == model.ModelId); + var alias = catalogModel?.Alias ?? model.Alias; + models.Add(new FoundryCachedModel(model.ModelId ?? string.Empty, alias)); + } + + Logger.LogInfo($"[FoundryClient] Found {models.Count} cached models"); + return models; + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error listing cached models: {ex.Message}"); + return []; + } + } + + public async Task IsModelLoaded(string modelId) + { + try + { + var loadedModels = await _foundryManager.ListLoadedModelsAsync().ConfigureAwait(false); + var isLoaded = loadedModels.Any(m => m.ModelId == modelId); + Logger.LogInfo($"[FoundryClient] IsModelLoaded({modelId}): {isLoaded}"); + Logger.LogInfo($"[FoundryClient] Loaded models: {string.Join(", ", loadedModels.Select(m => m.ModelId))}"); + return isLoaded; + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] IsModelLoaded exception: {ex.Message}"); + return false; + } + } + + public async Task EnsureModelLoaded(string modelId) + { + try + { + Logger.LogInfo($"[FoundryClient] EnsureModelLoaded called with: {modelId}"); + + // Check if already loaded + if (await IsModelLoaded(modelId).ConfigureAwait(false)) + { + Logger.LogInfo($"[FoundryClient] Model already loaded: {modelId}"); + return true; + } + + // Check if model exists in cache + var cachedModels = await ListCachedModels().ConfigureAwait(false); + Logger.LogInfo($"[FoundryClient] Cached models: {string.Join(", ", cachedModels.Select(m => m.Name))}"); + + if (!cachedModels.Any(m => m.Name == modelId)) + { + Logger.LogWarning($"[FoundryClient] Model not found in cache: {modelId}"); + return false; + } + + // Load the model + Logger.LogInfo($"[FoundryClient] Loading model: {modelId}"); + await _foundryManager.LoadModelAsync(modelId).ConfigureAwait(false); + + // Verify it's loaded + var loaded = await IsModelLoaded(modelId).ConfigureAwait(false); + Logger.LogInfo($"[FoundryClient] Model load result: {loaded}"); + return loaded; + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] EnsureModelLoaded exception: {ex.Message}"); + return false; + } + } +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs new file mode 100644 index 0000000000..5dcb4076ed --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs @@ -0,0 +1,17 @@ +// 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.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = false)] +[JsonSerializable(typeof(FoundryCatalogModel))] +[JsonSerializable(typeof(List))] +internal sealed partial class FoundryJsonContext : JsonSerializerContext +{ +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs b/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs new file mode 100644 index 0000000000..fda91217eb --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs @@ -0,0 +1,16 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record ModelSettings +{ + // The sample shows an empty array; keep it open-ended. + [JsonPropertyName("parameters")] + public List Parameters { get; init; } = []; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs b/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs new file mode 100644 index 0000000000..a2cbb9fe45 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs @@ -0,0 +1,16 @@ +// 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.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record PromptTemplate +{ + [JsonPropertyName("assistant")] + public string Assistant { get; init; } = string.Empty; + + [JsonPropertyName("prompt")] + public string Prompt { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs b/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs new file mode 100644 index 0000000000..e2019c8f87 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs @@ -0,0 +1,16 @@ +// 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.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record Runtime +{ + [JsonPropertyName("deviceType")] + public string DeviceType { get; init; } = string.Empty; + + [JsonPropertyName("executionProvider")] + public string ExecutionProvider { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs new file mode 100644 index 0000000000..b866ab05d9 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs @@ -0,0 +1,185 @@ +// 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.ClientModel; +using LanguageModelProvider.FoundryLocal; +using ManagedCommon; +using Microsoft.Extensions.AI; +using OpenAI; + +namespace LanguageModelProvider; + +public sealed class FoundryLocalModelProvider : ILanguageModelProvider +{ + private IEnumerable? _downloadedModels; + private FoundryClient? _foundryManager; + private string? _serviceUrl; + + public static FoundryLocalModelProvider Instance { get; } = new(); + + public string Name => "FoundryLocal"; + + public string ProviderDescription => "The model will run locally via Foundry Local"; + + public string UrlPrefix => "fl://"; + + public IChatClient? GetIChatClient(string url) + { + try + { + Logger.LogInfo($"[FoundryLocal] GetIChatClient called with url: {url}"); + InitializeAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Logger.LogError($"[FoundryLocal] Failed to initialize: {ex.Message}"); + return null; + } + + if (string.IsNullOrWhiteSpace(_serviceUrl) || _foundryManager == null) + { + Logger.LogError("[FoundryLocal] Service URL or manager is null"); + return null; + } + + // Extract model ID from URL (format: fl://modelname) + var modelId = url.Replace(UrlPrefix, string.Empty).Trim('/'); + if (string.IsNullOrWhiteSpace(modelId)) + { + Logger.LogError("[FoundryLocal] Model ID is empty after extraction"); + return null; + } + + Logger.LogInfo($"[FoundryLocal] Extracted model ID: {modelId}"); + + // Ensure the model is loaded before returning chat client + try + { + var isLoaded = _foundryManager.EnsureModelLoaded(modelId).GetAwaiter().GetResult(); + if (!isLoaded) + { + Logger.LogError($"[FoundryLocal] Failed to load model: {modelId}"); + return null; + } + + Logger.LogInfo($"[FoundryLocal] Model is loaded: {modelId}"); + } + catch (Exception ex) + { + Logger.LogError($"[FoundryLocal] Exception ensuring model loaded: {ex.Message}"); + return null; + } + + // Use ServiceUri instead of Endpoint since Endpoint already includes /v1 + var baseUri = _foundryManager.GetServiceUri(); + if (baseUri == null) + { + Logger.LogError("[FoundryLocal] Service URI is null"); + return null; + } + + var endpointUri = new Uri($"{baseUri.ToString().TrimEnd('/')}/v1"); + Logger.LogInfo($"[FoundryLocal] Creating OpenAI client with endpoint: {endpointUri}"); + Logger.LogInfo($"[FoundryLocal] Model ID for chat client: {modelId}"); + + return new OpenAIClient( + new ApiKeyCredential("none"), + new OpenAIClientOptions { Endpoint = endpointUri }) + .GetChatClient(modelId) + .AsIChatClient(); + } + + public string GetIChatClientString(string url) + { + try + { + InitializeAsync().GetAwaiter().GetResult(); + } + catch + { + return string.Empty; + } + + var modelId = url.Split('/').LastOrDefault(); + + if (string.IsNullOrWhiteSpace(_serviceUrl) || string.IsNullOrWhiteSpace(modelId)) + { + return string.Empty; + } + + return $"new OpenAIClient(new ApiKeyCredential(\"none\"), new OpenAIClientOptions{{ Endpoint = new Uri(\"{_serviceUrl}/v1\") }}).GetChatClient(\"{modelId}\").AsIChatClient()"; + } + + public async Task> GetModelsAsync(bool ignoreCached = false, CancellationToken cancelationToken = default) + { + if (ignoreCached) + { + Logger.LogInfo("[FoundryLocal] Ignoring cached models, resetting"); + Reset(); + } + + await InitializeAsync(cancelationToken); + + Logger.LogInfo($"[FoundryLocal] Returning {_downloadedModels?.Count() ?? 0} downloaded models"); + return _downloadedModels ?? []; + } + + private void Reset() + { + _downloadedModels = null; + _ = InitializeAsync(); + } + + private async Task InitializeAsync(CancellationToken cancelationToken = default) + { + if (_foundryManager != null && _downloadedModels != null && _downloadedModels.Any()) + { + return; + } + + Logger.LogInfo("[FoundryLocal] Initializing provider"); + _foundryManager ??= await FoundryClient.CreateAsync(); + + if (_foundryManager == null) + { + Logger.LogError("[FoundryLocal] Failed to create Foundry client"); + return; + } + + _serviceUrl ??= await _foundryManager.GetServiceUrl(); + Logger.LogInfo($"[FoundryLocal] Service URL: {_serviceUrl}"); + + var cachedModels = await _foundryManager.ListCachedModels(); + Logger.LogInfo($"[FoundryLocal] Found {cachedModels.Count} cached models"); + + List downloadedModels = []; + + foreach (var model in cachedModels) + { + Logger.LogInfo($"[FoundryLocal] Adding unmatched cached model: {model.Name}"); + downloadedModels.Add(new ModelDetails + { + Id = $"fl-{model.Name}", + Name = model.Name, + Url = $"{UrlPrefix}{model.Name}", + Description = $"{model.Name} running locally with Foundry Local", + HardwareAccelerators = [HardwareAccelerator.FOUNDRYLOCAL], + SupportedOnQualcomm = true, + ProviderModelDetails = model, + }); + } + + _downloadedModels = downloadedModels; + Logger.LogInfo($"[FoundryLocal] Initialization complete. Total downloaded models: {downloadedModels.Count}"); + } + + public async Task IsAvailable() + { + Logger.LogInfo("[FoundryLocal] Checking availability"); + await InitializeAsync(); + var available = _foundryManager != null; + Logger.LogInfo($"[FoundryLocal] Available: {available}"); + return available; + } +} diff --git a/src/common/LanguageModelProvider/HardwareAccelerator.cs b/src/common/LanguageModelProvider/HardwareAccelerator.cs new file mode 100644 index 0000000000..d2c94b8155 --- /dev/null +++ b/src/common/LanguageModelProvider/HardwareAccelerator.cs @@ -0,0 +1,22 @@ +// 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 LanguageModelProvider; + +public enum HardwareAccelerator +{ + CPU, + DML, + QNN, + WCRAPI, + OLLAMA, + OPENAI, + FOUNDRYLOCAL, + LEMONADE, + NPU, + GPU, + VitisAI, + OpenVINO, + NvTensorRT, +} diff --git a/src/common/LanguageModelProvider/ILanguageModelProvider.cs b/src/common/LanguageModelProvider/ILanguageModelProvider.cs new file mode 100644 index 0000000000..60b3d99386 --- /dev/null +++ b/src/common/LanguageModelProvider/ILanguageModelProvider.cs @@ -0,0 +1,22 @@ +// 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.Extensions.AI; + +namespace LanguageModelProvider; + +public interface ILanguageModelProvider +{ + string Name { get; } + + string UrlPrefix { get; } + + string ProviderDescription { get; } + + Task> GetModelsAsync(bool ignoreCached = false, CancellationToken cancelationToken = default); + + IChatClient? GetIChatClient(string url); + + string GetIChatClientString(string url); +} diff --git a/src/common/LanguageModelProvider/LanguageModelProvider.csproj b/src/common/LanguageModelProvider/LanguageModelProvider.csproj new file mode 100644 index 0000000000..4dba9247a3 --- /dev/null +++ b/src/common/LanguageModelProvider/LanguageModelProvider.csproj @@ -0,0 +1,20 @@ + + + + + + enable + enable + + + + + + + + + + + + + diff --git a/src/common/LanguageModelProvider/LanguageModelService.cs b/src/common/LanguageModelProvider/LanguageModelService.cs new file mode 100644 index 0000000000..1cfb3b3c49 --- /dev/null +++ b/src/common/LanguageModelProvider/LanguageModelService.cs @@ -0,0 +1,106 @@ +// 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 Microsoft.Extensions.AI; + +namespace LanguageModelProvider; + +public sealed class LanguageModelService +{ + private readonly ConcurrentDictionary _providersByPrefix; + + public LanguageModelService(IEnumerable providers) + { + ArgumentNullException.ThrowIfNull(providers); + + _providersByPrefix = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var provider in providers) + { + if (!string.IsNullOrWhiteSpace(provider.UrlPrefix)) + { + _providersByPrefix[provider.UrlPrefix] = provider; + } + } + } + + public static LanguageModelService CreateDefault() + { + return new LanguageModelService(new[] + { + FoundryLocalModelProvider.Instance, + }); + } + + public IReadOnlyCollection Providers => _providersByPrefix.Values.ToArray(); + + public bool RegisterProvider(ILanguageModelProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + + if (string.IsNullOrWhiteSpace(provider.UrlPrefix)) + { + throw new ArgumentException("Provider must supply a URL prefix.", nameof(provider)); + } + + _providersByPrefix[provider.UrlPrefix] = provider; + return true; + } + + public ILanguageModelProvider? GetProviderFor(string? modelReference) + { + if (string.IsNullOrWhiteSpace(modelReference)) + { + return null; + } + + foreach (var provider in _providersByPrefix.Values) + { + if (modelReference.StartsWith(provider.UrlPrefix, StringComparison.OrdinalIgnoreCase)) + { + return provider; + } + } + + return null; + } + + public async Task> GetModelsAsync(bool refresh = false, CancellationToken cancellationToken = default) + { + List models = []; + + foreach (var provider in _providersByPrefix.Values) + { + cancellationToken.ThrowIfCancellationRequested(); + var providerModels = await provider.GetModelsAsync(refresh, cancellationToken).ConfigureAwait(false); + models.AddRange(providerModels); + } + + return models; + } + + public IChatClient? GetClient(ModelDetails model) + { + if (model is null) + { + return null; + } + + var reference = !string.IsNullOrWhiteSpace(model.Url) ? model.Url : model.Id; + return GetClient(reference); + } + + public IChatClient? GetClient(string? modelReference) + { + if (string.IsNullOrWhiteSpace(modelReference)) + { + return null; + } + + var provider = GetProviderFor(modelReference); + + return provider?.GetIChatClient(modelReference); + } +} diff --git a/src/common/LanguageModelProvider/ModelDetails.cs b/src/common/LanguageModelProvider/ModelDetails.cs new file mode 100644 index 0000000000..2e68ca6feb --- /dev/null +++ b/src/common/LanguageModelProvider/ModelDetails.cs @@ -0,0 +1,32 @@ +// 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; + +namespace LanguageModelProvider; + +public class ModelDetails +{ + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public long Size { get; set; } + + public bool IsUserAdded { get; set; } + + public string Icon { get; set; } = string.Empty; + + public List HardwareAccelerators { get; set; } = []; + + public bool SupportedOnQualcomm { get; set; } + + public string License { get; set; } = string.Empty; + + public object? ProviderModelDetails { get; set; } +} diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h index 471cefe480..db1e0f72e8 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -82,6 +82,14 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_RUN_AT_STARTUP = L"ConfigureRunAtStartup"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_LAUNCHER_ALL_PLUGINS = L"PowerLauncherAllPluginsEnabledState"; const std::wstring POLICY_ALLOW_ADVANCED_PASTE_ONLINE_AI_MODELS = L"AllowPowerToysAdvancedPasteOnlineAIModels"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_OPENAI = L"AllowAdvancedPasteOpenAI"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_AZURE_OPENAI = L"AllowAdvancedPasteAzureOpenAI"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_AZURE_AI_INFERENCE = L"AllowAdvancedPasteAzureAIInference"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_MISTRAL = L"AllowAdvancedPasteMistral"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_GOOGLE = L"AllowAdvancedPasteGoogle"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_ANTHROPIC = L"AllowAdvancedPasteAnthropic"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_OLLAMA = L"AllowAdvancedPasteOllama"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL = L"AllowAdvancedPasteFoundryLocal"; const std::wstring POLICY_MWB_CLIPBOARD_SHARING_ENABLED = L"MwbClipboardSharingEnabled"; const std::wstring POLICY_MWB_FILE_TRANSFER_ENABLED = L"MwbFileTransferEnabled"; const std::wstring POLICY_MWB_USE_ORIGINAL_USER_INTERFACE = L"MwbUseOriginalUserInterface"; @@ -575,6 +583,46 @@ namespace powertoys_gpo return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_ONLINE_AI_MODELS); } + inline gpo_rule_configured_t getAllowedAdvancedPasteOpenAIValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_OPENAI); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteAzureOpenAIValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_AZURE_OPENAI); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteAzureAIInferenceValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_AZURE_AI_INFERENCE); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteMistralValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_MISTRAL); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteGoogleValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_GOOGLE); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteAnthropicValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_ANTHROPIC); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteOllamaValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_OLLAMA); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteFoundryLocalValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL); + } + inline gpo_rule_configured_t getConfiguredMwbClipboardSharingEnabledValue() { return getConfiguredValue(POLICY_MWB_CLIPBOARD_SHARING_ENABLED); diff --git a/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj b/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj index 3186a01d43..b36e602d25 100644 --- a/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj +++ b/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj @@ -33,9 +33,4 @@ - - - - - diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs index deae2eb832..45fd36d10e 100644 --- a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs @@ -23,7 +23,8 @@ public sealed class SettingsResourceAdvancedPasteModuleTest : SettingsResourceMo { s.Properties.ShowCustomPreview = !s.Properties.ShowCustomPreview; s.Properties.CloseAfterLosingFocus = !s.Properties.CloseAfterLosingFocus; - s.Properties.IsAdvancedAIEnabled = !s.Properties.IsAdvancedAIEnabled; + + // s.Properties.IsAdvancedAIEnabled = !s.Properties.IsAdvancedAIEnabled; s.Properties.AdvancedPasteUIShortcut = new HotkeySettings { Key = "mock", diff --git a/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj b/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj index 230cd4556b..c0b9cafd30 100644 --- a/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj +++ b/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj @@ -41,8 +41,8 @@ - - - + + + \ No newline at end of file diff --git a/src/gpo/assets/PowerToys.admx b/src/gpo/assets/PowerToys.admx index 07d4f44bde..4b77a6783f 100644 --- a/src/gpo/assets/PowerToys.admx +++ b/src/gpo/assets/PowerToys.admx @@ -1,11 +1,11 @@ - + - + @@ -26,6 +26,7 @@ + @@ -614,6 +615,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml index 2703358bb0..1bfa55866d 100644 --- a/src/gpo/assets/en-US/PowerToys.adml +++ b/src/gpo/assets/en-US/PowerToys.adml @@ -1,7 +1,7 @@ - + PowerToys PowerToys @@ -33,6 +33,7 @@ PowerToys version 0.88.0 or later PowerToys version 0.89.0 or later PowerToys version 0.90.0 or later + PowerToys version 0.96.0 or later From PowerToys version 0.64.0 until PowerToys version 0.87.1 This policy configures the enabled state for all PowerToys utilities. @@ -291,6 +292,54 @@ If you don't configure this policy, the user will be able to control the setting QOI file preview: Configure enabled state QOI file thumbnail: Configure enabled state Allow using online AI models + Advanced Paste: Allow OpenAI endpoint + This policy controls whether users can use the OpenAI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use OpenAI as their AI provider. + +If you disable this policy, users will not be able to select or use OpenAI endpoint in Advanced Paste settings. + Advanced Paste: Allow Azure OpenAI endpoint + This policy controls whether users can use the Azure OpenAI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Azure OpenAI as their AI provider. + +If you disable this policy, users will not be able to select or use Azure OpenAI endpoint in Advanced Paste settings. + Advanced Paste: Allow Azure AI Inference endpoint + This policy controls whether users can use the Azure AI Inference endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Azure AI Inference as their AI provider. + +If you disable this policy, users will not be able to select or use Azure AI Inference endpoint in Advanced Paste settings. + Advanced Paste: Allow Mistral endpoint + This policy controls whether users can use the Mistral AI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Mistral as their AI provider. + +If you disable this policy, users will not be able to select or use Mistral endpoint in Advanced Paste settings. + Advanced Paste: Allow Google endpoint + This policy controls whether users can use the Google (Gemini) endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Google as their AI provider. + +If you disable this policy, users will not be able to select or use Google endpoint in Advanced Paste settings. + Advanced Paste: Allow Anthropic endpoint + This policy controls whether users can use the Anthropic (Claude) endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Anthropic as their AI provider. + +If you disable this policy, users will not be able to select or use Anthropic endpoint in Advanced Paste settings. + Advanced Paste: Allow Ollama endpoint + This policy controls whether users can use the Ollama local model endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Ollama as their AI provider. + +If you disable this policy, users will not be able to select or use Ollama endpoint in Advanced Paste settings. + Advanced Paste: Allow Foundry Local endpoint + This policy controls whether users can use the Foundry Local model endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Foundry Local as their AI provider. + +If you disable this policy, users will not be able to select or use Foundry Local endpoint in Advanced Paste settings. Clipboard sharing enabled File transfer enabled Original user interface is available diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs new file mode 100644 index 0000000000..f6c2f5098d --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs @@ -0,0 +1,66 @@ +// 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.Collections.ObjectModel; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.UnitTests.Mocks; + +/// +/// Minimal implementation used by integration tests that +/// need to construct the runtime Advanced Paste services. +/// +internal sealed class IntegrationTestUserSettings : IUserSettings +{ + private readonly PasteAIConfiguration _configuration; + private readonly IReadOnlyList _customActions; + private readonly IReadOnlyList _additionalActions; + + public IntegrationTestUserSettings() + { + var provider = new PasteAIProviderDefinition + { + Id = "integration-openai", + EnableAdvancedAI = true, + ServiceTypeKind = AIServiceType.OpenAI, + ModelName = "gpt-4o", + ModerationEnabled = true, + }; + + _configuration = new PasteAIConfiguration + { + ActiveProviderId = provider.Id, + Providers = new ObservableCollection { provider }, + }; + + _customActions = Array.Empty(); + _additionalActions = Array.Empty(); + } + + public bool IsAIEnabled => true; + + public bool ShowCustomPreview => false; + + public bool CloseAfterLosingFocus => false; + + public IReadOnlyList CustomActions => _customActions; + + public IReadOnlyList AdditionalActions => _additionalActions; + + public PasteAIConfiguration PasteAIConfiguration => _configuration; + + public event EventHandler Changed; + + public Task SetActiveAIProviderAsync(string providerId) + { + _configuration.ActiveProviderId = providerId ?? string.Empty; + Changed?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs index 3782b057f1..17b8139bad 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs @@ -13,6 +13,8 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Services.OpenAI; using AdvancedPaste.UnitTests.Mocks; using ManagedCommon; @@ -79,7 +81,9 @@ public sealed class AIServiceBatchIntegrationTests Assert.IsTrue(results.Count <= inputs.Count); CollectionAssert.AreEqual(results.Select(result => result.ToInput()).ToList(), inputs.Take(results.Count).ToList()); + #pragma warning disable IL2026, IL3050 // The tests rely on runtime JSON serialization for ad-hoc data files. async Task WriteResultsAsync() => await File.WriteAllTextAsync(resultsFile, JsonSerializer.Serialize(results, SerializerOptions)); + #pragma warning restore IL2026, IL3050 Logger.LogInfo($"Starting {nameof(TestGenerateBatchResults)}; Count={inputs.Count}, InCache={results.Count}"); @@ -101,8 +105,12 @@ public sealed class AIServiceBatchIntegrationTests await WriteResultsAsync(); } - private static async Task> GetDataListAsync(string filePath) => - File.Exists(filePath) ? JsonSerializer.Deserialize>(await File.ReadAllTextAsync(filePath)) : []; + private static async Task> GetDataListAsync(string filePath) + { + #pragma warning disable IL2026, IL3050 // Tests only run locally and can depend on runtime JSON serialization. + return File.Exists(filePath) ? JsonSerializer.Deserialize>(await File.ReadAllTextAsync(filePath)) : []; + #pragma warning restore IL2026, IL3050 + } private static async Task GetTextOutputAsync(BatchTestInput input, PasteFormats format) { @@ -130,23 +138,35 @@ public sealed class AIServiceBatchIntegrationTests private static async Task GetOutputDataPackageAsync(BatchTestInput batchTestInput, PasteFormats format) { - VaultCredentialsProvider credentialsProvider = new(); - PromptModerationService promptModerationService = new(credentialsProvider); + var services = CreateServices(); NoOpProgress progress = new(); - CustomTextTransformService customTextTransformService = new(credentialsProvider, promptModerationService); switch (format) { case PasteFormats.CustomTextTransformation: - return DataPackageHelpers.CreateFromText(await customTextTransformService.TransformTextAsync(batchTestInput.Prompt, batchTestInput.Clipboard, CancellationToken.None, progress)); + var transformResult = await services.CustomActionTransformService.TransformTextAsync(batchTestInput.Prompt, batchTestInput.Clipboard, CancellationToken.None, progress); + return DataPackageHelpers.CreateFromText(transformResult.Content ?? string.Empty); case PasteFormats.KernelQuery: var clipboardData = DataPackageHelpers.CreateFromText(batchTestInput.Clipboard).GetView(); - KernelService kernelService = new(new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, customTextTransformService); - return await kernelService.TransformClipboardAsync(batchTestInput.Prompt, clipboardData, isSavedQuery: false, CancellationToken.None, progress); + return await services.KernelService.TransformClipboardAsync(batchTestInput.Prompt, clipboardData, isSavedQuery: false, CancellationToken.None, progress); default: throw new InvalidOperationException($"Unexpected format {format}"); } } + + private static IntegrationTestServices CreateServices() + { + IntegrationTestUserSettings userSettings = new(); + EnhancedVaultCredentialsProvider credentialsProvider = new(userSettings); + PromptModerationService promptModerationService = new(credentialsProvider); + PasteAIProviderFactory providerFactory = new(); + ICustomActionTransformService customActionTransformService = new CustomActionTransformService(promptModerationService, providerFactory, credentialsProvider, userSettings); + IKernelService kernelService = new AdvancedAIKernelService(credentialsProvider, new NoOpKernelQueryCacheService(), promptModerationService, userSettings, customActionTransformService); + + return new IntegrationTestServices(customActionTransformService, kernelService); + } + + private readonly record struct IntegrationTestServices(ICustomActionTransformService CustomActionTransformService, IKernelService KernelService); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs index 998534cf5e..7c16089cd5 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs @@ -11,6 +11,8 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Services.OpenAI; using AdvancedPaste.Telemetry; using AdvancedPaste.UnitTests.Mocks; @@ -27,16 +29,19 @@ namespace AdvancedPaste.UnitTests.ServicesTests; public sealed class KernelServiceIntegrationTests : IDisposable { private const string StandardImageFile = "image_with_text_example.png"; - private KernelService _kernelService; + private IKernelService _kernelService; private AdvancedPasteEventListener _eventListener; [TestInitialize] public void TestInitialize() { - VaultCredentialsProvider credentialsProvider = new(); + IntegrationTestUserSettings userSettings = new(); + EnhancedVaultCredentialsProvider credentialsProvider = new(userSettings); PromptModerationService promptModerationService = new(credentialsProvider); + PasteAIProviderFactory providerFactory = new(); + CustomActionTransformService customActionTransformService = new(promptModerationService, providerFactory, credentialsProvider, userSettings); - _kernelService = new KernelService(new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, new CustomTextTransformService(credentialsProvider, promptModerationService)); + _kernelService = new AdvancedAIKernelService(credentialsProvider, new NoOpKernelQueryCacheService(), promptModerationService, userSettings, customActionTransformService); _eventListener = new(); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj index fba18de07c..54c1343c93 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj @@ -33,11 +33,14 @@ + + + @@ -49,7 +52,6 @@ - @@ -57,10 +59,17 @@ + + + + + + + @@ -102,6 +111,7 @@ + @@ -114,9 +124,38 @@ true + + + MSBuild:Compile + + MSBuild:Compile + + + MSBuild:Compile + + + + + + + Assets\Settings\Icons\Models\%(Filename)%(Extension) + PreserveNewest + + + + PreserveNewest + + + PreserveNewest + + + + PreserveNewest + + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml index da79c36a11..df6ed811ac 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml @@ -9,6 +9,7 @@ + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs index 3ac3baa9d0..2737fb44c2 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs @@ -10,10 +10,10 @@ using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Settings; using AdvancedPaste.ViewModels; using ManagedCommon; @@ -77,11 +77,12 @@ namespace AdvancedPaste { services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }).Build(); @@ -111,7 +112,11 @@ namespace AdvancedPaste /// Invoked when the application is launched. /// /// Details about the launch request and process. +#if DEBUG + protected async override void OnLaunched(LaunchActivatedEventArgs args) +#else protected override void OnLaunched(LaunchActivatedEventArgs args) +#endif { var cmdArgs = Environment.GetCommandLineArgs(); if (cmdArgs?.Length > 1) @@ -133,6 +138,10 @@ namespace AdvancedPaste { ProcessNamedPipe(cmdArgs[2]); } + +#if DEBUG + await ShowWindow(); // This allows for direct access without using PowerToys Runner, not all functionality might work +#endif } private void ProcessNamedPipe(string pipeName) diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml index f03a579821..a250fdffdc 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml @@ -11,7 +11,7 @@ - + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml new file mode 100644 index 0000000000..e008d35a9a --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs new file mode 100644 index 0000000000..a28c87ac61 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs @@ -0,0 +1,127 @@ +// 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 AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace AdvancedPaste.Controls +{ + public sealed partial class ClipboardHistoryItemPreviewControl : UserControl + { + public static readonly DependencyProperty ClipboardItemProperty = DependencyProperty.Register( + nameof(ClipboardItem), + typeof(ClipboardItem), + typeof(ClipboardHistoryItemPreviewControl), + new PropertyMetadata(defaultValue: null, OnClipboardItemChanged)); + + public ClipboardItem ClipboardItem + { + get => (ClipboardItem)GetValue(ClipboardItemProperty); + set => SetValue(ClipboardItemProperty, value); + } + + // Computed properties for display + public string Header => ClipboardItem != null ? GetHeaderFromFormat(ClipboardItem.Format) : string.Empty; + + public string IconGlyph => ClipboardItem != null ? GetGlyphFromFormat(ClipboardItem.Format) : string.Empty; + + public string ContentText => ClipboardItem?.Content ?? string.Empty; + + public ImageSource ContentImage => ClipboardItem?.Image; + + public DateTimeOffset? Timestamp => ClipboardItem?.Timestamp ?? ClipboardItem?.Item?.Timestamp; + + public bool HasImage => ContentImage is not null; + + public bool HasText => !string.IsNullOrEmpty(ContentText) && !HasImage; + + public bool HasGlyph => !HasImage && !HasText && !string.IsNullOrEmpty(IconGlyph); + + public ClipboardHistoryItemPreviewControl() + { + InitializeComponent(); + } + + private static void OnClipboardItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is ClipboardHistoryItemPreviewControl control) + { + // Notify bindings that all computed properties may have changed + control.Bindings.Update(); + } + } + + private static string GetHeaderFromFormat(ClipboardFormat format) + { + // Check flags in priority order (most specific first) + if (format.HasFlag(ClipboardFormat.Image)) + { + return GetStringOrFallback("ClipboardPreviewCategoryImage", "Image"); + } + + if (format.HasFlag(ClipboardFormat.Video)) + { + return GetStringOrFallback("ClipboardPreviewCategoryVideo", "Video"); + } + + if (format.HasFlag(ClipboardFormat.Audio)) + { + return GetStringOrFallback("ClipboardPreviewCategoryAudio", "Audio"); + } + + if (format.HasFlag(ClipboardFormat.File)) + { + return GetStringOrFallback("ClipboardPreviewCategoryFile", "File"); + } + + if (format.HasFlag(ClipboardFormat.Text) || format.HasFlag(ClipboardFormat.Html)) + { + return GetStringOrFallback("ClipboardPreviewCategoryText", "Text"); + } + + return GetStringOrFallback("ClipboardPreviewCategoryUnknown", "Clipboard"); + } + + private static string GetGlyphFromFormat(ClipboardFormat format) + { + // Check flags in priority order (most specific first) + if (format.HasFlag(ClipboardFormat.Image)) + { + return "\uEB9F"; // Image icon + } + + if (format.HasFlag(ClipboardFormat.Video)) + { + return "\uE714"; // Video icon + } + + if (format.HasFlag(ClipboardFormat.Audio)) + { + return "\uE189"; // Audio icon + } + + if (format.HasFlag(ClipboardFormat.File)) + { + return "\uE8A5"; // File icon + } + + if (format.HasFlag(ClipboardFormat.Text) || format.HasFlag(ClipboardFormat.Html)) + { + return "\uE8D2"; // Text icon + } + + return "\uE77B"; // Generic clipboard icon + } + + private static string GetStringOrFallback(string resourceKey, string fallback) + { + var value = ResourceLoaderInstance.ResourceLoader.GetString(resourceKey); + return string.IsNullOrEmpty(value) ? fallback : value; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml index dd09c717b0..4450f7fdb1 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml @@ -7,8 +7,10 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:AdvancedPaste.Controls" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:settings="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" + x:Name="PromptBoxControl" mc:Ignorable="d"> @@ -34,7 +36,7 @@ - + 44 + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png b/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png index 73621edfc0f883365e89a3a26a6dfd9d5f6d36d8..78a9a18606333acba281b372b402348e62b3f36d 100644 GIT binary patch literal 26499 zcmV((K;XZLP)?W5>T|>{*4Ip3|D31ygMX}3*VyN~&MOwmD4v&*v+QK4Iorm{<>%b2 zbE4aGuJJi$2R!@8dqbiZ<)R-A(6w)#q+QvX5|} z%VQtA{%e1H8k*g|70F?8d8e&9hC|qG`wV}vzxIEg1R#o)d{}1jz+Kl0e$FdAv63xS zJL^=et5EE)Iv=QK0J{Aaio|)iZ~~S>7YCBn_#FGNEj$k+mMWT66OxI}@K3VH`-hlD zr|RM;KLyj-i|T>mxz8?kj&rfR@cU|Ox|HGwWaxc%(fp|a%)m4)wKKmP!QLoh2~8=q zW=v32N}w5SpI2wSYq?)?)~c`hea`)vz_wfOUS0FJVzmjjGvmu6phj)5?avpFvsHxY_smsfPd>Y<9-%;Wf`6oT&iVX2SE2?OIU{fH_G{Q4e7K zJWVQ->Ee)PUi~JvRQrB6m*(K;IvAHE$~9$#D{4kFU|m4}>KRBWo8pd5Fzc*jdX9XT zm+T9+cYu%odOXy4qlX80JnIoKn##XObc=90isu#6CAFsq+K;py^{!A?8=hL zhUeA!kzAL7!ro`Her`>v;_0ED8d+CB&7{W~C#gX?0fP?4mn?B=E* zW?RdXOI>77M_ZcisHE3rRjn?~&u$CpywU*&p;TRsH2+M$lw?6MRme6Iu&L1`SH zu8zTvWk8p@q%_j$d0Y$*vI8&(n<6wZ7~JIJE)@Xf?e^abPM%1h(VxI+DUC}V;nTn} zB~XoXE(D_56_x<>j*$7#n34<#Ek1j%5$X`q7?6Hz_9@;nRyf zRG{s#2kK0aO3Ol~kTby*3Q-$i7*AAj9lBC2BQBwC2IUwJWGlv3(`KKxbbGkYDFWFu zD#7=X-tA1H2Ocskk7`O$>R;pk#yrJMk1jzj#;bs-LGV{i0bmCR2}N z=Oq#>~szZ&yL;i-Nrh!h)c!4r6!PTp}t!py58iS>*MRiG61CU@% z!)GvOm|%yYcUFA{BRy+2k#MSs;W%0`q}TK^>UC$nMotZ2sWSx&k!aiAhebZYMDR{~ zkj|0&S)^h}3L|MWbaZFQ-i(=fX-WeI?36-9T||*z-UwVp$s_AEmtQ19TxF}@vWn=z$r6vyo2pp$nF}}|PT`@0bLr%qdQfZANG4UwdT6Pf#AVPseigq+lO92I# zZ;2syl&TejXLe?_xu59}%~eTnQG~=;7z6UX8Pk(Lr(|>h5p{VUT_xsw#mnA5XlAuX zo>M-QE`b1KuNW2BQs^SPJEl$fwzg3!=x~-@pR;=GR9~U)Y-vuwIsOH+y&+ScXw5H zoe75P$R^l%o+_JT@R(F@`NFg&kujxF(F{Kt(5a5z*3jvdZcOIvat264s_pj_$aX&s z%b$v^nl6#_C|M)P-_>JM znLRwg7D{H!@oDKDK(6@8`GtauX1TaJM!7}Wl`;VPI2M+ZE^$4_=VmJXV@;jM09f^5 zB1up#vg-?&txLd*(|ZI7Be6>+WWDkufqAv8Dn5HC`G2Y+rJj2(y&0&TrkDFR}K3~nK#Yr4J5IDX3cSWzk*2R~8K`T>D zt*&Zv@ugBs7z|Xx{%HbXuLcl zwJ@ax#4MeIkp+Lw$((=zzdT;bQuL`64>6mg*BPY>yedx&IKXSx8@0*lqvj~egUzKX z0ZR<1sC7zCjq(BNj?s@hc+hhg{HH+~_quI_)>HE^(~L46uKIA~QvPnY^66P}u~0p2 zk9M!uoc`&G*I0A(fOc1rVT^db*9DzEGP4bRjA36Eb7t)|!|3i@a53kjqvAxRm_(+y z1`;JgSIWM;wQQvZ`5~&`sA_6m^hBST6Kp2V1F~k~vuz1X23k8iMZ!Q8!(B7DlsL%= zl3oh(XZAK?#r*Ebj%Xp-k0gw&CC{g9JH{8%sZo<}3Pyr(9E#ir)kO~^m^DL&FtZ1~ zKW9BjlBeIEYl2b1WRr&E)&N@*p#56P&c)38aV;`B{V^liM_ZP1Wc?-W10ttSK#SRB z$nQ9C2?Y*#RTs`R+F~=YSPpg~i_z?N;#Q32V+n^PR@=uGz(!vXsT>|WTn{`elI$q7 zQiM8!ia}oC99&nkZ2H3Dmmzt*{h+*3j)bTCjARX_VKmuVCrNE4)W?cPARQ(i)q8e4 z0~)|;Qov+os>5|Ana!X>yUM6wgv{>Z;DYJ;%5iHZ6u3#S=dF;h?yROpa+lK}Zl_Y5 z*93CyBD7EK;Z`L0GxYd#*eXNP8G7GLSJg6nyxR75Uf(0wsvI~ha8zpBQv;?4&eF*? z@^ekMnfR@izQK$UW+g6IuewG%6v;L?FEE!&`Msa3u-5o}zQ=io?jF8zrY|m;ZxuS1 zB7rc%x=enb!Fq)UecLOv)&5A6EJqUfFm8J-dq-Act&zOuRXVaDghwCj$aEDam^mTr zB?Vf6FG;atvUQ4iC&6@%TcPQr1g@jh(DGGIrel)WU=7HHm=UW)O#FV{uE%}<41?Zd zh}I+$kOJc9`l9VE^+?3{yn%V*MFHr1I5tI&AMLTbSuBWgsZ7xi){WGfFXaoP@noP# zXNjfaq=>Vns-Z-{h)yE9sh0c~eL*%LXQ!M9f<0B=w!p^Lm)=p1dNgi%rwIJuj^(hRniwBi_?=Lof;2UDZV z_@TX;xSpzMVeF|S?r7PJ8^q41mDte##^0!ur4(eVfG*7;(O`&iZqtM7dwr(*+1r#; zAwEFGWZgSMLf#8-ah|Pz%rM222+Jh=ihdMO&h~>b(NUfkzqK&2Zje90ii)N8TTHE?v;e)Ig5nl@S7kf}MwYaP&X zBW01<&hFhX5ORWr1D+#t4Kr8>uA|k(=yqJ9Pt`}5REV^z0ZAUXF4#9T^6%`1-pALV ze)y<51 zVs^Oa!{nwhS{D#Q49H-Drn;{&C6#}BNoxc&=v{le=M1cJ*atzCS5BA}_jD@UFA*c{ zP-CKzD#@ZLTA_N3S~SV?+R-e=1@wkmN2s3PD7Be+u;0X40cf(G-saZ*N|qTjN#&y# z%ey^luo!(A>SJKl$q%K10gcL=-N57Q%eb|iQSaGRe5AP}US0oez-(5N@H4qfwm7Zm zNL3P4C)0HUBJt9WXY~gA14^HB>Ol9GrdTxPkHm3b<%J{D=^32PSR7|+%n zg|43Cp&lyY&>(?SR)r<4L1Ke)&U*~1>HvhEZbrooQk7ji&gJr-vKJ9uriMMgXcr*B zp#%P5T-IlARcLl@uR@f4GBh52P0O&#B78MDU|Y{z*NLs^O^y^?Iuqb zE(S=W$+mhRE9#4Yc3^c!&ivUcY&$v?(mjo?FAQqxOGgbamw$?=CT!kE(ARr71roW*18Ex{dO zK4$<72egIB+*L6da;C;&@QT1E=gfLWQ?BRGVQna1)WBCuASY~QcUDWJz!CS)ilG|l zM-Fb{nHu4jm)nlWi%wWxCkA2!u%f@R;q(6M?-|3sRx@4YWPEp zLR;Hi6c_VdM({>~3a>U`nkl_t|FjpEv)b2b2XI(&@BXVCLw`vw%doR~2p-(iJy>>h zJl|D>;JQV$lsb|e$AIz5J4|TggKrZQIs@lJ(^Kh5Cgt@kQPDuy2cD`T zxS0P?uA*WwrkIscYOdQ`mHq>wEe(D$@UGG0PqabDv>#aC)#fuvkWOIHnH@O94bY1G zlI`$gB=mWB?rI{P(~n3HSbg##2yg=bQ#Gc*TwMu*`Y{R|^Z+T028Et*J$HK^FXU@N z6(?&strFfGV5wEB^N>tgdP`vJuGRU%{r+qXD#YYCGRlei9=mnrub>4^@XjD2`tGa+ zU-vK_zhO{>vek(@Ggg+18-`muMCd{4cvQ^KZD=1x;tOkkGN=HaKLtZXhvZr5C^bHVn ztu){q?ID|Dq7iWf<6TZ4`7ib13}pu^h9jvkP&FQUD;+hL7^ZV=1{tDHfuxtD2sW}( zHfY}1P?>Lj&WJV%>N6|24$IeKF>l(`?h=7~OR;$75r4BIP9Vln(?Vv&+-0G)&Iv~R zRO7K7{CDCus`smBx6244BTX zjU+CDgNoQ#ez(6%GnPuzk{|YFaT|}mA;4v5qnf~6*k-dW8n_i-m zyHB%GN3Q7CW+#rf0*_F{Di%BAxnP!p9#3Y8{RZAyt$|qD5^I-xRRKXZm*G{le0JcV z>h@5SGX$1T;mW~4m*Q2O0w}5u^M26LH@WB&a%S5`w=@w%M zTL-Z0lw!leof3=!Nod(Dtxh014SsD1NTqRPBR;QRQU{*3kQmUqDnpud$9Vl!lpKw? z>{;jS+uGko`EsPF`$7%2>|--}H}48%Q59@(i+C_$3#tj0LH0DWa_dwv=zzgNAHZ7+ zpqKSM04_ zAczMrStxJW4ZWh!)bBb*pWBPtyUk-`2Lt$bzyeQ(8FvDSjy}(4*$*BBaPeTmUF&!H zMj6ZXh)f-9;PW5VJyWa_auqozSVZb3r^M6B=MjV<=?9FE0-6Z1E}f3iyF{-CDl|}` zQS`8wB}2$Q2C~GmOU8h6um%$o6^#>^(h`AT)nIqj3MIh974IKn4?a!4%g#5ONhzZi zk?Mu`nUTx!7oYNdgyyV0l5s;4U)&b{8EEIs(y|vPNC>Y8oG3GfL`pWR7wv^$gz#K< zRg@`~l0Os66>}A6_M7AFbck?cB~mc*Lzq^Q$sPl&gdN>Ul=9|lIb5}}PKP@a9Qq3& z9Of`d6YXt0z>fwhP&lg(mK&2o{yM`;8{t@m8m3@CDDYBYf`J!9TXf@t|X2REF z+QKz)P6*VBqKf$SYBbxzk(r7dvmE!|K7Xa;7*9JF(BR)fj2AQAFmr9*yY`%%S42&a zg@ETLQLf^UzWrOoeFKuRh>ev778>GYGDo@fhpRDTSuNs+j}JPLBU;C~-PT(DhFjPf zl16uV>yWS5lU)2J%+nAhvr+&{bymUzM%HZ}{fcrwZ?B7m6>g_%pD|$HWrh_nCVO?% zt78`>5nRIo%f{Kt)>L@7a2VO(Qcc2CN7_(VoJ$|@Z7i%dD5s5)b^A9jre~)z#9rfd z=(ao_OKCLdBzb3YBT8LzAzzPjDDC=S?tu5U)Yrob1yqAuR$D;oY^$S|$xMPV0*I`^N6Dt|H=meh9M@fB2#w#f${|-T~#^B(1x1wVb6V7wK-Q1Cml;0YKyZntm+CjW3cNMy<&kX1Mbs@Xa%n zofuT2>3kv}Mzjnf3AikIDRoxVHOnurEs(VwhUZ`go#5kW3NR(Bq$4*BOwCjZPK&%? zTNYy}G}<*bfa3vQ1%_Qkc|!x)fn20rcSrfKkUi2_IKd-_VA}P-+7eNlIgN z{mX8oyA^Oc5!!|6rR@k;UTpv!rGKpzvid{MFwC zB+%k!n)J8JJOnry(RsCz<_K(8-)qQWiZVdi?&nOVXHSCDOW30H)2961DkOr6khyn9 zrPlt+!G?oRW~2}=<>=#d-^mA2pKz%fcPZ6G$V#DBs~O-JZ5(|bG0GK@RB~umfVq}H z9>JgiO$kYNN>;nVoFq{lbC39eOsRL8p-7~t;|n83vw)mnKUAqCFxIbPyCD0@1W zN8|NV?#Xn-_{_hfb6h=Pd5ogNA}Ad-ravonethq1qyA+%7skHN2EAAhr9O^mzkUGX z&JzaYRE+C-Bcp>sg6smnPfC&DBz?&ouY?BBS4VsI?AvF zSE$!PB3g^poG%Upg>0QDwX%IsoQDP`M~1_mqW4{$m8KGPtYR1V6XLgr4?(Yn;^g|m z?wp1NpxtG^o%T>7Oam|ic3d8^#RKg3HiJaj$L(t=zX+y7%A}Cwq(BH)Eolw&_yRac zahIW-8L*5ZwCbR5JaoFG4^qSy4M1GKzqyc;==9K=EJ47*gvX3bErmJOP`T5iS?q}s zkwzzEpN!$*z?!^)#vt?IxTnccZLPGkwUY*P|= z)eNXo6(CF!tLVVEgwAAI!^boa+{)T0f8;P)h1o-oP8FUnKOBoztbo`KKtZ<&AiH!@%E z40&^*oc9-Gwe)(gnq-d;uyJI`T%7X==vlCuB{5?L8~|IH;V|B*6p2``kp=qt=~Hy%K^lxObZ6=0~Blvj3N&%F-I}3hpw-4v#cu-() zA-uQJ%bWv`*?UesRRI@2VBDGWxPiRNz9F;6$vi}X~a@MjS*0*6i%M>z5o zrGecm+siQxe#T<&_+@8j_x$xkC~#8C1Eo?SVgVD`ky=H2Pot^;QRE5`y#c1Kubl#HaM11J zIwIA&DOv9f2-aDv?>A`a=rD(O9FSu^}pd58KPJb~r@o_OVA9Ib(P#6cdQ8B)i-(KD`@Q+Ubu<0W6jN z*lf0%<+MnjWIzHgEwj429lk9)zc$h%+?{JMp0@EBA!Idy!pl?ia>pe?I z<&fBI46ekEDYyI+(fO|#)%VYc7?H!mkWWJ|Dil_so2DCSZsp~1P^iaClFVBOlQ~Z5 zt!4^R9)}B(&vMx3^p_c7tCbeR5i~d#SjG1u6tpEyOy09TgznGoo z*ulagYcNTCF7q<*DhAk23gq~=eEYpM@^NCkB*<{zD8RJpV!Cu{1?p5DMKA#cDCH?pl3MPreJgsS#v>^5gwupd-k9gaV%`%0D+>il$3#TE6&x(GbE>4= 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 Tko-lx00000NkvXXu0mjfoFv2( diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs new file mode 100644 index 0000000000..ba7d33f4fb --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs @@ -0,0 +1,54 @@ +// 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 AdvancedPaste.Models; +using Microsoft.SemanticKernel; + +namespace AdvancedPaste.Helpers; + +/// +/// Helper class for extracting AI service usage information from chat messages. +/// +public static class AIServiceUsageHelper +{ + /// + /// Extracts AI service usage information from OpenAI chat message metadata. + /// + /// The chat message containing usage metadata. + /// AI service usage information or AIServiceUsage.None if extraction fails. + public static AIServiceUsage GetOpenAIServiceUsage(ChatMessageContent chatMessage) + { + // Try to get usage information from metadata + if (chatMessage.Metadata?.TryGetValue("Usage", out var usageObj) == true) + { + // Handle different possible usage types through reflection to be version-agnostic + var usageType = usageObj.GetType(); + + try + { + // Try common property names for prompt tokens + var promptTokensProp = usageType.GetProperty("PromptTokens") ?? + usageType.GetProperty("InputTokens") ?? + usageType.GetProperty("InputTokenCount"); + + var completionTokensProp = usageType.GetProperty("CompletionTokens") ?? + usageType.GetProperty("OutputTokens") ?? + usageType.GetProperty("OutputTokenCount"); + + if (promptTokensProp != null && completionTokensProp != null) + { + var promptTokens = (int)(promptTokensProp.GetValue(usageObj) ?? 0); + var completionTokens = (int)(completionTokensProp.GetValue(usageObj) ?? 0); + return new AIServiceUsage(promptTokens, completionTokens); + } + } + catch + { + // If reflection fails, fall back to no usage + } + } + + return AIServiceUsage.None; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs new file mode 100644 index 0000000000..9f824d3399 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs @@ -0,0 +1,84 @@ +// 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.Threading.Tasks; +using AdvancedPaste.Models; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.ApplicationModel.DataTransfer; + +namespace AdvancedPaste.Helpers +{ + internal static class ClipboardItemHelper + { + /// + /// Creates a ClipboardItem from current clipboard data. + /// + public static async Task CreateFromCurrentClipboardAsync( + DataPackageView clipboardData, + ClipboardFormat availableFormats, + DateTimeOffset? timestamp = null, + BitmapImage existingImage = null) + { + if (clipboardData == null || availableFormats == ClipboardFormat.None) + { + return null; + } + + var clipboardItem = new ClipboardItem + { + Format = availableFormats, + Timestamp = timestamp, + }; + + // Text or HTML content + if (availableFormats.HasFlag(ClipboardFormat.Text) || availableFormats.HasFlag(ClipboardFormat.Html)) + { + clipboardItem.Content = await clipboardData.GetTextOrEmptyAsync(); + } + + // Image content + else if (availableFormats.HasFlag(ClipboardFormat.Image)) + { + // Reuse existing image if provided + if (existingImage != null) + { + clipboardItem.Image = existingImage; + } + else + { + clipboardItem.Image = await TryCreateBitmapImageAsync(clipboardData); + } + } + + return clipboardItem; + } + + /// + /// Creates a BitmapImage from clipboard data. + /// + private static async Task TryCreateBitmapImageAsync(DataPackageView clipboardData) + { + try + { + var imageReference = await clipboardData.GetBitmapAsync(); + if (imageReference != null) + { + using (var imageStream = await imageReference.OpenReadAsync()) + { + var bitmapImage = new BitmapImage(); + await bitmapImage.SetSourceAsync(imageStream); + return bitmapImage; + } + } + } + catch + { + // Silently fail - caller can check for null + } + + return null; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs index 529773f9a6..2cd7554a50 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs @@ -6,11 +6,13 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; +using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Models; using ManagedCommon; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.Win32; using Windows.ApplicationModel.DataTransfer; using Windows.Data.Html; @@ -180,6 +182,46 @@ internal static class DataPackageHelpers } } + internal static async Task GetClipboardTextOrThrowAsync(this DataPackageView dataPackageView, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(dataPackageView); + + try + { + if (dataPackageView.Contains(StandardDataFormats.Text)) + { + return await dataPackageView.GetTextAsync(); + } + + if (dataPackageView.Contains(StandardDataFormats.Html)) + { + var html = await dataPackageView.GetHtmlFormatAsync(); + return HtmlUtilities.ConvertToText(html); + } + + if (dataPackageView.Contains(StandardDataFormats.Bitmap)) + { + var bitmap = await dataPackageView.GetImageContentAsync(); + if (bitmap != null) + { + return await OcrHelpers.ExtractTextAsync(bitmap, cancellationToken); + } + } + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + throw CreateClipboardTextMissingException(ex); + } + + throw CreateClipboardTextMissingException(); + } + + private static PasteActionException CreateClipboardTextMissingException(Exception innerException = null) + { + var message = ResourceLoaderInstance.ResourceLoader.GetString("ClipboardEmptyWarning"); + return new PasteActionException(message, innerException ?? new InvalidOperationException("Clipboard does not contain text content.")); + } + internal static async Task GetHtmlContentAsync(this DataPackageView dataPackageView) => dataPackageView.Contains(StandardDataFormats.Html) ? await dataPackageView.GetHtmlFormatAsync() : string.Empty; @@ -195,6 +237,22 @@ internal static class DataPackageHelpers return null; } + internal static async Task GetPreviewBitmapAsync(this DataPackageView dataPackageView) + { + var stream = await dataPackageView.GetImageStreamAsync(); + if (stream == null) + { + return null; + } + + using (stream) + { + var bitmapImage = new BitmapImage(); + bitmapImage.SetSource(stream); + return bitmapImage; + } + } + private static async Task GetImageStreamAsync(this DataPackageView dataPackageView) { if (dataPackageView.Contains(StandardDataFormats.StorageItems)) diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs index 105fe2c0d8..e32cf61af4 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using AdvancedPaste.Models; using Microsoft.PowerToys.Settings.UI.Library; @@ -12,7 +13,7 @@ namespace AdvancedPaste.Settings { public interface IUserSettings { - public bool IsAdvancedAIEnabled { get; } + public bool IsAIEnabled { get; } public bool ShowCustomPreview { get; } @@ -22,6 +23,10 @@ namespace AdvancedPaste.Settings public IReadOnlyList AdditionalActions { get; } + public PasteAIConfiguration PasteAIConfiguration { get; } + public event EventHandler Changed; + + Task SetActiveAIProviderAsync(string providerId); } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs index 8a25b70f07..b6b6c19734 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs @@ -13,6 +13,7 @@ using AdvancedPaste.Models; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Utilities; +using Windows.Security.Credentials; namespace AdvancedPaste.Settings { @@ -33,7 +34,7 @@ namespace AdvancedPaste.Settings public event EventHandler Changed; - public bool IsAdvancedAIEnabled { get; private set; } + public bool IsAIEnabled { get; private set; } public bool ShowCustomPreview { get; private set; } @@ -43,13 +44,16 @@ namespace AdvancedPaste.Settings public IReadOnlyList CustomActions => _customActions; + public PasteAIConfiguration PasteAIConfiguration { get; private set; } + public UserSettings(IFileSystem fileSystem) { _settingsUtils = new SettingsUtils(fileSystem); - IsAdvancedAIEnabled = false; + IsAIEnabled = false; ShowCustomPreview = true; CloseAfterLosingFocus = false; + PasteAIConfiguration = new PasteAIConfiguration(); _additionalActions = []; _customActions = []; _taskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); @@ -94,13 +98,16 @@ namespace AdvancedPaste.Settings var settings = _settingsUtils.GetSettingsOrDefault(AdvancedPasteModuleName); if (settings != null) { + bool migratedLegacyEnablement = TryMigrateLegacyAIEnablement(settings); + void UpdateSettings() { var properties = settings.Properties; - IsAdvancedAIEnabled = properties.IsAdvancedAIEnabled; + IsAIEnabled = properties.IsAIEnabled; ShowCustomPreview = properties.ShowCustomPreview; CloseAfterLosingFocus = properties.CloseAfterLosingFocus; + PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration(); var sourceAdditionalActions = properties.AdditionalActions; (PasteFormats Format, IAdvancedPasteAction[] Actions)[] additionalActionFormats = @@ -126,6 +133,11 @@ namespace AdvancedPaste.Settings Task.Factory .StartNew(UpdateSettings, CancellationToken.None, TaskCreationOptions.None, _taskScheduler) .Wait(); + + if (migratedLegacyEnablement) + { + settings.Save(_settingsUtils); + } } retry = false; @@ -144,6 +156,114 @@ namespace AdvancedPaste.Settings } } + private static bool TryMigrateLegacyAIEnablement(AdvancedPasteSettings settings) + { + if (settings?.Properties is null) + { + return false; + } + + if (settings.Properties.IsAIEnabled || !LegacyOpenAIKeyExists()) + { + return false; + } + + settings.Properties.IsAIEnabled = true; + return true; + } + + private static bool LegacyOpenAIKeyExists() + { + try + { + PasswordVault vault = new(); + return vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey") is not null; + } + catch (Exception) + { + return false; + } + } + + public async Task SetActiveAIProviderAsync(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return; + } + + await Task.Run(() => + { + lock (_loadingSettingsLock) + { + var settings = _settingsUtils.GetSettingsOrDefault(AdvancedPasteModuleName); + var configuration = settings?.Properties?.PasteAIConfiguration; + var providers = configuration?.Providers; + + if (configuration == null || providers == null || providers.Count == 0) + { + return; + } + + var target = providers.FirstOrDefault(provider => string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase)); + if (target == null) + { + return; + } + + if (string.Equals(configuration.ActiveProvider?.Id, providerId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + configuration.ActiveProviderId = providerId; + + foreach (var provider in providers) + { + provider.IsActive = string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase); + } + + try + { + settings.Save(_settingsUtils); + } + catch (Exception ex) + { + Logger.LogError("Failed to set active AI provider", ex); + return; + } + + try + { + Task.Factory + .StartNew( + () => + { + PasteAIConfiguration.ActiveProviderId = providerId; + + if (PasteAIConfiguration.Providers is not null) + { + foreach (var provider in PasteAIConfiguration.Providers) + { + provider.IsActive = string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase); + } + } + + Changed?.Invoke(this, EventArgs.Empty); + }, + CancellationToken.None, + TaskCreationOptions.None, + _taskScheduler) + .Wait(); + } + catch (Exception ex) + { + Logger.LogError("Failed to dispatch active AI provider change", ex); + } + } + }); + } + public void Dispose() { Dispose(true); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs index 1013108bc9..16814e7001 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs @@ -2,6 +2,7 @@ // 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 AdvancedPaste.Helpers; using Microsoft.UI.Xaml.Media.Imaging; using Windows.ApplicationModel.DataTransfer; @@ -12,10 +13,15 @@ public class ClipboardItem { public string Content { get; set; } - public ClipboardHistoryItem Item { get; set; } - public BitmapImage Image { get; set; } + public ClipboardFormat Format { get; set; } + + public DateTimeOffset? Timestamp { get; set; } + + // Only used for clipboard history items that have a ClipboardHistoryItem + public ClipboardHistoryItem Item { get; set; } + public string Description => !string.IsNullOrEmpty(Content) ? Content : Image is not null ? ResourceLoaderInstance.ResourceLoader.GetString("ClipboardHistoryImage") : string.Empty; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs new file mode 100644 index 0000000000..b9566ed481 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs @@ -0,0 +1,227 @@ +// 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.Linq; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Amazon; +using Microsoft.SemanticKernel.Connectors.AzureAIInference; +using Microsoft.SemanticKernel.Connectors.Google; +using Microsoft.SemanticKernel.Connectors.HuggingFace; +using Microsoft.SemanticKernel.Connectors.MistralAI; +using Microsoft.SemanticKernel.Connectors.Ollama; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services; + +public sealed class AdvancedAIKernelService : KernelServiceBase +{ + private sealed record RuntimeConfiguration( + AIServiceType ServiceType, + string ModelName, + string Endpoint, + string DeploymentName, + string ModelPath, + string SystemPrompt, + bool ModerationEnabled) : IKernelRuntimeConfiguration; + + private readonly IAICredentialsProvider credentialsProvider; + + public AdvancedAIKernelService( + IAICredentialsProvider credentialsProvider, + IKernelQueryCacheService queryCacheService, + IPromptModerationService promptModerationService, + IUserSettings userSettings, + ICustomActionTransformService customActionTransformService) + : base(queryCacheService, promptModerationService, userSettings, customActionTransformService) + { + ArgumentNullException.ThrowIfNull(credentialsProvider); + + this.credentialsProvider = credentialsProvider; + } + + protected override string AdvancedAIModelName => GetRuntimeConfiguration().ModelName; + + protected override PromptExecutionSettings PromptExecutionSettings => CreatePromptExecutionSettings(); + + protected override void AddChatCompletionService(IKernelBuilder kernelBuilder) + { + ArgumentNullException.ThrowIfNull(kernelBuilder); + + var runtimeConfig = GetRuntimeConfiguration(); + var serviceType = runtimeConfig.ServiceType; + var modelName = runtimeConfig.ModelName; + var requiresApiKey = RequiresApiKey(serviceType); + var apiKey = string.Empty; + if (requiresApiKey) + { + this.credentialsProvider.Refresh(); + apiKey = (this.credentialsProvider.GetKey() ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException($"An API key is required for {serviceType} but none was found in the credential vault."); + } + } + + var endpoint = string.IsNullOrWhiteSpace(runtimeConfig.Endpoint) ? null : runtimeConfig.Endpoint.Trim(); + var deployment = string.IsNullOrWhiteSpace(runtimeConfig.DeploymentName) ? modelName : runtimeConfig.DeploymentName; + + switch (serviceType) + { + case AIServiceType.OpenAI: + kernelBuilder.AddOpenAIChatCompletion(modelName, apiKey, serviceId: modelName); + break; + case AIServiceType.AzureOpenAI: + kernelBuilder.AddAzureOpenAIChatCompletion(deployment, RequireEndpoint(endpoint, serviceType), apiKey, serviceId: modelName); + break; + default: + throw new NotSupportedException($"Service type '{runtimeConfig.ServiceType}' is not supported"); + } + } + + protected override AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage) + { + return AIServiceUsageHelper.GetOpenAIServiceUsage(chatMessage); + } + + protected override bool ShouldModerateAdvancedAI() + { + if (!TryGetRuntimeConfiguration(out var runtimeConfig)) + { + return false; + } + + return runtimeConfig.ModerationEnabled && (runtimeConfig.ServiceType == AIServiceType.OpenAI || runtimeConfig.ServiceType == AIServiceType.AzureOpenAI); + } + + private static string GetModelName(PasteAIProviderDefinition config) + { + if (!string.IsNullOrWhiteSpace(config?.ModelName)) + { + return config.ModelName; + } + + return "gpt-4o"; + } + + protected override IKernelRuntimeConfiguration GetRuntimeConfiguration() + { + if (TryGetRuntimeConfiguration(out var runtimeConfig)) + { + return runtimeConfig; + } + + throw new InvalidOperationException("No Advanced AI provider is configured."); + } + + private bool TryGetRuntimeConfiguration(out IKernelRuntimeConfiguration runtimeConfig) + { + runtimeConfig = null; + + if (!TryResolveAdvancedProvider(out var provider)) + { + return false; + } + + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + if (!IsServiceTypeSupported(serviceType)) + { + return false; + } + + runtimeConfig = new RuntimeConfiguration( + serviceType, + GetModelName(provider), + provider.EndpointUrl, + provider.DeploymentName, + provider.ModelPath, + provider.SystemPrompt, + provider.ModerationEnabled); + return true; + } + + private bool TryResolveAdvancedProvider(out PasteAIProviderDefinition provider) + { + provider = null; + + var configuration = this.UserSettings?.PasteAIConfiguration; + if (configuration is null) + { + return false; + } + + var activeProvider = configuration.ActiveProvider; + if (IsAdvancedProvider(activeProvider)) + { + provider = activeProvider; + return true; + } + + if (activeProvider is not null) + { + return false; + } + + var fallback = configuration.Providers?.FirstOrDefault(IsAdvancedProvider); + if (fallback is not null) + { + provider = fallback; + return true; + } + + return false; + } + + private static bool IsAdvancedProvider(PasteAIProviderDefinition provider) + { + if (provider is null || !provider.EnableAdvancedAI) + { + return false; + } + + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + return IsServiceTypeSupported(serviceType); + } + + private static bool IsServiceTypeSupported(AIServiceType serviceType) + { + return serviceType is AIServiceType.OpenAI or AIServiceType.AzureOpenAI; + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return true; + } + + private static string RequireEndpoint(string endpoint, AIServiceType serviceType) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return endpoint; + } + + throw new InvalidOperationException($"Endpoint is required for {serviceType} configuration but was not provided."); + } + + private PromptExecutionSettings CreatePromptExecutionSettings() + { + var serviceType = GetRuntimeConfiguration().ServiceType; + return new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Required(), + Temperature = 0.01, + }; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs new file mode 100644 index 0000000000..562ea3976c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs @@ -0,0 +1,22 @@ +// 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 AdvancedPaste.Models; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class CustomActionTransformResult + { + public CustomActionTransformResult(string content, AIServiceUsage usage) + { + Content = content; + Usage = usage; + } + + public string Content { get; } + + public AIServiceUsage Usage { get; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs new file mode 100644 index 0000000000..721a96070d --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs @@ -0,0 +1,200 @@ +// 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.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using AdvancedPaste.Settings; +using AdvancedPaste.Telemetry; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class CustomActionTransformService : ICustomActionTransformService + { + private const string DefaultSystemPrompt = """ + You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. + Do not output anything else besides the reformatted clipboard content. + """; + + private readonly IPromptModerationService promptModerationService; + private readonly IPasteAIProviderFactory providerFactory; + private readonly IAICredentialsProvider credentialsProvider; + private readonly IUserSettings userSettings; + + public CustomActionTransformService(IPromptModerationService promptModerationService, IPasteAIProviderFactory providerFactory, IAICredentialsProvider credentialsProvider, IUserSettings userSettings) + { + this.promptModerationService = promptModerationService; + this.providerFactory = providerFactory; + this.credentialsProvider = credentialsProvider; + this.userSettings = userSettings; + } + + public async Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress) + { + var pasteConfig = userSettings?.PasteAIConfiguration; + var providerConfig = BuildProviderConfig(pasteConfig); + + return await TransformAsync(prompt, inputText, providerConfig, cancellationToken, progress); + } + + private async Task TransformAsync(string prompt, string inputText, PasteAIConfig providerConfig, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(providerConfig); + + if (string.IsNullOrWhiteSpace(prompt)) + { + return new CustomActionTransformResult(string.Empty, AIServiceUsage.None); + } + + if (string.IsNullOrWhiteSpace(inputText)) + { + Logger.LogWarning("Clipboard has no usable text data"); + return new CustomActionTransformResult(string.Empty, AIServiceUsage.None); + } + + var systemPrompt = providerConfig.SystemPrompt ?? DefaultSystemPrompt; + + var fullPrompt = (systemPrompt ?? string.Empty) + "\n\n" + (inputText ?? string.Empty); + + if (ShouldModerate(providerConfig)) + { + await promptModerationService.ValidateAsync(fullPrompt, cancellationToken); + } + + try + { + var provider = providerFactory.CreateProvider(providerConfig); + + var request = new PasteAIRequest + { + Prompt = prompt, + InputText = inputText, + SystemPrompt = systemPrompt, + }; + + var providerContent = await provider.ProcessPasteAsync( + request, + cancellationToken, + progress); + + var usage = request.Usage; + var content = providerContent ?? string.Empty; + + // Log endpoint usage + var endpointEvent = new AdvancedPasteEndpointUsageEvent(providerConfig.ProviderType); + PowerToysTelemetry.Log.WriteEvent(endpointEvent); + + Logger.LogDebug($"{nameof(CustomActionTransformService)}.{nameof(TransformAsync)} complete; ModelName={providerConfig.Model ?? string.Empty}, PromptTokens={usage.PromptTokens}, CompletionTokens={usage.CompletionTokens}"); + + return new CustomActionTransformResult(content, usage); + } + catch (Exception ex) + { + Logger.LogError($"{nameof(CustomActionTransformService)}.{nameof(TransformAsync)} failed", ex); + + if (ex is PasteActionException or OperationCanceledException) + { + throw; + } + + var statusCode = ExtractStatusCode(ex); + var failureMessage = providerConfig.ProviderType switch + { + AIServiceType.OpenAI or AIServiceType.AzureOpenAI => ErrorHelpers.TranslateErrorText(statusCode), + _ => ResourceLoaderInstance.ResourceLoader.GetString("PasteError"), + }; + + throw new PasteActionException(failureMessage, ex); + } + } + + private static int ExtractStatusCode(Exception exception) + { + if (exception is HttpOperationException httpOperationException) + { + return (int?)httpOperationException.StatusCode ?? -1; + } + + if (exception is HttpRequestException httpRequestException && httpRequestException.StatusCode is HttpStatusCode statusCode) + { + return (int)statusCode; + } + + return -1; + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private PasteAIConfig BuildProviderConfig(PasteAIConfiguration config) + { + config ??= new PasteAIConfiguration(); + var provider = config.ActiveProvider ?? config.Providers?.FirstOrDefault() ?? new PasteAIProviderDefinition(); + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + var systemPrompt = string.IsNullOrWhiteSpace(provider.SystemPrompt) ? DefaultSystemPrompt : provider.SystemPrompt; + var apiKey = AcquireApiKey(serviceType); + var modelName = provider.ModelName; + + var providerConfig = new PasteAIConfig + { + ProviderType = serviceType, + ApiKey = apiKey, + Model = modelName, + Endpoint = provider.EndpointUrl, + DeploymentName = provider.DeploymentName, + LocalModelPath = provider.ModelPath, + ModelPath = provider.ModelPath, + SystemPrompt = systemPrompt, + ModerationEnabled = provider.ModerationEnabled, + }; + + return providerConfig; + } + + private string AcquireApiKey(AIServiceType serviceType) + { + if (!RequiresApiKey(serviceType)) + { + return string.Empty; + } + + credentialsProvider.Refresh(); + return credentialsProvider.GetKey() ?? string.Empty; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.Onnx => false, + AIServiceType.Ollama => false, + AIServiceType.Anthropic => false, + AIServiceType.AmazonBedrock => false, + _ => true, + }; + } + + private static bool ShouldModerate(PasteAIConfig providerConfig) + { + if (providerConfig is null || !providerConfig.ModerationEnabled) + { + return false; + } + + return providerConfig.ProviderType == AIServiceType.OpenAI || providerConfig.ProviderType == AIServiceType.AzureOpenAI; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs new file mode 100644 index 0000000000..4b4148f995 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs @@ -0,0 +1,194 @@ +// 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 AdvancedPaste.Models; +using LanguageModelProvider; +using Microsoft.Extensions.AI; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions; + +public sealed class FoundryLocalPasteProvider : IPasteAIProvider +{ + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.FoundryLocal, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new FoundryLocalPasteProvider(config)); + + private static readonly LanguageModelService LanguageModels = LanguageModelService.CreateDefault(); + + private readonly PasteAIConfig _config; + + public FoundryLocalPasteProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _config = config; + } + + public string ProviderName => AIServiceType.FoundryLocal.ToNormalizedKey(); + + public string DisplayName => string.IsNullOrWhiteSpace(_config?.Model) ? "Foundry Local" : _config.Model; + + public async Task IsAvailableAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return await FoundryLocalModelProvider.Instance.IsAvailable().ConfigureAwait(false); + } + + public async Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + var systemPrompt = request.SystemPrompt; + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + throw new PasteActionException( + "System prompt is required for Foundry Local", + new ArgumentException("System prompt must be provided", nameof(request))); + } + + var prompt = request.Prompt; + var inputText = request.InputText; + if (string.IsNullOrWhiteSpace(prompt) || string.IsNullOrWhiteSpace(inputText)) + { + throw new PasteActionException( + "Prompt and input text are required", + new ArgumentException("Prompt and input text must be provided", nameof(request))); + } + + var modelReference = _config?.Model; + if (string.IsNullOrWhiteSpace(modelReference)) + { + throw new PasteActionException( + "No Foundry Local model selected", + new InvalidOperationException("Model identifier is required"), + aiServiceMessage: "Please select a model in the AI provider settings. Model identifier should be in the format 'fl://model-name'."); + } + + cancellationToken.ThrowIfCancellationRequested(); + var chatClient = LanguageModels.GetClient(modelReference); + if (chatClient is null) + { + throw new PasteActionException( + $"Unable to load Foundry Local model: {modelReference}", + new InvalidOperationException("Chat client resolution failed"), + aiServiceMessage: "The model may not be downloaded or the Foundry Local service may not be running. Please check the model status in settings."); + } + + // Extract actual model ID from the URL (format: fl://modelId) + var actualModelId = modelReference.Replace("fl://", string.Empty).Trim('/'); + + var userMessageContent = $""" + User instructions: + {prompt} + + Text: + {inputText} + + Output: + """; + + var chatMessages = new List + { + new(ChatRole.System, systemPrompt), + new(ChatRole.User, userMessageContent), + }; + + var chatOptions = CreateChatOptions(_config?.SystemPrompt, actualModelId); + + progress?.Report(0.1); + + var response = await chatClient.GetResponseAsync(chatMessages, chatOptions, cancellationToken).ConfigureAwait(false); + + progress?.Report(0.8); + + var responseText = GetResponseText(response); + request.Usage = ToUsage(response.Usage); + + progress?.Report(1.0); + + return responseText ?? string.Empty; + } + catch (OperationCanceledException) + { + // Let cancellation exceptions pass through unchanged + throw; + } + catch (PasteActionException) + { + // Let our custom exceptions pass through unchanged + throw; + } + catch (Exception ex) + { + // Wrap any other exceptions with context + var modelInfo = !string.IsNullOrWhiteSpace(_config?.Model) ? $" (Model: {_config.Model})" : string.Empty; + throw new PasteActionException( + $"Failed to generate response using Foundry Local{modelInfo}", + ex, + aiServiceMessage: $"Error details: {ex.Message}"); + } + } + + private static ChatOptions CreateChatOptions(string systemPrompt, string modelReference) + { + var options = new ChatOptions + { + ModelId = modelReference, + }; + + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + options.Instructions = systemPrompt; + } + + return options; + } + + private static string GetResponseText(ChatResponse response) + { + if (!string.IsNullOrWhiteSpace(response.Text)) + { + return response.Text; + } + + if (response.Messages is { Count: > 0 }) + { + var lastMessage = response.Messages.LastOrDefault(m => !string.IsNullOrWhiteSpace(m.Text)); + if (!string.IsNullOrWhiteSpace(lastMessage?.Text)) + { + return lastMessage.Text; + } + } + + return string.Empty; + } + + private static AIServiceUsage ToUsage(UsageDetails usageDetails) + { + if (usageDetails is null) + { + return AIServiceUsage.None; + } + + int promptTokens = (int)(usageDetails.InputTokenCount ?? 0); + int completionTokens = (int)(usageDetails.OutputTokenCount ?? 0); + + if (promptTokens == 0 && completionTokens == 0) + { + return AIServiceUsage.None; + } + + return new AIServiceUsage(promptTokens, completionTokens); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs new file mode 100644 index 0000000000..1c3ecb980c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs @@ -0,0 +1,17 @@ +// 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.Threading; +using System.Threading.Tasks; + +using AdvancedPaste.Settings; + +namespace AdvancedPaste.Services.CustomActions +{ + public interface ICustomActionTransformService + { + Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs new file mode 100644 index 0000000000..764d99f942 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs @@ -0,0 +1,19 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public interface IPasteAIProvider + { + Task IsAvailableAsync(CancellationToken cancellationToken); + + Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs new file mode 100644 index 0000000000..aacc61bec9 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs @@ -0,0 +1,11 @@ +// 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 AdvancedPaste.Services.CustomActions +{ + public interface IPasteAIProviderFactory + { + IPasteAIProvider CreateProvider(PasteAIConfig config); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs new file mode 100644 index 0000000000..f4d45ccd74 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs @@ -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; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class LocalModelPasteProvider : IPasteAIProvider + { + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.Onnx, + AIServiceType.ML, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new LocalModelPasteProvider(config)); + + private readonly PasteAIConfig _config; + + public LocalModelPasteProvider(PasteAIConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + } + + public Task IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true); + + public Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + // TODO: Implement local model inference logic using _config.LocalModelPath/_config.ModelPath + var content = request.InputText ?? string.Empty; + request.Usage = AIServiceUsage.None; + return Task.FromResult(content); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs new file mode 100644 index 0000000000..1d8a60f041 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs @@ -0,0 +1,32 @@ +// 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 AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace AdvancedPaste.Services.CustomActions +{ + public class PasteAIConfig + { + public AIServiceType ProviderType { get; set; } + + public string Model { get; set; } + + public string ApiKey { get; set; } + + public string Endpoint { get; set; } + + public string DeploymentName { get; set; } + + public string LocalModelPath { get; set; } + + public string ModelPath { get; set; } + + public string SystemPrompt { get; set; } + + public bool ModerationEnabled { get; set; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs new file mode 100644 index 0000000000..7339b4e4e3 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs @@ -0,0 +1,61 @@ +// 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 Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIProviderFactory : IPasteAIProviderFactory + { + private static readonly IReadOnlyList ProviderRegistrations = new[] + { + SemanticKernelPasteProvider.Registration, + LocalModelPasteProvider.Registration, + FoundryLocalPasteProvider.Registration, + }; + + private static readonly IReadOnlyDictionary> ProviderFactories = CreateProviderFactories(); + + public IPasteAIProvider CreateProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + var serviceType = config.ProviderType; + if (serviceType == AIServiceType.Unknown) + { + serviceType = AIServiceType.OpenAI; + config.ProviderType = serviceType; + } + + if (!ProviderFactories.TryGetValue(serviceType, out var factory)) + { + throw new NotSupportedException($"Provider {config.ProviderType} not supported"); + } + + return factory(config); + } + + private static IReadOnlyDictionary> CreateProviderFactories() + { + var map = new Dictionary>(); + + foreach (var registration in ProviderRegistrations) + { + Register(map, registration.SupportedTypes, registration.Factory); + } + + return map; + } + + private static void Register(Dictionary> map, IReadOnlyCollection types, Func factory) + { + foreach (var type in types) + { + map[type] = factory; + } + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs new file mode 100644 index 0000000000..6bd78450e8 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs @@ -0,0 +1,22 @@ +// 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; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIProviderRegistration + { + public PasteAIProviderRegistration(IReadOnlyCollection supportedTypes, Func factory) + { + SupportedTypes = supportedTypes ?? throw new ArgumentNullException(nameof(supportedTypes)); + Factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + public IReadOnlyCollection SupportedTypes { get; } + + public Func Factory { get; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs new file mode 100644 index 0000000000..0e15c93e05 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs @@ -0,0 +1,19 @@ +// 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 AdvancedPaste.Models; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIRequest + { + public string Prompt { get; init; } + + public string InputText { get; init; } + + public string SystemPrompt { get; init; } + + public AIServiceUsage Usage { get; set; } = AIServiceUsage.None; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs new file mode 100644 index 0000000000..00517e96d8 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs @@ -0,0 +1,203 @@ +// 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.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Amazon; +using Microsoft.SemanticKernel.Connectors.AzureAIInference; +using Microsoft.SemanticKernel.Connectors.Google; +using Microsoft.SemanticKernel.Connectors.HuggingFace; +using Microsoft.SemanticKernel.Connectors.MistralAI; +using Microsoft.SemanticKernel.Connectors.Ollama; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class SemanticKernelPasteProvider : IPasteAIProvider + { + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.OpenAI, + AIServiceType.AzureOpenAI, + AIServiceType.Mistral, + AIServiceType.Google, + AIServiceType.HuggingFace, + AIServiceType.AzureAIInference, + AIServiceType.Ollama, + AIServiceType.Anthropic, + AIServiceType.AmazonBedrock, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new SemanticKernelPasteProvider(config)); + + private readonly PasteAIConfig _config; + private readonly AIServiceType _serviceType; + + public SemanticKernelPasteProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _config = config; + _serviceType = config.ProviderType; + if (_serviceType == AIServiceType.Unknown) + { + _serviceType = AIServiceType.OpenAI; + _config.ProviderType = _serviceType; + } + } + + public IReadOnlyCollection SupportedServiceTypes => SupportedTypes; + + public Task IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true); + + public async Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + var systemPrompt = request.SystemPrompt; + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + throw new ArgumentException("System prompt must be provided", nameof(request)); + } + + var prompt = request.Prompt; + var inputText = request.InputText; + if (string.IsNullOrWhiteSpace(prompt) || string.IsNullOrWhiteSpace(inputText)) + { + throw new ArgumentException("Prompt and input text must be provided", nameof(request)); + } + + var userMessageContent = $""" + User instructions: + {prompt} + + Clipboard Content: + {inputText} + + Output: + """; + + var executionSettings = CreateExecutionSettings(); + var kernel = CreateKernel(); + var modelId = _config.Model; + + IChatCompletionService chatService; + if (!string.IsNullOrWhiteSpace(modelId)) + { + try + { + chatService = kernel.GetRequiredService(modelId); + } + catch (Exception) + { + chatService = kernel.GetRequiredService(); + } + } + else + { + chatService = kernel.GetRequiredService(); + } + + var chatHistory = new ChatHistory(); + chatHistory.AddSystemMessage(systemPrompt); + chatHistory.AddUserMessage(userMessageContent); + + var response = await chatService.GetChatMessageContentAsync(chatHistory, executionSettings, kernel, cancellationToken); + chatHistory.Add(response); + + request.Usage = AIServiceUsageHelper.GetOpenAIServiceUsage(response); + return response.Content; + } + + private Kernel CreateKernel() + { + var kernelBuilder = Kernel.CreateBuilder(); + var endpoint = string.IsNullOrWhiteSpace(_config.Endpoint) ? null : _config.Endpoint.Trim(); + var apiKey = _config.ApiKey?.Trim() ?? string.Empty; + + if (RequiresApiKey(_serviceType) && string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException($"API key is required for {_serviceType} but was not provided."); + } + + switch (_serviceType) + { + case AIServiceType.OpenAI: + kernelBuilder.AddOpenAIChatCompletion(_config.Model, apiKey, serviceId: _config.Model); + break; + case AIServiceType.AzureOpenAI: + var deploymentName = string.IsNullOrWhiteSpace(_config.DeploymentName) ? _config.Model : _config.DeploymentName; + kernelBuilder.AddAzureOpenAIChatCompletion(deploymentName, RequireEndpoint(endpoint, _serviceType), apiKey, serviceId: _config.Model); + break; + case AIServiceType.Mistral: + kernelBuilder.AddMistralChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.Google: + kernelBuilder.AddGoogleAIGeminiChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.HuggingFace: + kernelBuilder.AddHuggingFaceChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.AzureAIInference: + kernelBuilder.AddAzureAIInferenceChatCompletion(_config.Model, apiKey: apiKey, endpoint: new Uri(endpoint)); + break; + case AIServiceType.Ollama: + kernelBuilder.AddOllamaChatCompletion(_config.Model, endpoint: new Uri(endpoint)); + break; + case AIServiceType.Anthropic: + kernelBuilder.AddBedrockChatCompletionService(_config.Model); + break; + case AIServiceType.AmazonBedrock: + kernelBuilder.AddBedrockChatCompletionService(_config.Model); + break; + + default: + throw new NotSupportedException($"Provider '{_config.ProviderType}' is not supported by {nameof(SemanticKernelPasteProvider)}"); + } + + return kernelBuilder.Build(); + } + + private PromptExecutionSettings CreateExecutionSettings() + { + return _serviceType switch + { + AIServiceType.OpenAI or AIServiceType.AzureOpenAI => new OpenAIPromptExecutionSettings + { + Temperature = 0.01, + MaxTokens = 2000, + FunctionChoiceBehavior = null, + }, + _ => new PromptExecutionSettings(), + }; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.Ollama => false, + AIServiceType.Anthropic => false, + AIServiceType.AmazonBedrock => false, + _ => true, + }; + } + + private static string RequireEndpoint(string endpoint, AIServiceType serviceType) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return endpoint; + } + + throw new InvalidOperationException($"Endpoint is required for {serviceType} but was not provided."); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs new file mode 100644 index 0000000000..2542f7310e --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs @@ -0,0 +1,188 @@ +// 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.Linq; +using System.Threading; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; +using Windows.Security.Credentials; + +namespace AdvancedPaste.Services; + +/// +/// Enhanced credentials provider that supports different AI service types +/// Keys are stored in Windows Credential Vault with service-specific identifiers +/// +public sealed class EnhancedVaultCredentialsProvider : IAICredentialsProvider +{ + private sealed class CredentialSlot + { + public AIServiceType ServiceType { get; set; } = AIServiceType.Unknown; + + public string ProviderId { get; set; } = string.Empty; + + public (string Resource, string Username)? Entry { get; set; } + + public string Key { get; set; } = string.Empty; + } + + private readonly IUserSettings _userSettings; + private readonly CredentialSlot _slot; + private readonly Lock _syncRoot = new(); + + public EnhancedVaultCredentialsProvider(IUserSettings userSettings) + { + _userSettings = userSettings ?? throw new ArgumentNullException(nameof(userSettings)); + + _slot = new CredentialSlot(); + + Refresh(); + } + + public string GetKey() + { + using (_syncRoot.EnterScope()) + { + UpdateSlot(forceRefresh: false); + return _slot.Key; + } + } + + public bool IsConfigured() + { + return !string.IsNullOrEmpty(GetKey()); + } + + public bool Refresh() + { + using (_syncRoot.EnterScope()) + { + return UpdateSlot(forceRefresh: true); + } + } + + private bool UpdateSlot(bool forceRefresh) + { + var (serviceType, providerId) = ResolveCredentialTarget(); + var desiredServiceType = NormalizeServiceType(serviceType); + providerId ??= string.Empty; + + var hasChanged = false; + + if (_slot.ServiceType != desiredServiceType || !string.Equals(_slot.ProviderId, providerId, StringComparison.Ordinal)) + { + _slot.ServiceType = desiredServiceType; + _slot.ProviderId = providerId; + _slot.Entry = BuildCredentialEntry(desiredServiceType, providerId); + forceRefresh = true; + hasChanged = true; + } + + if (!forceRefresh) + { + return hasChanged; + } + + var newKey = LoadKey(_slot.Entry); + if (!string.Equals(_slot.Key, newKey, StringComparison.Ordinal)) + { + _slot.Key = newKey; + hasChanged = true; + } + + return hasChanged; + } + + private (AIServiceType ServiceType, string ProviderId) ResolveCredentialTarget() + { + var provider = _userSettings.PasteAIConfiguration?.ActiveProvider; + if (provider is null) + { + return (AIServiceType.OpenAI, string.Empty); + } + + return (provider.ServiceTypeKind, provider.Id ?? string.Empty); + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private static string LoadKey((string Resource, string Username)? entry) + { + if (entry is null) + { + return string.Empty; + } + + try + { + var credential = new PasswordVault().Retrieve(entry.Value.Resource, entry.Value.Username); + return credential?.Password ?? string.Empty; + } + catch (Exception) + { + return string.Empty; + } + } + + private static (string Resource, string Username)? BuildCredentialEntry(AIServiceType serviceType, string providerId) + { + string resource; + string serviceKey; + + switch (serviceType) + { + case AIServiceType.OpenAI: + resource = "https://platform.openai.com/api-keys"; + serviceKey = "openai"; + break; + case AIServiceType.AzureOpenAI: + resource = "https://azure.microsoft.com/products/ai-services/openai-service"; + serviceKey = "azureopenai"; + break; + case AIServiceType.AzureAIInference: + resource = "https://azure.microsoft.com/products/ai-services/ai-inference"; + serviceKey = "azureaiinference"; + break; + case AIServiceType.Mistral: + resource = "https://console.mistral.ai/account/api-keys"; + serviceKey = "mistral"; + break; + case AIServiceType.Google: + resource = "https://ai.google.dev/"; + serviceKey = "google"; + break; + case AIServiceType.HuggingFace: + resource = "https://huggingface.co/settings/tokens"; + serviceKey = "huggingface"; + break; + case AIServiceType.FoundryLocal: + case AIServiceType.ML: + case AIServiceType.Onnx: + case AIServiceType.Ollama: + case AIServiceType.Anthropic: + case AIServiceType.AmazonBedrock: + return null; + default: + return null; + } + + string username = $"PowerToys_AdvancedPaste_PasteAI_{serviceKey}_{NormalizeProviderIdentifier(providerId)}"; + return (resource, username); + } + + private static string NormalizeProviderIdentifier(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "default"; + } + + var filtered = new string(providerId.Where(char.IsLetterOrDigit).ToArray()); + return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant(); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs index 54759b7dc8..7aa6f63b19 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs @@ -4,11 +4,26 @@ namespace AdvancedPaste.Services; +/// +/// Provides access to AI credentials stored for Advanced Paste scenarios. +/// public interface IAICredentialsProvider { - bool IsConfigured { get; } + /// + /// Gets a value indicating whether any credential is configured. + /// + /// when a non-empty credential exists for the active AI provider. + bool IsConfigured(); - string Key { get; } + /// + /// Retrieves the credential for the active AI provider. + /// + /// Credential string or when missing. + string GetKey(); + /// + /// Refreshes the cached credential for the active AI provider. + /// + /// when the credential changed. bool Refresh(); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs deleted file mode 100644 index 75f1df259e..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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.Threading; -using System.Threading.Tasks; - -namespace AdvancedPaste.Services; - -public interface ICustomTextTransformService -{ - Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress); -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs new file mode 100644 index 0000000000..d634c13e30 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs @@ -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. + +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services; + +/// +/// Represents runtime information required to configure an AI kernel service. +/// +public interface IKernelRuntimeConfiguration +{ + AIServiceType ServiceType { get; } + + string ModelName { get; } + + string Endpoint { get; } + + string DeploymentName { get; } + + string ModelPath { get; } + + string SystemPrompt { get; } + + bool ModerationEnabled { get; } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs index e921b21e54..0ea9ef40bc 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs @@ -5,15 +5,16 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Models.KernelQueryCache; +using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; using AdvancedPaste.Telemetry; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Telemetry; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; @@ -21,15 +22,20 @@ using Windows.ApplicationModel.DataTransfer; namespace AdvancedPaste.Services; -public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheService, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) : IKernelService +public abstract class KernelServiceBase( + IKernelQueryCacheService queryCacheService, + IPromptModerationService promptModerationService, + IUserSettings userSettings, + ICustomActionTransformService customActionTransformService) : IKernelService { private const string PromptParameterName = "prompt"; private readonly IKernelQueryCacheService _queryCacheService = queryCacheService; private readonly IPromptModerationService _promptModerationService = promptModerationService; - private readonly ICustomTextTransformService _customTextTransformService = customTextTransformService; + private readonly IUserSettings _userSettings = userSettings; + private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; - protected abstract string ModelName { get; } + protected abstract string AdvancedAIModelName { get; } protected abstract PromptExecutionSettings PromptExecutionSettings { get; } @@ -37,6 +43,8 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi protected abstract AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage); + protected abstract IKernelRuntimeConfiguration GetRuntimeConfiguration(); + public async Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress progress) { Logger.LogTrace(); @@ -132,21 +140,20 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi private async Task<(ChatHistory ChatHistory, AIServiceUsage Usage)> ExecuteAICompletion(Kernel kernel, string prompt, CancellationToken cancellationToken) { + var runtimeConfig = GetRuntimeConfiguration(); + ChatHistory chatHistory = []; - chatHistory.AddSystemMessage(""" - You are an agent who is tasked with helping users paste their clipboard data. You have functions available to help you with this task. - You never need to ask permission, always try to do as the user asks. The user will only input one message and will not be available for further questions, so try your best. - The user will put in a request to format their clipboard data and you will fulfill it. - You will not directly see the output clipboard content, and do not need to provide it in the chat. You just need to do the transform operations as needed. - If you are unable to fulfill the request, end with an error message in the language of the user's request. - """); + chatHistory.AddSystemMessage(runtimeConfig.SystemPrompt); chatHistory.AddSystemMessage($"Available clipboard formats: {await kernel.GetDataFormatsAsync()}"); chatHistory.AddUserMessage(prompt); - await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); + if (ShouldModerateAdvancedAI()) + { + await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); + } - var chatResult = await kernel.GetRequiredService() + var chatResult = await kernel.GetRequiredService(AdvancedAIModelName) .GetChatMessageContentAsync(chatHistory, PromptExecutionSettings, kernel, cancellationToken); chatHistory.Add(chatResult); @@ -175,10 +182,18 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi return ([], AIServiceUsage.None); } + protected IUserSettings UserSettings => _userSettings; + private void LogResult(bool cacheUsed, bool isSavedQuery, IEnumerable actionChain, AIServiceUsage usage) { - AdvancedPasteSemanticKernelFormatEvent telemetryEvent = new(cacheUsed, isSavedQuery, usage.PromptTokens, usage.CompletionTokens, ModelName, AdvancedPasteSemanticKernelFormatEvent.FormatActionChain(actionChain)); + AdvancedPasteSemanticKernelFormatEvent telemetryEvent = new(cacheUsed, isSavedQuery, usage.PromptTokens, usage.CompletionTokens, AdvancedAIModelName, AdvancedPasteSemanticKernelFormatEvent.FormatActionChain(actionChain)); PowerToysTelemetry.Log.WriteEvent(telemetryEvent); + + // Log endpoint usage + var runtimeConfig = GetRuntimeConfiguration(); + var endpointEvent = new AdvancedPasteEndpointUsageEvent(runtimeConfig.ServiceType); + PowerToysTelemetry.Log.WriteEvent(endpointEvent); + var logEvent = new AIServiceFormatEvent(telemetryEvent); Logger.LogDebug($"{nameof(TransformClipboardAsync)} complete; {logEvent.ToJsonString()}"); } @@ -191,20 +206,96 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi return kernelBuilder.Build(); } - private IEnumerable GetKernelFunctions() => - from format in Enum.GetValues() - let metadata = PasteFormat.MetadataDict[format] - let coreDescription = metadata.KernelFunctionDescription - where !string.IsNullOrEmpty(coreDescription) - let requiresPrompt = metadata.RequiresPrompt - orderby requiresPrompt descending - select KernelFunctionFactory.CreateFromMethod( - method: requiresPrompt ? async (Kernel kernel, string prompt) => await ExecutePromptTransformAsync(kernel, format, prompt) - : async (Kernel kernel) => await ExecuteStandardTransformAsync(kernel, format), - functionName: format.ToString(), - description: requiresPrompt ? coreDescription : $"{coreDescription} Puts the result back on the clipboard.", - parameters: requiresPrompt ? [new(PromptParameterName) { Description = "Input instructions to AI", ParameterType = typeof(string) }] : null, - returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + private IEnumerable GetKernelFunctions() + { + // Get standard format functions + var standardFunctions = + from format in Enum.GetValues() + let metadata = PasteFormat.MetadataDict[format] + let coreDescription = metadata.KernelFunctionDescription + where !string.IsNullOrEmpty(coreDescription) + let requiresPrompt = metadata.RequiresPrompt + orderby requiresPrompt descending + select KernelFunctionFactory.CreateFromMethod( + method: requiresPrompt ? async (Kernel kernel, string prompt) => await ExecutePromptTransformAsync(kernel, format, prompt) + : async (Kernel kernel) => await ExecuteStandardTransformAsync(kernel, format), + functionName: format.ToString(), + description: requiresPrompt ? coreDescription : $"{coreDescription} Puts the result back on the clipboard.", + parameters: requiresPrompt ? [new(PromptParameterName) { Description = "Input instructions to AI", ParameterType = typeof(string) }] : null, + returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + + HashSet usedFunctionNames = new(Enum.GetNames(), StringComparer.OrdinalIgnoreCase); + + // Get custom action functions + var customActionFunctions = _userSettings.CustomActions + .Where(customAction => !string.IsNullOrWhiteSpace(customAction.Name) && !string.IsNullOrWhiteSpace(customAction.Prompt)) + .Select(customAction => + { + var sanitizedBaseName = SanitizeFunctionName(customAction.Name); + var functionName = GetUniqueFunctionName(sanitizedBaseName, usedFunctionNames, customAction.Id); + var description = string.IsNullOrWhiteSpace(customAction.Description) + ? $"Runs the \"{customAction.Name}\" custom action." + : customAction.Description; + return KernelFunctionFactory.CreateFromMethod( + method: async (Kernel kernel) => await ExecuteCustomActionAsync(kernel, customAction.Prompt), + functionName: functionName, + description: description, + parameters: null, + returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + }); + + return standardFunctions.Concat(customActionFunctions); + } + + private static string GetUniqueFunctionName(string baseName, HashSet usedFunctionNames, int customActionId) + { + ArgumentNullException.ThrowIfNull(usedFunctionNames); + + var candidate = string.IsNullOrEmpty(baseName) ? "_CustomAction" : baseName; + + if (usedFunctionNames.Add(candidate)) + { + return candidate; + } + + int suffix = 1; + while (true) + { + var nextCandidate = $"{candidate}_{customActionId}_{suffix}"; + if (usedFunctionNames.Add(nextCandidate)) + { + return nextCandidate; + } + + suffix++; + } + } + + private static string SanitizeFunctionName(string name) + { + // Remove invalid characters and ensure the function name is valid for kernel + var sanitized = new string(name.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray()); + + // Ensure it starts with a letter or underscore + if (sanitized.Length > 0 && !char.IsLetter(sanitized[0]) && sanitized[0] != '_') + { + sanitized = "_" + sanitized; + } + + // Ensure it's not empty + return string.IsNullOrEmpty(sanitized) ? "_CustomAction" : sanitized; + } + + private Task ExecuteCustomActionAsync(Kernel kernel, string fixedPrompt) => + ExecuteTransformAsync( + kernel, + new ActionChainItem(PasteFormats.CustomTextTransformation, Arguments: new() { { PromptParameterName, fixedPrompt } }), + async dataPackageView => + { + var input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken()); + var result = await _customActionTransformService.TransformTextAsync(fixedPrompt, input, kernel.GetCancellationToken(), kernel.GetProgress()); + return DataPackageHelpers.CreateFromText(result?.Content ?? string.Empty); + }); private Task ExecutePromptTransformAsync(Kernel kernel, PasteFormats format, string prompt) => ExecuteTransformAsync( @@ -212,7 +303,7 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi new ActionChainItem(format, Arguments: new() { { PromptParameterName, prompt } }), async dataPackageView => { - var input = await dataPackageView.GetTextAsync(); + var input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken()); string output = await GetPromptBasedOutput(format, prompt, input, kernel.GetCancellationToken(), kernel.GetProgress()); return DataPackageHelpers.CreateFromText(output); }); @@ -220,7 +311,7 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi private async Task GetPromptBasedOutput(PasteFormats format, string prompt, string input, CancellationToken cancellationToken, IProgress progress) => format switch { - PasteFormats.CustomTextTransformation => await _customTextTransformService.TransformTextAsync(prompt, input, cancellationToken, progress), + PasteFormats.CustomTextTransformation => (await _customActionTransformService.TransformTextAsync(prompt, input, cancellationToken, progress))?.Content ?? string.Empty, _ => throw new ArgumentException($"Unsupported format {format} for prompt transform", nameof(format)), }; @@ -281,4 +372,9 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi var usageString = usage.HasUsage ? $" [{usage}]" : string.Empty; return $"-> {role}: {redactedContent}{usageString}"; } + + protected virtual bool ShouldModerateAdvancedAI() + { + return false; + } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs deleted file mode 100644 index b6aa156b9d..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs +++ /dev/null @@ -1,113 +0,0 @@ -// 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.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -using AdvancedPaste.Helpers; -using AdvancedPaste.Models; -using AdvancedPaste.Telemetry; -using Azure; -using Azure.AI.OpenAI; -using ManagedCommon; -using Microsoft.PowerToys.Telemetry; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class CustomTextTransformService(IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService) : ICustomTextTransformService -{ - private const string ModelName = "gpt-3.5-turbo-instruct"; - - private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; - private readonly IPromptModerationService _promptModerationService = promptModerationService; - - private async Task GetAICompletionAsync(string systemInstructions, string userMessage, CancellationToken cancellationToken) - { - var fullPrompt = systemInstructions + "\n\n" + userMessage; - - await _promptModerationService.ValidateAsync(fullPrompt, cancellationToken); - - OpenAIClient azureAIClient = new(_aiCredentialsProvider.Key); - - var response = await azureAIClient.GetCompletionsAsync( - new() - { - DeploymentName = ModelName, - Prompts = - { - fullPrompt, - }, - Temperature = 0.01F, - MaxTokens = 2000, - }, - cancellationToken); - - if (response.Value.Choices[0].FinishReason == "length") - { - Logger.LogDebug("Cut off due to length constraints"); - } - - return response; - } - - public async Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress) - { - if (string.IsNullOrWhiteSpace(prompt)) - { - return string.Empty; - } - - if (string.IsNullOrWhiteSpace(inputText)) - { - Logger.LogWarning("Clipboard has no usable text data"); - return string.Empty; - } - - string systemInstructions = -$@"You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. -Do not output anything else besides the reformatted clipboard content."; - - string userMessage = -$@"User instructions: -{prompt} - -Clipboard Content: -{inputText} - -Output: -"; - - try - { - var response = await GetAICompletionAsync(systemInstructions, userMessage, cancellationToken); - - var usage = response.Usage; - AdvancedPasteGenerateCustomFormatEvent telemetryEvent = new(usage.PromptTokens, usage.CompletionTokens, ModelName); - PowerToysTelemetry.Log.WriteEvent(telemetryEvent); - var logEvent = new AIServiceFormatEvent(telemetryEvent); - - Logger.LogDebug($"{nameof(TransformTextAsync)} complete; {logEvent.ToJsonString()}"); - - return response.Choices[0].Text; - } - catch (Exception ex) - { - Logger.LogError($"{nameof(TransformTextAsync)} failed", ex); - - AdvancedPasteGenerateCustomErrorEvent errorEvent = new(ex is PasteActionModeratedException ? PasteActionModeratedException.ErrorDescription : ex.Message); - PowerToysTelemetry.Log.WriteEvent(errorEvent); - - if (ex is PasteActionException or OperationCanceledException) - { - throw; - } - else - { - throw new PasteActionException(ErrorHelpers.TranslateErrorText((ex as RequestFailedException)?.Status ?? -1), ex); - } - } - } -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs deleted file mode 100644 index b19a6d51cb..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs +++ /dev/null @@ -1,34 +0,0 @@ -// 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 AdvancedPaste.Models; -using Azure.AI.OpenAI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.OpenAI; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class KernelService(IKernelQueryCacheService queryCacheService, IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) : - KernelServiceBase(queryCacheService, promptModerationService, customTextTransformService) -{ - private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; - - protected override string ModelName => "gpt-4o"; - - protected override PromptExecutionSettings PromptExecutionSettings => - new OpenAIPromptExecutionSettings() - { - ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions, - Temperature = 0.01, - }; - - protected override void AddChatCompletionService(IKernelBuilder kernelBuilder) => kernelBuilder.AddOpenAIChatCompletion(ModelName, _aiCredentialsProvider.Key); - - protected override AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage) => - chatMessage.Metadata?.GetValueOrDefault("Usage") is CompletionsUsage completionsUsage - ? new(PromptTokens: completionsUsage.PromptTokens, CompletionTokens: completionsUsage.CompletionTokens) - : AIServiceUsage.None; -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs index 0ca15e4161..2668300526 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; using ManagedCommon; using OpenAI.Moderations; @@ -23,7 +24,16 @@ public sealed class PromptModerationService(IAICredentialsProvider aiCredentials { try { - ModerationClient moderationClient = new(ModelName, _aiCredentialsProvider.Key); + _aiCredentialsProvider.Refresh(); + var apiKey = _aiCredentialsProvider.GetKey()?.Trim() ?? string.Empty; + + if (string.IsNullOrEmpty(apiKey)) + { + Logger.LogWarning("Skipping OpenAI moderation because no credential is configured."); + return; + } + + ModerationClient moderationClient = new(ModelName, apiKey); var moderationClientResult = await moderationClient.ClassifyTextAsync(fullPrompt, cancellationToken); var moderationResult = moderationClientResult.Value; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs deleted file mode 100644 index 169c1c2422..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs +++ /dev/null @@ -1,37 +0,0 @@ -// 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 Windows.Security.Credentials; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class VaultCredentialsProvider : IAICredentialsProvider -{ - public VaultCredentialsProvider() => Refresh(); - - public string Key { get; private set; } - - public bool IsConfigured => !string.IsNullOrEmpty(Key); - - public bool Refresh() - { - var oldKey = Key; - Key = LoadKey(); - return oldKey != Key; - } - - private static string LoadKey() - { - try - { - return new PasswordVault().Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey")?.Password ?? string.Empty; - } - catch (Exception) - { - return string.Empty; - } - } -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs index 5d6740977b..aef9e39bb9 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs @@ -8,15 +8,16 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services.CustomActions; using Microsoft.PowerToys.Telemetry; using Windows.ApplicationModel.DataTransfer; namespace AdvancedPaste.Services; -public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomTextTransformService customTextTransformService) : IPasteFormatExecutor +public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor { private readonly IKernelService _kernelService = kernelService; - private readonly ICustomTextTransformService _customTextTransformService = customTextTransformService; + private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; public async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress progress) { @@ -36,7 +37,7 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomTex pasteFormat.Format switch { PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress), - PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText(await _customTextTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetTextAsync(), cancellationToken, progress)), + PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetClipboardTextOrThrowAsync(cancellationToken), cancellationToken, progress))?.Content ?? string.Empty), _ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress), }); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw index 604cbf403b..f6c66154af 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw +++ b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw @@ -144,16 +144,67 @@ The paste operation was moderated due to sensitive content. Please try another query. - + Clipboard history Clipboard history + + AI provider selector + + + Select an AI provider + + + Active provider: {0} + + + AI providers + + + No AI providers configured + + + Configure models in Settings + Image data Label used to represent an image in the clipboard history + + Text + + + Image + + + Audio + + + Video + + + File + + + Clipboard + + + Copied just now + + + Copied {0} sec ago + + + Copied {0} min ago + + + Copied {0} hr ago + + + Copied {0} day ago + More options @@ -196,7 +247,7 @@ Transcode to .mp3 Option to transcode audio files to MP3 format - + Transcode to .mp4 (H.264/AAC) Option to transcode video files to MP4 format with H.264 video codec and AAC audio codec @@ -272,11 +323,11 @@ Next result - - OpenAI Privacy + + Privacy Policy - - OpenAI Terms + + Terms To custom with AI is disabled by your organization @@ -287,4 +338,27 @@ PowerToys_Paste_ + + Just now + + + 1 minute ago + + + {0} minutes ago + + + Today, {0} + + + Yesterday, {0} + + + {0}, {1} + (e.g., “Wednesday, 17:05”) + + + {0}, {1} + (e.g., “10/20/2025, 17:05” in the user’s locale) + \ No newline at end of file diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs new file mode 100644 index 0000000000..04777eff79 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs @@ -0,0 +1,28 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace AdvancedPaste.Telemetry; + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class AdvancedPasteEndpointUsageEvent : EventBase, IEvent +{ + /// + /// Gets or sets the AI provider type (e.g., OpenAI, AzureOpenAI, Anthropic). + /// + public string ProviderType { get; set; } + + public AdvancedPasteEndpointUsageEvent(AIServiceType providerType) + { + ProviderType = providerType.ToString(); + } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs index 688c3047e2..ede006e960 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; +using System.Globalization; using System.IO.Abstractions; using System.Linq; using System.Runtime.InteropServices; @@ -22,6 +23,8 @@ using CommunityToolkit.Mvvm.Input; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.Win32; using Windows.ApplicationModel.DataTransfer; using Windows.System; @@ -37,12 +40,20 @@ namespace AdvancedPaste.ViewModels private readonly DispatcherTimer _clipboardTimer; private readonly IUserSettings _userSettings; private readonly IPasteFormatExecutor _pasteFormatExecutor; - private readonly IAICredentialsProvider _aiCredentialsProvider; + private readonly IAICredentialsProvider _credentialsProvider; private CancellationTokenSource _pasteActionCancellationTokenSource; + private string _currentClipboardHistoryId; + private DateTimeOffset? _currentClipboardTimestamp; + private ClipboardFormat _lastClipboardFormats = ClipboardFormat.None; + private bool _clipboardHistoryUnavailableLogged; + public DataPackageView ClipboardData { get; set; } + [ObservableProperty] + private ClipboardItem _currentClipboardItem; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsCustomAIAvailable))] [NotifyPropertyChangedFor(nameof(ClipboardHasData))] @@ -58,6 +69,8 @@ namespace AdvancedPaste.ViewModels [NotifyPropertyChangedFor(nameof(CustomAIUnavailableErrorText))] [NotifyPropertyChangedFor(nameof(IsCustomAIServiceEnabled))] [NotifyPropertyChangedFor(nameof(IsCustomAIAvailable))] + [NotifyPropertyChangedFor(nameof(AllowedAIProviders))] + [NotifyPropertyChangedFor(nameof(ActiveAIProvider))] private bool _isAllowedByGPO; [ObservableProperty] @@ -79,11 +92,100 @@ namespace AdvancedPaste.ViewModels public ObservableCollection CustomActionPasteFormats { get; } = []; - public bool IsCustomAIServiceEnabled => IsAllowedByGPO && _aiCredentialsProvider.IsConfigured; + public bool IsCustomAIServiceEnabled + { + get + { + if (!IsAllowedByGPO || !_userSettings.IsAIEnabled) + { + return false; + } + + // Check if there are any allowed providers + if (!AllowedAIProviders.Any()) + { + return false; + } + + // We should handle the IsAIEnabled logic in settings, don't check again here. + // If setting says yes, and here should pass check, and if error happens, it happens. + return true; + } + } public bool IsCustomAIAvailable => IsCustomAIServiceEnabled && ClipboardHasDataForCustomAI; - public bool IsAdvancedAIEnabled => IsCustomAIServiceEnabled && _userSettings.IsAdvancedAIEnabled; + public bool IsAdvancedAIEnabled + { + get + { + if (!IsAllowedByGPO || !_userSettings.IsAIEnabled) + { + return false; + } + + if (!TryResolveAdvancedAIProvider(out _)) + { + return false; + } + + return _credentialsProvider.IsConfigured(); + } + } + + public ObservableCollection AIProviders => _userSettings?.PasteAIConfiguration?.Providers ?? new ObservableCollection(); + + public IEnumerable AllowedAIProviders + { + get + { + var providers = AIProviders; + if (providers is null || providers.Count == 0) + { + return Enumerable.Empty(); + } + + return providers.Where(IsProviderAllowedByGPO); + } + } + + public PasteAIProviderDefinition ActiveAIProvider + { + get + { + var provider = _userSettings?.PasteAIConfiguration?.ActiveProvider; + if (provider is null || !IsProviderAllowedByGPO(provider)) + { + return null; + } + + return provider; + } + } + + public string ActiveAIProviderTooltip + { + get + { + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var provider = ActiveAIProvider; + + if (provider is null) + { + return resourceLoader.GetString("AIProviderButtonTooltipEmpty"); + } + + var format = resourceLoader.GetString("AIProviderButtonTooltipFormat"); + var displayName = provider.DisplayName; + + if (!string.IsNullOrEmpty(format)) + { + return string.Format(CultureInfo.CurrentCulture, format, displayName); + } + + return displayName; + } + } public bool ClipboardHasData => AvailableClipboardFormats != ClipboardFormat.None; @@ -91,7 +193,10 @@ namespace AdvancedPaste.ViewModels public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress); - private PasteFormats CustomAIFormat => _userSettings.IsAdvancedAIEnabled ? PasteFormats.KernelQuery : PasteFormats.CustomTextTransformation; + private PasteFormats CustomAIFormat => + _userSettings.IsAIEnabled && TryResolveAdvancedAIProvider(out _) + ? PasteFormats.KernelQuery + : PasteFormats.CustomTextTransformation; private bool Visible { @@ -110,9 +215,9 @@ namespace AdvancedPaste.ViewModels public event EventHandler PreviewRequested; - public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider aiCredentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor) + public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor) { - _aiCredentialsProvider = aiCredentialsProvider; + _credentialsProvider = credentialsProvider; _userSettings = userSettings; _pasteFormatExecutor = pasteFormatExecutor; @@ -130,6 +235,7 @@ namespace AdvancedPaste.ViewModels _clipboardTimer.Start(); RefreshPasteFormats(); + UpdateAIProviderActiveFlags(); _userSettings.Changed += UserSettings_Changed; PropertyChanged += (_, e) => { @@ -158,15 +264,20 @@ namespace AdvancedPaste.ViewModels if (Visible) { await ReadClipboardAsync(); - UpdateAllowedByGPO(); } } private void UserSettings_Changed(object sender, EventArgs e) { + UpdateAIProviderActiveFlags(); + OnPropertyChanged(nameof(IsCustomAIServiceEnabled)); OnPropertyChanged(nameof(ClipboardHasDataForCustomAI)); OnPropertyChanged(nameof(IsCustomAIAvailable)); OnPropertyChanged(nameof(IsAdvancedAIEnabled)); + OnPropertyChanged(nameof(AIProviders)); + OnPropertyChanged(nameof(AllowedAIProviders)); + OnPropertyChanged(nameof(ActiveAIProvider)); + OnPropertyChanged(nameof(ActiveAIProviderTooltip)); EnqueueRefreshPasteFormats(); } @@ -192,6 +303,23 @@ namespace AdvancedPaste.ViewModels private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery) => PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled); + private void UpdateAIProviderActiveFlags() + { + var providers = _userSettings?.PasteAIConfiguration?.Providers; + if (providers is not null) + { + var activeId = ActiveAIProvider?.Id; + + foreach (var provider in providers) + { + provider.IsActive = !string.IsNullOrEmpty(activeId) && string.Equals(provider.Id, activeId, StringComparison.OrdinalIgnoreCase); + } + } + + OnPropertyChanged(nameof(ActiveAIProvider)); + OnPropertyChanged(nameof(ActiveAIProviderTooltip)); + } + private void RefreshPasteFormats() { var ctrlString = ResourceLoaderInstance.ResourceLoader.GetString("CtrlKey"); @@ -253,8 +381,96 @@ namespace AdvancedPaste.ViewModels return; } - ClipboardData = Clipboard.GetContent(); - AvailableClipboardFormats = await ClipboardData.GetAvailableFormatsAsync(); + try + { + ClipboardData = Clipboard.GetContent(); + AvailableClipboardFormats = ClipboardData != null ? await ClipboardData.GetAvailableFormatsAsync() : ClipboardFormat.None; + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + // Logger.LogDebug("Failed to read clipboard content", ex); + ClipboardData = null; + AvailableClipboardFormats = ClipboardFormat.None; + } + + await UpdateClipboardPreviewAsync(); + } + + private async Task UpdateClipboardPreviewAsync() + { + if (ClipboardData is null || !ClipboardHasData) + { + ResetClipboardPreview(); + _currentClipboardHistoryId = null; + _currentClipboardTimestamp = null; + _lastClipboardFormats = ClipboardFormat.None; + return; + } + + var formatsChanged = AvailableClipboardFormats != _lastClipboardFormats; + _lastClipboardFormats = AvailableClipboardFormats; + + var clipboardChanged = await UpdateClipboardTimestampAsync(formatsChanged); + + // Create ClipboardItem directly from current clipboard data using helper + CurrentClipboardItem = await ClipboardItemHelper.CreateFromCurrentClipboardAsync( + ClipboardData, + AvailableClipboardFormats, + _currentClipboardTimestamp, + clipboardChanged ? null : CurrentClipboardItem?.Image); + } + + private async Task UpdateClipboardTimestampAsync(bool formatsChanged) + { + bool clipboardChanged = formatsChanged; + + if (Clipboard.IsHistoryEnabled()) + { + try + { + var historyItems = await Clipboard.GetHistoryItemsAsync(); + if (historyItems.Status == ClipboardHistoryItemsResultStatus.Success && historyItems.Items.Count > 0) + { + var latest = historyItems.Items[0]; + if (_currentClipboardHistoryId != latest.Id) + { + clipboardChanged = true; + _currentClipboardHistoryId = latest.Id; + } + + _currentClipboardTimestamp = latest.Timestamp; + _clipboardHistoryUnavailableLogged = false; + return clipboardChanged; + } + } + catch (Exception ex) + { + if (!_clipboardHistoryUnavailableLogged) + { + Logger.LogDebug("Failed to access clipboard history timestamp", ex.Message); + _clipboardHistoryUnavailableLogged = true; + } + } + } + + if (!_currentClipboardTimestamp.HasValue || clipboardChanged) + { + _currentClipboardTimestamp = DateTimeOffset.Now; + clipboardChanged = true; + } + + return clipboardChanged; + } + + private void ResetClipboardPreview() + { + // Clear to avoid leaks due to Garbage Collection not clearing the bitmap from memory + if (CurrentClipboardItem?.Image is not null) + { + CurrentClipboardItem.Image.ClearValue(BitmapImage.UriSourceProperty); + } + + CurrentClipboardItem = null; } public async Task OnShowAsync() @@ -270,7 +486,7 @@ namespace AdvancedPaste.ViewModels _dispatcherQueue.TryEnqueue(() => { - GetMainWindow()?.FinishLoading(_aiCredentialsProvider.IsConfigured); + GetMainWindow()?.FinishLoading(IsCustomAIServiceEnabled); OnPropertyChanged(nameof(InputTxtBoxPlaceholderText)); OnPropertyChanged(nameof(CustomAIUnavailableErrorText)); OnPropertyChanged(nameof(IsCustomAIServiceEnabled)); @@ -319,7 +535,7 @@ namespace AdvancedPaste.ViewModels return ResourceLoaderInstance.ResourceLoader.GetString("OpenAIGpoDisabled"); } - if (!_aiCredentialsProvider.IsConfigured) + if (!IsCustomAIServiceEnabled) { return ResourceLoaderInstance.ResourceLoader.GetString("OpenAINotConfigured"); } @@ -515,11 +731,113 @@ namespace AdvancedPaste.ViewModels IsAllowedByGPO = PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOnlineAIModelsValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled; } + private bool IsProviderAllowedByGPO(PasteAIProviderDefinition provider) + { + if (provider is null) + { + return false; + } + + var serviceType = provider.ServiceType.ToAIServiceType(); + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + + // Check global online AI GPO for online services + if (metadata.IsOnlineService && !IsAllowedByGPO) + { + return false; + } + + // Check individual endpoint GPO + return serviceType switch + { + AIServiceType.OpenAI => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOpenAIValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.AzureOpenAI => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAzureOpenAIValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.AzureAIInference => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAzureAIInferenceValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Mistral => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteMistralValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Google => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteGoogleValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Anthropic => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAnthropicValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Ollama => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOllamaValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.FoundryLocal => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteFoundryLocalValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + _ => true, // Allow unknown types by default + }; + } + + private bool TryResolveAdvancedAIProvider(out PasteAIProviderDefinition provider) + { + provider = null; + + var configuration = _userSettings?.PasteAIConfiguration; + if (configuration is null) + { + return false; + } + + var activeProvider = configuration.ActiveProvider; + if (IsAdvancedAIProvider(activeProvider)) + { + provider = activeProvider; + return true; + } + + if (activeProvider is not null) + { + return false; + } + + var fallback = configuration.Providers?.FirstOrDefault(IsAdvancedAIProvider); + if (fallback is not null) + { + provider = fallback; + return true; + } + + return false; + } + + private static bool IsAdvancedAIProvider(PasteAIProviderDefinition provider) + { + return provider is not null && provider.EnableAdvancedAI && SupportsAdvancedAI(provider.ServiceTypeKind); + } + + private static bool SupportsAdvancedAI(AIServiceType serviceType) + { + return serviceType is AIServiceType.OpenAI + or AIServiceType.AzureOpenAI; + } + private bool UpdateOpenAIKey() { UpdateAllowedByGPO(); - return IsAllowedByGPO && _aiCredentialsProvider.Refresh(); + return _credentialsProvider.Refresh(); + } + + [RelayCommand] + private async Task SetActiveProviderAsync(PasteAIProviderDefinition provider) + { + if (provider is null || string.IsNullOrEmpty(provider.Id)) + { + return; + } + + if (string.Equals(ActiveAIProvider?.Id, provider.Id, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + await _userSettings.SetActiveAIProviderAsync(provider.Id); + } + catch (Exception ex) + { + Logger.LogError("Failed to activate AI provider", ex); + return; + } + + UpdateAIProviderActiveFlags(); + OnPropertyChanged(nameof(AIProviders)); + EnqueueRefreshPasteFormats(); } public async Task CancelPasteActionAsync() diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj index 083aa868d3..2cf2920673 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj @@ -2,7 +2,7 @@ - + 15.0 diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp index 6af0d636ac..c7d22d474f 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp @@ -16,7 +16,8 @@ #include #include -#include +#include +#include #include BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) @@ -54,12 +55,14 @@ namespace const wchar_t JSON_KEY_ADVANCED_PASTE_UI_HOTKEY[] = L"advanced-paste-ui-hotkey"; const wchar_t JSON_KEY_PASTE_AS_MARKDOWN_HOTKEY[] = L"paste-as-markdown-hotkey"; const wchar_t JSON_KEY_PASTE_AS_JSON_HOTKEY[] = L"paste-as-json-hotkey"; - const wchar_t JSON_KEY_IS_ADVANCED_AI_ENABLED[] = L"IsAdvancedAIEnabled"; + const wchar_t JSON_KEY_IS_AI_ENABLED[] = L"IsAIEnabled"; + const wchar_t JSON_KEY_IS_OPEN_AI_ENABLED[] = L"IsOpenAIEnabled"; const wchar_t JSON_KEY_SHOW_CUSTOM_PREVIEW[] = L"ShowCustomPreview"; + const wchar_t JSON_KEY_PASTE_AI_CONFIGURATION[] = L"paste-ai-configuration"; + const wchar_t JSON_KEY_PROVIDERS[] = L"providers"; + const wchar_t JSON_KEY_SERVICE_TYPE[] = L"service-type"; + const wchar_t JSON_KEY_ENABLE_ADVANCED_AI[] = L"enable-advanced-ai"; const wchar_t JSON_KEY_VALUE[] = L"value"; - - const wchar_t OPENAI_VAULT_RESOURCE[] = L"https://platform.openai.com/api-keys"; - const wchar_t OPENAI_VAULT_USERNAME[] = L"PowerToys_AdvancedPaste_OpenAIKey"; } class AdvancedPaste : public PowertoyModuleIface @@ -94,6 +97,7 @@ private: using CustomAction = ActionData; std::vector m_custom_actions; + bool m_is_ai_enabled = false; bool m_is_advanced_ai_enabled = false; bool m_preview_custom_format_output = true; @@ -145,32 +149,11 @@ private: return jsonObject; } - static bool open_ai_key_exists() - { - try - { - winrt::Windows::Security::Credentials::PasswordVault().Retrieve(OPENAI_VAULT_RESOURCE, OPENAI_VAULT_USERNAME); - return true; - } - catch (const winrt::hresult_error& ex) - { - // Looks like the only way to access the PasswordVault is through an API that throws an exception in case the resource doesn't exist. - // If the debugger breaks here, just continue. - // If you want to disable breaking here in a more permanent way, just add a condition in Visual Studio's Exception Settings to not break on win::hresult_error, but that might make you not hit other exceptions you might want to catch. - if (ex.code() == HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) - { - return false; // Credential doesn't exist. - } - Logger::error("Unexpected error while retrieving OpenAI key from vault: {}", winrt::to_string(ex.message())); - return false; - } - } - - bool is_open_ai_enabled() + bool is_ai_enabled() { return gpo_policy_enabled_configuration() != powertoys_gpo::gpo_rule_configured_disabled && powertoys_gpo::getAllowedAdvancedPasteOnlineAIModelsValue() != powertoys_gpo::gpo_rule_configured_disabled && - open_ai_key_exists(); + m_is_ai_enabled; } static std::wstring kebab_to_pascal_case(const std::wstring& kebab_str) @@ -201,6 +184,13 @@ private: return result; } + static std::wstring to_lower_case(const std::wstring& value) + { + std::wstring result = value; + std::transform(result.begin(), result.end(), result.begin(), [](wchar_t ch) { return std::towlower(ch); }); + return result; + } + bool migrate_data_and_remove_data_file(Hotkey& old_paste_as_plain_hotkey) { const wchar_t OLD_JSON_KEY_ACTIVATION_SHORTCUT[] = L"ActivationShortcut"; @@ -267,6 +257,61 @@ private: } } + bool has_advanced_ai_provider(const winrt::Windows::Data::Json::JsonObject& propertiesObject) + { + if (!propertiesObject.HasKey(JSON_KEY_PASTE_AI_CONFIGURATION)) + { + return false; + } + + const auto configValue = propertiesObject.GetNamedValue(JSON_KEY_PASTE_AI_CONFIGURATION); + if (configValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) + { + return false; + } + + const auto configObject = configValue.GetObjectW(); + if (!configObject.HasKey(JSON_KEY_PROVIDERS)) + { + return false; + } + + const auto providersValue = configObject.GetNamedValue(JSON_KEY_PROVIDERS); + if (providersValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Array) + { + return false; + } + + const auto providers = providersValue.GetArray(); + for (const auto providerValue : providers) + { + if (providerValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) + { + continue; + } + + const auto providerObject = providerValue.GetObjectW(); + if (!providerObject.GetNamedBoolean(JSON_KEY_ENABLE_ADVANCED_AI, false)) + { + continue; + } + + if (!providerObject.HasKey(JSON_KEY_SERVICE_TYPE)) + { + continue; + } + + const std::wstring serviceType = providerObject.GetNamedString(JSON_KEY_SERVICE_TYPE, L"").c_str(); + const auto normalizedServiceType = to_lower_case(serviceType); + if (normalizedServiceType == L"openai" || normalizedServiceType == L"azureopenai") + { + return true; + } + } + + return false; + } + void read_settings(PowerToysSettings::PowerToyValues& settings) { const auto settingsObject = settings.get_raw_json(); @@ -341,7 +386,7 @@ private: if (propertiesObject.HasKey(JSON_KEY_CUSTOM_ACTIONS)) { const auto customActions = propertiesObject.GetNamedObject(JSON_KEY_CUSTOM_ACTIONS).GetNamedArray(JSON_KEY_VALUE); - if (customActions.Size() > 0 && is_open_ai_enabled()) + if (customActions.Size() > 0 && is_ai_enabled()) { for (const auto& customAction : customActions) { @@ -365,9 +410,19 @@ private: { const auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); - if (propertiesObject.HasKey(JSON_KEY_IS_ADVANCED_AI_ENABLED)) + m_is_advanced_ai_enabled = has_advanced_ai_provider(propertiesObject); + + if (propertiesObject.HasKey(JSON_KEY_IS_AI_ENABLED)) { - m_is_advanced_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_ADVANCED_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE); + m_is_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE, false); + } + else if (propertiesObject.HasKey(JSON_KEY_IS_OPEN_AI_ENABLED)) + { + m_is_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_OPEN_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE, false); + } + else + { + m_is_ai_enabled = false; } if (propertiesObject.HasKey(JSON_KEY_SHOW_CUSTOM_PREVIEW)) diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json index 31ad05c701..bc0803796e 100644 --- a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json @@ -1 +1 @@ -{"properties":{"IsAdvancedAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}}},"name":"AdvancedPaste","version":"1"} \ No newline at end of file +{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"} \ No newline at end of file diff --git a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/nuget.config b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/nuget.config deleted file mode 100644 index e6a17ffdfe..0000000000 --- a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/nuget.config +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/settings-ui/Settings.UI.Library/AIProviderConfigurationSnapshot.cs b/src/settings-ui/Settings.UI.Library/AIProviderConfigurationSnapshot.cs new file mode 100644 index 0000000000..456632545f --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIProviderConfigurationSnapshot.cs @@ -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. + +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Stores provider-specific configuration overrides so each AI service can keep distinct settings. + /// + public class AIProviderConfigurationSnapshot + { + [JsonPropertyName("model-name")] + public string ModelName { get; set; } = string.Empty; + + [JsonPropertyName("endpoint-url")] + public string EndpointUrl { get; set; } = string.Empty; + + [JsonPropertyName("api-version")] + public string ApiVersion { get; set; } = string.Empty; + + [JsonPropertyName("deployment-name")] + public string DeploymentName { get; set; } = string.Empty; + + [JsonPropertyName("model-path")] + public string ModelPath { get; set; } = string.Empty; + + [JsonPropertyName("system-prompt")] + public string SystemPrompt { get; set; } = string.Empty; + + [JsonPropertyName("moderation-enabled")] + public bool ModerationEnabled { get; set; } = true; + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceType.cs b/src/settings-ui/Settings.UI.Library/AIServiceType.cs new file mode 100644 index 0000000000..5cbd6a855c --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceType.cs @@ -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 Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Supported AI service types for PowerToys AI experiences. + /// + public enum AIServiceType + { + Unknown = 0, + OpenAI, + AzureOpenAI, + Onnx, + ML, + FoundryLocal, + Mistral, + Google, + HuggingFace, + AzureAIInference, + Ollama, + Anthropic, + AmazonBedrock, + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs new file mode 100644 index 0000000000..91f035d23a --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs @@ -0,0 +1,88 @@ +// 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; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public static class AIServiceTypeExtensions + { + /// + /// Convert a persisted string value into an . + /// Supports historical casing and aliases. + /// + public static AIServiceType ToAIServiceType(this string serviceType) + { + if (string.IsNullOrWhiteSpace(serviceType)) + { + return AIServiceType.OpenAI; + } + + var normalized = serviceType.Trim().ToLowerInvariant(); + return normalized switch + { + "openai" => AIServiceType.OpenAI, + "azureopenai" or "azure" => AIServiceType.AzureOpenAI, + "onnx" => AIServiceType.Onnx, + "foundrylocal" or "foundry" or "fl" => AIServiceType.FoundryLocal, + "ml" or "windowsml" or "winml" => AIServiceType.ML, + "mistral" => AIServiceType.Mistral, + "google" or "googleai" or "googlegemini" => AIServiceType.Google, + "huggingface" => AIServiceType.HuggingFace, + "azureaiinference" or "azureinference" => AIServiceType.AzureAIInference, + "ollama" => AIServiceType.Ollama, + "anthropic" => AIServiceType.Anthropic, + "amazonbedrock" or "bedrock" => AIServiceType.AmazonBedrock, + _ => AIServiceType.Unknown, + }; + } + + /// + /// Convert an to the canonical string used for persistence. + /// + public static string ToConfigurationString(this AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "OpenAI", + AIServiceType.AzureOpenAI => "AzureOpenAI", + AIServiceType.Onnx => "Onnx", + AIServiceType.FoundryLocal => "FoundryLocal", + AIServiceType.ML => "ML", + AIServiceType.Mistral => "Mistral", + AIServiceType.Google => "Google", + AIServiceType.HuggingFace => "HuggingFace", + AIServiceType.AzureAIInference => "AzureAIInference", + AIServiceType.Ollama => "Ollama", + AIServiceType.Anthropic => "Anthropic", + AIServiceType.AmazonBedrock => "AmazonBedrock", + AIServiceType.Unknown => string.Empty, + _ => throw new ArgumentOutOfRangeException(nameof(serviceType), serviceType, "Unsupported AI service type."), + }; + } + + /// + /// Convert an into the normalized key used internally. + /// + public static string ToNormalizedKey(this AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "openai", + AIServiceType.AzureOpenAI => "azureopenai", + AIServiceType.Onnx => "onnx", + AIServiceType.FoundryLocal => "foundrylocal", + AIServiceType.ML => "ml", + AIServiceType.Mistral => "mistral", + AIServiceType.Google => "google", + AIServiceType.HuggingFace => "huggingface", + AIServiceType.AzureAIInference => "azureaiinference", + AIServiceType.Ollama => "ollama", + AIServiceType.Anthropic => "anthropic", + AIServiceType.AmazonBedrock => "amazonbedrock", + _ => string.Empty, + }; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs new file mode 100644 index 0000000000..df01b1816a --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs @@ -0,0 +1,44 @@ +// 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; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Metadata information for an AI service type. + /// + public class AIServiceTypeMetadata + { + public AIServiceType ServiceType { get; init; } + + public string DisplayName { get; init; } + + public string IconPath { get; init; } + + public bool IsOnlineService { get; init; } + + public bool IsAvailableInUI { get; init; } = true; + + public bool IsLocalModel { get; init; } + + public string LegalDescription { get; init; } + + public string TermsLabel { get; init; } + + public Uri TermsUri { get; init; } + + public string PrivacyLabel { get; init; } + + public Uri PrivacyUri { get; init; } + + public bool HasLegalInfo => !string.IsNullOrWhiteSpace(LegalDescription); + + public bool HasTermsLink => TermsUri is not null && !string.IsNullOrEmpty(TermsLabel); + + public bool HasPrivacyLink => PrivacyUri is not null && !string.IsNullOrEmpty(PrivacyLabel); + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs new file mode 100644 index 0000000000..ca0bd9e011 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs @@ -0,0 +1,222 @@ +// 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; + +namespace Microsoft.PowerToys.Settings.UI.Library; + +/// +/// Centralized registry for AI service type metadata. +/// +public static class AIServiceTypeRegistry +{ + private static readonly Dictionary MetadataMap = new() + { + [AIServiceType.AmazonBedrock] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AmazonBedrock, + DisplayName = "Amazon Bedrock", + IsAvailableInUI = false, // Currently disabled in UI + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Bedrock.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AmazonBedrock_LegalDescription", + TermsLabel = "AdvancedPaste_AmazonBedrock_TermsLabel", + TermsUri = new Uri("https://aws.amazon.com/service-terms/"), + PrivacyLabel = "AdvancedPaste_AmazonBedrock_PrivacyLabel", + PrivacyUri = new Uri("https://aws.amazon.com/privacy/"), + }, + [AIServiceType.Anthropic] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Anthropic, + DisplayName = "Anthropic", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Anthropic.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Anthropic_LegalDescription", + TermsLabel = "AdvancedPaste_Anthropic_TermsLabel", + TermsUri = new Uri("https://www.anthropic.com/legal/terms-of-service"), + PrivacyLabel = "AdvancedPaste_Anthropic_PrivacyLabel", + PrivacyUri = new Uri("https://www.anthropic.com/legal/privacy"), + }, + [AIServiceType.AzureAIInference] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AzureAIInference, + DisplayName = "Azure AI Inference", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/FoundryLocal.svg", // No icon for Azure AI Inference, use Foundry Local temporarily + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AzureAIInference_LegalDescription", + TermsLabel = "AdvancedPaste_AzureAIInference_TermsLabel", + TermsUri = new Uri("https://azure.microsoft.com/support/legal/"), + PrivacyLabel = "AdvancedPaste_AzureAIInference_PrivacyLabel", + PrivacyUri = new Uri("https://privacy.microsoft.com/privacystatement"), + }, + [AIServiceType.AzureOpenAI] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AzureOpenAI, + DisplayName = "Azure OpenAI", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/AzureAI.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AzureOpenAI_LegalDescription", + TermsLabel = "AdvancedPaste_AzureOpenAI_TermsLabel", + TermsUri = new Uri("https://azure.microsoft.com/support/legal/"), + PrivacyLabel = "AdvancedPaste_AzureOpenAI_PrivacyLabel", + PrivacyUri = new Uri("https://privacy.microsoft.com/privacystatement"), + }, + [AIServiceType.FoundryLocal] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.FoundryLocal, + DisplayName = "Foundry Local", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/FoundryLocal.svg", + IsOnlineService = false, + IsLocalModel = true, + LegalDescription = "AdvancedPaste_FoundryLocal_LegalDescription", // Resource key for localized description + }, + [AIServiceType.Google] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Google, + DisplayName = "Google", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Gemini.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Google_LegalDescription", + TermsLabel = "AdvancedPaste_Google_TermsLabel", + TermsUri = new Uri("https://policies.google.com/terms"), + PrivacyLabel = "AdvancedPaste_Google_PrivacyLabel", + PrivacyUri = new Uri("https://policies.google.com/privacy"), + }, + [AIServiceType.HuggingFace] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.HuggingFace, + DisplayName = "Hugging Face", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/HuggingFace.svg", + IsOnlineService = true, + IsAvailableInUI = false, // Currently disabled in UI + }, + [AIServiceType.Mistral] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Mistral, + DisplayName = "Mistral", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Mistral.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Mistral_LegalDescription", + TermsLabel = "AdvancedPaste_Mistral_TermsLabel", + TermsUri = new Uri("https://mistral.ai/terms-of-service/"), + PrivacyLabel = "AdvancedPaste_Mistral_PrivacyLabel", + PrivacyUri = new Uri("https://mistral.ai/privacy-policy/"), + }, + [AIServiceType.ML] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.ML, + DisplayName = "Windows ML", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/WindowsML.svg", + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + IsAvailableInUI = false, + IsOnlineService = false, + IsLocalModel = true, + }, + [AIServiceType.Ollama] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Ollama, + DisplayName = "Ollama", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Ollama.svg", + + // Ollama provide online service, but we treat it as local model at first version since it can is known for local model. + IsOnlineService = false, + IsLocalModel = true, + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + TermsLabel = "AdvancedPaste_Ollama_TermsLabel", + TermsUri = new Uri("https://ollama.com/terms"), + PrivacyLabel = "AdvancedPaste_Ollama_PrivacyLabel", + PrivacyUri = new Uri("https://ollama.com/privacy"), + }, + [AIServiceType.Onnx] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Onnx, + DisplayName = "ONNX", + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Onnx.svg", + IsOnlineService = false, + IsAvailableInUI = false, + }, + [AIServiceType.OpenAI] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.OpenAI, + DisplayName = "OpenAI", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_OpenAI_LegalDescription", + TermsLabel = "AdvancedPaste_OpenAI_TermsLabel", + TermsUri = new Uri("https://openai.com/terms"), + PrivacyLabel = "AdvancedPaste_OpenAI_PrivacyLabel", + PrivacyUri = new Uri("https://openai.com/privacy"), + }, + [AIServiceType.Unknown] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Unknown, + DisplayName = "Unknown", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg", + IsOnlineService = false, + IsAvailableInUI = false, + }, + }; + + /// + /// Get metadata for a specific service type. + /// + public static AIServiceTypeMetadata GetMetadata(AIServiceType serviceType) + { + return MetadataMap.TryGetValue(serviceType, out var metadata) + ? metadata + : MetadataMap[AIServiceType.Unknown]; + } + + /// + /// Get metadata for a service type from its string representation. + /// + public static AIServiceTypeMetadata GetMetadata(string serviceType) + { + var type = serviceType.ToAIServiceType(); + return GetMetadata(type); + } + + /// + /// Get icon path for a service type. + /// + public static string GetIconPath(AIServiceType serviceType) + { + return GetMetadata(serviceType).IconPath; + } + + /// + /// Get icon path for a service type from its string representation. + /// + public static string GetIconPath(string serviceType) + { + return GetMetadata(serviceType).IconPath; + } + + /// + /// Get all service types available in the UI. + /// + public static IEnumerable GetAvailableServiceTypes() + { + return MetadataMap.Values.Where(m => m.IsAvailableInUI); + } + + /// + /// Get all online service types available in the UI. + /// + public static IEnumerable GetOnlineServiceTypes() + { + return GetAvailableServiceTypes().Where(m => m.IsOnlineService); + } + + /// + /// Get all local service types available in the UI. + /// + public static IEnumerable GetLocalServiceTypes() + { + return GetAvailableServiceTypes().Where(m => m.IsLocalModel); + } +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs index 43baf89351..c981295906 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs @@ -14,6 +14,7 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction { private int _id; private string _name = string.Empty; + private string _description = string.Empty; private string _prompt = string.Empty; private HotkeySettings _shortcut = new(); private bool _isShown; @@ -43,6 +44,13 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction } } + [JsonPropertyName("description")] + public string Description + { + get => _description; + set => Set(ref _description, value ?? string.Empty); + } + [JsonPropertyName("prompt")] public string Prompt { @@ -128,6 +136,7 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction { Id = other.Id; Name = other.Name; + Description = other.Description; Prompt = other.Prompt; Shortcut = other.GetShortcutClone(); IsShown = other.IsShown; diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs index d40bd686d3..200e5e459d 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs @@ -2,6 +2,7 @@ // 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.Text.Json; using System.Text.Json.Serialization; @@ -23,13 +24,38 @@ namespace Microsoft.PowerToys.Settings.UI.Library PasteAsJsonShortcut = new(); CustomActions = new(); AdditionalActions = new(); - IsAdvancedAIEnabled = false; + IsAIEnabled = false; ShowCustomPreview = true; CloseAfterLosingFocus = false; + PasteAIConfiguration = new(); } [JsonConverter(typeof(BoolPropertyJsonConverter))] - public bool IsAdvancedAIEnabled { get; set; } + public bool IsAIEnabled { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData + { + get => _extensionData; + set + { + _extensionData = value; + + if (_extensionData != null && _extensionData.TryGetValue("IsOpenAIEnabled", out var legacyElement) && legacyElement.ValueKind == JsonValueKind.Object && legacyElement.TryGetProperty("value", out var valueElement)) + { + IsAIEnabled = valueElement.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => IsAIEnabled, + }; + + _extensionData.Remove("IsOpenAIEnabled"); + } + } + } + + private Dictionary _extensionData; [JsonConverter(typeof(BoolPropertyJsonConverter))] public bool ShowCustomPreview { get; set; } @@ -57,6 +83,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library [CmdConfigureIgnoreAttribute] public AdvancedPasteAdditionalActions AdditionalActions { get; init; } + [JsonPropertyName("paste-ai-configuration")] + [CmdConfigureIgnoreAttribute] + public PasteAIConfiguration PasteAIConfiguration { get; set; } + public override string ToString() => JsonSerializer.Serialize(this); } diff --git a/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs b/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs new file mode 100644 index 0000000000..3ed0937280 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs @@ -0,0 +1,103 @@ +// 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Configuration for Paste AI features (custom action transformations like custom prompt processing) + /// + public class PasteAIConfiguration : INotifyPropertyChanged + { + private string _activeProviderId = string.Empty; + private ObservableCollection _providers = new(); + private bool _useSharedCredentials = true; + private Dictionary _legacyProviderConfigurations; + + public event PropertyChangedEventHandler PropertyChanged; + + [JsonPropertyName("active-provider-id")] + public string ActiveProviderId + { + get => _activeProviderId; + set => SetProperty(ref _activeProviderId, value ?? string.Empty); + } + + [JsonPropertyName("providers")] + public ObservableCollection Providers + { + get => _providers; + set => SetProperty(ref _providers, value ?? new ObservableCollection()); + } + + [JsonPropertyName("use-shared-credentials")] + public bool UseSharedCredentials + { + get => _useSharedCredentials; + set => SetProperty(ref _useSharedCredentials, value); + } + + [JsonPropertyName("provider-configurations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary LegacyProviderConfigurations + { + get => _legacyProviderConfigurations; + set => _legacyProviderConfigurations = value; + } + + [JsonIgnore] + public PasteAIProviderDefinition ActiveProvider + { + get + { + if (_providers is null || _providers.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(_activeProviderId)) + { + var match = _providers.FirstOrDefault(provider => string.Equals(provider.Id, _activeProviderId, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + return match; + } + } + + return _providers[0]; + } + } + + [JsonIgnore] + public AIServiceType ActiveServiceTypeKind => ActiveProvider?.ServiceTypeKind ?? AIServiceType.OpenAI; + + public override string ToString() + => JsonSerializer.Serialize(this); + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs new file mode 100644 index 0000000000..0fbb3328e7 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs @@ -0,0 +1,175 @@ +// 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.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Represents a single Paste AI provider configuration entry. + /// + public class PasteAIProviderDefinition : INotifyPropertyChanged + { + private string _id = Guid.NewGuid().ToString("N"); + private string _serviceType = "OpenAI"; + private string _modelName = string.Empty; + private string _endpointUrl = string.Empty; + private string _apiVersion = string.Empty; + private string _deploymentName = string.Empty; + private string _modelPath = string.Empty; + private string _systemPrompt = string.Empty; + private bool _moderationEnabled = true; + private bool _isActive; + private bool _enableAdvancedAI; + private bool _isLocalModel; + + public event PropertyChangedEventHandler PropertyChanged; + + [JsonPropertyName("id")] + public string Id + { + get => _id; + set => SetProperty(ref _id, value); + } + + [JsonPropertyName("service-type")] + public string ServiceType + { + get => _serviceType; + set + { + if (SetProperty(ref _serviceType, string.IsNullOrWhiteSpace(value) ? "OpenAI" : value)) + { + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + [JsonIgnore] + public AIServiceType ServiceTypeKind + { + get => ServiceType.ToAIServiceType(); + set => ServiceType = value.ToConfigurationString(); + } + + [JsonPropertyName("model-name")] + public string ModelName + { + get => _modelName; + set + { + if (SetProperty(ref _modelName, value ?? string.Empty)) + { + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + [JsonPropertyName("endpoint-url")] + public string EndpointUrl + { + get => _endpointUrl; + set => SetProperty(ref _endpointUrl, value ?? string.Empty); + } + + [JsonPropertyName("api-version")] + public string ApiVersion + { + get => _apiVersion; + set => SetProperty(ref _apiVersion, value ?? string.Empty); + } + + [JsonPropertyName("deployment-name")] + public string DeploymentName + { + get => _deploymentName; + set => SetProperty(ref _deploymentName, value ?? string.Empty); + } + + [JsonPropertyName("model-path")] + public string ModelPath + { + get => _modelPath; + set => SetProperty(ref _modelPath, value ?? string.Empty); + } + + [JsonPropertyName("system-prompt")] + public string SystemPrompt + { + get => _systemPrompt; + set => SetProperty(ref _systemPrompt, value?.Trim() ?? string.Empty); + } + + [JsonPropertyName("moderation-enabled")] + public bool ModerationEnabled + { + get => _moderationEnabled; + set => SetProperty(ref _moderationEnabled, value); + } + + [JsonPropertyName("enable-advanced-ai")] + public bool EnableAdvancedAI + { + get => _enableAdvancedAI; + set => SetProperty(ref _enableAdvancedAI, value); + } + + [JsonPropertyName("is-local-model")] + public bool IsLocalModel + { + get => _isLocalModel; + set => SetProperty(ref _isLocalModel, value); + } + + [JsonIgnore] + public bool IsActive + { + get => _isActive; + set => SetProperty(ref _isActive, value); + } + + [JsonIgnore] + public string DisplayName => string.IsNullOrWhiteSpace(ModelName) ? ServiceType : ModelName; + + public PasteAIProviderDefinition Clone() + { + return new PasteAIProviderDefinition + { + Id = Id, + ServiceType = ServiceType, + ModelName = ModelName, + EndpointUrl = EndpointUrl, + ApiVersion = ApiVersion, + DeploymentName = DeploymentName, + ModelPath = ModelPath, + SystemPrompt = SystemPrompt, + ModerationEnabled = ModerationEnabled, + EnableAdvancedAI = EnableAdvancedAI, + IsLocalModel = IsLocalModel, + IsActive = IsActive, + }; + } + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Anthropic.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Anthropic.svg new file mode 100644 index 0000000000..f990d4650f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Anthropic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg new file mode 100644 index 0000000000..7497187ad7 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg new file mode 100644 index 0000000000..e6fd7121b2 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Bedrock.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Bedrock.svg new file mode 100644 index 0000000000..d7a3d800d9 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Bedrock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg new file mode 100644 index 0000000000..7066f294f9 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg new file mode 100644 index 0000000000..56a5fe461b --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/HuggingFace.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/HuggingFace.svg new file mode 100644 index 0000000000..9fe0aff336 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/HuggingFace.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg new file mode 100644 index 0000000000..ce2471552e --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg new file mode 100644 index 0000000000..e44dda654d --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg new file mode 100644 index 0000000000..301a40fd55 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg new file mode 100644 index 0000000000..87aacb3a4f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg new file mode 100644 index 0000000000..f72a3c64d1 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg new file mode 100644 index 0000000000..fafc16b59f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs b/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs new file mode 100644 index 0000000000..7d632906c2 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs @@ -0,0 +1,30 @@ +// 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 Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Microsoft.PowerToys.Settings.UI.Converters; + +public partial class ServiceTypeToIconConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is not string serviceType || string.IsNullOrWhiteSpace(serviceType)) + { + return new ImageIcon { Source = new SvgImageSource(new Uri(AIServiceTypeRegistry.GetIconPath(AIServiceType.OpenAI))) }; + } + + var iconPath = AIServiceTypeRegistry.GetIconPath(serviceType); + return new ImageIcon { Source = new SvgImageSource(new Uri(iconPath)) }; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index dd70af7533..057413a408 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -20,6 +20,12 @@ PowerToys.Settings.pri + + + + + + @@ -53,6 +59,13 @@ + + + + PreserveNewest + + + @@ -105,6 +118,7 @@ + @@ -197,4 +211,4 @@ - \ No newline at end of file + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml new file mode 100644 index 0000000000..f695301e3a --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs new file mode 100644 index 0000000000..3d1c4c2159 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs @@ -0,0 +1,457 @@ +// 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; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using LanguageModelProvider; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls; + +public sealed partial class FoundryLocalModelPicker : UserControl +{ + private INotifyCollectionChanged _cachedModelsSubscription; + private INotifyCollectionChanged _downloadableModelsSubscription; + private bool _suppressSelection; + + public FoundryLocalModelPicker() + { + InitializeComponent(); + Loaded += (_, _) => UpdateVisualStates(); + } + + public delegate void ModelSelectionChangedEventHandler(object sender, ModelDetails model); + + public delegate void DownloadRequestedEventHandler(object sender, object payload); + + public delegate void LoadRequestedEventHandler(object sender, FoundryLoadRequestedEventArgs args); + + public event ModelSelectionChangedEventHandler SelectionChanged; + + public event LoadRequestedEventHandler LoadRequested; + + public IEnumerable CachedModels + { + get => (IEnumerable)GetValue(CachedModelsProperty); + set => SetValue(CachedModelsProperty, value); + } + + public static readonly DependencyProperty CachedModelsProperty = + DependencyProperty.Register(nameof(CachedModels), typeof(IEnumerable), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnCachedModelsChanged)); + + public IEnumerable DownloadableModels + { + get => (IEnumerable)GetValue(DownloadableModelsProperty); + set => SetValue(DownloadableModelsProperty, value); + } + + public static readonly DependencyProperty DownloadableModelsProperty = + DependencyProperty.Register(nameof(DownloadableModels), typeof(IEnumerable), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnDownloadableModelsChanged)); + + public ModelDetails SelectedModel + { + get => (ModelDetails)GetValue(SelectedModelProperty); + set => SetValue(SelectedModelProperty, value); + } + + public static readonly DependencyProperty SelectedModelProperty = + DependencyProperty.Register(nameof(SelectedModel), typeof(ModelDetails), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnSelectedModelChanged)); + + public bool IsLoading + { + get => (bool)GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + public static readonly DependencyProperty IsLoadingProperty = + DependencyProperty.Register(nameof(IsLoading), typeof(bool), typeof(FoundryLocalModelPicker), new PropertyMetadata(false, OnStatePropertyChanged)); + + public bool IsAvailable + { + get => (bool)GetValue(IsAvailableProperty); + set => SetValue(IsAvailableProperty, value); + } + + public static readonly DependencyProperty IsAvailableProperty = + DependencyProperty.Register(nameof(IsAvailable), typeof(bool), typeof(FoundryLocalModelPicker), new PropertyMetadata(false, OnStatePropertyChanged)); + + public string StatusText + { + get => (string)GetValue(StatusTextProperty); + set => SetValue(StatusTextProperty, value); + } + + public static readonly DependencyProperty StatusTextProperty = + DependencyProperty.Register(nameof(StatusText), typeof(string), typeof(FoundryLocalModelPicker), new PropertyMetadata(string.Empty, OnStatePropertyChanged)); + + public bool HasCachedModels => CachedModels?.Any() ?? false; + + public bool HasDownloadableModels => DownloadableModels?.Cast().Any() ?? false; + + public void RequestLoad(bool refresh) + { + if (IsLoading) + { + // Allow refresh requests to continue even if already loading by cancelling via host. + } + else + { + IsLoading = true; + } + + IsAvailable = false; + StatusText = "Loading Foundry Local status..."; + LoadRequested?.Invoke(this, new FoundryLoadRequestedEventArgs(refresh)); + } + + private static void OnCachedModelsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.SubscribeToCachedModels(e.OldValue as IEnumerable, e.NewValue as IEnumerable); + control.UpdateVisualStates(); + } + + private static void OnDownloadableModelsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.SubscribeToDownloadableModels(e.OldValue as IEnumerable, e.NewValue as IEnumerable); + control.UpdateVisualStates(); + } + + private static void OnSelectedModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + if (control._suppressSelection) + { + return; + } + + try + { + control._suppressSelection = true; + if (control.CachedModelsComboBox is not null) + { + control.CachedModelsComboBox.SelectedItem = e.NewValue; + } + } + finally + { + control._suppressSelection = false; + } + + control.UpdateSelectedModelDetails(); + } + + private static void OnStatePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.UpdateVisualStates(); + } + + private void SubscribeToCachedModels(IEnumerable oldValue, IEnumerable newValue) + { + if (_cachedModelsSubscription is not null) + { + _cachedModelsSubscription.CollectionChanged -= CachedModels_CollectionChanged; + _cachedModelsSubscription = null; + } + + if (newValue is INotifyCollectionChanged observable) + { + observable.CollectionChanged += CachedModels_CollectionChanged; + _cachedModelsSubscription = observable; + } + } + + private void SubscribeToDownloadableModels(IEnumerable oldValue, IEnumerable newValue) + { + if (_downloadableModelsSubscription is not null) + { + _downloadableModelsSubscription.CollectionChanged -= DownloadableModels_CollectionChanged; + _downloadableModelsSubscription = null; + } + + if (newValue is INotifyCollectionChanged observable) + { + observable.CollectionChanged += DownloadableModels_CollectionChanged; + _downloadableModelsSubscription = observable; + } + } + + private void CachedModels_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + UpdateVisualStates(); + } + + private void DownloadableModels_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + UpdateVisualStates(); + } + + private void CachedModelsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressSelection) + { + return; + } + + try + { + _suppressSelection = true; + var selected = CachedModelsComboBox.SelectedItem as ModelDetails; + SetValue(SelectedModelProperty, selected); + SelectionChanged?.Invoke(this, selected); + } + finally + { + _suppressSelection = false; + } + + UpdateSelectedModelDetails(); + } + + private void UpdateSelectedModelDetails() + { + if (SelectedModelDetailsPanel is null || SelectedModelDescriptionText is null || SelectedModelTagsPanel is null) + { + return; + } + + if (!HasCachedModels || SelectedModel is not ModelDetails model) + { + SelectedModelDetailsPanel.Visibility = Visibility.Collapsed; + SelectedModelDescriptionText.Text = string.Empty; + SelectedModelTagsPanel.Children.Clear(); + SelectedModelTagsPanel.Visibility = Visibility.Collapsed; + return; + } + + SelectedModelDetailsPanel.Visibility = Visibility.Visible; + SelectedModelDescriptionText.Text = string.IsNullOrWhiteSpace(model.Description) + ? "No description provided." + : model.Description; + + SelectedModelTagsPanel.Children.Clear(); + + AddTag(GetModelSizeText(model.Size)); + AddTag(GetLicenseShortText(model.License), model.License); + + foreach (var deviceTag in GetDeviceTags(model.HardwareAccelerators)) + { + AddTag(deviceTag); + } + + SelectedModelTagsPanel.Visibility = SelectedModelTagsPanel.Children.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + + void AddTag(string text, string tooltip = null) + { + if (string.IsNullOrWhiteSpace(text) || SelectedModelTagsPanel is null) + { + return; + } + + Border tag = new(); + if (Resources.TryGetValue("TagBorderStyle", out var borderStyleObj) && borderStyleObj is Style borderStyle) + { + tag.Style = borderStyle; + } + + TextBlock label = new() + { + Text = text, + }; + + if (Resources.TryGetValue("TagTextStyle", out var textStyleObj) && textStyleObj is Style textStyle) + { + label.Style = textStyle; + } + + tag.Child = label; + + if (!string.IsNullOrWhiteSpace(tooltip)) + { + ToolTipService.SetToolTip(tag, new TextBlock + { + Text = tooltip, + TextWrapping = TextWrapping.Wrap, + }); + } + + SelectedModelTagsPanel.Children.Add(tag); + } + } + + private void LaunchFoundryModelListButton_Click(object sender, RoutedEventArgs e) + { + try + { + ProcessStartInfo processInfo = new() + { + FileName = "powershell.exe", + Arguments = "-NoExit -Command \"foundry model list\"", + UseShellExecute = true, + }; + + Process.Start(processInfo); + StatusText = "Opening PowerShell and running 'foundry model list'..."; + } + catch (Exception ex) + { + StatusText = $"Unable to start PowerShell. {ex.Message}"; + Debug.WriteLine($"[FoundryLocalModelPicker] Failed to run 'foundry model list': {ex}"); + } + } + + private void RefreshModelsButton_Click(object sender, RoutedEventArgs e) + { + RequestLoad(refresh: true); + } + + private void UpdateVisualStates() + { + LoadingIndicator.IsActive = IsLoading; + + if (IsLoading) + { + VisualStateManager.GoToState(this, "ShowLoading", true); + } + else if (!IsAvailable) + { + VisualStateManager.GoToState(this, "ShowNotAvailable", true); + } + else + { + VisualStateManager.GoToState(this, "ShowModels", true); + } + + if (LoadingStatusTextBlock is not null) + { + LoadingStatusTextBlock.Text = string.IsNullOrWhiteSpace(StatusText) + ? "Loading Foundry Local status..." + : StatusText; + } + + NoModelsPanel.Visibility = HasCachedModels ? Visibility.Collapsed : Visibility.Visible; + if (CachedModelsComboBox is not null) + { + CachedModelsComboBox.Visibility = HasCachedModels ? Visibility.Visible : Visibility.Collapsed; + CachedModelsComboBox.IsEnabled = HasCachedModels; + } + + UpdateSelectedModelDetails(); + + Bindings.Update(); + } + + public static string GetModelSizeText(long size) + { + if (size <= 0) + { + return string.Empty; + } + + const long kiloByte = 1024; + const long megaByte = kiloByte * 1024; + const long gigaByte = megaByte * 1024; + + if (size >= gigaByte) + { + return $"{size / (double)gigaByte:0.##} GB"; + } + + if (size >= megaByte) + { + return $"{size / (double)megaByte:0.##} MB"; + } + + if (size >= kiloByte) + { + return $"{size / (double)kiloByte:0.##} KB"; + } + + return $"{size} B"; + } + + public static Visibility GetModelSizeVisibility(long size) + { + return size > 0 ? Visibility.Visible : Visibility.Collapsed; + } + + public static IEnumerable GetDeviceTags(IReadOnlyCollection accelerators) + { + if (accelerators is null || accelerators.Count == 0) + { + return Array.Empty(); + } + + HashSet tags = new(StringComparer.OrdinalIgnoreCase); + + foreach (var accelerator in accelerators) + { + switch (accelerator) + { + case HardwareAccelerator.CPU: + tags.Add("CPU"); + break; + case HardwareAccelerator.GPU: + case HardwareAccelerator.DML: + tags.Add("GPU"); + break; + case HardwareAccelerator.NPU: + case HardwareAccelerator.QNN: + tags.Add("NPU"); + break; + } + } + + return tags.Count > 0 ? tags.ToArray() : Array.Empty(); + } + + public static Visibility GetDeviceVisibility(IReadOnlyCollection accelerators) + { + return GetDeviceTags(accelerators).Any() ? Visibility.Visible : Visibility.Collapsed; + } + + public static string GetLicenseShortText(string license) + { + if (string.IsNullOrWhiteSpace(license)) + { + return string.Empty; + } + + var trimmed = license.Trim(); + int separatorIndex = trimmed.IndexOfAny(['(', '[', ':']); + if (separatorIndex > 0) + { + trimmed = trimmed[..separatorIndex].Trim(); + } + + if (trimmed.Length > 24) + { + trimmed = $"{trimmed[..24].TrimEnd()}…"; + } + + return trimmed; + } + + public static Visibility GetLicenseVisibility(string license) + { + return string.IsNullOrWhiteSpace(license) ? Visibility.Collapsed : Visibility.Visible; + } + + public sealed class FoundryLoadRequestedEventArgs : EventArgs + { + public FoundryLoadRequestedEventArgs(bool refresh) + { + Refresh = refresh; + } + + public bool Refresh { get; } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml index 628df84c01..8d6b0afa68 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml @@ -3,28 +3,43 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:models="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:viewmodels="using:Microsoft.PowerToys.Settings.UI.ViewModels" x:Name="RootPage" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> + + + ms-appx:///Assets/Settings/Modules/APDialog.dark.png + ms-appx:///Assets/Settings/Icons/Models/OpenAI.dark.svg ms-appx:///Assets/Settings/Modules/APDialog.light.png + ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg ms-appx:///Assets/Settings/Modules/APDialog.light.png + ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg + + + - + - + IsEnabled="{x:Bind ViewModel.IsOnlineAIModelsDisallowedByGPO, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" + IsExpanded="{x:Bind ViewModel.IsAIEnabled, Mode=OneWay}" + ItemsSource="{x:Bind ViewModel.PasteAIConfiguration.Providers, Mode=OneWay}"> + - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + DataContext="{x:Bind ViewModel.AdditionalActions.ImageToText, Mode=OneWay}" + HeaderIcon="{ui:FontIcon Glyph=}"> + + + + + + + + + + + + - - - - - - - - - - - - - - - - | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + 900 + 700 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs index 72de0843d1..19375d90f7 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs @@ -18,6 +18,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views InitializeComponent(); var settingsUtils = new SettingsUtils(); ViewModel = new HostsViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, App.IsElevated); + BackupsCountInputSettingsCard.Header = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Header"); + BackupsCountInputSettingsCard.Description = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Description"); + BackupsCountInputAgeSettingsCard.Header = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Header"); + BackupsCountInputAgeSettingsCard.Description = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Age_Description"); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index 7c6d401ac1..95ce0b5f19 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -5599,4 +5599,48 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Learn more + + Backup + + + Backup hosts file + "Hosts" refers to the system hosts file, do not loc + + + Automatically create a backup of the hosts file when you save for the first time in a session + "Hosts" refers to the system hosts file, do not loc + + + Location + + + Select location + + + Automatically delete backups + + + Days + + + Set the number of backups to keep. Older backups will be deleted once the limit is reached. + + + Set the number of days to keep backups. Older backups will be deleted once the limit is reached. + + + Never + + + Based on count + + + Based on age and count + + + Set an optional number of backups to always keep despite their age + + + Backup count + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs index 34b6157d63..04eea7c1e4 100644 --- a/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs @@ -5,8 +5,8 @@ using System; using System.Runtime.CompilerServices; using System.Threading; - using global::PowerToys.GPOWrapper; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -33,6 +33,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public ButtonClickCommand LaunchEventHandler => new ButtonClickCommand(Launch); + public ButtonClickCommand SelectBackupPathEventHandler => new ButtonClickCommand(SelectBackupPath); + public bool IsEnabled { get => _isEnabled; @@ -144,6 +146,74 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool BackupHosts + { + get => Settings.Properties.BackupHosts; + set + { + if (value != Settings.Properties.BackupHosts) + { + Settings.Properties.BackupHosts = value; + NotifyPropertyChanged(); + } + } + } + + public string BackupPath + { + get => Settings.Properties.BackupPath; + set + { + if (value != Settings.Properties.BackupPath) + { + Settings.Properties.BackupPath = value; + NotifyPropertyChanged(); + } + } + } + + public int DeleteBackupsMode + { + get => (int)Settings.Properties.DeleteBackupsMode; + set + { + if (value != (int)Settings.Properties.DeleteBackupsMode) + { + Settings.Properties.DeleteBackupsMode = (HostsDeleteBackupMode)value; + NotifyPropertyChanged(); + OnPropertyChanged(nameof(MinimumBackupsCount)); + } + } + } + + public int DeleteBackupsDays + { + get => Settings.Properties.DeleteBackupsDays; + set + { + if (value != Settings.Properties.DeleteBackupsDays) + { + Settings.Properties.DeleteBackupsDays = value; + NotifyPropertyChanged(); + } + } + } + + public int DeleteBackupsCount + { + get => Settings.Properties.DeleteBackupsCount; + set + { + if (value != Settings.Properties.DeleteBackupsCount) + { + Settings.Properties.DeleteBackupsCount = value; + NotifyPropertyChanged(); + } + } + } + + public int MinimumBackupsCount => DeleteBackupsMode == 1 ? 1 : 0; + public HostsViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository moduleSettingsRepository, Func ipcMSGCallBackFunc, bool isElevated) { SettingsUtils = settingsUtils; @@ -192,5 +262,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels InitializeEnabledValue(); OnPropertyChanged(nameof(IsEnabled)); } + + public void SelectBackupPath() + { + // This function was changed to use the shell32 API to open folder dialog + // as the old one (PickSingleFolderAsync) can't work when the process is elevated + // TODO: go back PickSingleFolderAsync when it's fixed + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow()); + var result = ShellGetFolder.GetFolderDialog(hwnd); + if (!string.IsNullOrEmpty(result)) + { + BackupPath = result; + } + } } } From 1ad468641be12977cd37799f18dc28c05d0640e8 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Wed, 5 Nov 2025 10:42:24 +0100 Subject: [PATCH 005/100] [UX] Dashboard utilities sorting (#42065) ## Summary of the Pull Request This PR adds a sorting button to the module list so it can be sorted alphabetically (default) or by status. Fixes: #41837 image @yeelam-gordon When running the runner, I do see the settings value is being updated. But when running the runner again the setting doesn't seem to be saved? Is that because of a bug in my implementation, or am I testing it wrong :)? image ## PR Checklist - [x] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --------- Signed-off-by: Shawn Yuan (from Dev Box) Co-authored-by: Shawn Yuan (from Dev Box) --- src/runner/general_settings.cpp | 30 +++++++++ src/runner/general_settings.h | 7 ++ .../Settings.UI.Library/GeneralSettings.cs | 10 +++ .../Converters/EnumToBooleanConverter.cs | 31 +++++++++ .../SettingsXAML/Views/DashboardPage.xaml | 30 ++++++++- .../SettingsXAML/Views/DashboardPage.xaml.cs | 10 +++ .../Settings.UI/Strings/en-us/Resources.resw | 16 ++++- .../ViewModels/DashboardViewModel.cs | 67 ++++++++++++++----- 8 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs diff --git a/src/runner/general_settings.cpp b/src/runner/general_settings.cpp index 50dd8dbbc8..9f8ceeb8ad 100644 --- a/src/runner/general_settings.cpp +++ b/src/runner/general_settings.cpp @@ -35,6 +35,31 @@ namespace ensure_ignored_conflict_properties_shape(obj); return obj; } + + DashboardSortOrder parse_dashboard_sort_order(const json::JsonObject& obj, DashboardSortOrder fallback) + { + if (json::has(obj, L"dashboard_sort_order", json::JsonValueType::Number)) + { + const auto raw_value = static_cast(obj.GetNamedNumber(L"dashboard_sort_order", static_cast(static_cast(fallback)))); + return raw_value == static_cast(DashboardSortOrder::ByStatus) ? DashboardSortOrder::ByStatus : DashboardSortOrder::Alphabetical; + } + + if (json::has(obj, L"dashboard_sort_order", json::JsonValueType::String)) + { + const auto raw = obj.GetNamedString(L"dashboard_sort_order"); + if (raw == L"ByStatus") + { + return DashboardSortOrder::ByStatus; + } + + if (raw == L"Alphabetical") + { + return DashboardSortOrder::Alphabetical; + } + } + + return fallback; + } } // TODO: would be nice to get rid of these globals, since they're basically cached json settings @@ -46,6 +71,7 @@ static bool download_updates_automatically = true; static bool show_whats_new_after_updates = true; static bool enable_experimentation = true; static bool enable_warnings_elevated_apps = true; +static DashboardSortOrder dashboard_sort_order = DashboardSortOrder::Alphabetical; static json::JsonObject ignored_conflict_properties = create_default_ignored_conflict_properties(); json::JsonObject GeneralSettings::to_json() @@ -75,6 +101,7 @@ json::JsonObject GeneralSettings::to_json() result.SetNamedValue(L"download_updates_automatically", json::value(downloadUpdatesAutomatically)); result.SetNamedValue(L"show_whats_new_after_updates", json::value(showWhatsNewAfterUpdates)); result.SetNamedValue(L"enable_experimentation", json::value(enableExperimentation)); + result.SetNamedValue(L"dashboard_sort_order", json::value(static_cast(dashboardSortOrder))); result.SetNamedValue(L"is_admin", json::value(isAdmin)); result.SetNamedValue(L"enable_warnings_elevated_apps", json::value(enableWarningsElevatedApps)); result.SetNamedValue(L"theme", json::value(theme)); @@ -99,6 +126,7 @@ json::JsonObject load_general_settings() show_whats_new_after_updates = loaded.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = loaded.GetNamedBoolean(L"enable_experimentation", true); enable_warnings_elevated_apps = loaded.GetNamedBoolean(L"enable_warnings_elevated_apps", true); + dashboard_sort_order = parse_dashboard_sort_order(loaded, dashboard_sort_order); if (json::has(loaded, L"ignored_conflict_properties", json::JsonValueType::Object)) { @@ -128,6 +156,7 @@ GeneralSettings get_general_settings() .downloadUpdatesAutomatically = download_updates_automatically && is_user_admin, .showWhatsNewAfterUpdates = show_whats_new_after_updates, .enableExperimentation = enable_experimentation, + .dashboardSortOrder = dashboard_sort_order, .theme = settings_theme, .systemTheme = WindowsColors::is_dark_mode() ? L"dark" : L"light", .powerToysVersion = get_product_version(), @@ -159,6 +188,7 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save) show_whats_new_after_updates = general_configs.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = general_configs.GetNamedBoolean(L"enable_experimentation", true); + dashboard_sort_order = parse_dashboard_sort_order(general_configs, dashboard_sort_order); // apply_general_settings is called by the runner's WinMain, so we can just force the run at startup gpo rule here. auto gpo_run_as_startup = powertoys_gpo::getConfiguredRunAtStartupValue(); diff --git a/src/runner/general_settings.h b/src/runner/general_settings.h index 38fbd5789a..b4f7638846 100644 --- a/src/runner/general_settings.h +++ b/src/runner/general_settings.h @@ -2,6 +2,12 @@ #include +enum class DashboardSortOrder +{ + Alphabetical = 0, + ByStatus = 1, +}; + struct GeneralSettings { bool isStartupEnabled; @@ -16,6 +22,7 @@ struct GeneralSettings bool downloadUpdatesAutomatically; bool showWhatsNewAfterUpdates; bool enableExperimentation; + DashboardSortOrder dashboardSortOrder; std::wstring theme; std::wstring systemTheme; std::wstring powerToysVersion; diff --git a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs index 24ff4584fe..0f380aca78 100644 --- a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs +++ b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs @@ -13,6 +13,12 @@ using Settings.UI.Library.Attributes; namespace Microsoft.PowerToys.Settings.UI.Library { + public enum DashboardSortOrder + { + Alphabetical, + ByStatus, + } + public class GeneralSettings : ISettingsConfig { // Gets or sets a value indicating whether run powertoys on start-up. @@ -76,6 +82,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("enable_experimentation")] public bool EnableExperimentation { get; set; } + [JsonPropertyName("dashboard_sort_order")] + public DashboardSortOrder DashboardSortOrder { get; set; } + [JsonPropertyName("ignored_conflict_properties")] public ShortcutConflictProperties IgnoredConflictProperties { get; set; } @@ -89,6 +98,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library ShowNewUpdatesToastNotification = true; AutoDownloadUpdates = false; EnableExperimentation = true; + DashboardSortOrder = DashboardSortOrder.Alphabetical; Theme = "system"; SystemTheme = "light"; try diff --git a/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs b/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs new file mode 100644 index 0000000000..27689435cc --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs @@ -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. + +using System; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class EnumToBooleanConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value == null || parameter == null) + { + return false; + } + + // Get the enum value as string + var enumString = value.ToString(); + var parameterString = parameter.ToString(); + + return enumString.Equals(parameterString, StringComparison.OrdinalIgnoreCase); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml index 545122a56b..643811d2fc 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml @@ -19,6 +19,7 @@ x:Key="ModuleItemTemplateSelector" ActivationTemplate="{StaticResource ModuleItemActivationTemplate}" ShortcutTemplate="{StaticResource ModuleItemShortcutTemplate}" /> + @@ -276,9 +277,36 @@ Padding="0" VerticalAlignment="Top" DividerVisibility="Collapsed"> + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs index a06e5838a4..0d7273f924 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs @@ -66,5 +66,15 @@ namespace Microsoft.PowerToys.Settings.UI.Views App.GetOobeWindow().Activate(); } + + private void SortAlphabetical_Click(object sender, RoutedEventArgs e) + { + ViewModel.DashboardSortOrder = DashboardSortOrder.Alphabetical; + } + + private void SortByStatus_Click(object sender, RoutedEventArgs e) + { + ViewModel.DashboardSortOrder = DashboardSortOrder.ByStatus; + } } } diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index 95ce0b5f19..5f22c7e4dc 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -2999,7 +2999,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Crosshairs fixed length (px) px = pixels - + Crosshairs orientation @@ -4462,6 +4462,18 @@ Activate by holding the key for the character you want to add an accent to, then Home + + Sort utilities + + + Alphabetically + + + By status + + + Sort utilities + Preview @@ -4770,7 +4782,7 @@ Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or s Smooth zoomed image - + Specify the initial level of magnification when zooming in diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs index 344eaa183f..15a50b5dbd 100644 --- a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs @@ -62,6 +62,23 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + private DashboardSortOrder _dashboardSortOrder = DashboardSortOrder.Alphabetical; + + public DashboardSortOrder DashboardSortOrder + { + get => generalSettingsConfig.DashboardSortOrder; + set + { + if (Set(ref _dashboardSortOrder, value)) + { + generalSettingsConfig.DashboardSortOrder = value; + OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(generalSettingsConfig); + SendConfigMSG(outgoing.ToString()); + RefreshModuleList(); + } + } + } + private ISettingsRepository _settingsRepository; private GeneralSettings generalSettingsConfig; private Windows.ApplicationModel.Resources.ResourceLoader resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; @@ -73,14 +90,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels generalSettingsConfig = settingsRepository.SettingsConfig; generalSettingsConfig.AddEnabledModuleChangeNotification(ModuleEnabledChangedOnSettingsPage); + // Initialize dashboard sort order from settings + _dashboardSortOrder = generalSettingsConfig.DashboardSortOrder; + // set the callback functions value to handle outgoing IPC message. SendConfigMSG = ipcMSGCallBackFunc; - foreach (ModuleType moduleType in Enum.GetValues()) - { - AddDashboardListItem(moduleType); - } - + RefreshModuleList(); GetShortcutModules(); } @@ -113,21 +129,39 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); } - private void AddDashboardListItem(ModuleType moduleType) + private void RefreshModuleList() { - GpoRuleConfigured gpo = ModuleHelper.GetModuleGpoConfiguration(moduleType); - var newItem = new DashboardListItem() + AllModules.Clear(); + + var moduleItems = new List(); + + foreach (ModuleType moduleType in Enum.GetValues()) { - Tag = moduleType, - Label = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)), - IsEnabled = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType)), - IsLocked = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled, - Icon = ModuleHelper.GetModuleTypeFluentIconName(moduleType), - DashboardModuleItems = GetModuleItems(moduleType), + GpoRuleConfigured gpo = ModuleHelper.GetModuleGpoConfiguration(moduleType); + var newItem = new DashboardListItem() + { + Tag = moduleType, + Label = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)), + IsEnabled = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType)), + IsLocked = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled, + Icon = ModuleHelper.GetModuleTypeFluentIconName(moduleType), + DashboardModuleItems = GetModuleItems(moduleType), + }; + newItem.EnabledChangedCallback = EnabledChangedOnUI; + moduleItems.Add(newItem); + } + + // Sort based on current sort order + var sortedItems = DashboardSortOrder switch + { + DashboardSortOrder.ByStatus => moduleItems.OrderByDescending(x => x.IsEnabled).ThenBy(x => x.Label), + _ => moduleItems.OrderBy(x => x.Label), // Default alphabetical }; - AllModules.Add(newItem); - newItem.EnabledChangedCallback = EnabledChangedOnUI; + foreach (var item in sortedItems) + { + AllModules.Add(item); + } } private void EnabledChangedOnUI(DashboardListItem dashboardListItem) @@ -149,6 +183,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { try { + RefreshModuleList(); GetShortcutModules(); OnPropertyChanged(nameof(ShortcutModules)); From cd988b798b787b3e95c19d4d96614d5647cd1be6 Mon Sep 17 00:00:00 2001 From: Mike Hall Date: Wed, 5 Nov 2025 03:28:25 -0800 Subject: [PATCH 006/100] Add Cursor Wrap functionality to Powertoys Mouse Utils (#41826) ## Summary of the Pull Request Cursor Wrap makes it simple to move the mouse from one edge of a display (or set of displays) to the opposite edge of the display stack - on a single display Cursor Wrap will wrap top/bottom and left/right edges. https://github.com/user-attachments/assets/3feb606c-142b-4dab-9824-7597833d3ba4 ## PR Checklist - [x] Closes: CursorWrap #41759 - [x] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **Tests:** Added/updated and all pass - [x] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [x] **New binaries:** Added on the required places - [x] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments PR adds a new mouse utils module, this is 'Cursor Wrap' - Cursor Wrap works with 1-9 monitors based on the logical monitor layout of the PC - for a single monitor device the cursor is wrapped for the top/bottom and left/right edges of the display - for a multi-monitor setup the cursor is wrapped on the top/bottom left/right of the displays in the logical display layout. ## Validation Steps Performed Validation has been performed on a Surface Laptop 7 Pro (Intel) with a single display and with an HDMI USB-C second display configured to be a second monitor in top/left/right/bottom configuration - there are also tests that run as part of the build to validate logical monitor layout and cursor positioning. --------- Co-authored-by: Niels Laute Co-authored-by: Kai Tao (from Dev Box) Co-authored-by: Gordon Lam (SH) --- .github/actions/spell-check/expect.txt | 3 + .pipelines/ESRPSigning_core.json | 1 + PowerToys.sln | 11 + src/common/GPOWrapper/GPOWrapper.cpp | 4 + src/common/GPOWrapper/GPOWrapper.h | 1 + src/common/GPOWrapper/GPOWrapper.idl | 1 + src/common/ManagedCommon/ModuleType.cs | 1 + src/common/logger/logger_settings.h | 1 + src/common/utils/gpo.h | 7 + .../MouseUtils/CursorWrap/CursorWrap.rc | 46 + .../MouseUtils/CursorWrap/CursorWrap.vcxproj | 130 ++ .../MouseUtils/CursorWrap/CursorWrapTests.h | 213 ++++ src/modules/MouseUtils/CursorWrap/dllmain.cpp | 1045 +++++++++++++++++ .../MouseUtils/CursorWrap/packages.config | 4 + src/modules/MouseUtils/CursorWrap/pch.cpp | 1 + src/modules/MouseUtils/CursorWrap/pch.h | 13 + src/modules/MouseUtils/CursorWrap/resource.h | 4 + src/modules/MouseUtils/CursorWrap/trace.cpp | 31 + src/modules/MouseUtils/CursorWrap/trace.h | 11 + .../SamplePagesExtension.csproj | 10 +- src/runner/main.cpp | 1 + .../CursorWrapProperties.cs | 32 + .../Settings.UI.Library/CursorWrapSettings.cs | 53 + .../Settings.UI.Library/EnabledModules.cs | 16 + .../SndCursorWrapSettings.cs | 29 + .../Assets/Settings/Icons/CursorWrap.png | Bin 0 -> 1124 bytes .../Settings.UI/Helpers/ModuleHelper.cs | 7 +- .../SettingsXAML/Views/MouseUtilsPage.xaml | 39 +- .../SettingsXAML/Views/MouseUtilsPage.xaml.cs | 1 + .../Settings.UI/Strings/en-us/Resources.resw | 37 +- .../ViewModels/MouseUtilsViewModel.cs | 144 ++- 31 files changed, 1888 insertions(+), 9 deletions(-) create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrap.rc create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrapTests.h create mode 100644 src/modules/MouseUtils/CursorWrap/dllmain.cpp create mode 100644 src/modules/MouseUtils/CursorWrap/packages.config create mode 100644 src/modules/MouseUtils/CursorWrap/pch.cpp create mode 100644 src/modules/MouseUtils/CursorWrap/pch.h create mode 100644 src/modules/MouseUtils/CursorWrap/resource.h create mode 100644 src/modules/MouseUtils/CursorWrap/trace.cpp create mode 100644 src/modules/MouseUtils/CursorWrap/trace.h create mode 100644 src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs create mode 100644 src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs create mode 100644 src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index c50b54b1d9..491347d5ca 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -22,6 +22,7 @@ ADate ADDSTRING ADDUNDORECORD ADifferent +adjacents ADMINS adml admx @@ -313,6 +314,8 @@ CURRENTDIR CURSORINFO cursorpos CURSORSHOWING +CURSORWRAP +CursorWrap customaction CUSTOMACTIONTEST CUSTOMFORMATPLACEHOLDER diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index 5b6dd50bb5..c419d1b588 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -181,6 +181,7 @@ "PowerToys.MousePointerCrosshairs.dll", "PowerToys.MouseJumpUI.dll", "PowerToys.MouseJumpUI.exe", + "PowerToys.CursorWrap.dll", "PowerToys.MouseWithoutBorders.dll", "PowerToys.MouseWithoutBorders.exe", diff --git a/PowerToys.sln b/PowerToys.sln index b343993b68..e34779c5bb 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -822,6 +822,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WebSea EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Shell.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Shell.UnitTests\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj", "{E816D7B4-4688-4ECB-97CC-3D8E798F3833}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "CursorWrap", "src\modules\MouseUtils\CursorWrap\CursorWrap.vcxproj", "{48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3DCCD936-D085-4869-A1DE-CA6A64152C94}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightSwitch.UITests", "src\modules\LightSwitch\Tests\LightSwitch.UITests\LightSwitch.UITests.csproj", "{F5333ED7-06D8-4AB3-953A-36D63F08CB6F}" @@ -2990,6 +2992,14 @@ Global {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|ARM64.Build.0 = Release|ARM64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.ActiveCfg = Release|x64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.Build.0 = Release|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|ARM64.Build.0 = Debug|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|x64.ActiveCfg = Debug|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|x64.Build.0 = Debug|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|ARM64.ActiveCfg = Release|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|ARM64.Build.0 = Release|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|x64.ActiveCfg = Release|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|x64.Build.0 = Release|x64 {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.ActiveCfg = Debug|ARM64 {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.Build.0 = Debug|ARM64 {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.Deploy.0 = Debug|ARM64 @@ -3351,6 +3361,7 @@ Global {E816D7B3-4688-4ECB-97CC-3D8E798F3832} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B2-4688-4ECB-97CC-3D8E798F3831} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B4-4688-4ECB-97CC-3D8E798F3833} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5} = {322566EF-20DC-43A6-B9F8-616AF942579A} {3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477} {F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94} {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp index b8df132fe4..52c91a0795 100644 --- a/src/common/GPOWrapper/GPOWrapper.cpp +++ b/src/common/GPOWrapper/GPOWrapper.cpp @@ -112,6 +112,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getConfiguredMousePointerCrosshairsEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredCursorWrapEnabledValue() + { + return static_cast(powertoys_gpo::getConfiguredCursorWrapEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredPowerRenameEnabledValue() { return static_cast(powertoys_gpo::getConfiguredPowerRenameEnabledValue()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index 4f60a989db..846fba0a61 100644 --- a/src/common/GPOWrapper/GPOWrapper.h +++ b/src/common/GPOWrapper/GPOWrapper.h @@ -35,6 +35,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredMouseHighlighterEnabledValue(); static GpoRuleConfigured GetConfiguredMouseJumpEnabledValue(); static GpoRuleConfigured GetConfiguredMousePointerCrosshairsEnabledValue(); + static GpoRuleConfigured GetConfiguredCursorWrapEnabledValue(); static GpoRuleConfigured GetConfiguredPowerRenameEnabledValue(); static GpoRuleConfigured GetConfiguredPowerLauncherEnabledValue(); static GpoRuleConfigured GetConfiguredQuickAccentEnabledValue(); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index d1af719998..ab6dbf0c29 100644 --- a/src/common/GPOWrapper/GPOWrapper.idl +++ b/src/common/GPOWrapper/GPOWrapper.idl @@ -38,6 +38,7 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredMouseHighlighterEnabledValue(); static GpoRuleConfigured GetConfiguredMouseJumpEnabledValue(); static GpoRuleConfigured GetConfiguredMousePointerCrosshairsEnabledValue(); + static GpoRuleConfigured GetConfiguredCursorWrapEnabledValue(); static GpoRuleConfigured GetConfiguredMouseWithoutBordersEnabledValue(); static GpoRuleConfigured GetConfiguredPowerRenameEnabledValue(); static GpoRuleConfigured GetConfiguredPowerLauncherEnabledValue(); diff --git a/src/common/ManagedCommon/ModuleType.cs b/src/common/ManagedCommon/ModuleType.cs index aa741e2f3a..d7ae386191 100644 --- a/src/common/ManagedCommon/ModuleType.cs +++ b/src/common/ManagedCommon/ModuleType.cs @@ -12,6 +12,7 @@ namespace ManagedCommon ColorPicker, CmdPal, CropAndLock, + CursorWrap, EnvironmentVariables, FancyZones, FileLocksmith, diff --git a/src/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index b2e05fadfe..881633e05e 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -59,6 +59,7 @@ struct LogSettings inline const static std::string mouseHighlighterLoggerName = "mouse-highlighter"; inline const static std::string mouseJumpLoggerName = "mouse-jump"; inline const static std::string mousePointerCrosshairsLoggerName = "mouse-pointer-crosshairs"; + inline const static std::string cursorWrapLoggerName = "cursor-wrap"; inline const static std::string imageResizerLoggerName = "imageresizer"; inline const static std::string powerRenameLoggerName = "powerrename"; inline const static std::string alwaysOnTopLoggerName = "always-on-top"; diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h index db1e0f72e8..ecf338d212 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace powertoys_gpo { @@ -51,6 +52,7 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_HIGHLIGHTER = L"ConfigureEnabledUtilityMouseHighlighter"; const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_JUMP = L"ConfigureEnabledUtilityMouseJump"; const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_POINTER_CROSSHAIRS = L"ConfigureEnabledUtilityMousePointerCrosshairs"; + const std::wstring POLICY_CONFIGURE_ENABLED_CURSOR_WRAP = L"ConfigureEnabledUtilityCursorWrap"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_RENAME = L"ConfigureEnabledUtilityPowerRename"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_LAUNCHER = L"ConfigureEnabledUtilityPowerLauncher"; const std::wstring POLICY_CONFIGURE_ENABLED_QUICK_ACCENT = L"ConfigureEnabledUtilityQuickAccent"; @@ -409,6 +411,11 @@ namespace powertoys_gpo return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_MOUSE_POINTER_CROSSHAIRS); } + inline gpo_rule_configured_t getConfiguredCursorWrapEnabledValue() + { + return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_CURSOR_WRAP); + } + inline gpo_rule_configured_t getConfiguredPowerRenameEnabledValue() { return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_POWER_RENAME); diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.rc b/src/modules/MouseUtils/CursorWrap/CursorWrap.rc new file mode 100644 index 0000000000..37752edae0 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.rc @@ -0,0 +1,46 @@ +#include +#include "resource.h" +#include "../../../../common/version/version.h" + +#define APSTUDIO_READONLY_SYMBOLS +#include "winres.h" +#undef APSTUDIO_READONLY_SYMBOLS + +1 VERSIONINFO + FILEVERSION FILE_VERSION + PRODUCTVERSION PRODUCT_VERSION + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS_NT_WINDOWS32 + FILETYPE VFT_DLL + FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", COMPANY_NAME + VALUE "FileDescription", "PowerToys CursorWrap" + VALUE "FileVersion", FILE_VERSION_STRING + VALUE "InternalName", "CursorWrap" + VALUE "LegalCopyright", COPYRIGHT_NOTE + VALUE "OriginalFilename", "PowerToys.CursorWrap.dll" + VALUE "ProductName", PRODUCT_NAME + VALUE "ProductVersion", PRODUCT_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END + +STRINGTABLE +BEGIN + IDS_CURSORWRAP_NAME L"CursorWrap" + IDS_CURSORWRAP_DISABLE_WRAP_DURING_DRAG L"Disable wrapping during drag" +END \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj new file mode 100644 index 0000000000..59e2095ca7 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj @@ -0,0 +1,130 @@ + + + + + 15.0 + {48a1db8c-5df8-4fb3-9e14-2b67f3f2d8b5} + Win32Proj + CursorWrap + CursorWrap + + + + + DynamicLibrary + true + v143 + Unicode + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + + + + + + ..\..\..\..\$(Platform)\$(Configuration)\ + PowerToys.CursorWrap + + + true + + + false + + + + Level3 + Disabled + true + _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + MultiThreadedDebug + stdcpplatest + + + Windows + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + Level3 + MaxSpeed + true + true + true + NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + MultiThreaded + stdcpplatest + + + Windows + true + true + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) + + + + + + + + + + + + + Create + + + + + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h b/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h new file mode 100644 index 0000000000..4274ad714f --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h @@ -0,0 +1,213 @@ +#pragma once + +#include +#include + +// Test case structure for comprehensive monitor layout testing +struct MonitorTestCase +{ + std::string name; + std::string description; + int grid[3][3]; // 3x3 grid representing monitor layout (0 = no monitor, 1-9 = monitor ID) + + // Test scenarios to validate + struct TestScenario + { + int sourceMonitor; // Which monitor to start cursor on (1-based) + int edgeDirection; // 0=top, 1=right, 2=bottom, 3=left + int expectedTargetMonitor; // Expected destination monitor (1-based, -1 = wrap within same monitor) + std::string description; + }; + + std::vector scenarios; +}; + +// Comprehensive test cases for all possible 3x3 monitor grid configurations +class CursorWrapTestSuite +{ +public: + static std::vector GetAllTestCases() + { + std::vector testCases; + + // Test Case 1: Single monitor (center) + testCases.push_back({ + "Single_Center", + "Single monitor in center position", + { + {0, 0, 0}, + {0, 1, 0}, + {0, 0, 0} + }, + { + {1, 0, -1, "Top edge wraps to bottom of same monitor"}, + {1, 1, -1, "Right edge wraps to left of same monitor"}, + {1, 2, -1, "Bottom edge wraps to top of same monitor"}, + {1, 3, -1, "Left edge wraps to right of same monitor"} + } + }); + + // Test Case 2: Two monitors horizontal (left + right) + testCases.push_back({ + "Dual_Horizontal_Left_Right", + "Two monitors: left + right", + { + {0, 0, 0}, + {1, 0, 2}, + {0, 0, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom of monitor 1"}, + {1, 1, 2, "Monitor 1 right edge moves to monitor 2 left"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, -1, "Monitor 1 left edge wraps to right of monitor 1"}, + {2, 0, -1, "Monitor 2 top wraps to bottom of monitor 2"}, + {2, 1, -1, "Monitor 2 right edge wraps to left of monitor 2"}, + {2, 2, -1, "Monitor 2 bottom wraps to top of monitor 2"}, + {2, 3, 1, "Monitor 2 left edge moves to monitor 1 right"} + } + }); + + // Test Case 3: Two monitors vertical (Monitor 2 above Monitor 1) - CORRECTED FOR USER'S SETUP + testCases.push_back({ + "Dual_Vertical_2_Above_1", + "Two monitors: Monitor 2 (top) above Monitor 1 (bottom/main)", + { + {0, 2, 0}, // Row 0: Monitor 2 (physically top monitor) + {0, 0, 0}, // Row 1: Empty + {0, 1, 0} // Row 2: Monitor 1 (physically bottom/main monitor) + }, + { + // Monitor 1 (bottom/main monitor) tests + {1, 0, 2, "Monitor 1 (bottom) top edge should move to Monitor 2 (top) bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left of monitor 1"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, -1, "Monitor 1 left wraps to right of monitor 1"}, + + // Monitor 2 (top monitor) tests + {2, 0, -1, "Monitor 2 (top) top wraps to bottom of monitor 2"}, + {2, 1, -1, "Monitor 2 right wraps to left of monitor 2"}, + {2, 2, 1, "Monitor 2 (top) bottom edge should move to Monitor 1 (bottom) top"}, + {2, 3, -1, "Monitor 2 left wraps to right of monitor 2"} + } + }); + + // Test Case 4: Three monitors L-shape (center + left + top) + testCases.push_back({ + "Triple_L_Shape", + "Three monitors in L-shape: center + left + top", + { + {0, 3, 0}, + {2, 1, 0}, + {0, 0, 0} + }, + { + {1, 0, 3, "Monitor 1 top moves to monitor 3 bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left of monitor 1"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, 2, "Monitor 1 left moves to monitor 2 right"}, + {2, 0, -1, "Monitor 2 top wraps to bottom of monitor 2"}, + {2, 1, 1, "Monitor 2 right moves to monitor 1 left"}, + {2, 2, -1, "Monitor 2 bottom wraps to top of monitor 2"}, + {2, 3, -1, "Monitor 2 left wraps to right of monitor 2"}, + {3, 0, -1, "Monitor 3 top wraps to bottom of monitor 3"}, + {3, 1, -1, "Monitor 3 right wraps to left of monitor 3"}, + {3, 2, 1, "Monitor 3 bottom moves to monitor 1 top"}, + {3, 3, -1, "Monitor 3 left wraps to right of monitor 3"} + } + }); + + // Test Case 5: Three monitors horizontal (left + center + right) + testCases.push_back({ + "Triple_Horizontal", + "Three monitors horizontal: left + center + right", + { + {0, 0, 0}, + {1, 2, 3}, + {0, 0, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom"}, + {1, 1, 2, "Monitor 1 right moves to monitor 2"}, + {1, 2, -1, "Monitor 1 bottom wraps to top"}, + {1, 3, -1, "Monitor 1 left wraps to right"}, + {2, 0, -1, "Monitor 2 top wraps to bottom"}, + {2, 1, 3, "Monitor 2 right moves to monitor 3"}, + {2, 2, -1, "Monitor 2 bottom wraps to top"}, + {2, 3, 1, "Monitor 2 left moves to monitor 1"}, + {3, 0, -1, "Monitor 3 top wraps to bottom"}, + {3, 1, -1, "Monitor 3 right wraps to left"}, + {3, 2, -1, "Monitor 3 bottom wraps to top"}, + {3, 3, 2, "Monitor 3 left moves to monitor 2"} + } + }); + + // Test Case 6: Three monitors vertical (top + center + bottom) + testCases.push_back({ + "Triple_Vertical", + "Three monitors vertical: top + center + bottom", + { + {0, 1, 0}, + {0, 2, 0}, + {0, 3, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left"}, + {1, 2, 2, "Monitor 1 bottom moves to monitor 2"}, + {1, 3, -1, "Monitor 1 left wraps to right"}, + {2, 0, 1, "Monitor 2 top moves to monitor 1"}, + {2, 1, -1, "Monitor 2 right wraps to left"}, + {2, 2, 3, "Monitor 2 bottom moves to monitor 3"}, + {2, 3, -1, "Monitor 2 left wraps to right"}, + {3, 0, 2, "Monitor 3 top moves to monitor 2"}, + {3, 1, -1, "Monitor 3 right wraps to left"}, + {3, 2, -1, "Monitor 3 bottom wraps to top"}, + {3, 3, -1, "Monitor 3 left wraps to right"} + } + }); + + return testCases; + } + + // Helper function to print test case in a readable format + static std::string FormatTestCase(const MonitorTestCase& testCase) + { + std::string result = "Test Case: " + testCase.name + "\n"; + result += "Description: " + testCase.description + "\n"; + result += "Layout:\n"; + + for (int row = 0; row < 3; row++) + { + result += " "; + for (int col = 0; col < 3; col++) + { + if (testCase.grid[row][col] == 0) + { + result += ". "; + } + else + { + result += std::to_string(testCase.grid[row][col]) + " "; + } + } + result += "\n"; + } + + result += "Test Scenarios:\n"; + for (const auto& scenario : testCase.scenarios) + { + result += " - " + scenario.description + "\n"; + } + + return result; + } + + // Helper function to validate a specific test case against actual behavior + static bool ValidateTestCase(const MonitorTestCase& testCase) + { + // This would be called with actual CursorWrap instance to validate behavior + // For now, just return true - this would need actual implementation + return true; + } +}; \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/dllmain.cpp b/src/modules/MouseUtils/CursorWrap/dllmain.cpp new file mode 100644 index 0000000000..ece1948d01 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/dllmain.cpp @@ -0,0 +1,1045 @@ +#include "pch.h" +#include "../../../interface/powertoy_module_interface.h" +#include "../../../common/SettingsAPI/settings_objects.h" +#include "trace.h" +#include "../../../common/utils/process_path.h" +#include "../../../common/utils/resources.h" +#include "../../../common/logger/logger.h" +#include "../../../common/utils/logger_helper.h" +#include +#include +#include +#include +#include +#include +#include +#include "resource.h" +#include "CursorWrapTests.h" + +// Disable C26451 arithmetic overflow warning for this file since the operations are safe in this context +#pragma warning(disable: 26451) + +extern "C" IMAGE_DOS_HEADER __ImageBase; + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + Trace::RegisterProvider(); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + case DLL_PROCESS_DETACH: + Trace::UnregisterProvider(); + break; + } + return TRUE; +} + +// Non-Localizable strings +namespace +{ + const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; + const wchar_t JSON_KEY_VALUE[] = L"value"; + const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"activation_shortcut"; + const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate"; + const wchar_t JSON_KEY_DISABLE_WRAP_DURING_DRAG[] = L"disable_wrap_during_drag"; +} + +// The PowerToy name that will be shown in the settings. +const static wchar_t* MODULE_NAME = L"CursorWrap"; +// Add a description that will we shown in the module settings page. +const static wchar_t* MODULE_DESC = L""; + +// Mouse hook data structure +struct MonitorInfo +{ + RECT rect; + bool isPrimary; + int monitorId; // Add monitor ID for easier debugging +}; + +// Add structure for logical monitor grid position +struct LogicalPosition +{ + int row; + int col; + bool isValid; +}; + +// Add monitor topology helper +struct MonitorTopology +{ + std::vector> grid; // 3x3 grid of monitors + std::map monitorToPosition; + std::map, HMONITOR> positionToMonitor; + + void Initialize(const std::vector& monitors); + LogicalPosition GetPosition(HMONITOR monitor) const; + HMONITOR GetMonitorAt(int row, int col) const; + HMONITOR FindAdjacentMonitor(HMONITOR current, int deltaRow, int deltaCol) const; +}; + +// Forward declaration +class CursorWrap; + +// Global instance pointer for the mouse hook +static CursorWrap* g_cursorWrapInstance = nullptr; + +// Implement the PowerToy Module Interface and all the required methods. +class CursorWrap : public PowertoyModuleIface +{ +private: + // The PowerToy state. + bool m_enabled = false; + bool m_autoActivate = false; + bool m_disableWrapDuringDrag = true; // Default to true to prevent wrap during drag + + // Mouse hook + HHOOK m_mouseHook = nullptr; + std::atomic m_hookActive{ false }; + + // Monitor information + std::vector m_monitors; + MonitorTopology m_topology; + + // Hotkey + Hotkey m_activationHotkey{}; + +public: + // Constructor + CursorWrap() + { + LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::cursorWrapLoggerName); + init_settings(); + UpdateMonitorInfo(); + g_cursorWrapInstance = this; // Set global instance pointer + }; + + // Destroy the powertoy and free memory + virtual void destroy() override + { + StopMouseHook(); + g_cursorWrapInstance = nullptr; // Clear global instance pointer + delete this; + } + + // Return the localized display name of the powertoy + virtual const wchar_t* get_name() override + { + return MODULE_NAME; + } + + // Return the non localized key of the powertoy, this will be cached by the runner + virtual const wchar_t* get_key() override + { + return MODULE_NAME; + } + + // Return the configured status for the gpo policy for the module + virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override + { + return powertoys_gpo::getConfiguredCursorWrapEnabledValue(); + } + + // Return JSON with the configuration options. + virtual bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast(&__ImageBase); + + PowerToysSettings::Settings settings(hinstance, get_name()); + + settings.set_description(IDS_CURSORWRAP_NAME); + settings.set_icon_key(L"pt-cursor-wrap"); + + // Create HotkeyObject from the Hotkey struct for the settings + auto hotkey_object = PowerToysSettings::HotkeyObject::from_settings( + m_activationHotkey.win, + m_activationHotkey.ctrl, + m_activationHotkey.alt, + m_activationHotkey.shift, + m_activationHotkey.key); + + settings.add_hotkey(JSON_KEY_ACTIVATION_SHORTCUT, IDS_CURSORWRAP_NAME, hotkey_object); + settings.add_bool_toggle(JSON_KEY_AUTO_ACTIVATE, IDS_CURSORWRAP_NAME, m_autoActivate); + settings.add_bool_toggle(JSON_KEY_DISABLE_WRAP_DURING_DRAG, IDS_CURSORWRAP_NAME, m_disableWrapDuringDrag); + + return settings.serialize_to_buffer(buffer, buffer_size); + } + + // Signal from the Settings editor to call a custom action. + // This can be used to spawn more complex editors. + virtual void call_custom_action(const wchar_t* /*action*/) override {} + + // Called by the runner to pass the updated settings values as a serialized JSON. + virtual void set_config(const wchar_t* config) override + { + try + { + // Parse the input JSON string. + PowerToysSettings::PowerToyValues values = + PowerToysSettings::PowerToyValues::from_json_string(config, get_key()); + + parse_settings(values); + } + catch (std::exception&) + { + Logger::error("Invalid json when trying to parse CursorWrap settings json."); + } + } + + // Enable the powertoy + virtual void enable() + { + m_enabled = true; + Trace::EnableCursorWrap(true); + + if (m_autoActivate) + { + StartMouseHook(); + } + } + + // Disable the powertoy + virtual void disable() + { + m_enabled = false; + Trace::EnableCursorWrap(false); + StopMouseHook(); + } + + // Returns if the powertoys is enabled + virtual bool is_enabled() override + { + return m_enabled; + } + + // Returns whether the PowerToys should be enabled by default + virtual bool is_enabled_by_default() const override + { + return false; + } + + // Legacy hotkey support + virtual size_t get_hotkeys(Hotkey* buffer, size_t buffer_size) override + { + if (buffer && buffer_size >= 1) + { + buffer[0] = m_activationHotkey; + } + return 1; + } + + virtual bool on_hotkey(size_t hotkeyId) override + { + if (!m_enabled || hotkeyId != 0) + { + return false; + } + + // Toggle cursor wrapping + if (m_hookActive) + { + StopMouseHook(); + } + else + { + StartMouseHook(); +#ifdef _DEBUG + // Run comprehensive tests when hook is started in debug builds + RunComprehensiveTests(); +#endif + } + + return true; + } + +private: + // Load the settings file. + void init_settings() + { + try + { + // Load and parse the settings file for this PowerToy. + PowerToysSettings::PowerToyValues settings = + PowerToysSettings::PowerToyValues::load_from_settings_file(CursorWrap::get_key()); + parse_settings(settings); + } + catch (std::exception&) + { + Logger::error("Invalid json when trying to load the CursorWrap settings json from file."); + } + } + + void parse_settings(PowerToysSettings::PowerToyValues& settings) + { + auto settingsObject = settings.get_raw_json(); + if (settingsObject.GetView().Size()) + { + try + { + // Parse activation HotKey + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); + auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject); + + m_activationHotkey.win = hotkey.win_pressed(); + m_activationHotkey.ctrl = hotkey.ctrl_pressed(); + m_activationHotkey.shift = hotkey.shift_pressed(); + m_activationHotkey.alt = hotkey.alt_pressed(); + m_activationHotkey.key = static_cast(hotkey.get_code()); + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap activation shortcut"); + } + + try + { + // Parse auto activate + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_AUTO_ACTIVATE); + m_autoActivate = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap auto activate from settings. Will use default value"); + } + + try + { + // Parse disable wrap during drag + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + if (propertiesObject.HasKey(JSON_KEY_DISABLE_WRAP_DURING_DRAG)) + { + auto disableDragObject = propertiesObject.GetNamedObject(JSON_KEY_DISABLE_WRAP_DURING_DRAG); + m_disableWrapDuringDrag = disableDragObject.GetNamedBoolean(JSON_KEY_VALUE); + } + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap disable wrap during drag from settings. Will use default value (true)"); + } + } + else + { + Logger::info("CursorWrap settings are empty"); + } + + // Set default hotkey if not configured + if (m_activationHotkey.key == 0) + { + m_activationHotkey.win = true; + m_activationHotkey.alt = true; + m_activationHotkey.ctrl = false; + m_activationHotkey.shift = false; + m_activationHotkey.key = 'U'; // Win+Alt+U + } + } + + void UpdateMonitorInfo() + { + m_monitors.clear(); + + EnumDisplayMonitors(nullptr, nullptr, [](HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) -> BOOL { + auto* self = reinterpret_cast(lParam); + + MONITORINFO mi{}; + mi.cbSize = sizeof(MONITORINFO); + if (GetMonitorInfo(hMonitor, &mi)) + { + MonitorInfo info{}; + info.rect = mi.rcMonitor; + info.isPrimary = (mi.dwFlags & MONITORINFOF_PRIMARY) != 0; + info.monitorId = static_cast(self->m_monitors.size()); + self->m_monitors.push_back(info); + } + + return TRUE; + }, reinterpret_cast(this)); + + // Initialize monitor topology + m_topology.Initialize(m_monitors); + } + + void StartMouseHook() + { + if (m_mouseHook || m_hookActive) + { + Logger::info("CursorWrap mouse hook already active"); + return; + } + + UpdateMonitorInfo(); + + m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, GetModuleHandle(nullptr), 0); + if (m_mouseHook) + { + m_hookActive = true; + Logger::info("CursorWrap mouse hook started successfully"); +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Hook installed"); +#endif + } + else + { + DWORD error = GetLastError(); + Logger::error(L"Failed to install CursorWrap mouse hook, error: {}", error); + } + } + + void StopMouseHook() + { + if (m_mouseHook) + { + UnhookWindowsHookEx(m_mouseHook); + m_mouseHook = nullptr; + m_hookActive = false; + Logger::info("CursorWrap mouse hook stopped"); +#ifdef _DEBUG + Logger::info("CursorWrap DEBUG: Mouse hook stopped"); +#endif + } + } + + static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) + { + if (nCode >= 0 && wParam == WM_MOUSEMOVE) + { + auto* pMouseStruct = reinterpret_cast(lParam); + POINT currentPos = { pMouseStruct->pt.x, pMouseStruct->pt.y }; + + if (g_cursorWrapInstance && g_cursorWrapInstance->m_hookActive) + { + POINT newPos = g_cursorWrapInstance->HandleMouseMove(currentPos); + if (newPos.x != currentPos.x || newPos.y != currentPos.y) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Wrapping cursor from ({}, {}) to ({}, {})", + currentPos.x, currentPos.y, newPos.x, newPos.y); +#endif + SetCursorPos(newPos.x, newPos.y); + return 1; // Suppress the original message + } + } + } + + return CallNextHookEx(nullptr, nCode, wParam, lParam); + } + + // *** COMPLETELY REWRITTEN CURSOR WRAPPING LOGIC *** + // Implements vertical scrolling to bottom/top of vertical stack as requested + POINT HandleMouseMove(const POINT& currentPos) + { + POINT newPos = currentPos; + + // Check if we should skip wrapping during drag if the setting is enabled + if (m_disableWrapDuringDrag && (GetAsyncKeyState(VK_LBUTTON) & 0x8000)) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Left mouse button is down and disable_wrap_during_drag is enabled - skipping wrap"); +#endif + return currentPos; // Return unchanged position (no wrapping) + } + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= HANDLE MOUSE MOVE START ======="); + Logger::info(L"CursorWrap DEBUG: Input position ({}, {})", currentPos.x, currentPos.y); +#endif + + // Find which monitor the cursor is currently on + HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST); + MONITORINFO currentMonitorInfo{}; + currentMonitorInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(currentMonitor, ¤tMonitorInfo); + + LogicalPosition currentLogicalPos = m_topology.GetPosition(currentMonitor); + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Current monitor bounds: Left={}, Top={}, Right={}, Bottom={}", + currentMonitorInfo.rcMonitor.left, currentMonitorInfo.rcMonitor.top, + currentMonitorInfo.rcMonitor.right, currentMonitorInfo.rcMonitor.bottom); + Logger::info(L"CursorWrap DEBUG: Logical position: Row={}, Col={}, Valid={}", + currentLogicalPos.row, currentLogicalPos.col, currentLogicalPos.isValid); +#endif + + bool wrapped = false; + + // *** VERTICAL WRAPPING LOGIC - CONFIRMED WORKING *** + // Move to bottom of vertical stack when hitting top edge + if (currentPos.y <= currentMonitorInfo.rcMonitor.top) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: TOP EDGE DETECTED ======="); +#endif + + // Find the bottom-most monitor in the vertical stack (same column) + HMONITOR bottomMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search down from current position to find the bottom-most monitor in same column + for (int row = 2; row >= 0; row--) { // Start from bottom and work up + HMONITOR candidateMonitor = m_topology.GetMonitorAt(row, currentLogicalPos.col); + if (candidateMonitor) { + bottomMonitor = candidateMonitor; + break; // Found the bottom-most monitor + } + } + } + + if (bottomMonitor && bottomMonitor != currentMonitor) { + // *** MOVE TO BOTTOM OF VERTICAL STACK *** + MONITORINFO bottomInfo{}; + bottomInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(bottomMonitor, &bottomInfo); + + // Calculate relative X position to maintain cursor X alignment + double relativeX = static_cast(currentPos.x - currentMonitorInfo.rcMonitor.left) / + (currentMonitorInfo.rcMonitor.right - currentMonitorInfo.rcMonitor.left); + + int targetWidth = bottomInfo.rcMonitor.right - bottomInfo.rcMonitor.left; + newPos.x = bottomInfo.rcMonitor.left + static_cast(relativeX * targetWidth); + newPos.y = bottomInfo.rcMonitor.bottom - 1; // Bottom edge of bottom monitor + + // Clamp X to target monitor bounds + newPos.x = max(bottomInfo.rcMonitor.left, min(newPos.x, bottomInfo.rcMonitor.right - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP SUCCESS - Moved to bottom of vertical stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN VERTICAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.y = currentMonitorInfo.rcMonitor.bottom - 1; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + else if (currentPos.y >= currentMonitorInfo.rcMonitor.bottom - 1) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: BOTTOM EDGE DETECTED ======="); +#endif + + // Find the top-most monitor in the vertical stack (same column) + HMONITOR topMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search up from current position to find the top-most monitor in same column + for (int row = 0; row <= 2; row++) { // Start from top and work down + HMONITOR candidateMonitor = m_topology.GetMonitorAt(row, currentLogicalPos.col); + if (candidateMonitor) { + topMonitor = candidateMonitor; + break; // Found the top-most monitor + } + } + } + + if (topMonitor && topMonitor != currentMonitor) { + // *** MOVE TO TOP OF VERTICAL STACK *** + MONITORINFO topInfo{}; + topInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(topMonitor, &topInfo); + + // Calculate relative X position to maintain cursor X alignment + double relativeX = static_cast(currentPos.x - currentMonitorInfo.rcMonitor.left) / + (currentMonitorInfo.rcMonitor.right - currentMonitorInfo.rcMonitor.left); + + int targetWidth = topInfo.rcMonitor.right - topInfo.rcMonitor.left; + newPos.x = topInfo.rcMonitor.left + static_cast(relativeX * targetWidth); + newPos.y = topInfo.rcMonitor.top; // Top edge of top monitor + + // Clamp X to target monitor bounds + newPos.x = max(topInfo.rcMonitor.left, min(newPos.x, topInfo.rcMonitor.right - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP SUCCESS - Moved to top of vertical stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN VERTICAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.y = currentMonitorInfo.rcMonitor.top; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + + // *** FIXED HORIZONTAL WRAPPING LOGIC *** + // Move to opposite end of horizontal stack when hitting left/right edge + // Only handle horizontal wrapping if we haven't already wrapped vertically + if (!wrapped && currentPos.x <= currentMonitorInfo.rcMonitor.left) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: LEFT EDGE DETECTED ======="); +#endif + + // Find the right-most monitor in the horizontal stack (same row) + HMONITOR rightMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search right from current position to find the right-most monitor in same row + for (int col = 2; col >= 0; col--) { // Start from right and work left + HMONITOR candidateMonitor = m_topology.GetMonitorAt(currentLogicalPos.row, col); + if (candidateMonitor) { + rightMonitor = candidateMonitor; + break; // Found the right-most monitor + } + } + } + + if (rightMonitor && rightMonitor != currentMonitor) { + // *** MOVE TO RIGHT END OF HORIZONTAL STACK *** + MONITORINFO rightInfo{}; + rightInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(rightMonitor, &rightInfo); + + // Calculate relative Y position to maintain cursor Y alignment + double relativeY = static_cast(currentPos.y - currentMonitorInfo.rcMonitor.top) / + (currentMonitorInfo.rcMonitor.bottom - currentMonitorInfo.rcMonitor.top); + + int targetHeight = rightInfo.rcMonitor.bottom - rightInfo.rcMonitor.top; + newPos.y = rightInfo.rcMonitor.top + static_cast(relativeY * targetHeight); + newPos.x = rightInfo.rcMonitor.right - 1; // Right edge of right monitor + + // Clamp Y to target monitor bounds + newPos.y = max(rightInfo.rcMonitor.top, min(newPos.y, rightInfo.rcMonitor.bottom - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP SUCCESS - Moved to right end of horizontal stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN HORIZONTAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.x = currentMonitorInfo.rcMonitor.right - 1; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + else if (!wrapped && currentPos.x >= currentMonitorInfo.rcMonitor.right - 1) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: RIGHT EDGE DETECTED ======="); +#endif + + // Find the left-most monitor in the horizontal stack (same row) + HMONITOR leftMonitor = nullptr; + + if (currentLogicalPos.isValid) { + // Search left from current position to find the left-most monitor in same row + for (int col = 0; col <= 2; col++) { // Start from left and work right + HMONITOR candidateMonitor = m_topology.GetMonitorAt(currentLogicalPos.row, col); + if (candidateMonitor) { + leftMonitor = candidateMonitor; + break; // Found the left-most monitor + } + } + } + + if (leftMonitor && leftMonitor != currentMonitor) { + // *** MOVE TO LEFT END OF HORIZONTAL STACK *** + MONITORINFO leftInfo{}; + leftInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(leftMonitor, &leftInfo); + + // Calculate relative Y position to maintain cursor Y alignment + double relativeY = static_cast(currentPos.y - currentMonitorInfo.rcMonitor.top) / + (currentMonitorInfo.rcMonitor.bottom - currentMonitorInfo.rcMonitor.top); + + int targetHeight = leftInfo.rcMonitor.bottom - leftInfo.rcMonitor.top; + newPos.y = leftInfo.rcMonitor.top + static_cast(relativeY * targetHeight); + newPos.x = leftInfo.rcMonitor.left; // Left edge of left monitor + + // Clamp Y to target monitor bounds + newPos.y = max(leftInfo.rcMonitor.top, min(newPos.y, leftInfo.rcMonitor.bottom - 1)); + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP SUCCESS - Moved to left end of horizontal stack"); + Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); +#endif + } else { + // *** NO OTHER MONITOR IN HORIZONTAL STACK - WRAP WITHIN CURRENT MONITOR *** + newPos.x = currentMonitorInfo.rcMonitor.left; + wrapped = true; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP - No other monitor in stack, wrapping within current monitor"); +#endif + } + } + +#ifdef _DEBUG + if (wrapped) + { + Logger::info(L"CursorWrap DEBUG: ======= WRAP RESULT ======="); + Logger::info(L"CursorWrap DEBUG: Original: ({}, {}) -> New: ({}, {})", + currentPos.x, currentPos.y, newPos.x, newPos.y); + } + else + { + Logger::info(L"CursorWrap DEBUG: No wrapping performed - cursor not at edge"); + } + Logger::info(L"CursorWrap DEBUG: ======= HANDLE MOUSE MOVE END ======="); +#endif + + return newPos; + } + + // Add test method for monitor topology validation + void RunMonitorTopologyTests() + { +#ifdef _DEBUG + Logger::info(L"CursorWrap: Running monitor topology tests..."); + + // Test all 9 possible monitor positions in 3x3 grid + const char* gridNames[3][3] = { + {"TL", "TC", "TR"}, // Top-Left, Top-Center, Top-Right + {"ML", "MC", "MR"}, // Middle-Left, Middle-Center, Middle-Right + {"BL", "BC", "BR"} // Bottom-Left, Bottom-Center, Bottom-Right + }; + + for (int row = 0; row < 3; row++) + { + for (int col = 0; col < 3; col++) + { + HMONITOR monitor = m_topology.GetMonitorAt(row, col); + if (monitor) + { + std::string gridName(gridNames[row][col]); + std::wstring wGridName(gridName.begin(), gridName.end()); + Logger::info(L"CursorWrap TEST: Monitor at [{}][{}] ({}) exists", + row, col, wGridName.c_str()); + + // Test adjacent monitor finding + HMONITOR up = m_topology.FindAdjacentMonitor(monitor, -1, 0); + HMONITOR down = m_topology.FindAdjacentMonitor(monitor, 1, 0); + HMONITOR left = m_topology.FindAdjacentMonitor(monitor, 0, -1); + HMONITOR right = m_topology.FindAdjacentMonitor(monitor, 0, 1); + + Logger::info(L"CursorWrap TEST: Adjacent monitors - Up: {}, Down: {}, Left: {}, Right: {}", + up ? L"YES" : L"NO", down ? L"YES" : L"NO", + left ? L"YES" : L"NO", right ? L"YES" : L"NO"); + } + } + } + + Logger::info(L"CursorWrap: Monitor topology tests completed."); +#endif + } + + // Add method to trigger test suite (can be called via hotkey in debug builds) + void RunComprehensiveTests() + { +#ifdef _DEBUG + RunMonitorTopologyTests(); + + // Test cursor wrapping scenarios + Logger::info(L"CursorWrap: Testing cursor wrapping scenarios..."); + + // Simulate cursor positions at each monitor edge and verify expected behavior + for (const auto& monitor : m_monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + LogicalPosition pos = m_topology.GetPosition(hMonitor); + + if (pos.isValid) + { + Logger::info(L"CursorWrap TEST: Testing monitor at position [{}][{}]", pos.row, pos.col); + + // Test top edge + POINT topEdge = {(monitor.rect.left + monitor.rect.right) / 2, monitor.rect.top}; + POINT newPos = HandleMouseMove(topEdge); + Logger::info(L"CursorWrap TEST: Top edge ({}, {}) -> ({}, {})", + topEdge.x, topEdge.y, newPos.x, newPos.y); + + // Test bottom edge + POINT bottomEdge = {(monitor.rect.left + monitor.rect.right) / 2, monitor.rect.bottom - 1}; + newPos = HandleMouseMove(bottomEdge); + Logger::info(L"CursorWrap TEST: Bottom edge ({}, {}) -> ({}, {})", + bottomEdge.x, bottomEdge.y, newPos.x, newPos.y); + + // Test left edge + POINT leftEdge = {monitor.rect.left, (monitor.rect.top + monitor.rect.bottom) / 2}; + newPos = HandleMouseMove(leftEdge); + Logger::info(L"CursorWrap TEST: Left edge ({}, {}) -> ({}, {})", + leftEdge.x, leftEdge.y, newPos.x, newPos.y); + + // Test right edge + POINT rightEdge = {monitor.rect.right - 1, (monitor.rect.top + monitor.rect.bottom) / 2}; + newPos = HandleMouseMove(rightEdge); + Logger::info(L"CursorWrap TEST: Right edge ({}, {}) -> ({}, {})", + rightEdge.x, rightEdge.y, newPos.x, newPos.y); + } + } + + Logger::info(L"CursorWrap: Comprehensive tests completed."); +#endif + } +}; + +// Implementation of MonitorTopology methods +void MonitorTopology::Initialize(const std::vector& monitors) +{ + // Clear existing data + grid.assign(3, std::vector(3, nullptr)); + monitorToPosition.clear(); + positionToMonitor.clear(); + + if (monitors.empty()) return; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= TOPOLOGY INITIALIZATION START ======="); + Logger::info(L"CursorWrap DEBUG: Initializing topology for {} monitors", monitors.size()); + for (const auto& monitor : monitors) + { + Logger::info(L"CursorWrap DEBUG: Monitor {}: bounds=({},{},{},{}), isPrimary={}", + monitor.monitorId, monitor.rect.left, monitor.rect.top, + monitor.rect.right, monitor.rect.bottom, monitor.isPrimary); + } +#endif + + // Special handling for 2 monitors - use physical position, not discovery order + if (monitors.size() == 2) + { + // Determine if arrangement is horizontal or vertical by comparing centers + POINT center0 = {(monitors[0].rect.left + monitors[0].rect.right) / 2, + (monitors[0].rect.top + monitors[0].rect.bottom) / 2}; + POINT center1 = {(monitors[1].rect.left + monitors[1].rect.right) / 2, + (monitors[1].rect.top + monitors[1].rect.bottom) / 2}; + + int xDiff = abs(center0.x - center1.x); + int yDiff = abs(center0.y - center1.y); + + bool isHorizontal = xDiff > yDiff; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor centers: M0=({}, {}), M1=({}, {})", + center0.x, center0.y, center1.x, center1.y); + Logger::info(L"CursorWrap DEBUG: Differences: X={}, Y={}, IsHorizontal={}", + xDiff, yDiff, isHorizontal); +#endif + + if (isHorizontal) + { + // Horizontal arrangement - place in middle row [1,0] and [1,2] + for (const auto& monitor : monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + POINT center = {(monitor.rect.left + monitor.rect.right) / 2, + (monitor.rect.top + monitor.rect.bottom) / 2}; + + int row = 1; // Middle row + int col = (center.x < (center0.x + center1.x) / 2) ? 0 : 2; // Left or right based on center + + grid[row][col] = hMonitor; + monitorToPosition[hMonitor] = {row, col, true}; + positionToMonitor[{row, col}] = hMonitor; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor {} (horizontal) placed at grid[{}][{}]", + monitor.monitorId, row, col); +#endif + } + } + else + { + // *** VERTICAL ARRANGEMENT - CRITICAL LOGIC *** + // Sort monitors by Y coordinate to determine vertical order + std::vector> sortedMonitors; + for (int i = 0; i < 2; i++) { + sortedMonitors.push_back({i, monitors[i]}); + } + + // Sort by Y coordinate (top to bottom) + std::sort(sortedMonitors.begin(), sortedMonitors.end(), + [](const std::pair& a, const std::pair& b) { + int centerA = (a.second.rect.top + a.second.rect.bottom) / 2; + int centerB = (b.second.rect.top + b.second.rect.bottom) / 2; + return centerA < centerB; // Top first + }); + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: VERTICAL ARRANGEMENT DETECTED"); + Logger::info(L"CursorWrap DEBUG: Top monitor: ID={}, Y-center={}", + sortedMonitors[0].second.monitorId, + (sortedMonitors[0].second.rect.top + sortedMonitors[0].second.rect.bottom) / 2); + Logger::info(L"CursorWrap DEBUG: Bottom monitor: ID={}, Y-center={}", + sortedMonitors[1].second.monitorId, + (sortedMonitors[1].second.rect.top + sortedMonitors[1].second.rect.bottom) / 2); +#endif + + // Place monitors in grid based on sorted order + for (int i = 0; i < 2; i++) { + const auto& monitorPair = sortedMonitors[i]; + const auto& monitor = monitorPair.second; + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + + int col = 1; // Middle column for vertical arrangement + int row = (i == 0) ? 0 : 2; // Top monitor at row 0, bottom at row 2 + + grid[row][col] = hMonitor; + monitorToPosition[hMonitor] = {row, col, true}; + positionToMonitor[{row, col}] = hMonitor; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor {} (vertical) placed at grid[{}][{}] - {} position", + monitor.monitorId, row, col, (i == 0) ? L"TOP" : L"BOTTOM"); +#endif + } + } + } + else + { + // For more than 2 monitors, use the general algorithm + RECT totalBounds = monitors[0].rect; + for (const auto& monitor : monitors) + { + totalBounds.left = min(totalBounds.left, monitor.rect.left); + totalBounds.top = min(totalBounds.top, monitor.rect.top); + totalBounds.right = max(totalBounds.right, monitor.rect.right); + totalBounds.bottom = max(totalBounds.bottom, monitor.rect.bottom); + } + + int totalWidth = totalBounds.right - totalBounds.left; + int totalHeight = totalBounds.bottom - totalBounds.top; + int gridWidth = max(1, totalWidth / 3); + int gridHeight = max(1, totalHeight / 3); + + // Place monitors in the 3x3 grid based on their center points + for (const auto& monitor : monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + + // Calculate center point of monitor + int centerX = (monitor.rect.left + monitor.rect.right) / 2; + int centerY = (monitor.rect.top + monitor.rect.bottom) / 2; + + // Map to grid position + int col = (centerX - totalBounds.left) / gridWidth; + int row = (centerY - totalBounds.top) / gridHeight; + + // Ensure we stay within bounds + col = max(0, min(2, col)); + row = max(0, min(2, row)); + + grid[row][col] = hMonitor; + monitorToPosition[hMonitor] = {row, col, true}; + positionToMonitor[{row, col}] = hMonitor; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}], center=({}, {})", + monitor.monitorId, row, col, centerX, centerY); +#endif + } + } + +#ifdef _DEBUG + // *** CRITICAL: Print topology map using OutputDebugString for debug builds *** + Logger::info(L"CursorWrap DEBUG: ======= FINAL TOPOLOGY MAP ======="); + OutputDebugStringA("CursorWrap TOPOLOGY MAP:\n"); + for (int r = 0; r < 3; r++) + { + std::string rowStr = " "; + for (int c = 0; c < 3; c++) + { + if (grid[r][c]) + { + // Find monitor ID for this handle + int monitorId = -1; + for (const auto& monitor : monitors) + { + HMONITOR handle = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + if (handle == grid[r][c]) + { + monitorId = monitor.monitorId + 1; // Convert to 1-based for display + break; + } + } + rowStr += std::to_string(monitorId) + " "; + } + else + { + rowStr += ". "; + } + } + rowStr += "\n"; + OutputDebugStringA(rowStr.c_str()); + + // Also log to PowerToys logger + std::wstring wRowStr(rowStr.begin(), rowStr.end()); + Logger::info(wRowStr.c_str()); + } + OutputDebugStringA("======= END TOPOLOGY MAP =======\n"); + + // Additional validation logging + Logger::info(L"CursorWrap DEBUG: ======= GRID POSITION VALIDATION ======="); + for (const auto& monitor : monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + LogicalPosition pos = GetPosition(hMonitor); + if (pos.isValid) + { + Logger::info(L"CursorWrap DEBUG: Monitor {} -> grid[{}][{}]", monitor.monitorId, pos.row, pos.col); + OutputDebugStringA(("Monitor " + std::to_string(monitor.monitorId) + " -> grid[" + std::to_string(pos.row) + "][" + std::to_string(pos.col) + "]\n").c_str()); + + // Test adjacent finding + HMONITOR up = FindAdjacentMonitor(hMonitor, -1, 0); + HMONITOR down = FindAdjacentMonitor(hMonitor, 1, 0); + HMONITOR left = FindAdjacentMonitor(hMonitor, 0, -1); + HMONITOR right = FindAdjacentMonitor(hMonitor, 0, 1); + + Logger::info(L"CursorWrap DEBUG: Monitor {} adjacents - Up: {}, Down: {}, Left: {}, Right: {}", + monitor.monitorId, up ? L"YES" : L"NO", down ? L"YES" : L"NO", + left ? L"YES" : L"NO", right ? L"YES" : L"NO"); + } + } + Logger::info(L"CursorWrap DEBUG: ======= TOPOLOGY INITIALIZATION COMPLETE ======="); +#endif +} + +LogicalPosition MonitorTopology::GetPosition(HMONITOR monitor) const +{ + auto it = monitorToPosition.find(monitor); + if (it != monitorToPosition.end()) + { + return it->second; + } + return {-1, -1, false}; +} + +HMONITOR MonitorTopology::GetMonitorAt(int row, int col) const +{ + if (row >= 0 && row < 3 && col >= 0 && col < 3) + { + return grid[row][col]; + } + return nullptr; +} + +HMONITOR MonitorTopology::FindAdjacentMonitor(HMONITOR current, int deltaRow, int deltaCol) const +{ + LogicalPosition currentPos = GetPosition(current); + if (!currentPos.isValid) return nullptr; + + int newRow = currentPos.row + deltaRow; + int newCol = currentPos.col + deltaCol; + + return GetMonitorAt(newRow, newCol); +} + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new CursorWrap(); +} diff --git a/src/modules/MouseUtils/CursorWrap/packages.config b/src/modules/MouseUtils/CursorWrap/packages.config new file mode 100644 index 0000000000..2c5d71ae86 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/pch.cpp b/src/modules/MouseUtils/CursorWrap/pch.cpp new file mode 100644 index 0000000000..17305716aa --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/pch.h b/src/modules/MouseUtils/CursorWrap/pch.h new file mode 100644 index 0000000000..86f11c99ba --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/pch.h @@ -0,0 +1,13 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +#include + +#include +#include +#include + +// Note: Common includes moved to individual source files due to include path issues +// #include +// #include +// #include \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/resource.h b/src/modules/MouseUtils/CursorWrap/resource.h new file mode 100644 index 0000000000..9b49c0e3cc --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/resource.h @@ -0,0 +1,4 @@ +#pragma once + +#define IDS_CURSORWRAP_NAME 101 +#define IDS_CURSORWRAP_DISABLE_WRAP_DURING_DRAG 102 diff --git a/src/modules/MouseUtils/CursorWrap/trace.cpp b/src/modules/MouseUtils/CursorWrap/trace.cpp new file mode 100644 index 0000000000..ebfe32c23c --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/trace.cpp @@ -0,0 +1,31 @@ +#include "pch.h" +#include "trace.h" + +#include "../../../../common/Telemetry/TraceBase.h" + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +void Trace::RegisterProvider() +{ + TraceLoggingRegister(g_hProvider); +} + +void Trace::UnregisterProvider() +{ + TraceLoggingUnregister(g_hProvider); +} + +void Trace::EnableCursorWrap(const bool enabled) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "CursorWrap_EnableCursorWrap", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, "Enabled")); +} \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/trace.h b/src/modules/MouseUtils/CursorWrap/trace.h new file mode 100644 index 0000000000..b2f6a9a8eb --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/trace.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +class Trace : public telemetry::TraceBase +{ +public: + static void RegisterProvider(); + static void UnregisterProvider(); + static void EnableCursorWrap(const bool enabled) noexcept; +}; \ No newline at end of file diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj b/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj index 4007e6a986..964211ddff 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj @@ -62,10 +62,18 @@ true - + + true true true + + + false + false + false + + diff --git a/src/runner/main.cpp b/src/runner/main.cpp index 4b29149f78..c20293f9ed 100644 --- a/src/runner/main.cpp +++ b/src/runner/main.cpp @@ -161,6 +161,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"PowerToys.MouseJump.dll", L"PowerToys.AlwaysOnTopModuleInterface.dll", L"PowerToys.MousePointerCrosshairs.dll", + L"PowerToys.CursorWrap.dll", L"PowerToys.PowerAccentModuleInterface.dll", L"PowerToys.PowerOCRModuleInterface.dll", L"PowerToys.AdvancedPasteModuleInterface.dll", diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs new file mode 100644 index 0000000000..cf66b4ba09 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs @@ -0,0 +1,32 @@ +// 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.Text.Json.Serialization; + +using Settings.UI.Library.Attributes; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class CursorWrapProperties + { + [CmdConfigureIgnore] + public HotkeySettings DefaultActivationShortcut => new HotkeySettings(true, false, true, false, 0x55); // Win + Alt + U + + [JsonPropertyName("activation_shortcut")] + public HotkeySettings ActivationShortcut { get; set; } + + [JsonPropertyName("auto_activate")] + public BoolProperty AutoActivate { get; set; } + + [JsonPropertyName("disable_wrap_during_drag")] + public BoolProperty DisableWrapDuringDrag { get; set; } + + public CursorWrapProperties() + { + ActivationShortcut = DefaultActivationShortcut; + AutoActivate = new BoolProperty(false); + DisableWrapDuringDrag = new BoolProperty(true); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs new file mode 100644 index 0000000000..8c9059123c --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs @@ -0,0 +1,53 @@ +// 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.Text.Json.Serialization; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class CursorWrapSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig + { + public const string ModuleName = "CursorWrap"; + + [JsonPropertyName("properties")] + public CursorWrapProperties Properties { get; set; } + + public CursorWrapSettings() + { + Name = ModuleName; + Properties = new CursorWrapProperties(); + Version = "1.0"; + } + + public string GetModuleName() + { + return Name; + } + + public ModuleType GetModuleType() => ModuleType.CursorWrap; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_CursorWrap_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + + // This can be utilized in the future if the settings.json file is to be modified/deleted. + public bool UpgradeSettingsConfiguration() + { + return false; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs index 977c03b839..d7100d9ae4 100644 --- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs +++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs @@ -513,6 +513,22 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + private bool cursorWrap; // defaulting to off + + [JsonPropertyName("CursorWrap")] + public bool CursorWrap + { + get => cursorWrap; + set + { + if (cursorWrap != value) + { + LogTelemetryEvent(value); + cursorWrap = value; + } + } + } + private bool lightSwitch; [JsonPropertyName("LightSwitch")] diff --git a/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs new file mode 100644 index 0000000000..3d6d781d03 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs @@ -0,0 +1,29 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class SndCursorWrapSettings + { + [JsonPropertyName("CursorWrap")] + public CursorWrapSettings CursorWrap { get; set; } + + public SndCursorWrapSettings() + { + } + + public SndCursorWrapSettings(CursorWrapSettings settings) + { + CursorWrap = settings; + } + + public string ToJsonString() + { + return JsonSerializer.Serialize(this); + } + } +} diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png new file mode 100644 index 0000000000000000000000000000000000000000..4374dfdc829f56c9fe800403950cb4b4e912b79a GIT binary patch literal 1124 zcmV-q1e^PbP)M020V? z9*!@Q{Bv$ZC9b@#_vn^7mCgmEnF!IHpXMdN3^y!A|kqs?M7g#`T~++~_rKw%Km0B`cW$49b@2*8BwVDIX|(m}GHnhAI=}`E zcaoywJ$U&?5Go#je4+4|J989it)P$)lJibrCwjUzCN zab4BabiR!hU2QCL$ImWv)PP$-upgGCWk4C0>i4<(qJG* zgj0eDkQTDB-A={BAMr?}#ExA%k<>x-VGP*z@W>(nApvp+)NXKbTtWa8T8wi9k@H;S zYZOE**4kj8qDV*}Xd7d-Urq@|Bg(QwNsKqg3*Ip9?EvRShW#b-yOS*mFTRa0C&HKDXn@^YJ3Kh?qx5T@R0}-T2bs zq~}71DdG0S%Unh3-q*4<10(BtNf`Qzy0=bN3+Rs-elCmXpTCHl7rlKn%raQ_oo<2O zd=3Sm2)dv;hv+sOs~n0TTHU;n%_a305$=PncB!wM5hy~I5FH^vAF3~631A6GgMlK9 zWcpB+U^%uGV5{RA2t*hp3b0}d@pkSmu&Fr;|@9Jxt3ut0N$~t z`b{ZS3Ebe_r^?O2>8u$JL{Z>WkR;YpyuLB- zZR~x~I_iTIXH@D*rF3anNE&_|>X*VaW%;d~^z-!thu$0oFUEQ}Vv9vpz?D)Q q-{jHnqe typeof(CmdPalPage), ModuleType.ColorPicker => typeof(ColorPickerPage), ModuleType.CropAndLock => typeof(CropAndLockPage), + ModuleType.CursorWrap => typeof(MouseUtilsPage), ModuleType.LightSwitch => typeof(LightSwitchPage), ModuleType.EnvironmentVariables => typeof(EnvironmentVariablesPage), ModuleType.FancyZones => typeof(FancyZonesPage), diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml index 498adf4803..fc7fb9c39f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml @@ -273,6 +273,44 @@ + + + + + + + + + + + + + + + + + + + + + + + - .GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), + SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index 5f22c7e4dc..4852d42e40 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -2695,12 +2695,47 @@ From there, simply click on one of the supported files in the File Explorer and Use a keyboard shortcut to highlight left and right mouse clicks. Mouse as in the hardware peripheral. + + + + + Enable CursorWrap + + CursorWrap + + + Wrap the mouse cursor between monitor edges + + + + + Activation shortcut + + + Hotkey to toggle cursor wrapping on/off + + + Set shortcut + + + + Disable wrapping while dragging + + + + + Auto-activate on startup + + + Automatically activate on utility startup + + Mouse Pointer Crosshairs Mouse as in the hardware peripheral. - + Draw crosshairs centered around the mouse pointer. Mouse as in the hardware peripheral. diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs index eae4f932d6..518b2a6fa4 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs @@ -29,7 +29,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private MousePointerCrosshairsSettings MousePointerCrosshairsSettingsConfig { get; set; } - public MouseUtilsViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository findMyMouseSettingsRepository, ISettingsRepository mouseHighlighterSettingsRepository, ISettingsRepository mouseJumpSettingsRepository, ISettingsRepository mousePointerCrosshairsSettingsRepository, Func ipcMSGCallBackFunc) + private CursorWrapSettings CursorWrapSettingsConfig { get; set; } + + public MouseUtilsViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository findMyMouseSettingsRepository, ISettingsRepository mouseHighlighterSettingsRepository, ISettingsRepository mouseJumpSettingsRepository, ISettingsRepository mousePointerCrosshairsSettingsRepository, ISettingsRepository cursorWrapSettingsRepository, Func ipcMSGCallBackFunc) { SettingsUtils = settingsUtils; @@ -103,6 +105,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _mousePointerCrosshairsOrientation = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsOrientation.Value; _mousePointerCrosshairsAutoActivate = MousePointerCrosshairsSettingsConfig.Properties.AutoActivate.Value; + ArgumentNullException.ThrowIfNull(cursorWrapSettingsRepository); + + CursorWrapSettingsConfig = cursorWrapSettingsRepository.SettingsConfig; + _cursorWrapAutoActivate = CursorWrapSettingsConfig.Properties.AutoActivate.Value; + + // Null-safe access in case property wasn't upgraded yet - default to TRUE + _cursorWrapDisableWrapDuringDrag = CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag?.Value ?? true; + int isEnabled = 0; Utilities.NativeMethods.SystemParametersInfo(Utilities.NativeMethods.SPI_GETCLIENTAREAANIMATION, 0, ref isEnabled, 0); @@ -144,13 +154,25 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) { // Get the enabled state from GPO. - _mousePointerCrosshairsEnabledStateIsGPOConfigured = true; + _mousePointerCrosshairsEnabledStateGPOConfigured = true; _isMousePointerCrosshairsEnabled = _mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; } else { _isMousePointerCrosshairsEnabled = GeneralSettingsConfig.Enabled.MousePointerCrosshairs; } + + _cursorWrapEnabledGpoRuleConfiguration = GPOWrapper.GetConfiguredCursorWrapEnabledValue(); + if (_cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) + { + // Get the enabled state from GPO. + _cursorWrapEnabledStateIsGPOConfigured = true; + _isCursorWrapEnabled = _cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + } + else + { + _isCursorWrapEnabled = GeneralSettingsConfig.Enabled.CursorWrap; + } } public override Dictionary GetAllHotkeySettings() @@ -163,6 +185,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels MousePointerCrosshairsActivationShortcut, GlidingCursorActivationShortcut], [MouseJumpSettings.ModuleName] = [MouseJumpActivationShortcut], + [CursorWrapSettings.ModuleName] = [CursorWrapActivationShortcut], }; return hotkeysDict; @@ -663,7 +686,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels get => _isMousePointerCrosshairsEnabled; set { - if (_mousePointerCrosshairsEnabledStateIsGPOConfigured) + if (_mousePointerCrosshairsEnabledStateGPOConfigured) { // If it's GPO configured, shouldn't be able to change this state. return; @@ -686,7 +709,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public bool IsMousePointerCrosshairsEnabledGpoConfigured { - get => _mousePointerCrosshairsEnabledStateIsGPOConfigured; + get => _mousePointerCrosshairsEnabledStateGPOConfigured; } public HotkeySettings MousePointerCrosshairsActivationShortcut @@ -959,6 +982,110 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels SettingsUtils.SaveSettings(MousePointerCrosshairsSettingsConfig.ToJsonString(), MousePointerCrosshairsSettings.ModuleName); } + public bool IsCursorWrapEnabled + { + get => _isCursorWrapEnabled; + set + { + if (_cursorWrapEnabledStateIsGPOConfigured) + { + // If it's GPO configured, shouldn't be able to change this state. + return; + } + + if (_isCursorWrapEnabled != value) + { + _isCursorWrapEnabled = value; + + GeneralSettingsConfig.Enabled.CursorWrap = value; + OnPropertyChanged(nameof(IsCursorWrapEnabled)); + + OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(GeneralSettingsConfig); + SendConfigMSG(outgoing.ToString()); + + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool IsCursorWrapEnabledGpoConfigured + { + get => _cursorWrapEnabledStateIsGPOConfigured; + } + + public HotkeySettings CursorWrapActivationShortcut + { + get + { + return CursorWrapSettingsConfig.Properties.ActivationShortcut; + } + + set + { + if (CursorWrapSettingsConfig.Properties.ActivationShortcut != value) + { + CursorWrapSettingsConfig.Properties.ActivationShortcut = value ?? CursorWrapSettingsConfig.Properties.DefaultActivationShortcut; + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool CursorWrapAutoActivate + { + get + { + return _cursorWrapAutoActivate; + } + + set + { + if (value != _cursorWrapAutoActivate) + { + _cursorWrapAutoActivate = value; + CursorWrapSettingsConfig.Properties.AutoActivate.Value = value; + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool CursorWrapDisableWrapDuringDrag + { + get + { + return _cursorWrapDisableWrapDuringDrag; + } + + set + { + if (value != _cursorWrapDisableWrapDuringDrag) + { + _cursorWrapDisableWrapDuringDrag = value; + + // Ensure the property exists before setting value + if (CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag == null) + { + CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag = new BoolProperty(value); + } + else + { + CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag.Value = value; + } + + NotifyCursorWrapPropertyChanged(); + } + } + } + + public void NotifyCursorWrapPropertyChanged([CallerMemberName] string propertyName = null) + { + OnPropertyChanged(propertyName); + + SndCursorWrapSettings outsettings = new SndCursorWrapSettings(CursorWrapSettingsConfig); + SndModuleSettings ipcMessage = new SndModuleSettings(outsettings); + SendConfigMSG(ipcMessage.ToJsonString()); + SettingsUtils.SaveSettings(CursorWrapSettingsConfig.ToJsonString(), CursorWrapSettings.ModuleName); + } + public void RefreshEnabledState() { InitializeEnabledValues(); @@ -966,6 +1093,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(IsMouseHighlighterEnabled)); OnPropertyChanged(nameof(IsMouseJumpEnabled)); OnPropertyChanged(nameof(IsMousePointerCrosshairsEnabled)); + OnPropertyChanged(nameof(IsCursorWrapEnabled)); } private Func SendConfigMSG { get; } @@ -999,7 +1127,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _highlighterAutoActivate; private GpoRuleConfigured _mousePointerCrosshairsEnabledGpoRuleConfiguration; - private bool _mousePointerCrosshairsEnabledStateIsGPOConfigured; + private bool _mousePointerCrosshairsEnabledStateGPOConfigured; private bool _isMousePointerCrosshairsEnabled; private string _mousePointerCrosshairsColor; private int _mousePointerCrosshairsOpacity; @@ -1013,5 +1141,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private int _mousePointerCrosshairsOrientation; private bool _mousePointerCrosshairsAutoActivate; private bool _isAnimationEnabledBySystem; + + private GpoRuleConfigured _cursorWrapEnabledGpoRuleConfiguration; + private bool _cursorWrapEnabledStateIsGPOConfigured; + private bool _isCursorWrapEnabled; + private bool _cursorWrapAutoActivate; + private bool _cursorWrapDisableWrapDuringDrag; // Will be initialized in constructor from settings } } From b5b73618558dd79119617937dee5ca193539430d Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Wed, 5 Nov 2025 12:26:55 +0000 Subject: [PATCH 007/100] [General] Include high-volume bugs in Issue Template header (#43134) ## Summary of the Pull Request Adds references to 3 bugs which are logged by those who are unfamiliar with the issue search function. It is hoped that this will help ease the amount of triaging. ## PR Checklist - [ ] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed - Checked the YML preview on my own fork. --- .github/ISSUE_TEMPLATE/bug_report.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f9389b8d91..1a85de1e06 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,6 +7,13 @@ body: - type: markdown attributes: value: Please make sure to [search for existing issues](https://github.com/microsoft/PowerToys/issues) before filing a new one! +- type: markdown + attributes: + value: | + We are aware of the following high-volume issues and are actively working on them. Please check if your issue is one of these before filing a new bug report: + * **PowerToys Run crash related to "Desktop composition is disabled"**: This may appear as `COMException: 0x80263001`. For more details, see issue [#31226](https://github.com/microsoft/PowerToys/issues/31226). + * **PowerToys Run crash with `COMException (0xD0000701)`**: For more details, see issue [#30769](https://github.com/microsoft/PowerToys/issues/30769). + * **PowerToys Run crash with a "Cyclic reference" error**: This `System.InvalidOperationException` is detailed in issue [#36451](https://github.com/microsoft/PowerToys/issues/36451). - id: version type: input attributes: From b7b6ae64857915d2c3e6c92e945ea8979d9adb17 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Wed, 5 Nov 2025 14:23:17 +0100 Subject: [PATCH 008/100] Tweaking the focus state for AP (#43306) Adding the AI underline to the inputbox image --- .../AdvancedPasteXAML/Controls/PromptBox.xaml | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml index 4450f7fdb1..9a6b8d04c9 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml @@ -14,28 +14,13 @@ mc:Ignorable="d"> - - - #65C8F2 - - - - - - - - #005FB8 - - - - - - - - #48B1E9 - - - + + + + + + + 44