diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs index b6b6c19734..cf93f10796 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs @@ -163,28 +163,143 @@ namespace AdvancedPaste.Settings return false; } - if (settings.Properties.IsAIEnabled || !LegacyOpenAIKeyExists()) + var properties = settings.Properties; + var configuration = properties.PasteAIConfiguration; + + if (configuration is null) + { + configuration = new PasteAIConfiguration(); + properties.PasteAIConfiguration = configuration; + } + + bool hasLegacyProviders = configuration.LegacyProviderConfigurations is { Count: > 0 }; + bool legacyAdvancedAIConsumed = properties.TryConsumeLegacyAdvancedAIEnabled(out var advancedFlag); + bool legacyAdvancedAIEnabled = legacyAdvancedAIConsumed && advancedFlag; + PasswordCredential legacyCredential = TryGetLegacyOpenAICredential(); + + if (!hasLegacyProviders && legacyCredential is null && !legacyAdvancedAIConsumed) { return false; } - settings.Properties.IsAIEnabled = true; - return true; + bool configurationUpdated = false; + + if (hasLegacyProviders) + { + configurationUpdated |= AdvancedPasteMigrationHelper.MigrateLegacyProviderConfigurations(configuration); + } + + PasteAIProviderDefinition openAIProvider = null; + if (legacyCredential is not null || hasLegacyProviders || legacyAdvancedAIConsumed) + { + var ensureResult = AdvancedPasteMigrationHelper.EnsureOpenAIProvider(configuration); + openAIProvider = ensureResult.Provider; + configurationUpdated |= ensureResult.Updated; + } + + if (legacyAdvancedAIConsumed && openAIProvider is not null && openAIProvider.EnableAdvancedAI != legacyAdvancedAIEnabled) + { + openAIProvider.EnableAdvancedAI = legacyAdvancedAIEnabled; + configurationUpdated = true; + } + + if (legacyCredential is not null && openAIProvider is not null) + { + StoreMigratedOpenAICredential(openAIProvider.Id, openAIProvider.ServiceType, legacyCredential.Password); + RemoveLegacyOpenAICredential(); + } + + bool enabledUpdated = false; + if (!properties.IsAIEnabled && legacyCredential is not null) + { + properties.IsAIEnabled = true; + enabledUpdated = true; + } + + return configurationUpdated || enabledUpdated || legacyAdvancedAIConsumed; } - private static bool LegacyOpenAIKeyExists() + private static PasswordCredential TryGetLegacyOpenAICredential() { try { PasswordVault vault = new(); - return vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey") is not null; + var credential = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + credential?.RetrievePassword(); + return credential; } catch (Exception) { - return false; + return null; } } + private static void RemoveLegacyOpenAICredential() + { + try + { + PasswordVault vault = new(); + TryRemoveCredential(vault, "https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + } + catch (Exception) + { + } + } + + private static void StoreMigratedOpenAICredential(string providerId, string serviceType, string password) + { + if (string.IsNullOrWhiteSpace(password)) + { + return; + } + + try + { + var serviceKind = serviceType.ToAIServiceType(); + if (serviceKind != AIServiceType.OpenAI) + { + return; + } + + string resource = "https://platform.openai.com/api-keys"; + string username = $"PowerToys_AdvancedPaste_PasteAI_openai_{NormalizeProviderIdentifier(providerId)}"; + + PasswordVault vault = new(); + TryRemoveCredential(vault, resource, username); + + PasswordCredential credential = new(resource, username, password); + vault.Add(credential); + } + catch (Exception ex) + { + Logger.LogError("Failed to migrate legacy OpenAI credential", ex); + } + } + + private static void TryRemoveCredential(PasswordVault vault, string credentialResource, string credentialUserName) + { + try + { + PasswordCredential existingCred = vault.Retrieve(credentialResource, credentialUserName); + vault.Remove(existingCred); + } + catch (Exception) + { + // Credential doesn't exist, which is fine + } + } + + private static string NormalizeProviderIdentifier(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "default"; + } + + var filtered = new string(providerId.Where(char.IsLetterOrDigit).ToArray()); + return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant(); + } + public async Task SetActiveAIProviderAsync(string providerId) { if (string.IsNullOrWhiteSpace(providerId)) diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs new file mode 100644 index 0000000000..8a606ad83a --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Helper methods for migrating legacy Advanced Paste settings to the updated schema. + /// + public static class AdvancedPasteMigrationHelper + { + /// + /// Moves legacy provider configuration snapshots into the strongly-typed providers collection. + /// + /// The configuration instance to migrate. + /// True if the configuration was modified. + public static bool MigrateLegacyProviderConfigurations(PasteAIConfiguration configuration) + { + if (configuration is null) + { + return false; + } + + configuration.Providers ??= new ObservableCollection(); + + bool configurationUpdated = false; + + if (configuration.LegacyProviderConfigurations is { Count: > 0 }) + { + foreach (var entry in configuration.LegacyProviderConfigurations) + { + var result = EnsureProvider(configuration, entry.Key, entry.Value); + configurationUpdated |= result.Updated; + } + + configuration.LegacyProviderConfigurations = null; + } + + configurationUpdated |= EnsureActiveProviderIsValid(configuration); + + return configurationUpdated; + } + + /// + /// Ensures an OpenAI provider exists in the configuration, creating one if necessary. + /// + /// The configuration instance. + /// The ensured provider and a flag indicating whether changes were made. + public static (PasteAIProviderDefinition Provider, bool Updated) EnsureOpenAIProvider(PasteAIConfiguration configuration) + { + return EnsureProvider(configuration, AIServiceType.OpenAI.ToConfigurationString(), null); + } + + /// + /// Ensures a provider for the supplied service type exists, optionally applying a legacy snapshot. + /// + /// The configuration instance. + /// The persisted service type key. + /// An optional snapshot containing legacy values. + /// The ensured provider and whether the configuration was updated. + public static (PasteAIProviderDefinition Provider, bool Updated) EnsureProvider(PasteAIConfiguration configuration, string serviceTypeKey, AIProviderConfigurationSnapshot snapshot) + { + if (configuration is null) + { + return (null, false); + } + + configuration.Providers ??= new ObservableCollection(); + + var normalizedServiceType = NormalizeServiceType(serviceTypeKey); + var existingProvider = configuration.Providers.FirstOrDefault(provider => string.Equals(provider.ServiceType, normalizedServiceType, StringComparison.OrdinalIgnoreCase)); + bool configurationUpdated = false; + + if (existingProvider is null) + { + existingProvider = CreateProvider(normalizedServiceType, snapshot); + configuration.Providers.Add(existingProvider); + configurationUpdated = true; + } + else if (snapshot is not null) + { + configurationUpdated |= ApplySnapshot(existingProvider, snapshot); + } + + configurationUpdated |= EnsureActiveProviderIsValid(configuration, existingProvider); + + return (existingProvider, configurationUpdated); + } + + private static string NormalizeServiceType(string serviceTypeKey) + { + var serviceType = serviceTypeKey.ToAIServiceType(); + return serviceType.ToConfigurationString(); + } + + private static PasteAIProviderDefinition CreateProvider(string serviceTypeKey, AIProviderConfigurationSnapshot snapshot) + { + var serviceType = serviceTypeKey.ToAIServiceType(); + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + var provider = new PasteAIProviderDefinition + { + ServiceType = serviceTypeKey, + ModelName = !string.IsNullOrWhiteSpace(snapshot?.ModelName) ? snapshot.ModelName : PasteAIProviderDefaults.GetDefaultModelName(serviceType), + EndpointUrl = snapshot?.EndpointUrl ?? string.Empty, + ApiVersion = snapshot?.ApiVersion ?? string.Empty, + DeploymentName = snapshot?.DeploymentName ?? string.Empty, + ModelPath = snapshot?.ModelPath ?? string.Empty, + SystemPrompt = snapshot?.SystemPrompt ?? string.Empty, + ModerationEnabled = snapshot?.ModerationEnabled ?? true, + IsLocalModel = metadata.IsLocalModel, + }; + + return provider; + } + + private static bool ApplySnapshot(PasteAIProviderDefinition provider, AIProviderConfigurationSnapshot snapshot) + { + bool updated = false; + + if (!string.IsNullOrWhiteSpace(snapshot.ModelName) && !string.Equals(provider.ModelName, snapshot.ModelName, StringComparison.Ordinal)) + { + provider.ModelName = snapshot.ModelName; + updated = true; + } + + if (!string.IsNullOrWhiteSpace(snapshot.EndpointUrl) && !string.Equals(provider.EndpointUrl, snapshot.EndpointUrl, StringComparison.Ordinal)) + { + provider.EndpointUrl = snapshot.EndpointUrl; + updated = true; + } + + if (!string.IsNullOrWhiteSpace(snapshot.ApiVersion) && !string.Equals(provider.ApiVersion, snapshot.ApiVersion, StringComparison.Ordinal)) + { + provider.ApiVersion = snapshot.ApiVersion; + updated = true; + } + + if (!string.IsNullOrWhiteSpace(snapshot.DeploymentName) && !string.Equals(provider.DeploymentName, snapshot.DeploymentName, StringComparison.Ordinal)) + { + provider.DeploymentName = snapshot.DeploymentName; + updated = true; + } + + if (!string.IsNullOrWhiteSpace(snapshot.ModelPath) && !string.Equals(provider.ModelPath, snapshot.ModelPath, StringComparison.Ordinal)) + { + provider.ModelPath = snapshot.ModelPath; + updated = true; + } + + if (!string.IsNullOrWhiteSpace(snapshot.SystemPrompt) && !string.Equals(provider.SystemPrompt, snapshot.SystemPrompt, StringComparison.Ordinal)) + { + provider.SystemPrompt = snapshot.SystemPrompt; + updated = true; + } + + if (provider.ModerationEnabled != snapshot.ModerationEnabled) + { + provider.ModerationEnabled = snapshot.ModerationEnabled; + updated = true; + } + + return updated; + } + + private static bool EnsureActiveProviderIsValid(PasteAIConfiguration configuration, PasteAIProviderDefinition preferredProvider = null) + { + if (configuration?.Providers is null || configuration.Providers.Count == 0) + { + if (!string.IsNullOrWhiteSpace(configuration?.ActiveProviderId)) + { + configuration.ActiveProviderId = string.Empty; + return true; + } + + return false; + } + + bool updated = false; + + var activeProvider = configuration.Providers.FirstOrDefault(provider => string.Equals(provider.Id, configuration.ActiveProviderId, StringComparison.OrdinalIgnoreCase)); + if (activeProvider is null) + { + activeProvider = preferredProvider ?? configuration.Providers.First(); + configuration.ActiveProviderId = activeProvider.Id; + updated = true; + } + + foreach (var provider in configuration.Providers) + { + bool shouldBeActive = string.Equals(provider.Id, configuration.ActiveProviderId, StringComparison.OrdinalIgnoreCase); + if (provider.IsActive != shouldBeActive) + { + provider.IsActive = shouldBeActive; + updated = true; + } + } + + return updated; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs index 200e5e459d..3c377900f8 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs @@ -52,10 +52,68 @@ namespace Microsoft.PowerToys.Settings.UI.Library _extensionData.Remove("IsOpenAIEnabled"); } + + if (_extensionData != null && _extensionData.TryGetValue("IsAdvancedAIEnabled", out var legacyAdvancedElement)) + { + bool? legacyValue = legacyAdvancedElement.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Object when legacyAdvancedElement.TryGetProperty("value", out var advancedValueElement) => advancedValueElement.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null, + }, + _ => null, + }; + + if (legacyValue.HasValue) + { + LegacyAdvancedAIEnabled = legacyValue.Value; + } + + _extensionData.Remove("IsAdvancedAIEnabled"); + } } } private Dictionary _extensionData; + private bool? _legacyAdvancedAIEnabled; + + [JsonPropertyName("IsAdvancedAIEnabled")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public BoolProperty LegacyAdvancedAIEnabledProperty + { + get => null; + set + { + if (value is not null) + { + LegacyAdvancedAIEnabled = value.Value; + } + } + } + + [JsonIgnore] + public bool? LegacyAdvancedAIEnabled + { + get => _legacyAdvancedAIEnabled; + private set => _legacyAdvancedAIEnabled = value; + } + + public bool TryConsumeLegacyAdvancedAIEnabled(out bool value) + { + if (_legacyAdvancedAIEnabled is bool flag) + { + value = flag; + _legacyAdvancedAIEnabled = null; + return true; + } + + value = default; + return false; + } [JsonConverter(typeof(BoolPropertyJsonConverter))] public bool ShowCustomPreview { get; set; } diff --git a/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.cs b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.cs new file mode 100644 index 0000000000..1ccfa753fa --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Provides default values for Paste AI provider definitions. + /// + public static class PasteAIProviderDefaults + { + /// + /// Gets the default model name for a given AI service type. + /// + public static string GetDefaultModelName(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "gpt-4o", + AIServiceType.AzureOpenAI => "gpt-4o", + AIServiceType.Mistral => "mistral-large-latest", + AIServiceType.Google => "gemini-1.5-pro", + AIServiceType.AzureAIInference => "gpt-4o-mini", + AIServiceType.Ollama => "llama3", + _ => string.Empty, + }; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml index 8d6b0afa68..457615bfe5 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml @@ -499,7 +499,7 @@ MinWidth="200" HorizontalAlignment="Stretch" Header="Model name" - PlaceholderText="gpt-4" + PlaceholderText="gpt-4o" Text="{x:Bind ViewModel.PasteAIProviderDraft.ModelName, Mode=TwoWay}" /> 0 }; + PasswordCredential legacyCredential = TryGetLegacyOpenAICredential(); + + if (!hasLegacyProviders && legacyCredential is null && !legacyAdvancedAIConsumed) { return; } - _advancedPasteSettings.Properties.IsAIEnabled = true; - SaveAndNotifySettings(); - OnPropertyChanged(nameof(IsAIEnabled)); + bool configurationUpdated = false; + + if (hasLegacyProviders) + { + configurationUpdated |= AdvancedPasteMigrationHelper.MigrateLegacyProviderConfigurations(configuration); + } + + PasteAIProviderDefinition openAIProvider = null; + if (legacyCredential is not null || hasLegacyProviders || legacyAdvancedAIConsumed) + { + var ensureResult = AdvancedPasteMigrationHelper.EnsureOpenAIProvider(configuration); + openAIProvider = ensureResult.Provider; + configurationUpdated |= ensureResult.Updated; + } + + if (legacyAdvancedAIConsumed && openAIProvider is not null && openAIProvider.EnableAdvancedAI != legacyAdvancedAIEnabled) + { + openAIProvider.EnableAdvancedAI = legacyAdvancedAIEnabled; + configurationUpdated = true; + } + + if (legacyCredential is not null && openAIProvider is not null) + { + SavePasteAIApiKey(openAIProvider.Id, openAIProvider.ServiceType, legacyCredential.Password); + RemoveLegacyOpenAICredential(); + } + + bool enabledChanged = false; + if (!properties.IsAIEnabled && legacyCredential is not null) + { + properties.IsAIEnabled = true; + enabledChanged = true; + } + + bool shouldPersist = configurationUpdated || enabledChanged || legacyAdvancedAIConsumed; + + if (shouldPersist) + { + SaveAndNotifySettings(); + + if (configurationUpdated) + { + OnPropertyChanged(nameof(PasteAIConfiguration)); + } + + if (enabledChanged) + { + OnPropertyChanged(nameof(IsAIEnabled)); + } + } } public bool IsEnabled @@ -229,34 +300,30 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public bool IsAIEnabled => _advancedPasteSettings.Properties.IsAIEnabled && !IsOnlineAIModelsDisallowedByGPO; - private bool LegacyOpenAIKeyExists() + private PasswordCredential TryGetLegacyOpenAICredential() { try { PasswordVault vault = new(); - - // return vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey") is not null; - var legacyOpenAIKey = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); - if (legacyOpenAIKey != null) - { - string credentialResource = GetAICredentialResource("OpenAI"); - var targetProvider = PasteAIConfiguration?.ActiveProvider ?? PasteAIConfiguration?.Providers?.FirstOrDefault(); - string providerId = targetProvider?.Id ?? string.Empty; - string serviceType = targetProvider?.ServiceType ?? "OpenAI"; - string credentialUserName = GetPasteAICredentialUserName(providerId, serviceType); - PasswordCredential cred = new(credentialResource, credentialUserName, legacyOpenAIKey.Password); - vault.Add(cred); - - // delete old key - TryRemoveCredential(vault, "https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); - return true; - } - - return false; + var credential = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + credential?.RetrievePassword(); + return credential; + } + catch (Exception) + { + return null; + } + } + + private void RemoveLegacyOpenAICredential() + { + try + { + PasswordVault vault = new(); + TryRemoveCredential(vault, "https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); } catch (Exception) { - return false; } } @@ -519,7 +586,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels var provider = new PasteAIProviderDefinition { ServiceType = persistedServiceType, - ModelName = GetDefaultModelName(normalizedServiceType), + ModelName = PasteAIProviderDefaults.GetDefaultModelName(normalizedServiceType), EndpointUrl = string.Empty, ApiVersion = string.Empty, DeploymentName = string.Empty, @@ -559,20 +626,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels return serviceTypeKind; } - private static string GetDefaultModelName(AIServiceType serviceType) - { - return serviceType switch - { - AIServiceType.OpenAI => "gpt-4", - AIServiceType.AzureOpenAI => "gpt-4", - AIServiceType.Mistral => "mistral-large-latest", - AIServiceType.Google => "gemini-2.5-pro", - AIServiceType.AzureAIInference => "gpt-4o-mini", - AIServiceType.Ollama => "llama3", - _ => string.Empty, - }; - } - public bool IsServiceTypeAllowedByGPO(AIServiceType serviceType) { var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); @@ -1352,7 +1405,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } pasteConfig.Providers ??= new ObservableCollection(); + + bool configurationUpdated = AdvancedPasteMigrationHelper.MigrateLegacyProviderConfigurations(pasteConfig); + SubscribeToPasteAIProviders(pasteConfig); + + if (configurationUpdated) + { + SaveAndNotifySettings(); + OnPropertyChanged(nameof(PasteAIConfiguration)); + } } private static string RetrieveCredentialValue(string credentialResource, string credentialUserName)