Compare commits

...

4 Commits

Author SHA1 Message Date
Niels Laute
16cce864a5 Update AdvancedPasteAIMode.cs 2025-09-26 14:54:18 +02:00
Niels Laute
b72d8bb46e XAML formatting 2025-09-26 14:45:22 +02:00
Niels Laute
25e1a5414b Re-applying changes from deprecated fork 2
Co-Authored-By: Sepcnt <30561671+sepcnt@users.noreply.github.com>
2025-09-26 14:44:59 +02:00
Niels Laute
dc94e28212 Re-applying changes from deprecated fork 1
Co-Authored-By: Sepcnt <30561671+sepcnt@users.noreply.github.com>
2025-09-26 14:31:08 +02:00
19 changed files with 505 additions and 107 deletions

View File

@@ -9,7 +9,7 @@
<PackageVersion Include="AdaptiveCards.Templating" Version="2.0.5" />
<PackageVersion Include="Microsoft.Bot.AdaptiveExpressions.Core" Version="4.23.0" />
<PackageVersion Include="Appium.WebDriver" Version="4.4.5" />
<PackageVersion Include="Azure.AI.OpenAI" Version="1.0.0-beta.17" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.2.0-beta.4" />
<PackageVersion Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
<PackageVersion Include="CommunityToolkit.Common" Version="8.4.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
@@ -45,7 +45,7 @@
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.8" />
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.15.0" />
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.46.0" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3179.45" />
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
@@ -71,7 +71,7 @@
<PackageVersion Include="NLog" Version="5.2.8" />
<PackageVersion Include="NLog.Extensions.Logging" Version="5.3.8" />
<PackageVersion Include="NLog.Schema" Version="5.2.8" />
<PackageVersion Include="OpenAI" Version="2.0.0" />
<PackageVersion Include="OpenAI" Version="2.2.0-beta.4" />
<PackageVersion Include="ReverseMarkdown" Version="4.1.0" />
<PackageVersion Include="RtfPipe" Version="2.0.7677.4303" />
<PackageVersion Include="ScipBe.Common.Office.OneNote" Version="3.0.1" />

View File

@@ -14,9 +14,11 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services.OpenAI;
using AdvancedPaste.Settings;
using AdvancedPaste.UnitTests.Mocks;
using ManagedCommon;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.UnitTests.ServicesTests;
@@ -130,10 +132,11 @@ public sealed class AIServiceBatchIntegrationTests
private static async Task<DataPackage> GetOutputDataPackageAsync(BatchTestInput batchTestInput, PasteFormats format)
{
Mock<IUserSettings> userSettings = new();
VaultCredentialsProvider credentialsProvider = new();
PromptModerationService promptModerationService = new(credentialsProvider);
PromptModerationService promptModerationService = new(userSettings.Object, credentialsProvider);
NoOpProgress progress = new();
CustomTextTransformService customTextTransformService = new(credentialsProvider, promptModerationService);
CustomTextTransformService customTextTransformService = new(userSettings.Object, credentialsProvider, promptModerationService);
switch (format)
{
@@ -142,7 +145,7 @@ public sealed class AIServiceBatchIntegrationTests
case PasteFormats.KernelQuery:
var clipboardData = DataPackageHelpers.CreateFromText(batchTestInput.Clipboard).GetView();
KernelService kernelService = new(new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, customTextTransformService);
KernelService kernelService = new(userSettings.Object, new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, customTextTransformService);
return await kernelService.TransformClipboardAsync(batchTestInput.Prompt, clipboardData, isSavedQuery: false, CancellationToken.None, progress);
default:

View File

@@ -12,10 +12,12 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services.OpenAI;
using AdvancedPaste.Settings;
using AdvancedPaste.Telemetry;
using AdvancedPaste.UnitTests.Mocks;
using AdvancedPaste.UnitTests.Utils;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.UnitTests.ServicesTests;
@@ -29,14 +31,17 @@ public sealed class KernelServiceIntegrationTests : IDisposable
private const string StandardImageFile = "image_with_text_example.png";
private KernelService _kernelService;
private AdvancedPasteEventListener _eventListener;
private Mock<IUserSettings> _userSettings;
[TestInitialize]
public void TestInitialize()
{
_userSettings = new();
VaultCredentialsProvider credentialsProvider = new();
PromptModerationService promptModerationService = new(credentialsProvider);
PromptModerationService promptModerationService = new(_userSettings.Object, credentialsProvider);
CustomTextTransformService customTextTransformService = new(_userSettings.Object, credentialsProvider, promptModerationService);
_kernelService = new KernelService(new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, new CustomTextTransformService(credentialsProvider, promptModerationService));
_kernelService = new KernelService(_userSettings.Object, new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, customTextTransformService);
_eventListener = new();
}

View File

@@ -153,49 +153,60 @@
x:FieldModifier="public"
TabIndex="0">
<controls:PromptBox.Footer>
<StackPanel Orientation="Horizontal">
<TextBlock
Margin="0,0,2,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}">
<Run x:Uid="AIMistakeNote" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</TextBlock>
<TextBlock
Margin="4,0,2,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}">
<Hyperlink
x:Name="TermsHyperlink"
NavigateUri="https://openai.com/policies/terms-of-use"
TabIndex="3">
<Run x:Uid="TermsLink" />
</Hyperlink>
<ToolTipService.ToolTip>
<TextBlock Text="https://openai.com/policies/terms-of-use" />
</ToolTipService.ToolTip>
</TextBlock>
<TextBlock
Margin="0,0,2,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
ToolTipService.ToolTip="">
<Run x:Uid="AIFooterSeparator" Foreground="{ThemeResource TextFillColorSecondaryBrush}">|</Run>
</TextBlock>
<TextBlock
Margin="0,0,2,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}">
<Hyperlink NavigateUri="https://openai.com/policies/privacy-policy" TabIndex="3">
<Run x:Uid="PrivacyLink" />
</Hyperlink>
<ToolTipService.ToolTip>
<TextBlock Text="https://openai.com/policies/privacy-policy" />
</ToolTipService.ToolTip>
</TextBlock>
<StackPanel Orientation="Vertical" Spacing="2">
<StackPanel Orientation="Horizontal">
<TextBlock
Margin="0,0,2,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}">
<Run x:Uid="AIMistakeNote" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</TextBlock>
<TextBlock
Margin="4,0,2,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}">
<Hyperlink
x:Name="TermsHyperlink"
NavigateUri="https://openai.com/policies/terms-of-use"
TabIndex="3">
<Run x:Uid="TermsLink" />
</Hyperlink>
<ToolTipService.ToolTip>
<TextBlock Text="https://openai.com/policies/terms-of-use" />
</ToolTipService.ToolTip>
</TextBlock>
<TextBlock
Margin="0,0,2,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
ToolTipService.ToolTip="">
<Run x:Uid="AIFooterSeparator" Foreground="{ThemeResource TextFillColorSecondaryBrush}">|</Run>
</TextBlock>
<TextBlock
Margin="0,0,2,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}">
<Hyperlink NavigateUri="https://openai.com/policies/privacy-policy" TabIndex="3">
<Run x:Uid="PrivacyLink" />
</Hyperlink>
<ToolTipService.ToolTip>
<TextBlock Text="https://openai.com/policies/privacy-policy" />
</ToolTipService.ToolTip>
</TextBlock>
</StackPanel>
<StackPanel Orientation="Horizontal" Visibility="{x:Bind ViewModel.IsLocalModelMode, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock
Margin="0,0,2,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}">
<Run x:Uid="CustomAIMistakeNote" />
</TextBlock>
</StackPanel>
</StackPanel>
</controls:PromptBox.Footer>
</controls:PromptBox>

View File

@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
@@ -22,19 +23,24 @@ using Windows.System;
namespace AdvancedPaste.Pages
{
public sealed partial class MainPage : Page
public sealed partial class MainPage : Page, INotifyPropertyChanged
{
private readonly ObservableCollection<ClipboardItem> clipboardHistory;
private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
private (VirtualKey Key, DateTime Timestamp) _lastKeyEvent = (VirtualKey.None, DateTime.MinValue);
public event PropertyChangedEventHandler PropertyChanged;
public OptionsViewModel ViewModel { get; private set; }
public bool IsLocalModelModeVisible => ViewModel?.IsLocalModelMode == true;
public MainPage()
{
this.InitializeComponent();
ViewModel = App.GetService<OptionsViewModel>();
ViewModel.PropertyChanged += ViewModel_PropertyChanged;
clipboardHistory = new ObservableCollection<ClipboardItem>();
@@ -42,6 +48,19 @@ namespace AdvancedPaste.Pages
Clipboard.HistoryChanged += LoadClipboardHistoryEvent;
}
private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(OptionsViewModel.IsLocalModelMode))
{
OnPropertyChanged(nameof(IsLocalModelModeVisible));
}
}
private void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private void LoadClipboardHistoryEvent(object sender, object e)
{
Task.Run(() =>

View File

@@ -14,6 +14,16 @@ namespace AdvancedPaste.Settings
{
public bool IsAdvancedAIEnabled { get; }
public AdvancedPasteAIMode AIMode { get; }
public bool IsLocalModelMode { get; }
public string CustomEndpoint { get; }
public string CustomModelName { get; }
public bool DisableModeration { get; }
public bool ShowCustomPreview { get; }
public bool CloseAfterLosingFocus { get; }

View File

@@ -35,6 +35,16 @@ namespace AdvancedPaste.Settings
public bool IsAdvancedAIEnabled { get; private set; }
public AdvancedPasteAIMode AIMode { get; private set; }
public bool IsLocalModelMode => AIMode == AdvancedPasteAIMode.LocalModel;
public string CustomEndpoint { get; private set; }
public string CustomModelName { get; private set; }
public bool DisableModeration { get; private set; }
public bool ShowCustomPreview { get; private set; }
public bool CloseAfterLosingFocus { get; private set; }
@@ -48,6 +58,10 @@ namespace AdvancedPaste.Settings
_settingsUtils = new SettingsUtils(fileSystem);
IsAdvancedAIEnabled = false;
AIMode = AdvancedPasteAIMode.Disabled;
CustomEndpoint = string.Empty;
CustomModelName = string.Empty;
DisableModeration = false;
ShowCustomPreview = true;
CloseAfterLosingFocus = false;
_additionalActions = [];
@@ -99,6 +113,34 @@ namespace AdvancedPaste.Settings
var properties = settings.Properties;
IsAdvancedAIEnabled = properties.IsAdvancedAIEnabled;
// Handle backwards compatibility for AIMode
if (properties.AIMode == AdvancedPasteAIMode.Disabled)
{
// Check if user has custom endpoint/model configured (local model mode)
if (!string.IsNullOrWhiteSpace(properties.CustomEndpoint) || !string.IsNullOrWhiteSpace(properties.CustomModelName))
{
AIMode = AdvancedPasteAIMode.LocalModel;
}
// Check if user has OpenAI key configured
else if (IsOpenAIKeyConfigured())
{
AIMode = AdvancedPasteAIMode.OpenAI;
}
else
{
AIMode = AdvancedPasteAIMode.Disabled;
}
}
else
{
AIMode = properties.AIMode;
}
CustomEndpoint = properties.CustomEndpoint;
CustomModelName = properties.CustomModelName;
DisableModeration = properties.DisableModeration;
ShowCustomPreview = properties.ShowCustomPreview;
CloseAfterLosingFocus = properties.CloseAfterLosingFocus;
@@ -144,6 +186,20 @@ namespace AdvancedPaste.Settings
}
}
private static bool IsOpenAIKeyConfigured()
{
try
{
var vault = new Windows.Security.Credentials.PasswordVault();
vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey");
return true;
}
catch
{
return false;
}
}
public void Dispose()
{
Dispose(true);

View File

@@ -3,54 +3,67 @@
// See the LICENSE file in the project root for more information.
using System;
using System.ClientModel;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Settings;
using AdvancedPaste.Telemetry;
using Azure;
using Azure.AI.OpenAI;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using OpenAI;
using OpenAI.Chat;
namespace AdvancedPaste.Services.OpenAI;
public sealed class CustomTextTransformService(IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService) : ICustomTextTransformService
public sealed class CustomTextTransformService(IUserSettings userSettings, IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService) : ICustomTextTransformService
{
private const string ModelName = "gpt-3.5-turbo-instruct";
private readonly IUserSettings _userSettings = userSettings;
private string ModelName => string.IsNullOrWhiteSpace(_userSettings.CustomModelName) ? "gpt-3.5-turbo-instruct" : _userSettings.CustomModelName;
private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider;
private readonly IPromptModerationService _promptModerationService = promptModerationService;
private async Task<Completions> GetAICompletionAsync(string systemInstructions, string userMessage, CancellationToken cancellationToken)
private async Task<ChatCompletion> GetAICompletionAsync(string systemInstructions, string userMessage, CancellationToken cancellationToken)
{
var fullPrompt = systemInstructions + "\n\n" + userMessage;
await _promptModerationService.ValidateAsync(fullPrompt, cancellationToken);
OpenAIClient azureAIClient = new(_aiCredentialsProvider.Key);
OpenAIClientOptions clientOptions = new();
if (!string.IsNullOrWhiteSpace(_userSettings.CustomEndpoint))
{
if (!Uri.TryCreate(_userSettings.CustomEndpoint, UriKind.Absolute, out var endpoint))
{
throw new ArgumentException($"Invalid custom endpoint URL: '{_userSettings.CustomEndpoint}'. Please ensure the URL includes the protocol (e.g., https://your-server.com/api) and is properly formatted.");
}
var response = await azureAIClient.GetCompletionsAsync(
clientOptions.Endpoint = endpoint;
}
OpenAIClient openAIClient = new(new ApiKeyCredential(_aiCredentialsProvider.Key), clientOptions);
var response = await openAIClient.GetChatClient(ModelName).CompleteChatAsync(
[
new SystemChatMessage(systemInstructions),
new UserChatMessage(userMessage)
],
new()
{
DeploymentName = ModelName,
Prompts =
{
fullPrompt,
},
Temperature = 0.01F,
MaxTokens = 2000,
MaxOutputTokenCount = 2000,
},
cancellationToken);
if (response.Value.Choices[0].FinishReason == "length")
if (response.Value.FinishReason == ChatFinishReason.Length)
{
Logger.LogDebug("Cut off due to length constraints");
}
return response;
return response.Value;
}
public async Task<string> TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress<double> progress)
@@ -85,13 +98,13 @@ Output:
var response = await GetAICompletionAsync(systemInstructions, userMessage, cancellationToken);
var usage = response.Usage;
AdvancedPasteGenerateCustomFormatEvent telemetryEvent = new(usage.PromptTokens, usage.CompletionTokens, ModelName);
AdvancedPasteGenerateCustomFormatEvent telemetryEvent = new(usage.InputTokenCount, usage.OutputTokenCount, ModelName);
PowerToysTelemetry.Log.WriteEvent(telemetryEvent);
var logEvent = new AIServiceFormatEvent(telemetryEvent);
Logger.LogDebug($"{nameof(TransformTextAsync)} complete; {logEvent.ToJsonString()}");
return response.Choices[0].Text;
return response.Content[0].Text;
}
catch (Exception ex)
{
@@ -106,7 +119,7 @@ Output:
}
else
{
throw new PasteActionException(ErrorHelpers.TranslateErrorText((ex as RequestFailedException)?.Status ?? -1), ex);
throw new PasteActionException(ErrorHelpers.TranslateErrorText((ex as ClientResultException)?.Status ?? -1), ex);
}
}
}

View File

@@ -2,21 +2,25 @@
// 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 AdvancedPaste.Models;
using Azure.AI.OpenAI;
using AdvancedPaste.Settings;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using ChatTokenUsage = OpenAI.Chat.ChatTokenUsage;
namespace AdvancedPaste.Services.OpenAI;
public sealed class KernelService(IKernelQueryCacheService queryCacheService, IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) :
public sealed class KernelService(IUserSettings userSettings, IKernelQueryCacheService queryCacheService, IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) :
KernelServiceBase(queryCacheService, promptModerationService, customTextTransformService)
{
private readonly IUserSettings _userSettings = userSettings;
private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider;
protected override string ModelName => "gpt-4o";
protected override string ModelName => string.IsNullOrWhiteSpace(_userSettings.CustomModelName) ? "gpt-4o" : _userSettings.CustomModelName;
protected override PromptExecutionSettings PromptExecutionSettings =>
new OpenAIPromptExecutionSettings()
@@ -25,10 +29,25 @@ public sealed class KernelService(IKernelQueryCacheService queryCacheService, IA
Temperature = 0.01,
};
protected override void AddChatCompletionService(IKernelBuilder kernelBuilder) => kernelBuilder.AddOpenAIChatCompletion(ModelName, _aiCredentialsProvider.Key);
protected override void AddChatCompletionService(IKernelBuilder kernelBuilder)
{
if (string.IsNullOrWhiteSpace(_userSettings.CustomEndpoint))
{
kernelBuilder.AddOpenAIChatCompletion(ModelName, _aiCredentialsProvider.Key);
}
else
{
if (!Uri.TryCreate(_userSettings.CustomEndpoint, UriKind.Absolute, out var endpoint))
{
throw new ArgumentException($"Invalid custom endpoint URL: '{_userSettings.CustomEndpoint}'. Please ensure the URL includes the protocol (e.g., https://your-server.com/api) and is properly formatted.");
}
kernelBuilder.AddOpenAIChatCompletion(ModelName, endpoint, _aiCredentialsProvider.Key);
}
}
protected override AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage) =>
chatMessage.Metadata?.GetValueOrDefault("Usage") is CompletionsUsage completionsUsage
? new(PromptTokens: completionsUsage.PromptTokens, CompletionTokens: completionsUsage.CompletionTokens)
chatMessage.Metadata?.GetValueOrDefault("Usage") is ChatTokenUsage completionsUsage
? new(PromptTokens: completionsUsage.InputTokenCount, CompletionTokens: completionsUsage.OutputTokenCount)
: AIServiceUsage.None;
}

View File

@@ -2,28 +2,50 @@
// 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.ClientModel;
using System.Threading;
using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Settings;
using ManagedCommon;
using OpenAI;
using OpenAI.Moderations;
namespace AdvancedPaste.Services.OpenAI;
public sealed class PromptModerationService(IAICredentialsProvider aiCredentialsProvider) : IPromptModerationService
public sealed class PromptModerationService(IUserSettings userSettings, IAICredentialsProvider aiCredentialsProvider) : IPromptModerationService
{
private readonly IUserSettings _userSettings = userSettings;
private const string ModelName = "omni-moderation-latest";
private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider;
public async Task ValidateAsync(string fullPrompt, CancellationToken cancellationToken)
{
if (_userSettings.DisableModeration)
{
Logger.LogDebug($"{nameof(PromptModerationService)}.{nameof(ValidateAsync)} skipped; moderation is disabled");
return;
}
try
{
ModerationClient moderationClient = new(ModelName, _aiCredentialsProvider.Key);
OpenAIClientOptions clientOptions = new();
if (!string.IsNullOrWhiteSpace(_userSettings.CustomEndpoint))
{
if (!Uri.TryCreate(_userSettings.CustomEndpoint, UriKind.Absolute, out var endpoint))
{
throw new ArgumentException($"Invalid custom endpoint URL: {_userSettings.CustomEndpoint}");
}
clientOptions.Endpoint = endpoint;
}
ModerationClient moderationClient = new(ModelName, new(_aiCredentialsProvider.Key), clientOptions);
var moderationClientResult = await moderationClient.ClassifyTextAsync(fullPrompt, cancellationToken);
var moderationResult = moderationClientResult.Value;

View File

@@ -120,6 +120,9 @@
<data name="AIMistakeNote.Text" xml:space="preserve">
<value>AI can make mistakes.</value>
</data>
<data name="CustomAIMistakeNote.Text" xml:space="preserve">
<value>You are using a custom model endpoint please verify all answers.</value>
</data>
<data name="ClipboardEmptyWarning" xml:space="preserve">
<value>Clipboard does not contain any usable formats</value>
</data>

View File

@@ -85,6 +85,14 @@ namespace AdvancedPaste.ViewModels
public bool IsAdvancedAIEnabled => IsCustomAIServiceEnabled && _userSettings.IsAdvancedAIEnabled;
public bool IsLocalModelMode => _userSettings.IsLocalModelMode;
public string CustomEndpoint => _userSettings.CustomEndpoint;
public string CustomModelName => _userSettings.CustomModelName;
public bool DisableModeration => _userSettings.DisableModeration;
public bool ClipboardHasData => AvailableClipboardFormats != ClipboardFormat.None;
public bool ClipboardHasDataForCustomAI => PasteFormat.SupportsClipboardFormats(CustomAIFormat, AvailableClipboardFormats);
@@ -167,6 +175,10 @@ namespace AdvancedPaste.ViewModels
OnPropertyChanged(nameof(ClipboardHasDataForCustomAI));
OnPropertyChanged(nameof(IsCustomAIAvailable));
OnPropertyChanged(nameof(IsAdvancedAIEnabled));
OnPropertyChanged(nameof(IsLocalModelMode));
OnPropertyChanged(nameof(CustomEndpoint));
OnPropertyChanged(nameof(CustomModelName));
OnPropertyChanged(nameof(DisableModeration));
EnqueueRefreshPasteFormats();
}
@@ -275,6 +287,9 @@ namespace AdvancedPaste.ViewModels
OnPropertyChanged(nameof(CustomAIUnavailableErrorText));
OnPropertyChanged(nameof(IsCustomAIServiceEnabled));
OnPropertyChanged(nameof(IsAdvancedAIEnabled));
OnPropertyChanged(nameof(CustomEndpoint));
OnPropertyChanged(nameof(CustomModelName));
OnPropertyChanged(nameof(DisableModeration));
OnPropertyChanged(nameof(IsCustomAIAvailable));
});
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#pragma once

View File

@@ -0,0 +1,13 @@
// 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
{
public enum AdvancedPasteAIMode
{
Disabled = 0,
OpenAI = 1,
LocalModel = 2,
}
}

View File

@@ -23,7 +23,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library
PasteAsJsonShortcut = new();
CustomActions = new();
AdditionalActions = new();
AIMode = AdvancedPasteAIMode.Disabled;
IsAdvancedAIEnabled = false;
CustomEndpoint = string.Empty;
CustomModelName = string.Empty;
DisableModeration = false;
ShowCustomPreview = true;
CloseAfterLosingFocus = false;
}
@@ -31,6 +35,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonConverter(typeof(BoolPropertyJsonConverter))]
public bool IsAdvancedAIEnabled { get; set; }
public AdvancedPasteAIMode AIMode { get; set; }
public string CustomEndpoint { get; set; }
public string CustomModelName { get; set; }
[JsonConverter(typeof(BoolPropertyJsonConverter))]
public bool DisableModeration { get; set; }
[JsonConverter(typeof(BoolPropertyJsonConverter))]
public bool ShowCustomPreview { get; set; }

View File

@@ -65,6 +65,12 @@
<FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE72E;" />
</InfoBar.IconSource>
</InfoBar>
<InfoBar
x:Uid="AdvancedPaste_LocalModelWarning"
IsClosable="False"
IsOpen="{x:Bind ViewModel.IsLocalModelMode, Mode=OneWay}"
Severity="Warning"
Visibility="{x:Bind ViewModel.IsLocalModelMode, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<tkcontrols:SettingsCard
Name="AdvancedPasteEnableAISettingsCard"
x:Uid="AdvancedPaste_EnableAISettingsCard"
@@ -72,17 +78,24 @@
<tkcontrols:SettingsCard.HeaderIcon>
<PathIcon Data="M128 766q0-42 24-77t65-48l178-57q32-11 61-30t52-42q50-50 71-114l58-179q13-40 48-65t78-26q42 0 77 24t50 65l58 177q21 66 72 117 49 50 117 72l176 58q43 14 69 48t26 80q0 41-25 76t-64 49l-178 58q-66 21-117 72-32 32-51 73t-33 84-26 83-30 73-45 51-71 20q-42 0-77-24t-49-65l-58-178q-8-25-19-47t-28-43q-34-43-77-68t-89-41-89-27-78-29-55-45-21-75zm1149 7q-76-29-145-53t-129-60-104-88-73-138l-57-176-67 176q-18 48-42 89t-60 78q-34 34-76 61t-89 43l-177 57q75 29 144 53t127 60 103 89 73 137l57 176 67-176q37-97 103-168t168-103l177-57zm-125 759q0-31 20-57t49-36l99-32q34-11 53-34t30-51 20-59 20-54 33-41 58-16q32 0 59 19t38 50q6 20 11 40t13 40 17 38 25 34q16 17 39 26t48 18 49 16 44 20 31 32 12 50q0 33-18 60t-51 38q-19 6-39 11t-41 13-39 17-34 25q-24 25-35 62t-24 73-35 61-68 25q-32 0-59-19t-38-50q-6-18-11-39t-13-41-17-40-24-33q-18-17-41-27t-47-17-49-15-43-20-30-33-12-54zm583 4q-43-13-74-30t-55-41-40-55-32-74q-12 41-29 72t-42 55-55 42-71 31q81 23 128 71t71 129q15-43 31-74t40-54 53-40 75-32z" />
</tkcontrols:SettingsCard.HeaderIcon>
<tkcontrols:SwitchPresenter TargetType="x:Boolean" Value="{x:Bind ViewModel.IsOpenAIEnabled, Mode=OneWay}">
<tkcontrols:Case Value="True">
<Button x:Uid="AdvancedPaste_DisableAIButton" Click="AdvancedPaste_DisableAIButton_Click" />
</tkcontrols:Case>
<tkcontrols:Case Value="False">
<Button
x:Uid="AdvancedPaste_EnableAIButton"
Click="AdvancedPaste_EnableAIButton_Click"
Style="{StaticResource AccentButtonStyle}" />
</tkcontrols:Case>
</tkcontrols:SwitchPresenter>
<ComboBox
x:Name="AIModeComboBox"
MinWidth="200"
ItemsSource="{x:Bind ViewModel.AIModeOptions, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedAIMode, Mode=TwoWay}"
SelectionChanged="AIModeComboBox_SelectionChanged">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock FontWeight="SemiBold" Text="{Binding DisplayName}" />
<TextBlock
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{Binding Description}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<tkcontrols:SettingsCard.Description>
<StackPanel Orientation="Vertical">
<TextBlock x:Uid="AdvancedPaste_EnableAISettingsCardDescription" />
@@ -94,9 +107,24 @@
Name="AdvancedPasteEnableAdvancedAI"
x:Uid="AdvancedPaste_EnableAdvancedAI"
HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/SemanticKernel.png}"
IsEnabled="{x:Bind ViewModel.IsOpenAIEnabled, Mode=OneWay}">
IsEnabled="{x:Bind ViewModel.IsAIEnabled, Mode=OneWay}">
<ToggleSwitch IsOn="{x:Bind ViewModel.IsAdvancedAIEnabled, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsExpander
x:Uid="AdvancedPaste_AdvancedSettings"
HeaderIcon="{ui:FontIcon Glyph=&#xEA86;}"
IsEnabled="{x:Bind ViewModel.ShowAdvancedSettings, Mode=OneWay}"
IsExpanded="False"
Visibility="{x:Bind ViewModel.ShowAdvancedSettings, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<tkcontrols:SettingsExpander.Items>
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_AdvancedSettings_CustomEndpoint">
<TextBox Text="{x:Bind ViewModel.CustomEndpoint, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_AdvancedSettings_CustomModelName">
<TextBox Text="{x:Bind ViewModel.CustomModelName, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
</controls:SettingsGroup>
<controls:SettingsGroup x:Uid="AdvancedPaste_BehaviorSettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.ShowClipboardHistoryIsGpoConfiguredInfoBar, Mode=OneWay}">

View File

@@ -48,26 +48,53 @@ namespace Microsoft.PowerToys.Settings.UI.Views
}
}
private async void AdvancedPaste_EnableAIButton_Click(object sender, RoutedEventArgs e)
private async void AIModeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
var resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader;
EnableAIDialog.PrimaryButtonText = resourceLoader.GetString("EnableAIDialog_SaveBtnText");
EnableAIDialog.SecondaryButtonText = resourceLoader.GetString("EnableAIDialog_CancelBtnText");
EnableAIDialog.PrimaryButtonCommand = SaveOpenAIKeyCommand;
var comboBox = sender as ComboBox;
var selectedItem = comboBox?.SelectedItem as AdvancedPasteViewModel.AIModeItem;
AdvancedPaste_EnableAIDialogOpenAIApiKey.Text = string.Empty;
if (selectedItem == null)
{
return;
}
await ShowEnableDialogAsync();
}
// Prevent recursion when updating the ViewModel
if (selectedItem.Mode == ViewModel.AIMode)
{
return;
}
private async Task ShowEnableDialogAsync()
{
await EnableAIDialog.ShowAsync();
}
// If user selects OpenAI but doesn't have a key, show the setup dialog
if (selectedItem.Mode == AdvancedPasteAIMode.OpenAI && !ViewModel.IsOpenAIEnabled)
{
var resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader;
EnableAIDialog.PrimaryButtonText = resourceLoader.GetString("EnableAIDialog_SaveBtnText");
EnableAIDialog.SecondaryButtonText = resourceLoader.GetString("EnableAIDialog_CancelBtnText");
EnableAIDialog.PrimaryButtonCommand = SaveOpenAIKeyCommand;
private void AdvancedPaste_DisableAIButton_Click(object sender, RoutedEventArgs e)
{
ViewModel.DisableAI();
AdvancedPaste_EnableAIDialogOpenAIApiKey.Text = string.Empty;
var result = await EnableAIDialog.ShowAsync();
// If user canceled the dialog, revert the selection
if (result != ContentDialogResult.Primary || string.IsNullOrEmpty(AdvancedPaste_EnableAIDialogOpenAIApiKey.Text))
{
// Revert ComboBox selection without triggering change event
comboBox.SelectionChanged -= AIModeComboBox_SelectionChanged;
comboBox.SelectedItem = ViewModel.SelectedAIMode;
comboBox.SelectionChanged += AIModeComboBox_SelectionChanged;
return;
}
}
// If user selects Disabled and had OpenAI enabled, disable it
if (selectedItem.Mode == AdvancedPasteAIMode.Disabled && ViewModel.IsOpenAIEnabled)
{
ViewModel.DisableAI();
}
// Update the ViewModel (this will handle the actual mode change)
ViewModel.AIMode = selectedItem.Mode;
}
private void AdvancedPaste_EnableAIDialogOpenAIApiKey_TextChanged(object sender, TextChangedEventArgs e)

View File

@@ -638,6 +638,30 @@ opera.exe</value>
<data name="AdvancedPaste_Additional_Actions_GroupSettings.Header" xml:space="preserve">
<value>Additional actions</value>
</data>
<data name="AdvancedPaste_AdvancedSettings.Header" xml:space="preserve">
<value>Advanced settings</value>
</data>
<data name="AdvancedPaste_AdvancedSettings.Description" xml:space="preserve">
<value>These settings allow you to connect to self-hosted models or alternative providers, which are compatible with the OpenAI API specification.</value>
</data>
<data name="AdvancedPaste_AdvancedSettings_CustomEndpoint.Header" xml:space="preserve">
<value>Custom endpoint</value>
</data>
<data name="AdvancedPaste_AdvancedSettings_CustomEndpoint.Description" xml:space="preserve">
<value>Enter the base URL of the OpenAI API-compatible service you wish to use (e.g., https://generativelanguage.googleapis.com/v1beta or https://api.anthropic.com/v1). This will override the default endpoint.</value>
</data>
<data name="AdvancedPaste_AdvancedSettings_CustomModelName.Header" xml:space="preserve">
<value>Custom model name</value>
</data>
<data name="AdvancedPaste_AdvancedSettings_CustomModelName.Description" xml:space="preserve">
<value>Specify the exact model identifier that your custom endpoint expects (e.g., claude-3-7-sonnet, gemini-2.5-flash, my-custom-finetune). Consult your provider's documentation for the correct name.</value>
</data>
<data name="AdvancedPaste_AdvancedSettings_DisableModeration.Header" xml:space="preserve">
<value>Disable moderation</value>
</data>
<data name="AdvancedPaste_AdvancedSettings_DisableModeration.Description" xml:space="preserve">
<value>Disable the built-in moderation checks for the selected model. This is not recommended unless you are using a custom model that has its own moderation capabilities.</value>
</data>
<data name="RemapKeysList.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Current key remappings</value>
</data>
@@ -3931,6 +3955,12 @@ Activate by holding the key for the character you want to add an accent to, then
<data name="AdvancedPaste_EnableAdvancedAI.Description" xml:space="preserve">
<value>Add advanced capabilities when using 'Paste with AI' including the power to 'chain' multiple transformations together and work with images and files. This feature may consume more API credits when used.</value>
</data>
<data name="AdvancedPaste_LocalModelWarning.Title" xml:space="preserve">
<value>Local Model Warning</value>
</data>
<data name="AdvancedPaste_LocalModelWarning.Message" xml:space="preserve">
<value>When using a local model, PowerToys will send your clipboard data to the endpoint you specify. Ensure you trust the endpoint and understand the privacy implications. Moderation is automatically disabled for local models.</value>
</data>
<data name="Oobe_AdvancedPaste.Description" xml:space="preserve">
<value>Advanced Paste is a tool to put your clipboard content into any format you need, focused towards developer workflows. It can paste as plain text, markdown, or json directly with the UX or with a direct keystroke invoke. These are fully locally executed. In addition, it has an AI powered option that is 100% opt-in and requires an Open AI key. Note: this will replace the formatted text in your clipboard with the selected format.</value>
</data>

View File

@@ -361,6 +361,113 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public AdvancedPasteAIMode AIMode
{
get => _advancedPasteSettings.Properties.AIMode;
set
{
if (value != _advancedPasteSettings.Properties.AIMode)
{
_advancedPasteSettings.Properties.AIMode = value;
// Auto-disable moderation for local models
if (value == AdvancedPasteAIMode.LocalModel)
{
_advancedPasteSettings.Properties.DisableModeration = true;
}
else if (value == AdvancedPasteAIMode.OpenAI)
{
_advancedPasteSettings.Properties.DisableModeration = false;
}
OnPropertyChanged(nameof(AIMode));
OnPropertyChanged(nameof(IsAIEnabled));
OnPropertyChanged(nameof(IsLocalModelMode));
OnPropertyChanged(nameof(ShowAdvancedSettings));
OnPropertyChanged(nameof(DisableModeration));
OnPropertyChanged(nameof(SelectedAIMode));
NotifySettingsChanged();
}
}
}
public bool IsAIEnabled => AIMode != AdvancedPasteAIMode.Disabled && IsOpenAIEnabled;
public bool IsLocalModelMode => AIMode == AdvancedPasteAIMode.LocalModel;
public bool ShowAdvancedSettings => IsLocalModelMode;
public class AIModeItem
{
public AdvancedPasteAIMode Mode { get; set; }
public string DisplayName { get; set; }
public string Description { get; set; }
}
public AIModeItem[] AIModeOptions { get; } = new[]
{
new AIModeItem { Mode = AdvancedPasteAIMode.Disabled, DisplayName = "Disabled", Description = "AI features are disabled" },
new AIModeItem { Mode = AdvancedPasteAIMode.OpenAI, DisplayName = "OpenAI", Description = "Use OpenAI cloud service" },
new AIModeItem { Mode = AdvancedPasteAIMode.LocalModel, DisplayName = "Local model", Description = "Use a local AI model endpoint" },
};
public AIModeItem SelectedAIMode
{
get => AIModeOptions.FirstOrDefault(x => x.Mode == AIMode) ?? AIModeOptions[0];
set
{
if (value != null && value.Mode != AIMode)
{
AIMode = value.Mode;
OnPropertyChanged(nameof(SelectedAIMode));
}
}
}
public string CustomEndpoint
{
get => _advancedPasteSettings.Properties.CustomEndpoint;
set
{
if (value != _advancedPasteSettings.Properties.CustomEndpoint)
{
_advancedPasteSettings.Properties.CustomEndpoint = value;
OnPropertyChanged(nameof(CustomEndpoint));
NotifySettingsChanged();
}
}
}
public string CustomModelName
{
get => _advancedPasteSettings.Properties.CustomModelName;
set
{
if (value != _advancedPasteSettings.Properties.CustomModelName)
{
_advancedPasteSettings.Properties.CustomModelName = value;
OnPropertyChanged(nameof(CustomModelName));
NotifySettingsChanged();
}
}
}
public bool DisableModeration
{
get => _advancedPasteSettings.Properties.DisableModeration;
set
{
if (value != _advancedPasteSettings.Properties.DisableModeration)
{
_advancedPasteSettings.Properties.DisableModeration = value;
OnPropertyChanged(nameof(DisableModeration));
NotifySettingsChanged();
}
}
}
public bool ShowCustomPreview
{
get => _advancedPasteSettings.Properties.ShowCustomPreview;
@@ -460,7 +567,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
PasswordVault vault = new PasswordVault();
PasswordCredential cred = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey");
vault.Remove(cred);
AIMode = AdvancedPasteAIMode.Disabled;
OnPropertyChanged(nameof(IsOpenAIEnabled));
OnPropertyChanged(nameof(SelectedAIMode));
NotifySettingsChanged();
}
catch (Exception)
@@ -475,7 +584,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
PasswordVault vault = new();
PasswordCredential cred = new("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey", password);
vault.Add(cred);
AIMode = AdvancedPasteAIMode.OpenAI; // Default to OpenAI when enabling
OnPropertyChanged(nameof(IsOpenAIEnabled));
OnPropertyChanged(nameof(SelectedAIMode));
IsAdvancedAIEnabled = true; // new users should get Semantic Kernel benefits immediately
NotifySettingsChanged();
}