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 }