/ src / settings-ui / Settings.UI / SettingsXAML / Views / AdvancedPastePage.xaml.cs
AdvancedPastePage.xaml.cs
   1  // Copyright (c) Microsoft Corporation
   2  // The Microsoft Corporation licenses this file to you under the MIT license.
   3  // See the LICENSE file in the project root for more information.
   4  
   5  using System;
   6  using System.Collections.Generic;
   7  using System.Collections.ObjectModel;
   8  using System.ComponentModel;
   9  using System.Linq;
  10  using System.Runtime.InteropServices;
  11  using System.Threading;
  12  using System.Threading.Tasks;
  13  using System.Windows.Input;
  14  
  15  using LanguageModelProvider;
  16  using Microsoft.PowerToys.Settings.UI.Controls;
  17  using Microsoft.PowerToys.Settings.UI.Helpers;
  18  using Microsoft.PowerToys.Settings.UI.Library;
  19  using Microsoft.PowerToys.Settings.UI.ViewModels;
  20  using Microsoft.UI.Xaml;
  21  using Microsoft.UI.Xaml.Controls;
  22  using Microsoft.UI.Xaml.Media;
  23  using Microsoft.UI.Xaml.Media.Imaging;
  24  
  25  namespace Microsoft.PowerToys.Settings.UI.Views
  26  {
  27      public sealed partial class AdvancedPastePage : NavigablePage, IRefreshablePage, IDisposable
  28      {
  29          private readonly ObservableCollection<ModelDetails> _foundryCachedModels = new();
  30          private CancellationTokenSource _foundryModelLoadCts;
  31          private bool _suppressFoundrySelectionChanged;
  32          private bool _isFoundryLocalAvailable;
  33          private bool _disposed;
  34          private const string PasteAiDialogDefaultTitle = "Paste with AI provider configuration";
  35  
  36          private const string AdvancedAISystemPrompt = "You are an agent who is tasked with helping users paste their clipboard data. You have functions available to help you with this task. Call function when necessary to help user finish the transformation task. You never need to ask permission, always try to do as the user asks. The user will only input one message and will not be available for further questions, so try your best. The user will put in a request to format their clipboard data and you will fulfill it. Do not output anything else besides the reformatted clipboard content.";
  37          private const string SimpleAISystemPrompt = "You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. Do not output anything else besides the reformatted clipboard content.";
  38          private static readonly string AdvancedAISystemPromptNormalized = AdvancedAISystemPrompt.Trim();
  39          private static readonly string SimpleAISystemPromptNormalized = SimpleAISystemPrompt.Trim();
  40  
  41          private AdvancedPasteViewModel ViewModel { get; set; }
  42  
  43          public ICommand EnableAdvancedPasteAICommand => new RelayCommand(EnableAdvancedPasteAI);
  44  
  45          public AdvancedPastePage()
  46          {
  47              var settingsUtils = SettingsUtils.Default;
  48              ViewModel = new AdvancedPasteViewModel(
  49                  settingsUtils,
  50                  SettingsRepository<GeneralSettings>.GetInstance(settingsUtils),
  51                  SettingsRepository<AdvancedPasteSettings>.GetInstance(settingsUtils),
  52                  ShellPage.SendDefaultIPCMessage);
  53              DataContext = ViewModel;
  54              InitializeComponent();
  55  
  56              if (FoundryLocalPicker is not null)
  57              {
  58                  FoundryLocalPicker.CachedModels = _foundryCachedModels;
  59                  FoundryLocalPicker.SelectionChanged += FoundryLocalPicker_SelectionChanged;
  60                  FoundryLocalPicker.LoadRequested += FoundryLocalPicker_LoadRequested;
  61              }
  62  
  63              Loaded += async (s, e) =>
  64              {
  65                  ViewModel.OnPageLoaded();
  66                  UpdatePasteAIUIVisibility();
  67                  await UpdateFoundryLocalUIAsync();
  68              };
  69  
  70              Unloaded += (_, _) =>
  71              {
  72                  if (_foundryModelLoadCts is not null)
  73                  {
  74                      _foundryModelLoadCts.Cancel();
  75                      _foundryModelLoadCts.Dispose();
  76                      _foundryModelLoadCts = null;
  77                  }
  78              };
  79          }
  80  
  81          public void RefreshEnabledState()
  82          {
  83              ViewModel.RefreshEnabledState();
  84              UpdatePasteAIUIVisibility();
  85              _ = UpdateFoundryLocalUIAsync();
  86          }
  87  
  88          private void EnableAdvancedPasteAI() => ViewModel.EnableAI();
  89  
  90          private void AdvancedPaste_EnableAIToggle_Toggled(object sender, RoutedEventArgs e)
  91          {
  92              if (ViewModel is null)
  93              {
  94                  return;
  95              }
  96  
  97              var toggle = (ToggleSwitch)sender;
  98  
  99              if (toggle.IsOn)
 100              {
 101                  ViewModel.EnableAI();
 102              }
 103              else
 104              {
 105                  ViewModel.DisableAI();
 106              }
 107          }
 108  
 109          public async void DeleteCustomActionButton_Click(object sender, RoutedEventArgs e)
 110          {
 111              var customAction = GetBoundCustomAction(sender, e);
 112              var resourceLoader = ResourceLoaderInstance.ResourceLoader;
 113  
 114              ContentDialog dialog = new()
 115              {
 116                  XamlRoot = RootPage.XamlRoot,
 117                  Title = customAction.Name,
 118                  PrimaryButtonText = resourceLoader.GetString("Yes"),
 119                  CloseButtonText = resourceLoader.GetString("No"),
 120                  DefaultButton = ContentDialogButton.Primary,
 121                  Content = new TextBlock() { Text = resourceLoader.GetString("Delete_Dialog_Description") },
 122              };
 123  
 124              dialog.PrimaryButtonClick += (_, _) => ViewModel.DeleteCustomAction(customAction);
 125  
 126              await dialog.ShowAsync();
 127          }
 128  
 129          private async void AddCustomActionButton_Click(object sender, RoutedEventArgs e)
 130          {
 131              var resourceLoader = ResourceLoaderInstance.ResourceLoader;
 132  
 133              CustomActionDialog.Title = resourceLoader.GetString("AddCustomAction");
 134              CustomActionDialog.DataContext = ViewModel.GetNewCustomAction(resourceLoader.GetString("AdvancedPasteUI_NewCustomActionPrefix"));
 135              CustomActionDialog.PrimaryButtonText = resourceLoader.GetString("CustomActionSave");
 136              await CustomActionDialog.ShowAsync();
 137          }
 138  
 139          private async void EditCustomActionButton_Click(object sender, RoutedEventArgs e)
 140          {
 141              var resourceLoader = ResourceLoaderInstance.ResourceLoader;
 142  
 143              CustomActionDialog.Title = resourceLoader.GetString("EditCustomAction");
 144              CustomActionDialog.DataContext = GetBoundCustomAction(sender, e).Clone();
 145              CustomActionDialog.PrimaryButtonText = resourceLoader.GetString("CustomActionUpdate");
 146              await CustomActionDialog.ShowAsync();
 147          }
 148  
 149          private void ReorderButtonDown_Click(object sender, RoutedEventArgs e)
 150          {
 151              var index = ViewModel.CustomActions.IndexOf(GetBoundCustomAction(sender, e));
 152              ViewModel.CustomActions.Move(index, index + 1);
 153          }
 154  
 155          private void ReorderButtonUp_Click(object sender, RoutedEventArgs e)
 156          {
 157              var index = ViewModel.CustomActions.IndexOf(GetBoundCustomAction(sender, e));
 158              ViewModel.CustomActions.Move(index, index - 1);
 159          }
 160  
 161          private void CustomActionDialog_Closed(ContentDialog sender, ContentDialogClosedEventArgs args)
 162          {
 163              if (args.Result != ContentDialogResult.Primary)
 164              {
 165                  return;
 166              }
 167  
 168              var dialogCustomAction = GetBoundCustomAction(sender, args);
 169              var existingCustomAction = ViewModel.CustomActions.FirstOrDefault(candidate => candidate.Id == dialogCustomAction.Id);
 170  
 171              if (existingCustomAction == null)
 172              {
 173                  ViewModel.AddCustomAction(dialogCustomAction);
 174              }
 175              else
 176              {
 177                  existingCustomAction.Update(dialogCustomAction);
 178              }
 179          }
 180  
 181          private AdvancedPasteCustomAction GetBoundCustomAction(object sender, object eventArgs = null)
 182          {
 183              if (TryResolveCustomAction(sender, out var action))
 184              {
 185                  return action;
 186              }
 187  
 188              if (eventArgs is RoutedEventArgs routedEventArgs && TryResolveCustomAction(routedEventArgs.OriginalSource, out action))
 189              {
 190                  return action;
 191              }
 192  
 193              if (CustomActionDialog?.DataContext is AdvancedPasteCustomAction dialogAction)
 194              {
 195                  return dialogAction;
 196              }
 197  
 198              throw new InvalidOperationException("Unable to determine Advanced Paste custom action from sender.");
 199          }
 200  
 201          private static bool TryResolveCustomAction(object source, out AdvancedPasteCustomAction action)
 202          {
 203              action = ResolveCustomAction(source);
 204              return action is not null;
 205          }
 206  
 207          private static AdvancedPasteCustomAction ResolveCustomAction(object source)
 208          {
 209              if (source is null)
 210              {
 211                  return null;
 212              }
 213  
 214              if (source is AdvancedPasteCustomAction directAction)
 215              {
 216                  return directAction;
 217              }
 218  
 219              if (source is MenuFlyoutItemBase menuItem && menuItem.Tag is AdvancedPasteCustomAction taggedAction)
 220              {
 221                  return taggedAction;
 222              }
 223  
 224              if (source is FrameworkElement element)
 225              {
 226                  return ResolveFromElement(element);
 227              }
 228  
 229              return null;
 230          }
 231  
 232          private static AdvancedPasteCustomAction ResolveFromElement(FrameworkElement element)
 233          {
 234              for (FrameworkElement current = element; current is not null; current = VisualTreeHelper.GetParent(current) as FrameworkElement)
 235              {
 236                  if (current.Tag is AdvancedPasteCustomAction tagged)
 237                  {
 238                      return tagged;
 239                  }
 240  
 241                  if (current.DataContext is AdvancedPasteCustomAction contextual)
 242                  {
 243                      return contextual;
 244                  }
 245              }
 246  
 247              return null;
 248          }
 249  
 250          private void BrowsePasteAIModelPath_Click(object sender, RoutedEventArgs e)
 251          {
 252              // Use Win32 file dialog to work around FileOpenPicker issues with elevated permissions
 253              string selectedFile = PickFileDialog(
 254                  "ONNX Model Files\0*.onnx\0All Files\0*.*\0",
 255                  "Select ONNX Model File");
 256  
 257              if (!string.IsNullOrEmpty(selectedFile))
 258              {
 259                  PasteAIModelPathTextBox.Text = selectedFile;
 260                  if (ViewModel?.PasteAIProviderDraft is not null)
 261                  {
 262                      ViewModel.PasteAIProviderDraft.ModelPath = selectedFile;
 263                  }
 264              }
 265          }
 266  
 267          private static string PickFileDialog(string filter, string title, string initialDir = null, int initialFilter = 0)
 268          {
 269              // Use Win32 OpenFileName dialog as FileOpenPicker doesn't work with elevated permissions
 270              OpenFileName openFileName = new OpenFileName();
 271              openFileName.StructSize = Marshal.SizeOf(openFileName);
 272              openFileName.Filter = filter;
 273  
 274              // Make buffer double MAX_PATH since it can use 2 chars per char
 275              openFileName.File = new string(new char[260 * 2]);
 276              openFileName.MaxFile = openFileName.File.Length;
 277              openFileName.FileTitle = new string(new char[260 * 2]);
 278              openFileName.MaxFileTitle = openFileName.FileTitle.Length;
 279              openFileName.InitialDir = initialDir;
 280              openFileName.Title = title;
 281              openFileName.FilterIndex = initialFilter;
 282              openFileName.DefExt = null;
 283              openFileName.Flags = (int)OpenFileNameFlags.OFN_NOCHANGEDIR; // OFN_NOCHANGEDIR flag is needed
 284              IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow());
 285              openFileName.Hwnd = windowHandle;
 286  
 287              bool result = NativeMethods.GetOpenFileName(openFileName);
 288              if (result)
 289              {
 290                  return openFileName.File;
 291              }
 292  
 293              return null;
 294          }
 295  
 296          private void ShowApiKeySavedMessage(string configType)
 297          {
 298              // This would typically show a TeachingTip or InfoBar
 299              // For now, we'll use a simple approach
 300              var resourceLoader = ResourceLoaderInstance.ResourceLoader;
 301  
 302              // In a real implementation, you'd want to show a proper notification
 303              System.Diagnostics.Debug.WriteLine($"{configType} API key saved successfully");
 304          }
 305  
 306          private void UpdatePasteAIUIVisibility()
 307          {
 308              var draft = ViewModel?.PasteAIProviderDraft;
 309              if (draft is null)
 310              {
 311                  return;
 312              }
 313  
 314              string selectedType = draft.ServiceType ?? string.Empty;
 315              AIServiceType serviceKind = draft.ServiceTypeKind;
 316  
 317              bool requiresEndpoint = RequiresEndpointForService(serviceKind);
 318              bool requiresDeployment = serviceKind == AIServiceType.AzureOpenAI;
 319              bool requiresApiVersion = serviceKind == AIServiceType.AzureOpenAI;
 320              bool requiresModelPath = serviceKind == AIServiceType.Onnx;
 321              bool isFoundryLocal = serviceKind == AIServiceType.FoundryLocal;
 322              bool requiresApiKey = RequiresApiKeyForService(selectedType);
 323              bool showModerationToggle = serviceKind == AIServiceType.OpenAI;
 324              bool showAdvancedAI = serviceKind == AIServiceType.OpenAI || serviceKind == AIServiceType.AzureOpenAI;
 325  
 326              if (string.IsNullOrWhiteSpace(draft.EndpointUrl))
 327              {
 328                  string storedEndpoint = ViewModel.GetPasteAIEndpoint(draft.Id, selectedType);
 329                  if (!string.IsNullOrWhiteSpace(storedEndpoint))
 330                  {
 331                      draft.EndpointUrl = storedEndpoint;
 332                  }
 333              }
 334  
 335              PasteAIEndpointUrlTextBox.Visibility = requiresEndpoint ? Visibility.Visible : Visibility.Collapsed;
 336              if (requiresEndpoint)
 337              {
 338                  PasteAIEndpointUrlTextBox.PlaceholderText = GetEndpointPlaceholder(serviceKind);
 339              }
 340  
 341              PasteAIDeploymentNameTextBox.Visibility = requiresDeployment ? Visibility.Visible : Visibility.Collapsed;
 342              PasteAIApiVersionTextBox.Visibility = requiresApiVersion ? Visibility.Visible : Visibility.Collapsed;
 343              PasteAIModelPanel.Visibility = requiresModelPath ? Visibility.Visible : Visibility.Collapsed;
 344              PasteAIModerationToggle.Visibility = showModerationToggle ? Visibility.Visible : Visibility.Collapsed;
 345              PasteAIEnableAdvancedAICheckBox.Visibility = showAdvancedAI ? Visibility.Visible : Visibility.Collapsed;
 346              PasteAIApiKeyPasswordBox.Visibility = requiresApiKey ? Visibility.Visible : Visibility.Collapsed;
 347              PasteAIModelNameTextBox.Visibility = isFoundryLocal ? Visibility.Collapsed : Visibility.Visible;
 348  
 349              if (requiresApiKey)
 350              {
 351                  PasteAIApiKeyPasswordBox.Password = ViewModel.GetPasteAIApiKey(draft.Id, selectedType);
 352              }
 353              else
 354              {
 355                  PasteAIApiKeyPasswordBox.Password = string.Empty;
 356              }
 357  
 358              // Update system prompt placeholder based on EnableAdvancedAI state
 359              UpdateSystemPromptPlaceholder();
 360  
 361              // Disable Save button if GPO blocks this provider
 362              if (PasteAIProviderConfigurationDialog is not null)
 363              {
 364                  bool isAllowedByGPO = ViewModel?.IsServiceTypeAllowedByGPO(serviceKind) ?? true;
 365  
 366                  if (!isAllowedByGPO)
 367                  {
 368                      // GPO blocks this provider, disable save button
 369                      PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = false;
 370                  }
 371                  else if (isFoundryLocal)
 372                  {
 373                      // For Foundry Local, UpdateFoundrySaveButtonState will handle button state
 374                      // based on model selection status
 375                  }
 376                  else
 377                  {
 378                      // GPO allows this provider, enable save button
 379                      PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = true;
 380                  }
 381              }
 382          }
 383  
 384          private Task UpdateFoundryLocalUIAsync()
 385          {
 386              string selectedType = ViewModel?.PasteAIProviderDraft?.ServiceType ?? string.Empty;
 387              bool isFoundryLocal = string.Equals(selectedType, "FoundryLocal", StringComparison.OrdinalIgnoreCase);
 388  
 389              if (FoundryLocalPanel is not null)
 390              {
 391                  FoundryLocalPanel.Visibility = isFoundryLocal ? Visibility.Visible : Visibility.Collapsed;
 392              }
 393  
 394              if (!isFoundryLocal)
 395              {
 396                  _foundryModelLoadCts?.Cancel();
 397                  _isFoundryLocalAvailable = false;
 398                  if (FoundryLocalPicker is not null)
 399                  {
 400                      FoundryLocalPicker.IsLoading = false;
 401                      FoundryLocalPicker.IsAvailable = false;
 402                      FoundryLocalPicker.StatusText = string.Empty;
 403                      FoundryLocalPicker.SelectedModel = null;
 404                  }
 405  
 406                  if (PasteAIProviderConfigurationDialog is not null)
 407                  {
 408                      PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = true;
 409                  }
 410  
 411                  return Task.CompletedTask;
 412              }
 413  
 414              if (PasteAIProviderConfigurationDialog is not null)
 415              {
 416                  PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = false;
 417              }
 418  
 419              FoundryLocalPicker?.RequestLoad();
 420  
 421              return Task.CompletedTask;
 422          }
 423  
 424          private async Task LoadFoundryLocalModelsAsync()
 425          {
 426              if (FoundryLocalPanel is null)
 427              {
 428                  return;
 429              }
 430  
 431              _foundryModelLoadCts?.Cancel();
 432              _foundryModelLoadCts?.Dispose();
 433              _foundryModelLoadCts = new CancellationTokenSource();
 434              var cancellationToken = _foundryModelLoadCts.Token;
 435  
 436              ShowFoundryLoadingState();
 437  
 438              try
 439              {
 440                  var provider = FoundryLocalModelProvider.Instance;
 441  
 442                  var isAvailable = await provider.IsAvailable();
 443                  if (cancellationToken.IsCancellationRequested)
 444                  {
 445                      return;
 446                  }
 447  
 448                  _isFoundryLocalAvailable = isAvailable;
 449  
 450                  if (!isAvailable)
 451                  {
 452                      ShowFoundryUnavailableState();
 453                      return;
 454                  }
 455  
 456                  IEnumerable<ModelDetails> cachedModelsEnumerable = await provider.GetModelsAsync(cancelationToken: cancellationToken).ConfigureAwait(false);
 457  
 458                  if (cancellationToken.IsCancellationRequested)
 459                  {
 460                      return;
 461                  }
 462  
 463                  var cachedModels = cachedModelsEnumerable?.ToList() ?? new List<ModelDetails>();
 464  
 465                  DispatcherQueue.TryEnqueue(() =>
 466                  {
 467                      UpdateFoundryCollections(cachedModels);
 468                      ShowFoundryAvailableState();
 469                      RestoreFoundrySelection(cachedModels);
 470                  });
 471              }
 472              catch (OperationCanceledException)
 473              {
 474                  // Loading cancelled; no action required.
 475              }
 476              catch (Exception ex)
 477              {
 478                  var errorMessage = $"Unable to load Foundry Local models. {ex.Message}";
 479                  System.Diagnostics.Debug.WriteLine($"[AdvancedPastePage] Failed to load Foundry Local models: {ex}");
 480                  DispatcherQueue.TryEnqueue(() =>
 481                  {
 482                      ShowFoundryUnavailableState(errorMessage);
 483                  });
 484              }
 485              finally
 486              {
 487                  DispatcherQueue.TryEnqueue(() =>
 488                  {
 489                      UpdateFoundrySaveButtonState();
 490                  });
 491              }
 492          }
 493  
 494          private void ShowFoundryLoadingState()
 495          {
 496              _isFoundryLocalAvailable = false;
 497  
 498              if (FoundryLocalPicker is not null)
 499              {
 500                  FoundryLocalPicker.IsLoading = true;
 501                  FoundryLocalPicker.IsAvailable = false;
 502                  FoundryLocalPicker.StatusText = "Loading Foundry Local status...";
 503                  FoundryLocalPicker.SelectedModel = null;
 504              }
 505          }
 506  
 507          private void ShowFoundryUnavailableState(string message = null)
 508          {
 509              _isFoundryLocalAvailable = false;
 510  
 511              if (FoundryLocalPicker is not null)
 512              {
 513                  FoundryLocalPicker.IsLoading = false;
 514                  FoundryLocalPicker.IsAvailable = false;
 515                  FoundryLocalPicker.SelectedModel = null;
 516                  FoundryLocalPicker.StatusText = message ?? "Foundry Local was not detected. Follow the CLI guide to install and start it.";
 517              }
 518  
 519              _foundryCachedModels.Clear();
 520          }
 521  
 522          private void ShowFoundryAvailableState()
 523          {
 524              _isFoundryLocalAvailable = true;
 525  
 526              if (FoundryLocalPicker is not null)
 527              {
 528                  FoundryLocalPicker.IsLoading = false;
 529                  FoundryLocalPicker.IsAvailable = true;
 530                  if (_foundryCachedModels.Count == 0)
 531                  {
 532                      FoundryLocalPicker.StatusText = "No local models detected. Use the button below to list models and download them with Foundry Local.";
 533                  }
 534                  else if (string.IsNullOrWhiteSpace(FoundryLocalPicker.StatusText))
 535                  {
 536                      FoundryLocalPicker.StatusText = "Select a downloaded model from the list to enable Advanced Paste.";
 537                  }
 538              }
 539  
 540              UpdateFoundrySaveButtonState();
 541          }
 542  
 543          private void UpdateFoundryCollections(IReadOnlyCollection<ModelDetails> cachedModels)
 544          {
 545              _foundryCachedModels.Clear();
 546  
 547              foreach (var model in cachedModels.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
 548              {
 549                  _foundryCachedModels.Add(model);
 550              }
 551  
 552              var cachedReferences = new HashSet<string>(_foundryCachedModels.Select(m => m.Name), StringComparer.OrdinalIgnoreCase);
 553          }
 554  
 555          private void RestoreFoundrySelection(IReadOnlyCollection<ModelDetails> cachedModels)
 556          {
 557              if (FoundryLocalPicker is null)
 558              {
 559                  return;
 560              }
 561  
 562              var currentModelReference = ViewModel?.PasteAIProviderDraft?.ModelName;
 563  
 564              ModelDetails matchingModel = null;
 565  
 566              if (!string.IsNullOrWhiteSpace(currentModelReference))
 567              {
 568                  matchingModel = cachedModels.FirstOrDefault(model =>
 569                      string.Equals(model.Name, currentModelReference, StringComparison.OrdinalIgnoreCase));
 570              }
 571  
 572              if (FoundryLocalPicker is null)
 573              {
 574                  return;
 575              }
 576  
 577              _suppressFoundrySelectionChanged = true;
 578              FoundryLocalPicker.SelectedModel = matchingModel;
 579              _suppressFoundrySelectionChanged = false;
 580  
 581              if (matchingModel is null)
 582              {
 583                  if (ViewModel?.PasteAIProviderDraft is not null)
 584                  {
 585                      ViewModel.PasteAIProviderDraft.ModelName = string.Empty;
 586                  }
 587  
 588                  if (FoundryLocalPicker is not null)
 589                  {
 590                      FoundryLocalPicker.StatusText = _foundryCachedModels.Count == 0
 591                          ? "No local models detected. Use the button below to list models and download them with Foundry Local."
 592                          : "Select a downloaded model from the list to enable Advanced Paste.";
 593                  }
 594              }
 595              else
 596              {
 597                  if (ViewModel?.PasteAIProviderDraft is not null)
 598                  {
 599                      ViewModel.PasteAIProviderDraft.ModelName = matchingModel.Name;
 600                  }
 601  
 602                  if (FoundryLocalPicker is not null)
 603                  {
 604                      FoundryLocalPicker.StatusText = $"{matchingModel.Name} selected.";
 605                  }
 606              }
 607  
 608              UpdateFoundrySaveButtonState();
 609          }
 610  
 611          private void UpdateFoundrySaveButtonState()
 612          {
 613              if (PasteAIProviderConfigurationDialog is null)
 614              {
 615                  return;
 616              }
 617  
 618              bool isFoundrySelected = string.Equals(ViewModel?.PasteAIProviderDraft?.ServiceType, "FoundryLocal", StringComparison.OrdinalIgnoreCase);
 619  
 620              if (!isFoundrySelected || ViewModel?.PasteAIProviderDraft is null)
 621              {
 622                  PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = true;
 623                  return;
 624              }
 625  
 626              // Check GPO first
 627              bool isAllowedByGPO = ViewModel?.IsServiceTypeAllowedByGPO(AIServiceType.FoundryLocal) ?? true;
 628              if (!isAllowedByGPO)
 629              {
 630                  PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = false;
 631                  return;
 632              }
 633  
 634              if (!_isFoundryLocalAvailable)
 635              {
 636                  PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = false;
 637                  return;
 638              }
 639  
 640              bool hasSelection = FoundryLocalPicker?.SelectedModel is ModelDetails;
 641              PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = hasSelection;
 642          }
 643  
 644          private void FoundryLocalPicker_SelectionChanged(object sender, ModelDetails selectedModel)
 645          {
 646              if (_suppressFoundrySelectionChanged)
 647              {
 648                  return;
 649              }
 650  
 651              if (selectedModel is not null)
 652              {
 653                  if (ViewModel?.PasteAIProviderDraft is not null)
 654                  {
 655                      ViewModel.PasteAIProviderDraft.ModelName = selectedModel.Name;
 656                  }
 657  
 658                  if (FoundryLocalPicker is not null)
 659                  {
 660                      FoundryLocalPicker.StatusText = $"{selectedModel.Name} selected.";
 661                  }
 662              }
 663              else
 664              {
 665                  if (ViewModel?.PasteAIProviderDraft is not null)
 666                  {
 667                      ViewModel.PasteAIProviderDraft.ModelName = string.Empty;
 668                  }
 669  
 670                  if (FoundryLocalPicker is not null)
 671                  {
 672                      FoundryLocalPicker.StatusText = "Select a downloaded model from the list to enable Advanced Paste.";
 673                  }
 674              }
 675  
 676              UpdateFoundrySaveButtonState();
 677          }
 678  
 679          private async void FoundryLocalPicker_LoadRequested(object sender)
 680          {
 681              await LoadFoundryLocalModelsAsync();
 682          }
 683  
 684          private sealed class FoundryDownloadableModel : INotifyPropertyChanged
 685          {
 686              private readonly List<string> _deviceTags;
 687              private double _progress;
 688              private bool _isDownloading;
 689              private bool _isDownloaded;
 690  
 691              public FoundryDownloadableModel(ModelDetails modelDetails)
 692              {
 693                  ModelDetails = modelDetails ?? throw new ArgumentNullException(nameof(modelDetails));
 694                  SizeTag = FoundryLocalModelPicker.GetModelSizeText(ModelDetails.Size);
 695                  LicenseTag = FoundryLocalModelPicker.GetLicenseShortText(ModelDetails.License);
 696                  _deviceTags = FoundryLocalModelPicker
 697                      .GetDeviceTags(ModelDetails.HardwareAccelerators)
 698                      .ToList();
 699              }
 700  
 701              public ModelDetails ModelDetails { get; }
 702  
 703              public string Name => string.IsNullOrWhiteSpace(ModelDetails.Name) ? "Model" : ModelDetails.Name;
 704  
 705              public string Description => string.IsNullOrWhiteSpace(ModelDetails.Description) ? "No description provided." : ModelDetails.Description;
 706  
 707              public string SizeTag { get; }
 708  
 709              public bool HasSizeTag => !string.IsNullOrWhiteSpace(SizeTag);
 710  
 711              public string LicenseTag { get; }
 712  
 713              public bool HasLicenseTag => !string.IsNullOrWhiteSpace(LicenseTag);
 714  
 715              public IReadOnlyList<string> DeviceTags => _deviceTags;
 716  
 717              public bool HasDeviceTags => _deviceTags.Count > 0;
 718  
 719              public double ProgressPercent => Math.Round(_progress * 100, 2);
 720  
 721              public Visibility ProgressVisibility => _isDownloading ? Visibility.Visible : Visibility.Collapsed;
 722  
 723              public string ActionLabel => _isDownloaded ? "Downloaded" : _isDownloading ? "Downloading..." : "Download";
 724  
 725              public bool CanDownload => !_isDownloading && !_isDownloaded;
 726  
 727              internal bool IsDownloading => _isDownloading;
 728  
 729              public event PropertyChangedEventHandler PropertyChanged;
 730  
 731              public void StartDownload()
 732              {
 733                  _isDownloading = true;
 734                  _isDownloaded = false;
 735                  _progress = 0;
 736                  NotifyStateChanged();
 737              }
 738  
 739              public void ReportProgress(float value)
 740              {
 741                  _progress = Math.Clamp(value, 0f, 1f);
 742                  RaisePropertyChanged(nameof(ProgressPercent));
 743              }
 744  
 745              public void MarkDownloaded()
 746              {
 747                  _isDownloading = false;
 748                  _isDownloaded = true;
 749                  _progress = 1;
 750                  NotifyStateChanged();
 751              }
 752  
 753              public void Reset()
 754              {
 755                  _isDownloading = false;
 756                  _isDownloaded = false;
 757                  _progress = 0;
 758                  NotifyStateChanged();
 759              }
 760  
 761              private void NotifyStateChanged()
 762              {
 763                  RaisePropertyChanged(nameof(ProgressPercent));
 764                  RaisePropertyChanged(nameof(ProgressVisibility));
 765                  RaisePropertyChanged(nameof(ActionLabel));
 766                  RaisePropertyChanged(nameof(CanDownload));
 767              }
 768  
 769              private void RaisePropertyChanged(string propertyName)
 770              {
 771                  PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
 772              }
 773          }
 774  
 775          private void PasteAIProviderConfigurationDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
 776          {
 777              var draft = ViewModel?.PasteAIProviderDraft;
 778              if (draft is null)
 779              {
 780                  args.Cancel = true;
 781                  return;
 782              }
 783  
 784              NormalizeSystemPrompt(draft);
 785              string serviceType = draft.ServiceType ?? "OpenAI";
 786              string apiKey = PasteAIApiKeyPasswordBox.Password;
 787              string trimmedApiKey = apiKey?.Trim() ?? string.Empty;
 788              var serviceKind = draft.ServiceTypeKind;
 789              bool requiresEndpoint = RequiresEndpointForService(serviceKind);
 790              string endpoint = (draft.EndpointUrl ?? string.Empty).Trim();
 791  
 792              // Never persist placeholder text or stale values for services that don't use an endpoint.
 793              if (!requiresEndpoint)
 794              {
 795                  endpoint = string.Empty;
 796              }
 797              else if (string.IsNullOrEmpty(endpoint))
 798              {
 799                  // If endpoint is required but not provided, use placeholder.
 800                  endpoint = GetEndpointPlaceholder(serviceKind);
 801              }
 802  
 803              // For endpoint-based services, keep empty if the user didn't provide a value.
 804              if (RequiresApiKeyForService(serviceType) && string.IsNullOrWhiteSpace(trimmedApiKey))
 805              {
 806                  args.Cancel = true;
 807                  return;
 808              }
 809  
 810              ViewModel.CommitPasteAIProviderDraft(trimmedApiKey, endpoint);
 811              PasteAIApiKeyPasswordBox.Password = string.Empty;
 812  
 813              // Show success message
 814              ShowApiKeySavedMessage("Paste AI");
 815          }
 816  
 817          private void PasteAIEnableAdvancedAICheckBox_Toggled(object sender, RoutedEventArgs e)
 818          {
 819              var draft = ViewModel?.PasteAIProviderDraft;
 820              if (draft is null)
 821              {
 822                  return;
 823              }
 824  
 825              NormalizeSystemPrompt(draft);
 826              UpdateSystemPromptPlaceholder();
 827          }
 828  
 829          private static bool RequiresApiKeyForService(string serviceType)
 830          {
 831              var serviceKind = serviceType.ToAIServiceType();
 832  
 833              return serviceKind switch
 834              {
 835                  AIServiceType.Onnx => false,
 836                  AIServiceType.Ollama => false,
 837                  AIServiceType.FoundryLocal => false,
 838                  AIServiceType.ML => false,
 839                  _ => true,
 840              };
 841          }
 842  
 843          private static bool RequiresEndpointForService(AIServiceType serviceKind)
 844          {
 845              return serviceKind is AIServiceType.AzureOpenAI
 846                  or AIServiceType.AzureAIInference
 847                  or AIServiceType.Mistral
 848                  or AIServiceType.Ollama;
 849          }
 850  
 851          private static string GetEndpointPlaceholder(AIServiceType serviceKind)
 852          {
 853              return serviceKind switch
 854              {
 855                  AIServiceType.AzureOpenAI => "https://your-resource.openai.azure.com/",
 856                  AIServiceType.AzureAIInference => "https://{resource-name}.cognitiveservices.azure.com/",
 857                  AIServiceType.Mistral => "https://api.mistral.ai/v1/",
 858                  AIServiceType.Ollama => "http://localhost:11434/",
 859                  _ => string.Empty,
 860              };
 861          }
 862  
 863          private bool HasServiceLegalInfo(string serviceType)
 864          {
 865              var metadata = AIServiceTypeRegistry.GetMetadata(serviceType);
 866              return metadata.HasLegalInfo;
 867          }
 868  
 869          private string GetServiceLegalDescription(string serviceType)
 870          {
 871              var metadata = AIServiceTypeRegistry.GetMetadata(serviceType);
 872              if (string.IsNullOrWhiteSpace(metadata.LegalDescription))
 873              {
 874                  return string.Empty;
 875              }
 876  
 877              var resourceLoader = ResourceLoaderInstance.ResourceLoader;
 878              return resourceLoader.GetString(metadata.LegalDescription);
 879          }
 880  
 881          private string GetServiceTermsLabel(string serviceType)
 882          {
 883              var metadata = AIServiceTypeRegistry.GetMetadata(serviceType);
 884              if (string.IsNullOrWhiteSpace(metadata.TermsLabel))
 885              {
 886                  return string.Empty;
 887              }
 888  
 889              var resourceLoader = ResourceLoaderInstance.ResourceLoader;
 890              return resourceLoader.GetString(metadata.TermsLabel);
 891          }
 892  
 893          private Uri GetServiceTermsUri(string serviceType)
 894          {
 895              var metadata = AIServiceTypeRegistry.GetMetadata(serviceType);
 896              return metadata.TermsUri;
 897          }
 898  
 899          private string GetServicePrivacyLabel(string serviceType)
 900          {
 901              var metadata = AIServiceTypeRegistry.GetMetadata(serviceType);
 902              if (string.IsNullOrWhiteSpace(metadata.PrivacyLabel))
 903              {
 904                  return string.Empty;
 905              }
 906  
 907              var resourceLoader = ResourceLoaderInstance.ResourceLoader;
 908              return resourceLoader.GetString(metadata.PrivacyLabel);
 909          }
 910  
 911          private Uri GetServicePrivacyUri(string serviceType)
 912          {
 913              var metadata = AIServiceTypeRegistry.GetMetadata(serviceType);
 914              return metadata.PrivacyUri;
 915          }
 916  
 917          private bool HasServiceTermsLink(string serviceType)
 918          {
 919              var metadata = AIServiceTypeRegistry.GetMetadata(serviceType);
 920              return metadata.HasTermsLink;
 921          }
 922  
 923          private bool HasServicePrivacyLink(string serviceType)
 924          {
 925              var metadata = AIServiceTypeRegistry.GetMetadata(serviceType);
 926              return metadata.HasPrivacyLink;
 927          }
 928  
 929          private Visibility GetServiceLegalVisibility(string serviceType) => HasServiceLegalInfo(serviceType) ? Visibility.Visible : Visibility.Collapsed;
 930  
 931          private Visibility GetServiceTermsVisibility(string serviceType) => HasServiceTermsLink(serviceType) ? Visibility.Visible : Visibility.Collapsed;
 932  
 933          private Visibility GetServicePrivacyVisibility(string serviceType) => HasServicePrivacyLink(serviceType) ? Visibility.Visible : Visibility.Collapsed;
 934  
 935          private static bool IsPlaceholderSystemPrompt(string prompt)
 936          {
 937              if (string.IsNullOrWhiteSpace(prompt))
 938              {
 939                  return true;
 940              }
 941  
 942              string trimmedPrompt = prompt.Trim();
 943              return string.Equals(trimmedPrompt, AdvancedAISystemPromptNormalized, StringComparison.Ordinal)
 944                  || string.Equals(trimmedPrompt, SimpleAISystemPromptNormalized, StringComparison.Ordinal);
 945          }
 946  
 947          private static void NormalizeSystemPrompt(PasteAIProviderDefinition draft)
 948          {
 949              if (draft is null)
 950              {
 951                  return;
 952              }
 953  
 954              if (IsPlaceholderSystemPrompt(draft.SystemPrompt))
 955              {
 956                  draft.SystemPrompt = string.Empty;
 957              }
 958          }
 959  
 960          private void UpdateSystemPromptPlaceholder()
 961          {
 962              var draft = ViewModel?.PasteAIProviderDraft;
 963              if (draft is null)
 964              {
 965                  return;
 966              }
 967  
 968              NormalizeSystemPrompt(draft);
 969              if (PasteAISystemPromptTextBox is null)
 970              {
 971                  return;
 972              }
 973  
 974              bool useAdvancedPlaceholder = PasteAIEnableAdvancedAICheckBox?.IsOn ?? draft.EnableAdvancedAI;
 975              PasteAISystemPromptTextBox.PlaceholderText = useAdvancedPlaceholder
 976                  ? AdvancedAISystemPrompt
 977                  : SimpleAISystemPrompt;
 978          }
 979  
 980          private void RefreshDialogBindings()
 981          {
 982              try
 983              {
 984                  Bindings?.Update();
 985              }
 986              catch (Exception)
 987              {
 988                  // Best-effort refresh only; ignore refresh failures.
 989              }
 990          }
 991  
 992          public void Dispose()
 993          {
 994              if (_disposed)
 995              {
 996                  return;
 997              }
 998  
 999              try
1000              {
1001                  _foundryModelLoadCts?.Cancel();
1002              }
1003              catch (Exception)
1004              {
1005                  // Ignore cancellation failures during disposal.
1006              }
1007  
1008              _foundryModelLoadCts?.Dispose();
1009              _foundryModelLoadCts = null;
1010  
1011              if (FoundryLocalPicker is not null)
1012              {
1013                  FoundryLocalPicker.SelectionChanged -= FoundryLocalPicker_SelectionChanged;
1014                  FoundryLocalPicker.LoadRequested -= FoundryLocalPicker_LoadRequested;
1015              }
1016  
1017              ViewModel?.Dispose();
1018  
1019              _disposed = true;
1020              GC.SuppressFinalize(this);
1021          }
1022  
1023          private void AddProviderMenuFlyout_Opening(object sender, object e)
1024          {
1025              if (sender is not MenuFlyout menuFlyout)
1026              {
1027                  return;
1028              }
1029  
1030              // Clear existing items
1031              menuFlyout.Items.Clear();
1032  
1033              // Add online models header
1034              var onlineHeader = new MenuFlyoutItem
1035              {
1036                  Text = "Online models",
1037                  FontSize = 12,
1038                  IsEnabled = false,
1039                  IsHitTestVisible = false,
1040              };
1041              menuFlyout.Items.Add(onlineHeader);
1042  
1043              // Add all online providers
1044              var onlineProviders = AIServiceTypeRegistry.GetOnlineServiceTypes();
1045  
1046              foreach (var metadata in onlineProviders)
1047              {
1048                  var menuItem = new MenuFlyoutItem
1049                  {
1050                      Text = metadata.DisplayName,
1051                      Tag = metadata.ServiceType.ToConfigurationString(),
1052                      Icon = new ImageIcon { Source = new SvgImageSource(new Uri(metadata.IconPath)) },
1053                  };
1054  
1055                  menuItem.Click += ProviderMenuFlyoutItem_Click;
1056                  menuFlyout.Items.Add(menuItem);
1057              }
1058  
1059              // Add local models header
1060              var localHeader = new MenuFlyoutItem
1061              {
1062                  Text = "Local models",
1063                  FontSize = 12,
1064                  IsEnabled = false,
1065                  IsHitTestVisible = false,
1066                  Margin = new Thickness(0, 16, 0, 0),
1067              };
1068              menuFlyout.Items.Add(localHeader);
1069  
1070              // Add all local providers
1071              var localProviders = AIServiceTypeRegistry.GetLocalServiceTypes();
1072  
1073              foreach (var metadata in localProviders)
1074              {
1075                  var menuItem = new MenuFlyoutItem
1076                  {
1077                      Text = metadata.DisplayName,
1078                      Tag = metadata.ServiceType.ToConfigurationString(),
1079                      Icon = new ImageIcon { Source = new SvgImageSource(new Uri(metadata.IconPath)) },
1080                  };
1081  
1082                  menuItem.Click += ProviderMenuFlyoutItem_Click;
1083                  menuFlyout.Items.Add(menuItem);
1084              }
1085          }
1086  
1087          private async void ProviderMenuFlyoutItem_Click(object sender, RoutedEventArgs e)
1088          {
1089              if (sender is not MenuFlyoutItem menuItem || menuItem.Tag is not string tag || string.IsNullOrWhiteSpace(tag))
1090              {
1091                  return;
1092              }
1093  
1094              if (ViewModel is null || PasteAIProviderConfigurationDialog is null)
1095              {
1096                  return;
1097              }
1098  
1099              string serviceType = tag.Trim();
1100              string displayName = string.IsNullOrWhiteSpace(menuItem.Text) ? serviceType : menuItem.Text.Trim();
1101  
1102              ViewModel.BeginAddPasteAIProvider(serviceType);
1103              if (ViewModel.PasteAIProviderDraft is null)
1104              {
1105                  return;
1106              }
1107  
1108              PasteAIProviderConfigurationDialog.Title = PasteAiDialogDefaultTitle;
1109              if (!string.IsNullOrWhiteSpace(displayName))
1110              {
1111                  PasteAIProviderConfigurationDialog.Title = $"{displayName} provider configuration";
1112              }
1113  
1114              await UpdateFoundryLocalUIAsync();
1115              UpdatePasteAIUIVisibility();
1116              RefreshDialogBindings();
1117  
1118              PasteAIApiKeyPasswordBox.Password = string.Empty;
1119              await PasteAIProviderConfigurationDialog.ShowAsync();
1120          }
1121  
1122          private async void EditPasteAIProviderButton_Click(object sender, RoutedEventArgs e)
1123          {
1124              // sender is MenuFlyoutItem with PasteAIProviderDefinition Tag
1125              if (sender is not MenuFlyoutItem menuItem || menuItem.Tag is not PasteAIProviderDefinition provider)
1126              {
1127                  return;
1128              }
1129  
1130              if (ViewModel is null || PasteAIProviderConfigurationDialog is null)
1131              {
1132                  return;
1133              }
1134  
1135              ViewModel.BeginEditPasteAIProvider(provider);
1136  
1137              string titlePrefix = string.IsNullOrWhiteSpace(provider.ModelName) ? provider.ServiceType : provider.ModelName;
1138              PasteAIProviderConfigurationDialog.Title = string.IsNullOrWhiteSpace(titlePrefix)
1139                  ? PasteAiDialogDefaultTitle
1140                  : $"{titlePrefix} provider configuration";
1141  
1142              UpdatePasteAIUIVisibility();
1143              await UpdateFoundryLocalUIAsync();
1144              RefreshDialogBindings();
1145              PasteAIApiKeyPasswordBox.Password = ViewModel.GetPasteAIApiKey(provider.Id, provider.ServiceType);
1146              await PasteAIProviderConfigurationDialog.ShowAsync();
1147          }
1148  
1149          private void RemovePasteAIProviderButton_Click(object sender, RoutedEventArgs e)
1150          {
1151              // sender is MenuFlyoutItem with PasteAIProviderDefinition Tag
1152              if (sender is not MenuFlyoutItem menuItem || menuItem.Tag is not PasteAIProviderDefinition provider)
1153              {
1154                  return;
1155              }
1156  
1157              ViewModel?.RemovePasteAIProvider(provider);
1158          }
1159  
1160          private void PasteAIProviderConfigurationDialog_Closed(ContentDialog sender, ContentDialogClosedEventArgs args)
1161          {
1162              ViewModel?.CancelPasteAIProviderDraft();
1163              PasteAIProviderConfigurationDialog.Title = PasteAiDialogDefaultTitle;
1164              PasteAIApiKeyPasswordBox.Password = string.Empty;
1165          }
1166      }
1167  }