diff --git a/.pipelines/ESRPSigning_advancedpaste_msix_content.json b/.pipelines/ESRPSigning_advancedpaste_msix_content.json new file mode 100644 index 000000000000..6522fc56b367 --- /dev/null +++ b/.pipelines/ESRPSigning_advancedpaste_msix_content.json @@ -0,0 +1,51 @@ +{ + "Version": "1.0.0", + "UseMinimatch": false, + "SignBatches": [ + { + "MatchedPath": [ + "*.dll", + "*.exe" + ], + "SigningInfo": { + "Operations": [ + { + "KeyCode": "CP-230012", + "OperationSetCode": "SigntoolSign", + "Parameters": [ + { + "parameterName": "OpusName", + "parameterValue": "Microsoft" + }, + { + "parameterName": "OpusInfo", + "parameterValue": "http://www.microsoft.com" + }, + { + "parameterName": "FileDigest", + "parameterValue": "/fd \"SHA256\"" + }, + { + "parameterName": "PageHash", + "parameterValue": "/NPH" + }, + { + "parameterName": "TimeStamp", + "parameterValue": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" + } + ], + "ToolName": "sign", + "ToolVersion": "1.0" + }, + { + "KeyCode": "CP-230012", + "OperationSetCode": "SigntoolVerify", + "Parameters": [], + "ToolName": "sign", + "ToolVersion": "1.0" + } + ] + } + } + ] +} diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index 09d65893d717..f264f5a1b270 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -49,8 +49,7 @@ "PowerToys.PowerOCR.exe", "PowerToys.AdvancedPasteModuleInterface.dll", - "WinUI3Apps\\PowerToys.AdvancedPaste.exe", - "WinUI3Apps\\PowerToys.AdvancedPaste.dll", + "WinUI3Apps\\AdvancedPaste\\*PowerToys.AdvancedPaste*.msix", "PowerToys.AwakeModuleInterface.dll", "PowerToys.Awake.exe", diff --git a/.pipelines/v2/release.yml b/.pipelines/v2/release.yml index d3adc45f047f..c6016c136433 100644 --- a/.pipelines/v2/release.yml +++ b/.pipelines/v2/release.yml @@ -102,7 +102,7 @@ extends: useManagedIdentity: $(SigningUseManagedIdentity) clientId: $(SigningOriginalClientId) # Have msbuild use the release nuget config profile - additionalBuildOptions: /p:RestoreConfigFile="$(Build.SourcesDirectory)\.pipelines\release-nuget.config" /p:EnableCmdPalAOT=true + additionalBuildOptions: /p:RestoreConfigFile="$(Build.SourcesDirectory)\.pipelines\release-nuget.config" /p:EnableCmdPalAOT=true /p:PhiSilicaLafToken=$(PhiSilicaLafToken) /p:PhiSilicaLafAttestation="$(PhiSilicaLafAttestation)" beforeBuildSteps: # Sets versions for all PowerToy created DLLs - pwsh: |- diff --git a/.pipelines/v2/templates/job-build-project.yml b/.pipelines/v2/templates/job-build-project.yml index 1aeb799a65f2..45a95897734b 100644 --- a/.pipelines/v2/templates/job-build-project.yml +++ b/.pipelines/v2/templates/job-build-project.yml @@ -269,6 +269,9 @@ jobs: - template: .\steps-setup-versioning.yml parameters: directory: $(build.sourcesdirectory)\src\modules\cmdpal + - template: .\steps-setup-versioning.yml + parameters: + directory: $(build.sourcesdirectory)\src\modules\AdvancedPaste @@ -484,6 +487,46 @@ jobs: **\*UnitTest*.dll !**\obj\** + - pwsh: |- + $SearchRoot = "$(Build.SourcesDirectory)/$(BuildPlatform)/$(BuildConfiguration)" + Write-Host "Searching for AdvancedPaste MSIX in: $SearchRoot" + if (-not (Test-Path $SearchRoot)) { + Write-Warning "Search root does not exist: $SearchRoot" + $SearchRoot = "$(Build.SourcesDirectory)" + Write-Host "Falling back to: $SearchRoot" + } + # Log the AdvancedPaste output folder structure for diagnostics + $apDir = Join-Path $SearchRoot "WinUI3Apps/AdvancedPaste" + if (Test-Path $apDir) { + Write-Host "AdvancedPaste output folder contents:" + Get-ChildItem $apDir -Recurse -Include "*.msix","*.appx" | ForEach-Object { Write-Host " - $($_.FullName)" } + } else { + Write-Host "AdvancedPaste output folder not found at: $apDir" + } + $Packages = Get-ChildItem -Path $SearchRoot -Recurse -Filter "PowerToys.AdvancedPaste*.msix" -ErrorAction SilentlyContinue + Write-Host "Found $($Packages.Count) AdvancedPaste MSIX package(s):" + foreach ($pkg in $Packages) { + Write-Host " - $($pkg.FullName)" + } + + if ($Packages.Count -gt 0) { + $PlatformPackage = $Packages | Where-Object { $_.Name -match "PowerToys\.AdvancedPaste.*_(x64|arm64)\.msix$" } | Select-Object -First 1 + if ($PlatformPackage) { + $Package = $PlatformPackage + Write-Host "Using platform-specific package: $($Package.FullName)" + } else { + $Package = $Packages | Select-Object -First 1 + Write-Host "Using first available package: $($Package.FullName)" + } + + $PackageFilename = $Package.FullName + Write-Host "##vso[task.setvariable variable=AdvancedPastePackagePath]${PackageFilename}" + } else { + Write-Error "No AdvancedPaste MSIX packages found! The build may have failed to generate the MSIX." + exit 1 + } + displayName: Locate the AdvancedPaste MSIX + - pwsh: |- $Packages = Get-ChildItem -Recurse -Filter "Microsoft.CmdPal.UI_*.msix" Write-Host "Found $($Packages.Count) CmdPal MSIX package(s):" @@ -533,6 +576,29 @@ jobs: Remove-Item -Force -Recurse "$(JobOutputDirectory)/_appx" -ErrorAction:Ignore displayName: Re-pack the new CmdPal package after signing + - pwsh: |- + & "$(MakeAppxPath)" unpack /p "$(AdvancedPastePackagePath)" /d "$(JobOutputDirectory)/AdvancedPastePackageContents" + displayName: Unpack the AdvancedPaste MSIX for signing + + - template: steps-esrp-signing.yml + parameters: + displayName: Sign AdvancedPaste MSIX content + signingIdentity: ${{ parameters.signingIdentity }} + inputs: + FolderPath: '$(JobOutputDirectory)/AdvancedPastePackageContents' + signType: batchSigning + batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_advancedpaste_msix_content.json' + ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' + + - pwsh: |- + $outDir = New-Item -Type Directory "$(JobOutputDirectory)/_appx" -ErrorAction:Ignore + $PackageFilename = Join-Path $outDir.FullName (Split-Path -Leaf "$(AdvancedPastePackagePath)") + & "$(MakeAppxPath)" pack /h SHA256 /o /p $PackageFilename /d "$(JobOutputDirectory)/AdvancedPastePackageContents" + Copy-Item -Force $PackageFilename "$(AdvancedPastePackagePath)" + Remove-Item -Force -Recurse "$(JobOutputDirectory)/AdvancedPastePackageContents" -ErrorAction:Ignore + Remove-Item -Force -Recurse "$(JobOutputDirectory)/_appx" -ErrorAction:Ignore + displayName: Re-pack the new AdvancedPaste package after signing + - pwsh: | $testsPath = "$(Build.SourcesDirectory)/$(BuildPlatform)/$(BuildConfiguration)/tests" if (Test-Path $testsPath) { @@ -565,6 +631,10 @@ jobs: Copy-Item -Verbose -Force "$(CmdPalPackagePath)" "$(JobOutputDirectory)" displayName: Stage the final CmdPal package + - pwsh: |- + Copy-Item -Verbose -Force "$(AdvancedPastePackagePath)" "$(JobOutputDirectory)" + displayName: Stage the final AdvancedPaste package + - template: steps-build-installer-vnext.yml diff --git a/.vscode/launch.json b/.vscode/launch.json index 940ff302deca..8b66248d748f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -43,7 +43,7 @@ "name": "Run AdvancedPaste (managed, no build, ARCH configurable)", "type": "coreclr", "request": "launch", - "program": "${workspaceFolder}\\${input:arch}\\Debug\\WinUI3Apps\\PowerToys.AdvancedPaste.exe", + "program": "${workspaceFolder}\\${input:arch}\\Debug\\WinUI3Apps\\AdvancedPaste\\PowerToys.AdvancedPaste.exe", "args": [], "cwd": "${workspaceFolder}", "env": {}, diff --git a/Directory.Build.props b/Directory.Build.props index aa2d3fc60081..2a91f0286a89 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,7 @@ $(MSBuildThisFileDirectory) + Copyright (C) Microsoft Corporation. All rights reserved. Copyright (C) Microsoft Corporation. All rights reserved. diff --git a/Directory.Packages.props b/Directory.Packages.props index 6ce7261dfaa2..448262feb78d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -78,10 +78,10 @@ - - - - + + + + diff --git a/doc/devdocs/modules/advancedpaste.md b/doc/devdocs/modules/advancedpaste.md index b2ab24443245..fec7b4eb7acb 100644 --- a/doc/devdocs/modules/advancedpaste.md +++ b/doc/devdocs/modules/advancedpaste.md @@ -33,7 +33,53 @@ See the `ExecutePasteFormatAsync(PasteFormat, PasteActionSource)` method in `Opt ## Debugging -TODO: Add debugging information +Advanced Paste is packaged as a self-contained MSIX with its own identity (`Microsoft.PowerToys.AdvancedPaste`). This gives it native package identity for Windows AI APIs (Phi Silica) and clean `ms-appx:///` resource resolution without workarounds. + +The MSIX is output to `WinUI3Apps/AdvancedPaste/` and registered by the module interface at runtime (or by the installer on release builds). + +### Running and attaching the debugger + +1. Set the **Runner** project (`src/runner`) as the startup project in Visual Studio. +2. Launch the Runner (F5). This starts the PowerToys tray icon and loads all module interfaces. +3. Open Settings (right-click tray icon → Settings) and enable the **Advanced Paste** module if it isn't already. The module launches `PowerToys.AdvancedPaste.exe` in the background immediately. +4. In Visual Studio, go to **Debug → Attach to Process** (`Ctrl+Alt+P`) and attach to `PowerToys.AdvancedPaste.exe` (select **Managed (.NET Core)** debugger). + +Alternatively, use the VS Code launch configuration **"Run AdvancedPaste"** from [.vscode/launch.json](/.vscode/launch.json) to launch the exe directly — but note that without the Runner, IPC and hotkeys won't work. + +### MSIX package identity + +Advanced Paste uses the Windows AI APIs (Phi Silica / `Microsoft.Windows.AI.Text.LanguageModel`) which require **package identity** at runtime. The app is packaged as a self-contained MSIX (following the same pattern as Command Palette). + +#### How it works + +- **Build**: The csproj has `EnableMsixTooling=true` and `GenerateAppxPackageOnBuild=true` (CI builds). This produces an MSIX in `AppPackages/`. +- **Local dev**: VS registers the package automatically via `Add-AppxPackage -Register AppxManifest.xml` when you build. +- **Installer**: The WiX installer deploys the MSIX file and a custom action (`InstallAdvancedPastePackageCA`) registers it via `PackageManager`. + +#### Local development setup + +For local debug builds, Visual Studio handles MSIX registration automatically. Select the **"PowerToys.AdvancedPaste (Package)"** launch profile in the debug dropdown. + +To manually register: +```powershell +Add-AppxPackage -Register "ARM64\Debug\WinUI3Apps\AdvancedPaste\AppxManifest.xml" +``` + +Verify: +```powershell +$pkg = Get-AppxPackage -Name "*AdvancedPaste*" +$pkg.Name # Microsoft.PowerToys.AdvancedPaste.Dev +$pkg.IsDevelopmentMode # True +``` + +### How Settings UI checks Phi Silica availability + +Settings UI does not have MSIX package identity. To check whether Phi Silica is available, it queries the running Advanced Paste process via a named pipe (`powertoys_advancedpaste_phi_status`). + +Advanced Paste checks LAF + `GetReadyState()` once on startup (with MSIX identity), caches the result, and serves it to any client that connects. Settings connects with a 5-second timeout and reads one of: +- `Available` — model is ready +- `NotReady` — model needs download via Windows Update +- `NotSupported` — not a Copilot+ PC, API unavailable, or Advanced Paste not running ## Settings diff --git a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp index 39786a16d886..6b3b2168261a 100644 --- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp @@ -1415,6 +1415,80 @@ UINT __stdcall UnRegisterCmdPalPackageCA(MSIHANDLE hInstall) return WcaFinalize(er); } +UINT __stdcall InstallAdvancedPastePackageCA(MSIHANDLE hInstall) +{ + using namespace winrt::Windows::Foundation; + using namespace winrt::Windows::Management::Deployment; + + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + std::wstring installationFolder; + + hr = WcaInitialize(hInstall, "InstallAdvancedPastePackage"); + hr = getInstallFolder(hInstall, installationFolder); + + try + { + auto msix = package::FindMsixFile(installationFolder + L"\\WinUI3Apps\\AdvancedPaste\\", false); + auto dependencies = package::FindMsixFile(installationFolder + L"\\WinUI3Apps\\AdvancedPaste\\Dependencies\\", true); + + if (!msix.empty()) + { + auto msixPath = msix[0]; + + if (!package::RegisterPackage(msixPath, dependencies)) + { + Logger::error(L"Failed to install AdvancedPaste package"); + er = ERROR_INSTALL_FAILURE; + } + } + } + catch (std::exception &e) + { + std::string errorMessage{"Exception thrown while trying to install AdvancedPaste package: "}; + errorMessage += e.what(); + Logger::error(errorMessage); + + er = ERROR_INSTALL_FAILURE; + } + + er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; + return WcaFinalize(er); +} + +UINT __stdcall UnRegisterAdvancedPastePackageCA(MSIHANDLE hInstall) +{ + using namespace winrt::Windows::Foundation; + using namespace winrt::Windows::Management::Deployment; + + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + + hr = WcaInitialize(hInstall, "UnRegisterAdvancedPastePackageCA"); + + try + { + std::wstring packageToRemoveDisplayName{L"PowerToys.AdvancedPaste"}; + + if (!package::UnRegisterPackage(packageToRemoveDisplayName)) + { + Logger::error(L"Failed to unregister package: " + packageToRemoveDisplayName); + er = ERROR_INSTALL_FAILURE; + } + } + catch (std::exception &e) + { + std::string errorMessage{"Exception thrown while trying to unregister the AdvancedPaste package: "}; + errorMessage += e.what(); + Logger::error(errorMessage); + + er = ERROR_INSTALL_FAILURE; + } + + er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; + return WcaFinalize(er); +} + UINT __stdcall UnRegisterContextMenuPackagesCA(MSIHANDLE hInstall) { diff --git a/installer/PowerToysSetupCustomActionsVNext/CustomAction.def b/installer/PowerToysSetupCustomActionsVNext/CustomAction.def index 86efe34aa6b9..8675f3513875 100644 --- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.def +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.def @@ -20,8 +20,10 @@ EXPORTS InstallEmbeddedMSIXCA InstallDSCModuleCA InstallCmdPalPackageCA + InstallAdvancedPastePackageCA UnApplyModulesRegistryChangeSetsCA UnRegisterCmdPalPackageCA + UnRegisterAdvancedPastePackageCA UnRegisterContextMenuPackagesCA UninstallEmbeddedMSIXCA UninstallDSCModuleCA diff --git a/installer/PowerToysSetupVNext/AdvancedPaste.wxs b/installer/PowerToysSetupVNext/AdvancedPaste.wxs index f7a01b29ab5e..419ba2192eea 100644 --- a/installer/PowerToysSetupVNext/AdvancedPaste.wxs +++ b/installer/PowerToysSetupVNext/AdvancedPaste.wxs @@ -1,27 +1,65 @@ - - - - - + - - + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + + + + + + + + + - diff --git a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj index a7a9744e878b..5570320d7d31 100644 --- a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj +++ b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj @@ -4,7 +4,7 @@ false - Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\x64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion) @@ -17,7 +17,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil - Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\ARM64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion) + Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\ARM64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion);AdvancedPasteVersion=$(AdvancedPasteVersion) IF NOT DEFINED IsPipeline ( call "$([MSBuild]::GetVsInstallRoot())\Common7\Tools\VsDevCmd.bat" -arch=arm64 -host_arch=amd64 -winsdk=10.0.19041.0 -vcvars_ver=$(VCToolsVersion) SET PTRoot=$(SolutionDir)\.. diff --git a/installer/PowerToysSetupVNext/Product.wxs b/installer/PowerToysSetupVNext/Product.wxs index 584d61c4497f..1be22d6a0e3f 100644 --- a/installer/PowerToysSetupVNext/Product.wxs +++ b/installer/PowerToysSetupVNext/Product.wxs @@ -109,6 +109,7 @@ + @@ -124,6 +125,7 @@ + @@ -144,6 +146,7 @@ + @@ -170,6 +173,8 @@ + + @@ -263,6 +268,10 @@ + + + + diff --git a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 index 001e0210194d..8995344b1855 100644 --- a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 +++ b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 @@ -139,8 +139,7 @@ Generate-FileList -fileDepsJson "" -fileListName WinUI3ApplicationsFiles -wxsFil Generate-FileComponents -fileListName "WinUI3ApplicationsFiles" -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs #AdvancedPaste -Generate-FileList -fileDepsJson "" -fileListName AdvancedPasteAssetsFiles -wxsFilePath $PSScriptRoot\AdvancedPaste.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\AdvancedPaste" -Generate-FileComponents -fileListName "AdvancedPasteAssetsFiles" -wxsFilePath $PSScriptRoot\AdvancedPaste.wxs +# AdvancedPaste ships as MSIX package (like CmdPal), not loose files. #AwakeFiles Generate-FileList -fileDepsJson "" -fileListName AwakeImagesFiles -wxsFilePath $PSScriptRoot\Awake.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Awake" diff --git a/src/CmdPalVersion.props b/src/CmdPalVersion.props index c3c5d7b6088d..69f19e3b9802 100644 --- a/src/CmdPalVersion.props +++ b/src/CmdPalVersion.props @@ -6,6 +6,9 @@ 0.0.1.0 + $(XES_APPXMANIFESTVERSION) + 0.0.1.0 + Local diff --git a/src/PackageIdentity/AppxManifest.xml b/src/PackageIdentity/AppxManifest.xml index 502cc33ff00d..31acebc7c8ee 100644 --- a/src/PackageIdentity/AppxManifest.xml +++ b/src/PackageIdentity/AppxManifest.xml @@ -48,16 +48,6 @@ AppListEntry="none"> - - - - + + + + 6mRhg/rVL3rZ+N1LtLghtA== + 8wekyb3d8bbwe + + diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs index 4446e24dde29..c02bb5eb8c22 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs @@ -55,6 +55,22 @@ public IntegrationTestUserSettings() public IReadOnlyList AdditionalActions => _additionalActions; + public string FixSpellingAndGrammarPrompt => string.Empty; + + public string FixSpellingAndGrammarSystemPrompt => string.Empty; + + public string FixSpellingAndGrammarProviderId => string.Empty; + + public bool FixSpellingAndGrammarCoachingEnabled => false; + + public bool FixSpellingAndGrammarCoachingShortcutSet => false; + + public string FixSpellingAndGrammarCoachingPrompt => string.Empty; + + public string FixSpellingAndGrammarCoachingSystemPrompt => string.Empty; + + public string FixSpellingAndGrammarCoachingProviderId => string.Empty; + public PasteAIConfiguration PasteAIConfiguration => _configuration; public event EventHandler Changed; diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/CustomActionKernelQueryCacheServiceTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/CustomActionKernelQueryCacheServiceTests.cs index b93fda488443..1994da0af086 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/CustomActionKernelQueryCacheServiceTests.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/CustomActionKernelQueryCacheServiceTests.cs @@ -41,7 +41,7 @@ public void TestInitialize() UpdateUserActions([], []); _fileSystem = new(); - _cacheService = new(_userSettings.Object, _fileSystem); + _cacheService = new(_userSettings.Object, _fileSystem, resourceId => resourceId); } [TestMethod] @@ -122,7 +122,7 @@ public async Task Test_Cache_Is_Persistent() await _cacheService.WriteAsync(JSONTestKey, TestValue); await _cacheService.WriteAsync(MarkdownTestKey, TestValue2); - _cacheService = new(_userSettings.Object, _fileSystem); // recreate using same mock file-system to simulate app restart + _cacheService = new(_userSettings.Object, _fileSystem, resourceId => resourceId); // recreate using same mock file-system to simulate app restart AssertAreEqual(TestValue, _cacheService.ReadOrNull(JSONTestKey)); AssertAreEqual(TestValue2, _cacheService.ReadOrNull(MarkdownTestKey)); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj index 090f3c75a7f5..63faecaa844f 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj @@ -1,30 +1,41 @@ + WinExe - $(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps + $(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\AdvancedPaste true Assets\AdvancedPaste\AdvancedPaste.ico app.manifest true false false - true - None + true true PowerToys.AdvancedPaste PowerToys AdvancedPaste AdvancedPaste true true - - PowerToys.AdvancedPaste.pri DISABLE_XAML_GENERATED_MAIN,TRACE + $(AdvancedPasteVersion) + + true + Never + PowerToys.AdvancedPaste + $(OutputPath)\AppPackages\PowerToys.AdvancedPaste_$(Version)_Test\ + + + + + + + PowerToys.GPOWrapper @@ -32,6 +43,25 @@ false + + + + $(ApplicationManifest.Replace("$(MSBuildProjectDirectory)\","")) + + + + + + + + $(IntermediateOutputPath)PhiSilicaLafCredentials.g.cs + + + + + + + @@ -66,9 +96,9 @@ + - @@ -76,11 +106,6 @@ - - - - - @@ -99,6 +124,10 @@ + + + + <_Parameter1>AdvancedPaste.UnitTests @@ -106,8 +135,6 @@ - - @@ -156,4 +183,5 @@ PreserveNewest + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs index 3fa940952ecd..4299219e9609 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs @@ -90,6 +90,10 @@ public App() viewModel = GetService(); UnhandledException += App_UnhandledException; + + // Start a background pipe server so Settings UI can query Phi Silica status. + // This runs with MSIX package identity, so LAF + GetReadyState() work. + StartPhiSilicaStatusServer(); } public MainWindow GetMainWindow() @@ -108,12 +112,111 @@ public static T GetService() return service; } + /// + /// Starts a background named pipe server that responds to Phi Silica status queries + /// from Settings UI. The pipe runs for the lifetime of the app. + /// + private static void StartPhiSilicaStatusServer() + { + Task.Run(async () => + { + // Check status once (with MSIX identity) and cache + string status; + try + { + PhiSilicaLafHelper.TryUnlock(); + var readyState = Microsoft.Windows.AI.Text.LanguageModel.GetReadyState(); + status = readyState switch + { + Microsoft.Windows.AI.AIFeatureReadyState.NotSupportedOnCurrentSystem => "NotSupported", + Microsoft.Windows.AI.AIFeatureReadyState.NotReady => "NotReady", + _ => "Available", + }; + } + catch + { + status = "NotSupported"; + } + + Logger.LogDebug($"Phi Silica status: {status}"); + + // Serve status to any client that connects + while (true) + { + try + { + using var server = new System.IO.Pipes.NamedPipeServerStream( + "powertoys_advancedpaste_phi_status", + System.IO.Pipes.PipeDirection.Out, + 1, + System.IO.Pipes.PipeTransmissionMode.Byte, + System.IO.Pipes.PipeOptions.Asynchronous); + + await server.WaitForConnectionAsync(); + + var bytes = System.Text.Encoding.UTF8.GetBytes(status); + await server.WriteAsync(bytes); + server.WaitForPipeDrain(); + } + catch (Exception ex) + { + Logger.LogError("Phi Silica status pipe error", ex); + await Task.Delay(1000); + } + } + }); + } + /// /// Invoked when the application is launched. /// /// Details about the launch request and process. protected override void OnLaunched(LaunchActivatedEventArgs args) { + // Try protocol activation args first (MSIX launch via x-advancedpaste:// URI) + try + { + var activatedArgs = Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent().GetActivatedEventArgs(); + Logger.LogDebug($"Activation kind: {activatedArgs?.Kind}"); + + if (activatedArgs?.Kind == Microsoft.Windows.AppLifecycle.ExtendedActivationKind.Protocol && + activatedArgs.Data is Windows.ApplicationModel.Activation.IProtocolActivatedEventArgs protocolArgs) + { + var uri = protocolArgs.Uri; + Logger.LogDebug($"Protocol URI: {uri}"); + + // Parse query parameters manually to avoid System.Web dependency + var pid = GetQueryParam(uri.Query, "pid"); + var pipeName = GetQueryParam(uri.Query, "pipe"); + + Logger.LogDebug($"Parsed pid={pid}, pipe={pipeName}"); + + if (int.TryParse(pid, out int powerToysRunnerPid)) + { + RunnerHelper.WaitForPowerToysRunner(powerToysRunnerPid, () => + { + _dispatcherQueue.TryEnqueue(() => + { + Dispose(); + Environment.Exit(0); + }); + }); + } + + if (!string.IsNullOrEmpty(pipeName)) + { + ProcessNamedPipe(pipeName); + } + + return; + } + } + catch (Exception ex) + { + Logger.LogError("Failed to process protocol activation args", ex); + } + + // Fallback: command-line args (direct exe launch) var cmdArgs = Environment.GetCommandLineArgs(); if (cmdArgs?.Length > 1) { @@ -138,11 +241,33 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) private void ProcessNamedPipe(string pipeName) { + Logger.LogDebug($"Connecting to named pipe: {pipeName}"); void OnMessage(string message) => _dispatcherQueue.TryEnqueue(async () => await OnNamedPipeMessage(message)); Task.Run(async () => await NamedPipeProcessor.ProcessNamedPipeAsync(pipeName, connectTimeout: TimeSpan.FromSeconds(10), OnMessage, CancellationToken.None)); } + private static string GetQueryParam(string query, string key) + { + if (string.IsNullOrEmpty(query)) + { + return null; + } + + // Remove leading '?' if present + var q = query.StartsWith('?') ? query.Substring(1) : query; + foreach (var part in q.Split('&')) + { + var kv = part.Split('=', 2); + if (kv.Length == 2 && kv[0].Equals(key, StringComparison.OrdinalIgnoreCase)) + { + return Uri.UnescapeDataString(kv[1]); + } + } + + return null; + } + private async Task OnNamedPipeMessage(string message) { var messageParts = message.Split(); @@ -188,14 +313,23 @@ private async Task OnAdvancedPasteAdditionalActionHotkey(string[] messageParts) } else { - if (!AdditionalActionIPCKeys.TryGetValue(messageParts[1], out PasteFormats pasteFormat)) + const string coachingSuffix = "-coaching"; + var actionKey = messageParts[1]; + bool forceCoaching = actionKey.EndsWith(coachingSuffix, StringComparison.OrdinalIgnoreCase); + + if (forceCoaching) + { + actionKey = actionKey[..^coachingSuffix.Length]; + } + + if (!AdditionalActionIPCKeys.TryGetValue(actionKey, out PasteFormats pasteFormat)) { Logger.LogWarning($"Unexpected additional action type {messageParts[1]}"); } else { await ShowWindow(); - await viewModel.ExecutePasteFormatAsync(pasteFormat, PasteActionSource.GlobalKeyboardShortcut); + await viewModel.ExecutePasteFormatAsync(pasteFormat, PasteActionSource.GlobalKeyboardShortcut, forceCoaching); } } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml index 6303564d9b92..254edd26c2a8 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml @@ -382,6 +382,7 @@ + - + + + + + + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs index d692263dc167..afa958faf83a 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs @@ -25,6 +25,22 @@ public interface IUserSettings public IReadOnlyList AdditionalActions { get; } + public string FixSpellingAndGrammarPrompt { get; } + + public string FixSpellingAndGrammarSystemPrompt { get; } + + public string FixSpellingAndGrammarProviderId { get; } + + public bool FixSpellingAndGrammarCoachingEnabled { get; } + + public bool FixSpellingAndGrammarCoachingShortcutSet { get; } + + public string FixSpellingAndGrammarCoachingPrompt { get; } + + public string FixSpellingAndGrammarCoachingSystemPrompt { get; } + + public string FixSpellingAndGrammarCoachingProviderId { get; } + public PasteAIConfiguration PasteAIConfiguration { get; } public event EventHandler Changed; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs index 08293d4be078..0074164242ff 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs @@ -157,8 +157,6 @@ internal struct PointInter { public int X; public int Y; - - public static explicit operator System.Windows.Point(PointInter point) => new System.Windows.Point(point.X, point.Y); } [DllImport("user32.dll")] diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ResourceLoaderInstance.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ResourceLoaderInstance.cs index 70085ee31420..6bc81aae5a70 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ResourceLoaderInstance.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ResourceLoaderInstance.cs @@ -11,7 +11,7 @@ internal static class ResourceLoaderInstance static ResourceLoaderInstance() { - ResourceLoader = new ResourceLoader("PowerToys.AdvancedPaste.pri"); + ResourceLoader = new ResourceLoader(); } } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs index 59f31f0e99c7..4c7622cf74b3 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs @@ -46,6 +46,22 @@ internal sealed partial class UserSettings : IUserSettings, IDisposable public IReadOnlyList CustomActions => _customActions; + public string FixSpellingAndGrammarPrompt { get; private set; } = string.Empty; + + public string FixSpellingAndGrammarSystemPrompt { get; private set; } = string.Empty; + + public string FixSpellingAndGrammarProviderId { get; private set; } = string.Empty; + + public bool FixSpellingAndGrammarCoachingEnabled { get; private set; } + + public bool FixSpellingAndGrammarCoachingShortcutSet { get; private set; } + + public string FixSpellingAndGrammarCoachingPrompt { get; private set; } = string.Empty; + + public string FixSpellingAndGrammarCoachingSystemPrompt { get; private set; } = string.Empty; + + public string FixSpellingAndGrammarCoachingProviderId { get; private set; } = string.Empty; + public PasteAIConfiguration PasteAIConfiguration { get; private set; } public UserSettings(IFileSystem fileSystem) @@ -113,10 +129,21 @@ void UpdateSettings() EnableClipboardPreview = properties.EnableClipboardPreview; PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration(); + var fixSpellingAction = properties.AdditionalActions.FixSpellingAndGrammar; + FixSpellingAndGrammarPrompt = fixSpellingAction.Prompt ?? string.Empty; + FixSpellingAndGrammarSystemPrompt = fixSpellingAction.SystemPrompt ?? string.Empty; + FixSpellingAndGrammarProviderId = fixSpellingAction.ProviderId ?? string.Empty; + FixSpellingAndGrammarCoachingEnabled = fixSpellingAction.CoachingEnabled; + FixSpellingAndGrammarCoachingShortcutSet = fixSpellingAction.CoachingShortcut?.Code > 0; + FixSpellingAndGrammarCoachingPrompt = fixSpellingAction.CoachingPrompt ?? string.Empty; + FixSpellingAndGrammarCoachingSystemPrompt = fixSpellingAction.CoachingSystemPrompt ?? string.Empty; + FixSpellingAndGrammarCoachingProviderId = fixSpellingAction.CoachingProviderId ?? string.Empty; + var sourceAdditionalActions = properties.AdditionalActions; (PasteFormats Format, IAdvancedPasteAction[] Actions)[] additionalActionFormats = [ (PasteFormats.ImageToText, [sourceAdditionalActions.ImageToText]), + (PasteFormats.FixSpellingAndGrammar, [sourceAdditionalActions.FixSpellingAndGrammar]), (PasteFormats.PasteAsTxtFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsTxtFile]), (PasteFormats.PasteAsPngFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsPngFile]), (PasteFormats.PasteAsHtmlFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsHtmlFile]), diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormat.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormat.cs index e1df90897e35..da956ffeed5c 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormat.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormat.cs @@ -24,20 +24,22 @@ private PasteFormat(PasteFormats format, ClipboardFormat clipboardFormats, bool IsEnabled = SupportsClipboardFormats(clipboardFormats) && (isAIServiceEnabled || !Metadata.RequiresAIService); } - public static PasteFormat CreateStandardFormat(PasteFormats format, ClipboardFormat clipboardFormats, bool isAIServiceEnabled, Func resourceLoader) => + public static PasteFormat CreateStandardFormat(PasteFormats format, ClipboardFormat clipboardFormats, bool isAIServiceEnabled, Func resourceLoader, string providerId = null) => new(format, clipboardFormats, isAIServiceEnabled) { Name = MetadataDict[format].ResourceId == null ? string.Empty : resourceLoader(MetadataDict[format].ResourceId), Prompt = string.Empty, IsSavedQuery = false, + ProviderId = providerId ?? string.Empty, }; - public static PasteFormat CreateCustomAIFormat(PasteFormats format, string name, string prompt, bool isSavedQuery, ClipboardFormat clipboardFormats, bool isAIServiceEnabled) => + public static PasteFormat CreateCustomAIFormat(PasteFormats format, string name, string prompt, bool isSavedQuery, ClipboardFormat clipboardFormats, bool isAIServiceEnabled, string providerId = null) => new(format, clipboardFormats, isAIServiceEnabled) { Name = name, Prompt = prompt, IsSavedQuery = isSavedQuery, + ProviderId = providerId ?? string.Empty, }; public PasteFormatMetadataAttribute Metadata => MetadataDict[Format]; @@ -50,6 +52,8 @@ public static PasteFormat CreateCustomAIFormat(PasteFormats format, string name, public string Prompt { get; private init; } + public string ProviderId { get; private init; } = string.Empty; + public bool IsSavedQuery { get; private init; } public bool IsEnabled { get; private init; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs index 1479912e66f7..8b06128798f4 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs @@ -38,6 +38,17 @@ public enum PasteFormats KernelFunctionDescription = "Takes clipboard text and formats it as JSON text.")] Json, + [PasteFormatMetadata( + IsCoreAction = false, + ResourceId = "FixSpellingAndGrammar", + IconGlyph = "\uE8E2", + RequiresAIService = true, + CanPreview = true, + SupportedClipboardFormats = ClipboardFormat.Text, + IPCKey = AdvancedPasteAdditionalActions.PropertyNames.FixSpellingAndGrammar, + KernelFunctionDescription = "Fixes all spelling and grammar errors in the clipboard text and returns the corrected version.")] + FixSpellingAndGrammar, + [PasteFormatMetadata( IsCoreAction = false, ResourceId = "ImageToText", diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Package-Dev.appxmanifest b/src/modules/AdvancedPaste/AdvancedPaste/Package-Dev.appxmanifest new file mode 100644 index 000000000000..aafe865166d3 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Package-Dev.appxmanifest @@ -0,0 +1,63 @@ + + + + + + + + + + PowerToys Advanced Paste (Dev) + A Lone Developer + Assets\AdvancedPaste\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + PowerToys Advanced Paste (Dev) + + + + + + + + + + + + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Package.appxmanifest b/src/modules/AdvancedPaste/AdvancedPaste/Package.appxmanifest new file mode 100644 index 000000000000..287e0788e9c1 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Package.appxmanifest @@ -0,0 +1,63 @@ + + + + + + + + + + PowerToys Advanced Paste + Microsoft Corporation + Assets\AdvancedPaste\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + PowerToys Advanced Paste + + + + + + + + + + + + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/PhiSilicaLafHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/PhiSilicaLafHelper.cs new file mode 100644 index 000000000000..25714688ea3f --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/PhiSilicaLafHelper.cs @@ -0,0 +1,56 @@ +// 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.Diagnostics; +using Windows.ApplicationModel; + +namespace AdvancedPaste; + +internal static class PhiSilicaLafHelper +{ + private const string FeatureId = "com.microsoft.windows.ai.languagemodel"; + + private static readonly object _lock = new(); + private static bool _attempted; + private static bool _unlocked; + + public static bool TryUnlock() + { + if (_attempted) + { + return _unlocked; + } + + lock (_lock) + { + if (_attempted) + { + return _unlocked; + } + + _attempted = true; + + try + { + var access = LimitedAccessFeatures.TryUnlockFeature( + FeatureId, + PhiSilicaLafCredentials.Token, + PhiSilicaLafCredentials.Attestation + " has registered their use of com.microsoft.windows.ai.languagemodel with Microsoft and agrees to the terms of use."); + + _unlocked = access.Status == LimitedAccessFeatureStatus.Available + || access.Status == LimitedAccessFeatureStatus.AvailableWithoutToken; + + Debug.WriteLine($"Phi Silica LAF unlock status: {access.Status}"); + } + catch (Exception ex) + { + Debug.WriteLine($"Phi Silica LAF unlock failed: {ex.Message}"); + _unlocked = false; + } + + return _unlocked; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Program.cs b/src/modules/AdvancedPaste/AdvancedPaste/Program.cs index ef089f9511ff..ab5e91f450b2 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Program.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Program.cs @@ -14,7 +14,7 @@ namespace AdvancedPaste public static class Program { [STAThread] - public static void Main(string[] args) + public static int Main(string[] args) { Logger.InitializeLogger("\\AdvancedPaste\\Logs"); @@ -23,7 +23,7 @@ public static void Main(string[] args) if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredAdvancedPasteEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled) { Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator."); - return; + return 1; } var instanceKey = AppInstance.FindOrRegisterForKey("PowerToys_AdvancedPaste_Instance"); @@ -41,6 +41,8 @@ public static void Main(string[] args) { Logger.LogWarning("Another instance of AdvancedPasteUI is running. Exiting."); } + + return 0; } } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActionKernelQueryCacheService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActionKernelQueryCacheService.cs index f7d888cf10f5..3c06bb56155f 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActionKernelQueryCacheService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActionKernelQueryCacheService.cs @@ -33,14 +33,21 @@ public sealed class CustomActionKernelQueryCacheService : IKernelQueryCacheServi private readonly IUserSettings _userSettings; private readonly IFileSystem _fileSystem; private readonly SettingsUtils _settingsUtil; + private readonly Func _getLocalizedString; private static string Version => Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString() ?? string.Empty; public CustomActionKernelQueryCacheService(IUserSettings userSettings, IFileSystem fileSystem) + : this(userSettings, fileSystem, ResourceLoaderInstance.ResourceLoader.GetString) + { + } + + internal CustomActionKernelQueryCacheService(IUserSettings userSettings, IFileSystem fileSystem, Func getLocalizedString) { _userSettings = userSettings; _fileSystem = fileSystem; _settingsUtil = new SettingsUtils(fileSystem); + _getLocalizedString = getLocalizedString; _userSettings.Changed += OnUserSettingsChanged; @@ -112,7 +119,7 @@ private void UpdateCacheablePrompts() let metadata = pair.Value where !string.IsNullOrEmpty(metadata.ResourceId) where metadata.IsCoreAction || _userSettings.AdditionalActions.Contains(format) - select ResourceLoaderInstance.ResourceLoader.GetString(metadata.ResourceId); + select _getLocalizedString(metadata.ResourceId); var customActionPrompts = from customAction in _userSettings.CustomActions select customAction.Prompt; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs index 05cdcbe81fa0..3cefc56b4c81 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs @@ -40,10 +40,15 @@ public CustomActionTransformService(IPromptModerationService promptModerationSer this.userSettings = userSettings; } - public async Task TransformAsync(string prompt, string inputText, byte[] imageBytes, CancellationToken cancellationToken, IProgress progress) + public async Task TransformAsync(string prompt, string inputText, byte[] imageBytes, CancellationToken cancellationToken, IProgress progress, string systemPromptOverride = null, string providerIdOverride = null) { var pasteConfig = userSettings?.PasteAIConfiguration; - var providerConfig = BuildProviderConfig(pasteConfig); + var providerConfig = BuildProviderConfig(pasteConfig, providerIdOverride); + + if (systemPromptOverride != null) + { + providerConfig.SystemPrompt = systemPromptOverride; + } return await TransformAsync(prompt, inputText, imageBytes, providerConfig, cancellationToken, progress); } @@ -148,13 +153,26 @@ private static AIServiceType NormalizeServiceType(AIServiceType serviceType) return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; } - private PasteAIConfig BuildProviderConfig(PasteAIConfiguration config) + private PasteAIConfig BuildProviderConfig(PasteAIConfiguration config, string providerIdOverride = null) { config ??= new PasteAIConfiguration(); - var provider = config.ActiveProvider ?? config.Providers?.FirstOrDefault() ?? new PasteAIProviderDefinition(); + PasteAIProviderDefinition provider; + + if (!string.IsNullOrWhiteSpace(providerIdOverride)) + { + provider = config.Providers?.FirstOrDefault(p => string.Equals(p.Id, providerIdOverride, StringComparison.OrdinalIgnoreCase)) + ?? config.ActiveProvider + ?? config.Providers?.FirstOrDefault() + ?? new PasteAIProviderDefinition(); + } + else + { + 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 apiKey = AcquireApiKey(serviceType, provider.Id); var modelName = provider.ModelName; var providerConfig = new PasteAIConfig @@ -173,15 +191,14 @@ private PasteAIConfig BuildProviderConfig(PasteAIConfiguration config) return providerConfig; } - private string AcquireApiKey(AIServiceType serviceType) + private string AcquireApiKey(AIServiceType serviceType, string providerId) { if (!RequiresApiKey(serviceType)) { return string.Empty; } - credentialsProvider.Refresh(); - return credentialsProvider.GetKey() ?? string.Empty; + return credentialsProvider.GetKey(serviceType, providerId ?? string.Empty); } private static bool RequiresApiKey(AIServiceType serviceType) @@ -190,6 +207,8 @@ private static bool RequiresApiKey(AIServiceType serviceType) { AIServiceType.Onnx => false, AIServiceType.Ollama => false, + AIServiceType.FoundryLocal => false, + AIServiceType.PhiSilica => false, _ => true, }; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs index 564db3fdc56e..361d96d4c7a3 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs @@ -12,6 +12,6 @@ namespace AdvancedPaste.Services.CustomActions { public interface ICustomActionTransformService { - Task TransformAsync(string prompt, string inputText, byte[] imageBytes, CancellationToken cancellationToken, IProgress progress); + Task TransformAsync(string prompt, string inputText, byte[] imageBytes, CancellationToken cancellationToken, IProgress progress, string systemPromptOverride = null, string providerIdOverride = null); } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs index 7339b4e4e38f..4f7e02fdc303 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs @@ -15,6 +15,7 @@ public sealed class PasteAIProviderFactory : IPasteAIProviderFactory SemanticKernelPasteProvider.Registration, LocalModelPasteProvider.Registration, FoundryLocalPasteProvider.Registration, + PhiSilicaPasteProvider.Registration, }; private static readonly IReadOnlyDictionary> ProviderFactories = CreateProviderFactories(); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PhiSilicaPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PhiSilicaPasteProvider.cs new file mode 100644 index 000000000000..ef87c204f706 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PhiSilicaPasteProvider.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 System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.Windows.AI; +using Microsoft.Windows.AI.ContentSafety; +using Microsoft.Windows.AI.Text; +using PhiSilicaLanguageModel = Microsoft.Windows.AI.Text.LanguageModel; + +namespace AdvancedPaste.Services.CustomActions; + +public sealed class PhiSilicaPasteProvider : IPasteAIProvider +{ + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.PhiSilica, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new PhiSilicaPasteProvider(config)); + + private static readonly SemaphoreSlim _initLock = new(1, 1); + private static PhiSilicaLanguageModel _cachedModel; + + private readonly PasteAIConfig _config; + + public PhiSilicaPasteProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _config = config; + } + + public Task IsAvailableAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + PhiSilicaLafHelper.TryUnlock(); + var readyState = PhiSilicaLanguageModel.GetReadyState(); + return Task.FromResult(readyState != AIFeatureReadyState.NotSupportedOnCurrentSystem); + } + catch (Exception) + { + return Task.FromResult(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 Phi Silica", + 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))); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var languageModel = await GetOrCreateModelAsync(cancellationToken).ConfigureAwait(false); + + progress?.Report(0.1); + + var contentFilterOptions = new ContentFilterOptions(); + var context = languageModel.CreateContext(systemPrompt, contentFilterOptions); + + var userPrompt = $""" + User instructions: + {prompt} + + Text: + {inputText} + + Output: + """; + + if ((ulong)userPrompt.Length > languageModel.GetUsablePromptLength(context, userPrompt)) + { + throw new PasteActionException( + "Prompt is too large for the Phi Silica model context", + new InvalidOperationException("Prompt exceeds usable prompt length"), + aiServiceMessage: "The input text is too large for on-device processing. Try with shorter text."); + } + + var options = new LanguageModelOptions + { + ContentFilterOptions = contentFilterOptions, + }; + + var result = await languageModel.GenerateResponseAsync(context, userPrompt, options).AsTask(cancellationToken).ConfigureAwait(false); + + progress?.Report(0.8); + + if (result.Status != LanguageModelResponseStatus.Complete) + { + var statusMessage = result.Status switch + { + LanguageModelResponseStatus.BlockedByPolicy => "Response was blocked by policy.", + LanguageModelResponseStatus.PromptBlockedByContentModeration => "Prompt was blocked by content moderation.", + LanguageModelResponseStatus.ResponseBlockedByContentModeration => "Response was blocked by content moderation.", + LanguageModelResponseStatus.PromptLargerThanContext => "Prompt is too large for the model context.", + _ => $"Unexpected status: {result.Status}", + }; + + throw new PasteActionException( + $"Phi Silica returned status: {result.Status}", + new InvalidOperationException($"LanguageModel response status: {result.Status}"), + aiServiceMessage: statusMessage); + } + + var responseText = result.Text ?? string.Empty; + request.Usage = AIServiceUsage.None; + + progress?.Report(1.0); + + return responseText; + } + catch (OperationCanceledException) + { + throw; + } + catch (PasteActionException) + { + throw; + } + catch (Exception ex) + { + throw new PasteActionException( + "Failed to generate response using Phi Silica", + ex, + aiServiceMessage: $"Error details: {ex.Message}"); + } + } + + private static async Task GetOrCreateModelAsync(CancellationToken cancellationToken) + { + if (_cachedModel is not null) + { + return _cachedModel; + } + + await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_cachedModel is not null) + { + return _cachedModel; + } + + PhiSilicaLafHelper.TryUnlock(); + var readyState = PhiSilicaLanguageModel.GetReadyState(); + + if (readyState is AIFeatureReadyState.NotSupportedOnCurrentSystem or AIFeatureReadyState.DisabledByUser) + { + throw new PasteActionException( + "Phi Silica is not supported on this device. A Copilot+ PC is required.", + new InvalidOperationException("Phi Silica requires a Copilot+ PC with an NPU."), + aiServiceMessage: "Phi Silica requires a Copilot+ PC with an NPU. For on-device AI on any Windows PC, consider using Foundry Local."); + } + + if (readyState is AIFeatureReadyState.NotReady) + { + var ensureResult = await PhiSilicaLanguageModel.EnsureReadyAsync().AsTask(cancellationToken).ConfigureAwait(false); + if (ensureResult.Status != AIFeatureReadyResultState.Success) + { + throw new PasteActionException( + "Failed to prepare Phi Silica model", + ensureResult.ExtendedError, + aiServiceMessage: $"Model preparation failed (status: {ensureResult.Status})"); + } + } + + if (PhiSilicaLanguageModel.GetReadyState() is not AIFeatureReadyState.Ready) + { + throw new PasteActionException( + "Phi Silica model is not ready", + new InvalidOperationException("Phi Silica model is not in Ready state after preparation."), + aiServiceMessage: "Phi Silica model is not available. Please ensure the model is downloaded and ready."); + } + + _cachedModel = await PhiSilicaLanguageModel.CreateAsync().AsTask(cancellationToken).ConfigureAwait(false); + return _cachedModel; + } + finally + { + _initLock.Release(); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs index 636d2e3e78b3..90c8d58b1bcc 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs @@ -175,6 +175,7 @@ private PromptExecutionSettings CreateExecutionSettings() AIServiceType.OpenAI or AIServiceType.AzureOpenAI => new OpenAIPromptExecutionSettings { FunctionChoiceBehavior = null, + ReasoningEffort = "minimal", }, _ => new PromptExecutionSettings(), }; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs index 648881fba04b..27bf71092842 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs @@ -55,6 +55,13 @@ public bool IsConfigured() return !string.IsNullOrEmpty(GetKey()); } + public string GetKey(AIServiceType serviceType, string providerId) + { + var normalizedType = NormalizeServiceType(serviceType); + var entry = BuildCredentialEntry(normalizedType, providerId ?? string.Empty); + return LoadKey(entry); + } + public bool Refresh() { using (_syncRoot.EnterScope()) @@ -121,6 +128,7 @@ private static string LoadKey((string Resource, string Username)? entry) try { var credential = new PasswordVault().Retrieve(entry.Value.Resource, entry.Value.Username); + credential?.RetrievePassword(); return credential?.Password ?? string.Empty; } catch (Exception) @@ -160,6 +168,7 @@ private static (string Resource, string Username)? BuildCredentialEntry(AIServic case AIServiceType.ML: case AIServiceType.Onnx: case AIServiceType.Ollama: + case AIServiceType.PhiSilica: return null; default: return null; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs index 7aa6f63b198a..db9739697b43 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs @@ -2,6 +2,8 @@ // 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; /// @@ -21,6 +23,14 @@ public interface IAICredentialsProvider /// Credential string or when missing. string GetKey(); + /// + /// Retrieves the credential for a specific AI provider. + /// + /// The AI service type. + /// The provider identifier. + /// Credential string or when missing. + string GetKey(AIServiceType serviceType, string providerId); + /// /// Refreshes the cached credential for the active AI provider. /// diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelService.cs index beb62fb293d2..6cef3b120836 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelService.cs @@ -12,5 +12,5 @@ namespace AdvancedPaste.Services; public interface IKernelService { - Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress progress); + Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress progress, string providerIdOverride = null); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs index 0d753d1ec327..392c84ee6e31 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs @@ -46,7 +46,7 @@ public abstract class KernelServiceBase( protected abstract IKernelRuntimeConfiguration GetRuntimeConfiguration(); - public async Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress progress) + public async Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress progress, string providerIdOverride = null) { Logger.LogTrace(); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs index ff64a5ad8328..0f02efa77eba 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs @@ -9,15 +9,18 @@ using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Telemetry; using Windows.ApplicationModel.DataTransfer; namespace AdvancedPaste.Services; -public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor +public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService, IUserSettings userSettings) : IPasteFormatExecutor { private readonly IKernelService _kernelService = kernelService; private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; + private readonly IUserSettings _userSettings = userSettings; public async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress progress) { @@ -36,8 +39,9 @@ public async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, return await Task.Run(async () => pasteFormat.Format switch { - PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress), - PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformAsync(pasteFormat.Prompt, await clipboardData.GetTextOrHtmlTextAsync(), await clipboardData.GetImageAsPngBytesAsync(), cancellationToken, progress))?.Content ?? string.Empty), + PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress, pasteFormat.ProviderId), + PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformAsync(pasteFormat.Prompt, await clipboardData.GetTextOrHtmlTextAsync(), await clipboardData.GetImageAsPngBytesAsync(), cancellationToken, progress, providerIdOverride: pasteFormat.ProviderId))?.Content ?? string.Empty), + PasteFormats.FixSpellingAndGrammar => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformAsync(GetFixSpellingPrompt(), await clipboardData.GetTextOrHtmlTextAsync(), null, cancellationToken, progress, GetFixSpellingSystemPrompt(), pasteFormat.ProviderId))?.Content ?? string.Empty), _ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress), }); } @@ -62,4 +66,16 @@ private static void WriteTelemetry(PasteFormats format, PasteActionSource source throw new ArgumentOutOfRangeException(nameof(format)); } } + + private string GetFixSpellingPrompt() + { + var customPrompt = _userSettings.FixSpellingAndGrammarPrompt; + return string.IsNullOrWhiteSpace(customPrompt) ? AdvancedPasteDefaultPrompts.FixSpellingAndGrammar : customPrompt; + } + + private string GetFixSpellingSystemPrompt() + { + var customSystemPrompt = _userSettings.FixSpellingAndGrammarSystemPrompt; + return string.IsNullOrWhiteSpace(customSystemPrompt) ? AdvancedPasteDefaultPrompts.FixSpellingAndGrammarSystem : customSystemPrompt; + } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw index f36577832131..388001b94d46 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw +++ b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw @@ -232,6 +232,12 @@ Paste as plain text + + Fix spelling and grammar + + + What was changed and why + Image to text diff --git a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs index b474b8215af6..6b704d57a86a 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs @@ -16,8 +16,8 @@ using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Settings; -using Common.UI; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ManagedCommon; @@ -41,6 +41,7 @@ public sealed partial class OptionsViewModel : ObservableObject, IProgress @@ -341,11 +343,21 @@ private void EnqueueRefreshPasteFormats() }); } - private PasteFormat CreateStandardPasteFormat(PasteFormats format) => - PasteFormat.CreateStandardFormat(format, AvailableClipboardFormats, IsCustomAIServiceEnabled, ResourceLoaderInstance.ResourceLoader.GetString); + private PasteFormat CreateStandardPasteFormat(PasteFormats format) + { + var providerId = GetProviderIdForFormat(format); + return PasteFormat.CreateStandardFormat(format, AvailableClipboardFormats, IsCustomAIServiceEnabled, ResourceLoaderInstance.ResourceLoader.GetString, providerId); + } - private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery) => - PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled); + private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery, string providerId = null) => + PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled, providerId); + + private string GetProviderIdForFormat(PasteFormats format) => + format switch + { + PasteFormats.FixSpellingAndGrammar => _userSettings.FixSpellingAndGrammarProviderId, + _ => string.Empty, + }; private void UpdateAIProviderActiveFlags() { @@ -418,7 +430,7 @@ void UpdateFormats(ObservableCollection collection, IEnumerable CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true)) : []); + IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true, customAction.ProviderId)) : []); } public void Dispose() @@ -539,6 +551,7 @@ public async Task OnShowAsync() { PasteActionError = PasteActionError.None; Query = string.Empty; + CoachingExplanation = null; await ReadClipboardAsync(); @@ -616,6 +629,12 @@ public string CustomAIUnavailableErrorText [ObservableProperty] private string _customFormatResult; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasCoachingExplanation))] + private string _coachingExplanation; + + public bool HasCoachingExplanation => !string.IsNullOrEmpty(CoachingExplanation); + [RelayCommand] public async Task PasteCustomAsync() { @@ -661,17 +680,37 @@ public void NextCustomFormat() [RelayCommand] public void OpenSettings() { - SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.AdvancedPaste); + try + { + var exePath = System.IO.Path.Combine( + ManagedCommon.PowerToysPathResolver.GetPowerToysInstallPath(), + "PowerToys.exe"); + + if (exePath != null && System.IO.File.Exists(exePath)) + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = exePath, + Arguments = "--open-settings=AdvancedPaste", + UseShellExecute = false, + }); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to open settings", ex); + } + GetMainWindow()?.Close(); } - internal async Task ExecutePasteFormatAsync(PasteFormats format, PasteActionSource source) + internal async Task ExecutePasteFormatAsync(PasteFormats format, PasteActionSource source, bool forceCoaching = false) { await ReadClipboardAsync(); - await ExecutePasteFormatAsync(CreateStandardPasteFormat(format), source); + await ExecutePasteFormatAsync(CreateStandardPasteFormat(format), source, forceCoaching); } - internal async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source) + internal async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, bool forceCoaching = false) { if (IsBusy) { @@ -704,12 +743,30 @@ internal async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteAction await delayTask; var outputText = await dataPackage.GetView().GetTextOrEmptyAsync(); + bool isCoachingAction = pasteFormat.Format == PasteFormats.FixSpellingAndGrammar && + (forceCoaching || (_userSettings.FixSpellingAndGrammarCoachingEnabled && !_userSettings.FixSpellingAndGrammarCoachingShortcutSet)); bool shouldPreview = pasteFormat.Metadata.CanPreview && _userSettings.ShowCustomPreview && !string.IsNullOrEmpty(outputText) && source != PasteActionSource.GlobalKeyboardShortcut; + // Coaching mode forces preview even for global keyboard shortcuts + if (isCoachingAction && !string.IsNullOrEmpty(outputText)) + { + shouldPreview = true; + } + if (shouldPreview) { GeneratedResponses.Add(outputText); CurrentResponseIndex = GeneratedResponses.Count - 1; + + if (isCoachingAction) + { + await GenerateCoachingExplanationAsync(outputText); + } + else + { + CoachingExplanation = null; + } + PreviewRequested?.Invoke(this, EventArgs.Empty); } else @@ -730,6 +787,65 @@ internal async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteAction Logger.LogDebug($"Finished executing {pasteFormat.Format} from source {source}; timeTakenMs={elapsedWatch.ElapsedMilliseconds}"); } + private async Task GenerateCoachingExplanationAsync(string correctedText) + { + try + { + var originalText = ClipboardData != null ? await ClipboardData.GetTextOrEmptyAsync() : string.Empty; + + if (string.IsNullOrEmpty(originalText)) + { + CoachingExplanation = null; + return; + } + + static string NormalizeForComparison(string s) => + s.Replace('\u2018', '\'') // left single quote + .Replace('\u2019', '\'') // right single quote / apostrophe + .Replace('\u201C', '"') // left double quote + .Replace('\u201D', '"') // right double quote + .Replace('\u2013', '-') // en dash + .Replace('\u2014', '-'); // em dash + + if (string.Equals(NormalizeForComparison(originalText), NormalizeForComparison(correctedText), StringComparison.Ordinal)) + { + CoachingExplanation = null; + return; + } + + var coachingInstruction = string.IsNullOrWhiteSpace(_userSettings.FixSpellingAndGrammarCoachingPrompt) + ? AdvancedPasteDefaultPrompts.FixSpellingAndGrammarCoaching + : _userSettings.FixSpellingAndGrammarCoachingPrompt; + var coachingInputText = $"Original:\n\"{originalText}\"\n\nCorrected:\n\"{correctedText}\""; + + var coachingSystemPrompt = string.IsNullOrWhiteSpace(_userSettings.FixSpellingAndGrammarCoachingSystemPrompt) + ? AdvancedPasteDefaultPrompts.FixSpellingAndGrammarCoachingSystem + : _userSettings.FixSpellingAndGrammarCoachingSystemPrompt; + + var coachingProviderId = _userSettings.FixSpellingAndGrammarCoachingProviderId; + if (string.IsNullOrWhiteSpace(coachingProviderId)) + { + coachingProviderId = _userSettings.FixSpellingAndGrammarProviderId; + } + + var result = await _customActionTransformService.TransformAsync( + coachingInstruction, + coachingInputText, + null, + _pasteActionCancellationTokenSource?.Token ?? CancellationToken.None, + null, + coachingSystemPrompt, + string.IsNullOrWhiteSpace(coachingProviderId) ? null : coachingProviderId); + + CoachingExplanation = result?.Content; + } + catch (Exception ex) + { + Logger.LogError("Error generating coaching explanation", ex); + CoachingExplanation = null; + } + } + internal async Task ExecutePasteFormatAsync(VirtualKey key) { var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats) diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPaste.base.rc b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPaste.base.rc index b30e3923c989..eb9e4e22d1c0 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPaste.base.rc +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPaste.base.rc @@ -1,40 +1,6 @@ #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" // US English (0x0409), Unicode (0x04B0) charset - BEGIN - VALUE "CompanyName", COMPANY_NAME - VALUE "FileDescription", FILE_DESCRIPTION - VALUE "FileVersion", FILE_VERSION_STRING - VALUE "InternalName", INTERNAL_NAME - VALUE "LegalCopyright", COPYRIGHT_NOTE - VALUE "OriginalFilename", ORIGINAL_FILENAME - VALUE "ProductName", PRODUCT_NAME - VALUE "ProductVersion", PRODUCT_VERSION_STRING - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1200 // US English (0x0409), Unicode (1200) charset - END -END diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj index 9f7675799ea4..ca40cf792f0a 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj @@ -15,7 +15,6 @@ DynamicLibrary - diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteProcessManager.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteProcessManager.cpp index b202f93f4e10..aec3792beb51 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteProcessManager.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteProcessManager.cpp @@ -3,8 +3,11 @@ #include #include +#include +#include #include #include +#include namespace { @@ -100,23 +103,110 @@ HRESULT AdvancedPasteProcessManager::start_process(const std::wstring& pipe_name { const unsigned long powertoys_pid = GetCurrentProcessId(); - const auto executable_args = std::format(L"{} {}", std::to_wstring(powertoys_pid), pipe_name); + // Ensure the AdvancedPaste MSIX package is registered + static const std::wstring packageName = +#ifdef _DEBUG + L"PowerToys.AdvancedPaste.Dev"; +#else + L"PowerToys.AdvancedPaste"; +#endif + + if (!package::GetRegisteredPackage(packageName, false).has_value()) + { + Logger::info(L"AdvancedPaste MSIX not registered. Registering..."); + + const auto installationFolder = get_module_folderpath(); + +#ifdef _DEBUG + auto msix = package::FindMsixFile(installationFolder + L"\\WinUI3Apps\\AdvancedPaste\\AppPackages\\PowerToys.AdvancedPaste_0.0.1.0_Debug_Test\\", false); + auto dependencies = package::FindMsixFile(installationFolder + L"\\WinUI3Apps\\AdvancedPaste\\AppPackages\\PowerToys.AdvancedPaste_0.0.1.0_Debug_Test\\Dependencies\\", true); +#else + auto msix = package::FindMsixFile(installationFolder + L"\\WinUI3Apps\\AdvancedPaste\\", false); + auto dependencies = package::FindMsixFile(installationFolder + L"\\WinUI3Apps\\AdvancedPaste\\Dependencies\\", true); +#endif + + if (!msix.empty()) + { + if (!package::RegisterPackage(msix[0], dependencies)) + { + Logger::error(L"Failed to register AdvancedPaste MSIX package"); + } + } + else + { + Logger::warn(L"No MSIX found for AdvancedPaste, falling back to direct exe launch"); + + // Fallback: launch exe directly (dev builds without GenerateAppxPackageOnBuild) + const auto executable_args = std::format(L"{} {}", std::to_wstring(powertoys_pid), pipe_name); + + SHELLEXECUTEINFOW sei{ sizeof(sei) }; + sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI }; + sei.lpFile = L"WinUI3Apps\\AdvancedPaste\\PowerToys.AdvancedPaste.exe"; + sei.nShow = SW_SHOWNORMAL; + sei.lpParameters = executable_args.data(); + if (ShellExecuteExW(&sei)) + { + Logger::trace("Successfully started Advanced Paste process (direct)"); + terminate_process(); + m_hProcess = sei.hProcess; + return S_OK; + } + else + { + Logger::error(L"Advanced Paste process failed to start. {}", get_last_error_or_default(GetLastError())); + return E_FAIL; + } + } + } + + // Launch via protocol URI, passing PID and pipe name as query parameters + const auto protocolUri = std::format(L"x-advancedpaste://launch?pid={}&pipe={}", std::to_wstring(powertoys_pid), pipe_name); SHELLEXECUTEINFOW sei{ sizeof(sei) }; - sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI }; - sei.lpFile = L"WinUI3Apps\\PowerToys.AdvancedPaste.exe"; + sei.fMask = { SEE_MASK_FLAG_NO_UI }; + sei.lpFile = protocolUri.c_str(); sei.nShow = SW_SHOWNORMAL; - sei.lpParameters = executable_args.data(); if (ShellExecuteExW(&sei)) { - Logger::trace("Successfully started Advanced Paste process"); - terminate_process(); - m_hProcess = sei.hProcess; + Logger::trace("Successfully started Advanced Paste via protocol"); + + // Find the AP process by name to track its lifetime + // Give it a moment to start up + for (int retry = 0; retry < 10; retry++) + { + Sleep(500); + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot != INVALID_HANDLE_VALUE) + { + PROCESSENTRY32W pe{ sizeof(pe) }; + if (Process32FirstW(snapshot, &pe)) + { + do + { + if (std::wstring(pe.szExeFile) == L"PowerToys.AdvancedPaste.exe") + { + auto hProc = OpenProcess(PROCESS_QUERY_INFORMATION | SYNCHRONIZE | PROCESS_TERMINATE, FALSE, pe.th32ProcessID); + if (hProc) + { + terminate_process(); + m_hProcess = hProc; + Logger::trace(L"Found Advanced Paste process: PID {}", pe.th32ProcessID); + CloseHandle(snapshot); + return S_OK; + } + } + } while (Process32NextW(snapshot, &pe)); + } + CloseHandle(snapshot); + } + } + + Logger::warn("Advanced Paste process not found after protocol launch"); return S_OK; } else { - Logger::error(L"Advanced Paste process failed to start. {}", get_last_error_or_default(GetLastError())); + Logger::error(L"Advanced Paste protocol launch failed. {}", get_last_error_or_default(GetLastError())); return E_FAIL; } } @@ -175,8 +265,8 @@ HRESULT AdvancedPasteProcessManager::start_named_pipe_server(const std::wstring& } } - // Wait for client. - const constexpr DWORD client_timeout_millis = 5000; + // Wait for client. Protocol activation takes longer than direct exe launch. + const constexpr DWORD client_timeout_millis = 15000; switch (WaitForSingleObject(overlapped.hEvent, client_timeout_millis)) { case WAIT_OBJECT_0: diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp index 17205687a54e..59e4e10ce549 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp @@ -66,6 +66,8 @@ namespace 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_COACHING_SHORTCUT[] = L"coaching-shortcut"; + const wchar_t JSON_KEY_COACHING_ENABLED[] = L"coaching-enabled"; const wchar_t JSON_KEY_VALUE[] = L"value"; } @@ -255,6 +257,21 @@ class AdvancedPaste : public PowertoyModuleIface }; m_additional_actions.push_back(additionalAction); + + // Register coaching shortcut as a separate hotkey with a "-coaching" suffix ID + if (action.HasKey(JSON_KEY_COACHING_SHORTCUT) && action.GetNamedBoolean(JSON_KEY_COACHING_ENABLED, false)) + { + auto coachingHotkey = parse_single_hotkey(action.GetNamedObject(JSON_KEY_COACHING_SHORTCUT), actionIsShown); + if (coachingHotkey.key != 0) + { + const AdditionalAction coachingAction + { + std::wstring(actionName.c_str()) + L"-coaching", + coachingHotkey + }; + m_additional_actions.push_back(coachingAction); + } + } } else { @@ -407,6 +424,7 @@ class AdvancedPaste : public PowertoyModuleIface // Define the expected order to ensure consistent hotkey ID assignment const std::vector expectedOrder = { L"image-to-text", + L"fix-spelling-and-grammar", L"paste-as-file", L"transcode" }; @@ -923,6 +941,12 @@ class AdvancedPaste : public PowertoyModuleIface m_triggerEventWaiter.start(CommonSharedConstants::ADVANCED_PASTE_SHOW_UI_EVENT, [this](DWORD) { // Same logic as hotkeyId == 1 (m_advanced_paste_ui_hotkey) Logger::trace(L"AdvancedPaste ShowUI event triggered"); + + if (m_auto_copy_selection_custom_action) + { + send_copy_selection(); // best-effort; ignore failure + } + m_process_manager.start(); m_process_manager.bring_to_front(); m_process_manager.send_message(CommonSharedConstants::ADVANCED_PASTE_SHOW_UI_MESSAGE); @@ -973,12 +997,11 @@ class AdvancedPaste : public PowertoyModuleIface } } - if (is_custom_action_hotkey && m_auto_copy_selection_custom_action) + // Try to capture selected text for all hotkey actions when the setting is enabled. + // If nothing is selected (clipboard unchanged), fall through to use existing clipboard content. + if (m_auto_copy_selection_custom_action) { - if (!send_copy_selection()) - { - return false; - } + send_copy_selection(); // best-effort; ignore failure } m_process_manager.start(); diff --git a/src/modules/AdvancedPaste/custom.props b/src/modules/AdvancedPaste/custom.props new file mode 100644 index 000000000000..3b4b52e42a8d --- /dev/null +++ b/src/modules/AdvancedPaste/custom.props @@ -0,0 +1,11 @@ + + + + + true + 2025 + 0 + 9 + PowerToys Advanced Paste + + diff --git a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props index 435f8b977a7f..5c6ab89881a8 100644 --- a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props +++ b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props @@ -10,7 +10,7 @@ - + diff --git a/src/settings-ui/Settings.UI.Library/AIServiceType.cs b/src/settings-ui/Settings.UI.Library/AIServiceType.cs index 27eccff1cf6e..e30ebbb5d2a7 100644 --- a/src/settings-ui/Settings.UI.Library/AIServiceType.cs +++ b/src/settings-ui/Settings.UI.Library/AIServiceType.cs @@ -19,5 +19,6 @@ public enum AIServiceType Google, AzureAIInference, Ollama, + PhiSilica, } } diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs index 5b19212ebaf2..887c0dc78e50 100644 --- a/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs @@ -31,6 +31,7 @@ public static AIServiceType ToAIServiceType(this string serviceType) "google" or "googleai" or "googlegemini" => AIServiceType.Google, "azureaiinference" or "azureinference" => AIServiceType.AzureAIInference, "ollama" => AIServiceType.Ollama, + "phisilica" or "phi" or "philm" => AIServiceType.PhiSilica, _ => AIServiceType.Unknown, }; } @@ -51,6 +52,7 @@ public static string ToConfigurationString(this AIServiceType serviceType) AIServiceType.Google => "Google", AIServiceType.AzureAIInference => "AzureAIInference", AIServiceType.Ollama => "Ollama", + AIServiceType.PhiSilica => "PhiSilica", AIServiceType.Unknown => string.Empty, _ => throw new ArgumentOutOfRangeException(nameof(serviceType), serviceType, "Unsupported AI service type."), }; @@ -72,6 +74,7 @@ public static string ToNormalizedKey(this AIServiceType serviceType) AIServiceType.Google => "google", AIServiceType.AzureAIInference => "azureaiinference", AIServiceType.Ollama => "ollama", + AIServiceType.PhiSilica => "phisilica", _ => string.Empty, }; } diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs index 653b85553e39..2c45d9bae60a 100644 --- a/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs @@ -118,6 +118,15 @@ public static class AIServiceTypeRegistry PrivacyLabel = "AdvancedPaste_OpenAI_PrivacyLabel", PrivacyUri = new Uri("https://openai.com/privacy"), }, + [AIServiceType.PhiSilica] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.PhiSilica, + DisplayName = "Phi Silica", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/WindowsML.svg", + IsOnlineService = false, + IsLocalModel = true, + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + }, [AIServiceType.Unknown] = new AIServiceTypeMetadata { ServiceType = AIServiceType.Unknown, diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs index 1642ecf9c421..6e46e54e4b64 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs @@ -12,7 +12,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library; public sealed partial class AdvancedPasteAdditionalAction : Observable, IAdvancedPasteAction { private HotkeySettings _shortcut = new(); + private HotkeySettings _coachingShortcut = new(); private bool _isShown; + private string _prompt = string.Empty; + private string _systemPrompt = string.Empty; + private string _coachingPrompt = string.Empty; + private string _coachingSystemPrompt = string.Empty; + private string _providerId = string.Empty; + private string _coachingProviderId = string.Empty; + private bool _coachingEnabled; private bool _hasConflict; private string _tooltip; @@ -33,6 +41,20 @@ public HotkeySettings Shortcut } } + [JsonPropertyName("coaching-shortcut")] + public HotkeySettings CoachingShortcut + { + get => _coachingShortcut; + set + { + if (_coachingShortcut != value) + { + _coachingShortcut = value ?? new(); + OnPropertyChanged(); + } + } + } + [JsonPropertyName("isShown")] public bool IsShown { @@ -40,6 +62,55 @@ public bool IsShown set => Set(ref _isShown, value); } + [JsonPropertyName("prompt")] + public string Prompt + { + get => _prompt; + set => Set(ref _prompt, value ?? string.Empty); + } + + [JsonPropertyName("system-prompt")] + public string SystemPrompt + { + get => _systemPrompt; + set => Set(ref _systemPrompt, value ?? string.Empty); + } + + [JsonPropertyName("coaching-prompt")] + public string CoachingPrompt + { + get => _coachingPrompt; + set => Set(ref _coachingPrompt, value ?? string.Empty); + } + + [JsonPropertyName("coaching-system-prompt")] + public string CoachingSystemPrompt + { + get => _coachingSystemPrompt; + set => Set(ref _coachingSystemPrompt, value ?? string.Empty); + } + + [JsonPropertyName("provider-id")] + public string ProviderId + { + get => _providerId; + set => Set(ref _providerId, value ?? string.Empty); + } + + [JsonPropertyName("coaching-provider-id")] + public string CoachingProviderId + { + get => _coachingProviderId; + set => Set(ref _coachingProviderId, value ?? string.Empty); + } + + [JsonPropertyName("coaching-enabled")] + public bool CoachingEnabled + { + get => _coachingEnabled; + set => Set(ref _coachingEnabled, value); + } + [JsonIgnore] public bool HasConflict { diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs index b193c01c74ee..b476f50f674a 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs @@ -11,12 +11,14 @@ namespace Microsoft.PowerToys.Settings.UI.Library; public sealed class AdvancedPasteAdditionalActions { private AdvancedPasteAdditionalAction _imageToText = new(); + private AdvancedPasteAdditionalAction _fixSpellingAndGrammar = new(); private AdvancedPastePasteAsFileAction _pasteAsFile = new(); private AdvancedPasteTranscodeAction _transcode = new(); public static class PropertyNames { public const string ImageToText = "image-to-text"; + public const string FixSpellingAndGrammar = "fix-spelling-and-grammar"; public const string PasteAsFile = "paste-as-file"; public const string Transcode = "transcode"; } @@ -28,6 +30,13 @@ public AdvancedPasteAdditionalAction ImageToText init => _imageToText = value ?? new(); } + [JsonPropertyName(PropertyNames.FixSpellingAndGrammar)] + public AdvancedPasteAdditionalAction FixSpellingAndGrammar + { + get => _fixSpellingAndGrammar; + init => _fixSpellingAndGrammar = value ?? new(); + } + [JsonPropertyName(PropertyNames.PasteAsFile)] public AdvancedPastePasteAsFileAction PasteAsFile { @@ -44,7 +53,7 @@ public AdvancedPasteTranscodeAction Transcode public IEnumerable GetAllActions() { - return GetAllActionsRecursive([ImageToText, PasteAsFile, Transcode]); + return GetAllActionsRecursive([ImageToText, FixSpellingAndGrammar, PasteAsFile, Transcode]); } /// diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs index c98129590698..4a3a763c4c94 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs @@ -16,6 +16,7 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction private string _name = string.Empty; private string _description = string.Empty; private string _prompt = string.Empty; + private string _providerId = string.Empty; private HotkeySettings _shortcut = new(); private bool _isShown; private bool _canMoveUp; @@ -64,6 +65,13 @@ public string Prompt } } + [JsonPropertyName("provider-id")] + public string ProviderId + { + get => _providerId; + set => Set(ref _providerId, value ?? string.Empty); + } + [JsonPropertyName("shortcut")] public HotkeySettings Shortcut { @@ -138,6 +146,7 @@ public void Update(AdvancedPasteCustomAction other) Name = other.Name; Description = other.Description; Prompt = other.Prompt; + ProviderId = other.ProviderId; Shortcut = other.GetShortcutClone(); IsShown = other.IsShown; CanMoveUp = other.CanMoveUp; diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteDefaultPrompts.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteDefaultPrompts.cs new file mode 100644 index 000000000000..c58170f7320f --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteDefaultPrompts.cs @@ -0,0 +1,20 @@ +// 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; + +/// +/// Shared default prompts for built-in AI actions. Referenced by both the AdvancedPaste module +/// and the Settings UI to ensure consistent defaults and enable "reset to default" functionality. +/// +public static class AdvancedPasteDefaultPrompts +{ + public const string FixSpellingAndGrammar = "Fix all spelling and grammar errors in the following text. Return only the corrected text without any additional explanation or commentary."; + + public const string FixSpellingAndGrammarSystem = "You are a professional proofreader. You fix spelling and grammar errors in text. You return only the corrected text with no commentary."; + + public const string FixSpellingAndGrammarCoaching = "Briefly explain what was changed and why in terms of language rules. Be concise as reviewer."; + + public const string FixSpellingAndGrammarCoachingSystem = "You are a writing coach and language teacher. You will be given an original sentence and a corrected version."; +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs index be001fd9d6a1..e03e9c6262df 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs @@ -96,6 +96,13 @@ public HotkeyAccessor[] GetAllHotkeyAccessors() customAction.Name)); } + // Coaching shortcut for Fix Spelling and Grammar + var fixSpellingAction = Properties.AdditionalActions.FixSpellingAndGrammar; + hotkeyAccessors.Add(new HotkeyAccessor( + () => fixSpellingAction.CoachingShortcut, + value => fixSpellingAction.CoachingShortcut = value ?? new HotkeySettings(), + "FixSpellingAndGrammarCoaching")); + return hotkeyAccessors.ToArray(); } diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index e726063b819e..bae384d99531 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -19,6 +19,7 @@ PowerToys.Settings.pri + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml index 5d1fa671ac7b..d5becba90a05 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml @@ -116,6 +116,17 @@ Header="{x:Bind ModelName, Mode=OneWay}" HeaderIcon="{x:Bind ServiceType, Mode=OneWay, Converter={StaticResource ServiceTypeToIconConverter}}"> + + +