From a48e741a407c6d0e2ecff109a050eb5e329e72f3 Mon Sep 17 00:00:00 2001 From: gkhmyznikov Date: Tue, 10 Mar 2026 13:14:15 -0700 Subject: [PATCH 01/95] initial --- .../AdvancedPasteXAML/Controls/PromptBox.xaml | 24 ++- .../AdvancedPaste/Helpers/IUserSettings.cs | 4 + .../AdvancedPaste/Helpers/UserSettings.cs | 9 + .../AdvancedPaste/Models/PasteFormats.cs | 11 ++ .../CustomActionTransformService.cs | 7 +- .../ICustomActionTransformService.cs | 2 +- .../Services/PasteFormatExecutor.cs | 13 +- .../Strings/en-us/Resources.resw | 6 + .../ViewModels/OptionsViewModel.cs | 175 +++++++++++++++++- 9 files changed, 245 insertions(+), 6 deletions(-) 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..c41a35fafa5d 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs @@ -25,6 +25,10 @@ public interface IUserSettings public IReadOnlyList AdditionalActions { get; } + public string FixSpellingAndGrammarPrompt { get; } + + public bool FixSpellingAndGrammarCoachingEnabled { get; } + public PasteAIConfiguration PasteAIConfiguration { get; } public event EventHandler Changed; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs index 59f31f0e99c7..8f66e76c5213 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs @@ -46,6 +46,10 @@ internal sealed partial class UserSettings : IUserSettings, IDisposable public IReadOnlyList CustomActions => _customActions; + public string FixSpellingAndGrammarPrompt { get; private set; } = string.Empty; + + public bool FixSpellingAndGrammarCoachingEnabled { get; private set; } + public PasteAIConfiguration PasteAIConfiguration { get; private set; } public UserSettings(IFileSystem fileSystem) @@ -113,10 +117,15 @@ void UpdateSettings() EnableClipboardPreview = properties.EnableClipboardPreview; PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration(); + var fixSpellingAction = properties.AdditionalActions.FixSpellingAndGrammar; + FixSpellingAndGrammarPrompt = fixSpellingAction.Prompt ?? string.Empty; + FixSpellingAndGrammarCoachingEnabled = fixSpellingAction.CoachingEnabled; + 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/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/Services/CustomActions/CustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs index 05cdcbe81fa0..ddcaa606829c 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs @@ -40,11 +40,16 @@ 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) { var pasteConfig = userSettings?.PasteAIConfiguration; var providerConfig = BuildProviderConfig(pasteConfig); + if (systemPromptOverride != null) + { + providerConfig.SystemPrompt = systemPromptOverride; + } + return await TransformAsync(prompt, inputText, imageBytes, providerConfig, cancellationToken, progress); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs index 564db3fdc56e..4aac8f164fd0 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); } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs index ff64a5ad8328..a80a356e31e7 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs @@ -9,15 +9,19 @@ using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; 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 const string DefaultFixSpellingPrompt = "Fix all spelling and grammar errors in the following text. Return only the corrected text without any additional explanation or commentary."; + 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) { @@ -38,6 +42,7 @@ public async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, { 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.FixSpellingAndGrammar => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformAsync(GetFixSpellingPrompt(), await clipboardData.GetTextOrHtmlTextAsync(), null, cancellationToken, progress))?.Content ?? string.Empty), _ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress), }); } @@ -62,4 +67,10 @@ private static void WriteTelemetry(PasteFormats format, PasteActionSource source throw new ArgumentOutOfRangeException(nameof(format)); } } + + private string GetFixSpellingPrompt() + { + var customPrompt = _userSettings.FixSpellingAndGrammarPrompt; + return string.IsNullOrWhiteSpace(customPrompt) ? DefaultFixSpellingPrompt : customPrompt; + } } 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..0b7b658cb46f 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs @@ -16,6 +16,7 @@ using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Settings; using Common.UI; using CommunityToolkit.Mvvm.ComponentModel; @@ -41,6 +42,7 @@ public sealed partial class OptionsViewModel : ObservableObject, IProgress @@ -539,6 +542,7 @@ public async Task OnShowAsync() { PasteActionError = PasteActionError.None; Query = string.Empty; + CoachingExplanation = null; await ReadClipboardAsync(); @@ -616,6 +620,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() { @@ -704,12 +714,29 @@ internal async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteAction await delayTask; var outputText = await dataPackage.GetView().GetTextOrEmptyAsync(); + bool isCoachingAction = pasteFormat.Format == PasteFormats.FixSpellingAndGrammar && _userSettings.FixSpellingAndGrammarCoachingEnabled; 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 +757,152 @@ 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; + } + + var diffs = ComputeWordDiffs(originalText, correctedText); + + if (diffs.Count == 0) + { + CoachingExplanation = null; + return; + } + + // Build a diff summary and ask AI only to explain *why* each pre-identified change matters + var diffLines = new System.Text.StringBuilder(); + for (int i = 0; i < diffs.Count; i++) + { + diffLines.AppendLine( + CultureInfo.CurrentCulture, + $"Change {i + 1}: \"{diffs[i].Original}\" → \"{diffs[i].Corrected}\""); + } + + var coachingPrompt = $"Here is the corrected sentence for context:\n\"{correctedText}\"\n\nHere are the exact changes that were made:\n{diffLines}\nFor each change, provide the change number and a brief one-sentence explanation of why the correction improves the text."; + + var coachingSystemPrompt = "You are a writing coach. You will be given a corrected sentence and a list of exact text changes already identified by a diff algorithm. Your ONLY job is to explain WHY each change improves the text. Do not verify or re-check the changes - they are correct. Just explain the linguistic reason for each one in one sentence."; + + var result = await _customActionTransformService.TransformAsync( + coachingPrompt, + diffLines.ToString(), + null, + _pasteActionCancellationTokenSource?.Token ?? CancellationToken.None, + null, + coachingSystemPrompt); + + CoachingExplanation = result?.Content; + } + catch (Exception ex) + { + Logger.LogError("Error generating coaching explanation", ex); + CoachingExplanation = null; + } + } + + private record struct TextDiff(string Original, string Corrected); + + private static List ComputeWordDiffs(string original, string corrected) + { + var diffs = new List(); + var originalWords = original.Split(' ', StringSplitOptions.None); + var correctedWords = corrected.Split(' ', StringSplitOptions.None); + + // Normalize smart quotes/dashes for comparison so invisible typographic changes don't appear as diffs + 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 + + // Use longest common subsequence to align words + int n = originalWords.Length, m = correctedWords.Length; + var dp = new int[n + 1, m + 1]; + + for (int i = 1; i <= n; i++) + { + for (int j = 1; j <= m; j++) + { + dp[i, j] = NormalizeForComparison(originalWords[i - 1]) == NormalizeForComparison(correctedWords[j - 1]) + ? dp[i - 1, j - 1] + 1 + : Math.Max(dp[i - 1, j], dp[i, j - 1]); + } + } + + // Backtrack to find diff regions + var origChanges = new List<(int Start, int End)>(); + var corrChanges = new List<(int Start, int End)>(); + int oi = n, ci = m; + int origEnd = n, corrEnd = m; + + while (oi > 0 || ci > 0) + { + if (oi > 0 && ci > 0 && NormalizeForComparison(originalWords[oi - 1]) == NormalizeForComparison(correctedWords[ci - 1])) + { + if (origEnd != oi || corrEnd != ci) + { + origChanges.Add((oi, origEnd)); + corrChanges.Add((ci, corrEnd)); + } + + oi--; + ci--; + origEnd = oi; + corrEnd = ci; + } + else if (ci > 0 && (oi == 0 || dp[oi, ci - 1] >= dp[oi - 1, ci])) + { + ci--; + } + else + { + oi--; + } + } + + if (origEnd != 0 || corrEnd != 0) + { + origChanges.Add((0, origEnd)); + corrChanges.Add((0, corrEnd)); + } + + origChanges.Reverse(); + corrChanges.Reverse(); + + for (int i = 0; i < origChanges.Count; i++) + { + var origSlice = string.Join(' ', originalWords[origChanges[i].Start..origChanges[i].End]); + var corrSlice = string.Join(' ', correctedWords[corrChanges[i].Start..corrChanges[i].End]); + + if (NormalizeForComparison(origSlice) != NormalizeForComparison(corrSlice)) + { + // Add surrounding context (1 word each side) for readability + var ctxStart = Math.Max(0, origChanges[i].Start - 1); + var ctxEnd = Math.Min(originalWords.Length, origChanges[i].End + 1); + var corrCtxStart = Math.Max(0, corrChanges[i].Start - 1); + var corrCtxEnd = Math.Min(correctedWords.Length, corrChanges[i].End + 1); + + var origContext = string.Join(' ', originalWords[ctxStart..ctxEnd]); + var corrContext = string.Join(' ', correctedWords[corrCtxStart..corrCtxEnd]); + + diffs.Add(new TextDiff( + Original: origSlice.Length > 0 ? $"{origContext}" : "(removed)", + Corrected: corrSlice.Length > 0 ? $"{corrContext}" : "(removed)")); + } + } + + return diffs; + } + internal async Task ExecutePasteFormatAsync(VirtualKey key) { var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats) From eed0e20f6b64875db553bfa391fd48910ad88f37 Mon Sep 17 00:00:00 2001 From: gkhmyznikov Date: Tue, 10 Mar 2026 14:44:43 -0700 Subject: [PATCH 02/95] fix shortcut --- .../AdvancedPasteModuleInterface/dllmain.cpp | 1 + .../AdvancedPasteAdditionalAction.cs | 16 +++++++++ .../AdvancedPasteAdditionalActions.cs | 11 +++++- .../SettingsXAML/Views/AdvancedPastePage.xaml | 36 +++++++++++++++++-- .../Settings.UI/Strings/en-us/Resources.resw | 24 +++++++++++-- 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp index 17205687a54e..9f8e007c4c9f 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp @@ -407,6 +407,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" }; diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs index 1642ecf9c421..0d63b4b6604a 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs @@ -13,6 +13,8 @@ public sealed partial class AdvancedPasteAdditionalAction : Observable, IAdvance { private HotkeySettings _shortcut = new(); private bool _isShown; + private string _prompt = string.Empty; + private bool _coachingEnabled; private bool _hasConflict; private string _tooltip; @@ -40,6 +42,20 @@ public bool IsShown set => Set(ref _isShown, value); } + [JsonPropertyName("prompt")] + public string Prompt + { + get => _prompt; + set => Set(ref _prompt, 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/SettingsXAML/Views/AdvancedPastePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml index 5d1fa671ac7b..a9b4b738fb42 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml @@ -164,7 +164,7 @@ - + @@ -195,7 +195,7 @@ Name="PasteAsPlainTextShortcut" x:Uid="PasteAsPlainText_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - + + + + + + + + + + + + + + + + Open editor - - + + Paste as plain text directly @@ -1981,6 +1981,24 @@ Made with 💗 by Microsoft and the PowerToys community. Image to text + + Fix spelling and grammar + + + Custom prompt + + + Override the default AI prompt used for fixing spelling and grammar. Leave empty to use the default prompt. + + + Fix all spelling and grammar errors in the following text. Return only the corrected text without any additional explanation or commentary. + + + Coaching mode + + + When enabled, shows a preview with an explanation of what was fixed and why, even when using a direct keyboard shortcut. + Paste as file @@ -2563,7 +2581,7 @@ From there, simply click on one of the supported files in the File Explorer and Holding Shift CursorWrap: Activation mode - wrap only when Shift held - + Mouse Pointer Crosshairs Mouse as in the hardware peripheral. From ecc93eaa33e6b8bf5b1237e74bea94f4629b4793 Mon Sep 17 00:00:00 2001 From: gkhmyznikov Date: Wed, 11 Mar 2026 15:18:51 -0700 Subject: [PATCH 03/95] more customization --- .../Mocks/IntegrationTestUserSettings.cs | 10 ++ .../AdvancedPaste/Helpers/IUserSettings.cs | 6 + .../AdvancedPaste/Helpers/UserSettings.cs | 9 ++ .../AdvancedPaste/Models/PasteFormat.cs | 8 +- .../CustomActionTransformService.cs | 21 ++- .../ICustomActionTransformService.cs | 2 +- .../AdvancedPaste/Services/IKernelService.cs | 2 +- .../Services/KernelServiceBase.cs | 2 +- .../Services/PasteFormatExecutor.cs | 11 +- .../ViewModels/OptionsViewModel.cs | 148 ++++-------------- .../AdvancedPasteModuleInterface/dllmain.cpp | 15 +- .../AdvancedPasteAdditionalAction.cs | 24 +++ .../AdvancedPasteCustomAction.cs | 9 ++ .../AdvancedPasteDefaultPrompts.cs | 18 +++ .../SettingsXAML/Views/AdvancedPastePage.xaml | 77 ++++++++- .../Views/AdvancedPastePage.xaml.cs | 36 +++++ .../Settings.UI/Strings/en-us/Resources.resw | 47 +++++- 17 files changed, 301 insertions(+), 144 deletions(-) create mode 100644 src/settings-ui/Settings.UI.Library/AdvancedPasteDefaultPrompts.cs diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs index 4446e24dde29..7c5ea9cad3e7 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs @@ -55,6 +55,16 @@ public IntegrationTestUserSettings() public IReadOnlyList AdditionalActions => _additionalActions; + public string FixSpellingAndGrammarPrompt => string.Empty; + + public string FixSpellingAndGrammarProviderId => string.Empty; + + public bool FixSpellingAndGrammarCoachingEnabled => false; + + public string FixSpellingAndGrammarCoachingPrompt => string.Empty; + + public string FixSpellingAndGrammarCoachingSystemPrompt => string.Empty; + public PasteAIConfiguration PasteAIConfiguration => _configuration; public event EventHandler Changed; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs index c41a35fafa5d..df371cc01a33 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs @@ -27,8 +27,14 @@ public interface IUserSettings public string FixSpellingAndGrammarPrompt { get; } + public string FixSpellingAndGrammarProviderId { get; } + public bool FixSpellingAndGrammarCoachingEnabled { get; } + public string FixSpellingAndGrammarCoachingPrompt { get; } + + public string FixSpellingAndGrammarCoachingSystemPrompt { get; } + public PasteAIConfiguration PasteAIConfiguration { get; } public event EventHandler Changed; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs index 8f66e76c5213..bd1a9fa6fb5b 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs @@ -48,8 +48,14 @@ internal sealed partial class UserSettings : IUserSettings, IDisposable public string FixSpellingAndGrammarPrompt { get; private set; } = string.Empty; + public string FixSpellingAndGrammarProviderId { get; private set; } = string.Empty; + public bool FixSpellingAndGrammarCoachingEnabled { get; private set; } + public string FixSpellingAndGrammarCoachingPrompt { get; private set; } = string.Empty; + + public string FixSpellingAndGrammarCoachingSystemPrompt { get; private set; } = string.Empty; + public PasteAIConfiguration PasteAIConfiguration { get; private set; } public UserSettings(IFileSystem fileSystem) @@ -119,7 +125,10 @@ void UpdateSettings() var fixSpellingAction = properties.AdditionalActions.FixSpellingAndGrammar; FixSpellingAndGrammarPrompt = fixSpellingAction.Prompt ?? string.Empty; + FixSpellingAndGrammarProviderId = fixSpellingAction.ProviderId ?? string.Empty; FixSpellingAndGrammarCoachingEnabled = fixSpellingAction.CoachingEnabled; + FixSpellingAndGrammarCoachingPrompt = fixSpellingAction.CoachingPrompt ?? string.Empty; + FixSpellingAndGrammarCoachingSystemPrompt = fixSpellingAction.CoachingSystemPrompt ?? string.Empty; var sourceAdditionalActions = properties.AdditionalActions; (PasteFormats Format, IAdvancedPasteAction[] Actions)[] additionalActionFormats = 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/Services/CustomActions/CustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs index ddcaa606829c..f8907f4dc216 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs @@ -40,10 +40,10 @@ public CustomActionTransformService(IPromptModerationService promptModerationSer this.userSettings = userSettings; } - public async Task TransformAsync(string prompt, string inputText, byte[] imageBytes, CancellationToken cancellationToken, IProgress progress, string systemPromptOverride = null) + 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) { @@ -153,10 +153,23 @@ 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); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs index 4aac8f164fd0..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, string systemPromptOverride = null); + 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/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 a80a356e31e7..bea4352a922c 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs @@ -10,6 +10,7 @@ using AdvancedPaste.Models; using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Telemetry; using Windows.ApplicationModel.DataTransfer; @@ -17,8 +18,6 @@ namespace AdvancedPaste.Services; public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService, IUserSettings userSettings) : IPasteFormatExecutor { - private const string DefaultFixSpellingPrompt = "Fix all spelling and grammar errors in the following text. Return only the corrected text without any additional explanation or commentary."; - private readonly IKernelService _kernelService = kernelService; private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; private readonly IUserSettings _userSettings = userSettings; @@ -40,9 +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.FixSpellingAndGrammar => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformAsync(GetFixSpellingPrompt(), await clipboardData.GetTextOrHtmlTextAsync(), null, 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, providerIdOverride: pasteFormat.ProviderId))?.Content ?? string.Empty), _ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress), }); } @@ -71,6 +70,6 @@ private static void WriteTelemetry(PasteFormats format, PasteActionSource source private string GetFixSpellingPrompt() { var customPrompt = _userSettings.FixSpellingAndGrammarPrompt; - return string.IsNullOrWhiteSpace(customPrompt) ? DefaultFixSpellingPrompt : customPrompt; + return string.IsNullOrWhiteSpace(customPrompt) ? AdvancedPasteDefaultPrompts.FixSpellingAndGrammar : customPrompt; } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs index 0b7b658cb46f..a03fb567ab20 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs @@ -344,11 +344,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, string providerId = null) => + PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled, providerId); - private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery) => - PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled); + private string GetProviderIdForFormat(PasteFormats format) => + format switch + { + PasteFormats.FixSpellingAndGrammar => _userSettings.FixSpellingAndGrammarProviderId, + _ => string.Empty, + }; private void UpdateAIProviderActiveFlags() { @@ -421,7 +431,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() @@ -769,30 +779,32 @@ private async Task GenerateCoachingExplanationAsync(string correctedText) return; } - var diffs = ComputeWordDiffs(originalText, correctedText); + 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 (diffs.Count == 0) + if (string.Equals(NormalizeForComparison(originalText), NormalizeForComparison(correctedText), StringComparison.Ordinal)) { CoachingExplanation = null; return; } - // Build a diff summary and ask AI only to explain *why* each pre-identified change matters - var diffLines = new System.Text.StringBuilder(); - for (int i = 0; i < diffs.Count; i++) - { - diffLines.AppendLine( - CultureInfo.CurrentCulture, - $"Change {i + 1}: \"{diffs[i].Original}\" → \"{diffs[i].Corrected}\""); - } - - var coachingPrompt = $"Here is the corrected sentence for context:\n\"{correctedText}\"\n\nHere are the exact changes that were made:\n{diffLines}\nFor each change, provide the change number and a brief one-sentence explanation of why the correction improves the text."; + var coachingInstruction = string.IsNullOrWhiteSpace(_userSettings.FixSpellingAndGrammarCoachingPrompt) + ? AdvancedPasteDefaultPrompts.FixSpellingAndGrammarCoaching + : _userSettings.FixSpellingAndGrammarCoachingPrompt; + var coachingInputText = $"Original:\n\"{originalText}\"\n\nCorrected:\n\"{correctedText}\""; - var coachingSystemPrompt = "You are a writing coach. You will be given a corrected sentence and a list of exact text changes already identified by a diff algorithm. Your ONLY job is to explain WHY each change improves the text. Do not verify or re-check the changes - they are correct. Just explain the linguistic reason for each one in one sentence."; + var coachingSystemPrompt = string.IsNullOrWhiteSpace(_userSettings.FixSpellingAndGrammarCoachingSystemPrompt) + ? AdvancedPasteDefaultPrompts.FixSpellingAndGrammarCoachingSystem + : _userSettings.FixSpellingAndGrammarCoachingSystemPrompt; var result = await _customActionTransformService.TransformAsync( - coachingPrompt, - diffLines.ToString(), + coachingInstruction, + coachingInputText, null, _pasteActionCancellationTokenSource?.Token ?? CancellationToken.None, null, @@ -807,102 +819,6 @@ private async Task GenerateCoachingExplanationAsync(string correctedText) } } - private record struct TextDiff(string Original, string Corrected); - - private static List ComputeWordDiffs(string original, string corrected) - { - var diffs = new List(); - var originalWords = original.Split(' ', StringSplitOptions.None); - var correctedWords = corrected.Split(' ', StringSplitOptions.None); - - // Normalize smart quotes/dashes for comparison so invisible typographic changes don't appear as diffs - 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 - - // Use longest common subsequence to align words - int n = originalWords.Length, m = correctedWords.Length; - var dp = new int[n + 1, m + 1]; - - for (int i = 1; i <= n; i++) - { - for (int j = 1; j <= m; j++) - { - dp[i, j] = NormalizeForComparison(originalWords[i - 1]) == NormalizeForComparison(correctedWords[j - 1]) - ? dp[i - 1, j - 1] + 1 - : Math.Max(dp[i - 1, j], dp[i, j - 1]); - } - } - - // Backtrack to find diff regions - var origChanges = new List<(int Start, int End)>(); - var corrChanges = new List<(int Start, int End)>(); - int oi = n, ci = m; - int origEnd = n, corrEnd = m; - - while (oi > 0 || ci > 0) - { - if (oi > 0 && ci > 0 && NormalizeForComparison(originalWords[oi - 1]) == NormalizeForComparison(correctedWords[ci - 1])) - { - if (origEnd != oi || corrEnd != ci) - { - origChanges.Add((oi, origEnd)); - corrChanges.Add((ci, corrEnd)); - } - - oi--; - ci--; - origEnd = oi; - corrEnd = ci; - } - else if (ci > 0 && (oi == 0 || dp[oi, ci - 1] >= dp[oi - 1, ci])) - { - ci--; - } - else - { - oi--; - } - } - - if (origEnd != 0 || corrEnd != 0) - { - origChanges.Add((0, origEnd)); - corrChanges.Add((0, corrEnd)); - } - - origChanges.Reverse(); - corrChanges.Reverse(); - - for (int i = 0; i < origChanges.Count; i++) - { - var origSlice = string.Join(' ', originalWords[origChanges[i].Start..origChanges[i].End]); - var corrSlice = string.Join(' ', correctedWords[corrChanges[i].Start..corrChanges[i].End]); - - if (NormalizeForComparison(origSlice) != NormalizeForComparison(corrSlice)) - { - // Add surrounding context (1 word each side) for readability - var ctxStart = Math.Max(0, origChanges[i].Start - 1); - var ctxEnd = Math.Min(originalWords.Length, origChanges[i].End + 1); - var corrCtxStart = Math.Max(0, corrChanges[i].Start - 1); - var corrCtxEnd = Math.Min(correctedWords.Length, corrChanges[i].End + 1); - - var origContext = string.Join(' ', originalWords[ctxStart..ctxEnd]); - var corrContext = string.Join(' ', correctedWords[corrCtxStart..corrCtxEnd]); - - diffs.Add(new TextDiff( - Original: origSlice.Length > 0 ? $"{origContext}" : "(removed)", - Corrected: corrSlice.Length > 0 ? $"{corrContext}" : "(removed)")); - } - } - - return diffs; - } - internal async Task ExecutePasteFormatAsync(VirtualKey key) { var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats) diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp index 9f8e007c4c9f..0c654d66611d 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp @@ -924,6 +924,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); @@ -974,12 +980,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/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs index 0d63b4b6604a..a9633b0620d8 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs @@ -14,6 +14,9 @@ public sealed partial class AdvancedPasteAdditionalAction : Observable, IAdvance private HotkeySettings _shortcut = new(); private bool _isShown; private string _prompt = string.Empty; + private string _coachingPrompt = string.Empty; + private string _coachingSystemPrompt = string.Empty; + private string _providerId = string.Empty; private bool _coachingEnabled; private bool _hasConflict; private string _tooltip; @@ -49,6 +52,27 @@ public string Prompt set => Set(ref _prompt, 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-enabled")] public bool CoachingEnabled { 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..0795fee6e28d --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteDefaultPrompts.cs @@ -0,0 +1,18 @@ +// 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 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/SettingsXAML/Views/AdvancedPastePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml index a9b4b738fb42..8f25c8c276c3 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml @@ -233,15 +233,34 @@ + + + - + + + public interface IKernelRuntimeConfiguration { + string ProviderId { get; } + AIServiceType ServiceType { get; } string ModelName { get; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs index 392c84ee6e31..5c0b775e541e 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs @@ -36,21 +36,20 @@ public abstract class KernelServiceBase( private readonly IUserSettings _userSettings = userSettings; private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; - protected abstract string AdvancedAIModelName { get; } + protected abstract PromptExecutionSettings GetPromptExecutionSettings(IKernelRuntimeConfiguration runtimeConfig); - protected abstract PromptExecutionSettings PromptExecutionSettings { get; } - - protected abstract void AddChatCompletionService(IKernelBuilder kernelBuilder); + protected abstract void AddChatCompletionService(IKernelBuilder kernelBuilder, IKernelRuntimeConfiguration runtimeConfig); protected abstract AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage); - protected abstract IKernelRuntimeConfiguration GetRuntimeConfiguration(); + protected abstract IKernelRuntimeConfiguration GetRuntimeConfiguration(string providerIdOverride); public async Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress progress, string providerIdOverride = null) { Logger.LogTrace(); - var kernel = CreateKernel(); + var runtimeConfig = GetRuntimeConfiguration(providerIdOverride); + var kernel = CreateKernel(runtimeConfig); kernel.SetDataPackageView(clipboardData); kernel.SetCancellationToken(cancellationToken); kernel.SetProgress(progress); @@ -63,9 +62,9 @@ public async Task TransformClipboardAsync(string prompt, DataPackag try { - (chatHistory, var usage) = cacheUsed ? await ExecuteCachedActionChain(kernel, maybeCacheValue.ActionChain) : await ExecuteAICompletion(kernel, prompt, cancellationToken); + (chatHistory, var usage) = cacheUsed ? await ExecuteCachedActionChain(kernel, maybeCacheValue.ActionChain) : await ExecuteAICompletion(kernel, prompt, runtimeConfig, cancellationToken); - LogResult(cacheUsed, isSavedQuery, kernel.GetOrAddActionChain(), usage); + LogResult(cacheUsed, isSavedQuery, kernel.GetOrAddActionChain(), usage, runtimeConfig); var outputPackage = kernel.GetDataPackage(); var hasUsableData = await outputPackage.GetView().HasUsableDataAsync(); @@ -163,10 +162,8 @@ private static string GetFullPrompt(ChatHistory initialHistory) return $"{combinedSystemMessage}{newLine}{newLine}User instructions:{newLine}{userPromptMessage.Content}"; } - private async Task<(ChatHistory ChatHistory, AIServiceUsage Usage)> ExecuteAICompletion(Kernel kernel, string prompt, CancellationToken cancellationToken) + private async Task<(ChatHistory ChatHistory, AIServiceUsage Usage)> ExecuteAICompletion(Kernel kernel, string prompt, IKernelRuntimeConfiguration runtimeConfig, CancellationToken cancellationToken) { - var runtimeConfig = GetRuntimeConfiguration(); - ChatHistory chatHistory = []; var systemPrompt = string.IsNullOrWhiteSpace(runtimeConfig.SystemPrompt) ? DefaultSystemPrompt : runtimeConfig.SystemPrompt; @@ -188,13 +185,13 @@ private static string GetFullPrompt(ChatHistory initialHistory) chatHistory.AddUserMessage(prompt); } - if (ShouldModerateAdvancedAI()) + if (ShouldModerateAdvancedAI(runtimeConfig)) { await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); } - var chatResult = await kernel.GetRequiredService(AdvancedAIModelName) - .GetChatMessageContentAsync(chatHistory, PromptExecutionSettings, kernel, cancellationToken); + var chatResult = await kernel.GetRequiredService(runtimeConfig.ModelName) + .GetChatMessageContentAsync(chatHistory, GetPromptExecutionSettings(runtimeConfig), kernel, cancellationToken); chatHistory.Add(chatResult); var totalUsage = chatHistory.Select(GetAIServiceUsage) @@ -224,32 +221,30 @@ private static string GetFullPrompt(ChatHistory initialHistory) protected IUserSettings UserSettings => _userSettings; - private void LogResult(bool cacheUsed, bool isSavedQuery, IEnumerable actionChain, AIServiceUsage usage) + private void LogResult(bool cacheUsed, bool isSavedQuery, IEnumerable actionChain, AIServiceUsage usage, IKernelRuntimeConfiguration runtimeConfig) { - var runtimeConfig = GetRuntimeConfiguration(); - AdvancedPasteSemanticKernelFormatEvent telemetryEvent = new( cacheUsed, isSavedQuery, usage.PromptTokens, usage.CompletionTokens, - AdvancedAIModelName, + runtimeConfig.ModelName, runtimeConfig.ServiceType.ToString(), AdvancedPasteSemanticKernelFormatEvent.FormatActionChain(actionChain)); PowerToysTelemetry.Log.WriteEvent(telemetryEvent); // Log endpoint usage - var endpointEvent = new AdvancedPasteEndpointUsageEvent(runtimeConfig.ServiceType, AdvancedAIModelName, isAdvanced: true); + var endpointEvent = new AdvancedPasteEndpointUsageEvent(runtimeConfig.ServiceType, runtimeConfig.ModelName, isAdvanced: true); PowerToysTelemetry.Log.WriteEvent(endpointEvent); var logEvent = new AIServiceFormatEvent(telemetryEvent); Logger.LogDebug($"{nameof(TransformClipboardAsync)} complete; {logEvent.ToJsonString()}"); } - private Kernel CreateKernel() + private Kernel CreateKernel(IKernelRuntimeConfiguration runtimeConfig) { var kernelBuilder = Kernel.CreateBuilder(); - AddChatCompletionService(kernelBuilder); + AddChatCompletionService(kernelBuilder, runtimeConfig); kernelBuilder.Plugins.AddFromFunctions("Actions", GetKernelFunctions()); return kernelBuilder.Build(); } @@ -436,7 +431,7 @@ static string FormatKernelContent(KernelContent kernelContent) => return $"-> {role}: {redactedContent}{usageString}"; } - protected virtual bool ShouldModerateAdvancedAI() + protected virtual bool ShouldModerateAdvancedAI(IKernelRuntimeConfiguration runtimeConfig) { return false; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs index 6b704d57a86a..7a280a7297e1 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs @@ -133,7 +133,7 @@ public bool IsAdvancedAIEnabled return false; } - if (!TryResolveAdvancedAIProvider(out _)) + if (!AdvancedAIProviderResolver.TryResolveAdvancedProvider(_userSettings?.PasteAIConfiguration, providerIdOverride: null, out _)) { return false; } @@ -237,8 +237,10 @@ public Uri PrivacyLinkUri public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress); - private PasteFormats CustomAIFormat => - _userSettings.IsAIEnabled && TryResolveAdvancedAIProvider(out _) + private PasteFormats CustomAIFormat => GetCustomAIFormat(); + + private PasteFormats GetCustomAIFormat(string providerIdOverride = null) => + _userSettings.IsAIEnabled && AdvancedAIProviderResolver.TryResolveAdvancedProvider(_userSettings?.PasteAIConfiguration, providerIdOverride, out _) ? PasteFormats.KernelQuery : PasteFormats.CustomTextTransformation; @@ -350,7 +352,7 @@ private PasteFormat CreateStandardPasteFormat(PasteFormats format) } private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery, string providerId = null) => - PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled, providerId); + PasteFormat.CreateCustomAIFormat(GetCustomAIFormat(providerId), name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled, providerId); private string GetProviderIdForFormat(PasteFormats format) => format switch @@ -867,7 +869,7 @@ internal async Task ExecuteCustomActionAsync(int customActionId, PasteActionSour if (customAction != null) { await ReadClipboardAsync(); - await ExecutePasteFormatAsync(CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true), source); + await ExecutePasteFormatAsync(CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true, customAction.ProviderId), source); } } @@ -876,7 +878,7 @@ internal async Task ExecuteCustomAIFormatFromCurrentQueryAsync(PasteActionSource var customAction = _userSettings.CustomActions .FirstOrDefault(customAction => Models.KernelQueryCache.CacheKey.PromptComparer.Equals(customAction.Prompt, Query)); - await ExecutePasteFormatAsync(CreateCustomAIPasteFormat(customAction?.Name ?? "Default", Query, isSavedQuery: customAction != null), triggerSource); + await ExecutePasteFormatAsync(CreateCustomAIPasteFormat(customAction?.Name ?? "Default", Query, isSavedQuery: customAction != null, customAction?.ProviderId), triggerSource); } private void HideWindow() @@ -939,49 +941,6 @@ private bool IsProviderAllowedByGPO(PasteAIProviderDefinition provider) }; } - 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(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml index 97bde63f21b9..fc04cd2ed45d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml @@ -128,6 +128,7 @@ Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />