Signed-off-by: Shawn Yuan (from Dev Box) <shuaiyuan@microsoft.com>
This commit is contained in:
Shawn Yuan (from Dev Box)
2025-11-11 16:37:34 +08:00
parent c953d5146a
commit d2cf2be2cb
17 changed files with 633 additions and 24 deletions

View File

@@ -55,6 +55,8 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
public PasteAIConfiguration PasteAIConfiguration => _configuration; public PasteAIConfiguration PasteAIConfiguration => _configuration;
public string CustomModelStoragePath => AdvancedPasteProperties.GetDefaultCustomModelStoragePath();
public event EventHandler Changed; public event EventHandler Changed;
public Task SetActiveAIProviderAsync(string providerId) public Task SetActiveAIProviderAsync(string providerId)

View File

@@ -144,7 +144,7 @@ public sealed class AIServiceBatchIntegrationTests
switch (format) switch (format)
{ {
case PasteFormats.CustomTextTransformation: case PasteFormats.CustomTextTransformation:
var transformResult = await services.CustomActionTransformService.TransformTextAsync(batchTestInput.Prompt, batchTestInput.Clipboard, CancellationToken.None, progress); var transformResult = await services.CustomActionTransformService.TransformTextAsync(null, batchTestInput.Prompt, batchTestInput.Clipboard, CancellationToken.None, progress);
return DataPackageHelpers.CreateFromText(transformResult.Content ?? string.Empty); return DataPackageHelpers.CreateFromText(transformResult.Content ?? string.Empty);
case PasteFormats.KernelQuery: case PasteFormats.KernelQuery:

View File

@@ -25,6 +25,8 @@ namespace AdvancedPaste.Settings
public PasteAIConfiguration PasteAIConfiguration { get; } public PasteAIConfiguration PasteAIConfiguration { get; }
public string CustomModelStoragePath { get; }
public event EventHandler Changed; public event EventHandler Changed;
Task SetActiveAIProviderAsync(string providerId); Task SetActiveAIProviderAsync(string providerId);

View File

@@ -4,6 +4,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@@ -46,6 +47,8 @@ namespace AdvancedPaste.Settings
public PasteAIConfiguration PasteAIConfiguration { get; private set; } public PasteAIConfiguration PasteAIConfiguration { get; private set; }
public string CustomModelStoragePath { get; private set; }
public UserSettings(IFileSystem fileSystem) public UserSettings(IFileSystem fileSystem)
{ {
_settingsUtils = new SettingsUtils(fileSystem); _settingsUtils = new SettingsUtils(fileSystem);
@@ -54,6 +57,8 @@ namespace AdvancedPaste.Settings
ShowCustomPreview = true; ShowCustomPreview = true;
CloseAfterLosingFocus = false; CloseAfterLosingFocus = false;
PasteAIConfiguration = new PasteAIConfiguration(); PasteAIConfiguration = new PasteAIConfiguration();
CustomModelStoragePath = NormalizeDirectoryPath(AdvancedPasteProperties.GetDefaultCustomModelStoragePath());
EnsureDirectoryExists(CustomModelStoragePath);
_additionalActions = []; _additionalActions = [];
_customActions = []; _customActions = [];
_taskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); _taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
@@ -108,6 +113,8 @@ namespace AdvancedPaste.Settings
ShowCustomPreview = properties.ShowCustomPreview; ShowCustomPreview = properties.ShowCustomPreview;
CloseAfterLosingFocus = properties.CloseAfterLosingFocus; CloseAfterLosingFocus = properties.CloseAfterLosingFocus;
PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration(); PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration();
CustomModelStoragePath = NormalizeDirectoryPath(properties.CustomModelStoragePath);
EnsureDirectoryExists(CustomModelStoragePath);
var sourceAdditionalActions = properties.AdditionalActions; var sourceAdditionalActions = properties.AdditionalActions;
(PasteFormats Format, IAdvancedPasteAction[] Actions)[] additionalActionFormats = (PasteFormats Format, IAdvancedPasteAction[] Actions)[] additionalActionFormats =
@@ -288,5 +295,39 @@ namespace AdvancedPaste.Settings
{ {
Dispose(false); Dispose(false);
} }
private static string NormalizeDirectoryPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return AdvancedPasteProperties.GetDefaultCustomModelStoragePath();
}
try
{
path = Environment.ExpandEnvironmentVariables(path.Trim());
return Path.GetFullPath(path);
}
catch (Exception)
{
return path;
}
}
private static void EnsureDirectoryExists(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
try
{
Directory.CreateDirectory(path);
}
catch (Exception)
{
}
}
} }
} }

View File

@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using Microsoft.PowerToys.Settings.UI.Library;
namespace AdvancedPaste.Models; namespace AdvancedPaste.Models;
@@ -32,12 +33,13 @@ public sealed class PasteFormat
IsSavedQuery = false, IsSavedQuery = false,
}; };
public static PasteFormat CreateCustomAIFormat(PasteFormats format, string name, string prompt, bool isSavedQuery, ClipboardFormat clipboardFormats, bool isAIServiceEnabled) => public static PasteFormat CreateCustomAIFormat(PasteFormats format, string name, string prompt, bool isSavedQuery, ClipboardFormat clipboardFormats, bool isAIServiceEnabled, AdvancedPasteCustomAction customAction = null) =>
new(format, clipboardFormats, isAIServiceEnabled) new(format, clipboardFormats, isAIServiceEnabled)
{ {
Name = name, Name = name,
Prompt = prompt, Prompt = prompt,
IsSavedQuery = isSavedQuery, IsSavedQuery = isSavedQuery,
CustomAction = customAction,
}; };
public PasteFormatMetadataAttribute Metadata => MetadataDict[Format]; public PasteFormatMetadataAttribute Metadata => MetadataDict[Format];
@@ -52,6 +54,8 @@ public sealed class PasteFormat
public bool IsSavedQuery { get; private init; } public bool IsSavedQuery { get; private init; }
public AdvancedPasteCustomAction CustomAction { get; private init; }
public bool IsEnabled { get; private init; } public bool IsEnabled { get; private init; }
public string AccessibleName => $"{Name} ({ShortcutText})"; public string AccessibleName => $"{Name} ({ShortcutText})";

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using System;
using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
@@ -40,10 +41,12 @@ namespace AdvancedPaste.Services.CustomActions
this.userSettings = userSettings; this.userSettings = userSettings;
} }
public async Task<CustomActionTransformResult> TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress<double> progress) public async Task<CustomActionTransformResult> TransformTextAsync(AdvancedPasteCustomAction customAction, string prompt, string inputText, CancellationToken cancellationToken, IProgress<double> progress)
{ {
var pasteConfig = userSettings?.PasteAIConfiguration; var pasteConfig = userSettings?.PasteAIConfiguration;
var providerConfig = BuildProviderConfig(pasteConfig); var providerConfig = customAction?.UseCustomModel == true
? BuildCustomModelConfig(customAction)
: BuildProviderConfig(pasteConfig);
return await TransformAsync(prompt, inputText, providerConfig, cancellationToken, progress); return await TransformAsync(prompt, inputText, providerConfig, cancellationToken, progress);
} }
@@ -164,6 +167,24 @@ namespace AdvancedPaste.Services.CustomActions
return providerConfig; return providerConfig;
} }
private PasteAIConfig BuildCustomModelConfig(AdvancedPasteCustomAction customAction)
{
ArgumentNullException.ThrowIfNull(customAction);
var modelPath = ResolveCustomModelPath(customAction);
var systemPrompt = DetermineCustomModelSystemPrompt();
return new PasteAIConfig
{
ProviderType = AIServiceType.ML,
Model = Path.GetFileNameWithoutExtension(modelPath),
LocalModelPath = modelPath,
ModelPath = modelPath,
SystemPrompt = systemPrompt,
ModerationEnabled = false,
};
}
private string AcquireApiKey(AIServiceType serviceType) private string AcquireApiKey(AIServiceType serviceType)
{ {
if (!RequiresApiKey(serviceType)) if (!RequiresApiKey(serviceType))
@@ -196,5 +217,57 @@ namespace AdvancedPaste.Services.CustomActions
return providerConfig.ProviderType == AIServiceType.OpenAI || providerConfig.ProviderType == AIServiceType.AzureOpenAI; return providerConfig.ProviderType == AIServiceType.OpenAI || providerConfig.ProviderType == AIServiceType.AzureOpenAI;
} }
private string ResolveCustomModelPath(AdvancedPasteCustomAction customAction)
{
var modelPath = customAction.CustomModelPath?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(modelPath))
{
throw new PasteActionException(GetCustomModelMissingMessage(), new FileNotFoundException());
}
var storagePath = userSettings?.CustomModelStoragePath;
var expandedPath = Environment.ExpandEnvironmentVariables(modelPath);
if (!Path.IsPathRooted(expandedPath) && !string.IsNullOrEmpty(storagePath))
{
expandedPath = Path.Combine(storagePath, expandedPath);
}
string fullPath;
try
{
fullPath = Path.GetFullPath(expandedPath);
}
catch (Exception ex)
{
throw new PasteActionException(GetCustomModelMissingMessage(), ex);
}
if (!File.Exists(fullPath))
{
throw new PasteActionException(GetCustomModelMissingMessage(), new FileNotFoundException(fullPath));
}
return fullPath;
}
private static string GetCustomModelMissingMessage()
{
var message = ResourceLoaderInstance.ResourceLoader.GetString("AdvancedPasteCustomModelNotFound");
return string.IsNullOrWhiteSpace(message) ? "The selected custom model could not be found." : message;
}
private string DetermineCustomModelSystemPrompt()
{
var customPrompt = userSettings?.PasteAIConfiguration?.ActiveProvider?.SystemPrompt
?? userSettings?.PasteAIConfiguration?.Providers?.FirstOrDefault()?.SystemPrompt;
if (string.IsNullOrWhiteSpace(customPrompt))
{
return DefaultSystemPrompt;
}
return customPrompt;
}
} }
} }

View File

@@ -7,11 +7,12 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AdvancedPaste.Settings; using AdvancedPaste.Settings;
using Microsoft.PowerToys.Settings.UI.Library;
namespace AdvancedPaste.Services.CustomActions namespace AdvancedPaste.Services.CustomActions
{ {
public interface ICustomActionTransformService public interface ICustomActionTransformService
{ {
Task<CustomActionTransformResult> TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress<double> progress); Task<CustomActionTransformResult> TransformTextAsync(AdvancedPasteCustomAction customAction, string prompt, string inputText, CancellationToken cancellationToken, IProgress<double> progress);
} }
} }

View File

@@ -3,11 +3,15 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models; using AdvancedPaste.Models;
using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library;
using Windows.AI.MachineLearning;
namespace AdvancedPaste.Services.CustomActions namespace AdvancedPaste.Services.CustomActions
{ {
@@ -23,6 +27,8 @@ namespace AdvancedPaste.Services.CustomActions
private readonly PasteAIConfig _config; private readonly PasteAIConfig _config;
private static readonly ConcurrentDictionary<string, Lazy<Task<LearningModel>>> ModelCache = new(StringComparer.OrdinalIgnoreCase);
public LocalModelPasteProvider(PasteAIConfig config) public LocalModelPasteProvider(PasteAIConfig config)
{ {
_config = config ?? throw new ArgumentNullException(nameof(config)); _config = config ?? throw new ArgumentNullException(nameof(config));
@@ -30,14 +36,120 @@ namespace AdvancedPaste.Services.CustomActions
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true); public Task<bool> IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true);
public Task<string> ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress<double> progress) public async Task<string> ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress<double> progress)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
// TODO: Implement local model inference logic using _config.LocalModelPath/_config.ModelPath cancellationToken.ThrowIfCancellationRequested();
var content = request.InputText ?? string.Empty;
var modelPath = _config.LocalModelPath ?? _config.ModelPath;
if (string.IsNullOrWhiteSpace(modelPath))
{
throw new PasteActionException(GetInferenceErrorMessage(), new InvalidOperationException("Local model path not provided."));
}
try
{
var model = await GetOrLoadModelAsync(modelPath).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
using LearningModelSession session = new(model);
var binding = new LearningModelBinding(session);
BindInputs(session.Model, binding, request);
var correlationId = Guid.NewGuid().ToString();
var evaluation = await session.EvaluateAsync(binding, correlationId).AsTask(cancellationToken).ConfigureAwait(false);
var output = ExtractStringOutput(session.Model, evaluation);
request.Usage = AIServiceUsage.None; request.Usage = AIServiceUsage.None;
return Task.FromResult(content); progress?.Report(1.0);
return output ?? string.Empty;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
throw new PasteActionException(GetInferenceErrorMessage(), ex);
}
}
private static async Task<LearningModel> GetOrLoadModelAsync(string modelPath)
{
var loader = ModelCache.GetOrAdd(modelPath, path => new Lazy<Task<LearningModel>>(() => Task.Run(() => LearningModel.LoadFromFilePath(path))));
return await loader.Value.ConfigureAwait(false);
}
private static void BindInputs(LearningModel model, LearningModelBinding binding, PasteAIRequest request)
{
var stringInputs = model.InputFeatures
.OfType<TensorFeatureDescriptor>()
.Where(descriptor => descriptor.TensorKind == TensorKind.String)
.ToList();
if (stringInputs.Count == 0)
{
throw new PasteActionException(GetInferenceErrorMessage(), new InvalidOperationException("Model does not expose string inputs."));
}
var prompt = request.Prompt ?? string.Empty;
var input = request.InputText ?? string.Empty;
var combined = string.IsNullOrWhiteSpace(prompt) ? input : string.Join(Environment.NewLine + Environment.NewLine, prompt, input);
foreach (var inputDescriptor in stringInputs)
{
var value = ResolveInputValue(inputDescriptor.Name, prompt, input, combined);
var tensor = TensorString.CreateFromArray(new long[] { 1 }, new[] { value });
binding.Bind(inputDescriptor.Name, tensor);
}
}
private static string ResolveInputValue(string featureName, string prompt, string input, string combined)
{
if (string.IsNullOrEmpty(featureName))
{
return combined;
}
switch (featureName.ToLowerInvariant())
{
case "prompt":
case "instruction":
case "instructions":
return prompt;
case "input":
case "text":
case "input_text":
case "inputtext":
return input;
default:
return combined;
}
}
private static string ExtractStringOutput(LearningModel model, LearningModelEvaluationResult evaluation)
{
foreach (var outputDescriptor in model.OutputFeatures.OfType<TensorFeatureDescriptor>().Where(descriptor => descriptor.TensorKind == TensorKind.String))
{
if (evaluation.Outputs.TryGetValue(outputDescriptor.Name, out var value) && value is TensorString tensor)
{
var vector = tensor.GetAsVectorView();
if (vector.Count > 0)
{
return vector[0];
}
}
}
throw new PasteActionException(GetInferenceErrorMessage(), new InvalidOperationException("Model did not return a string output."));
}
private static string GetInferenceErrorMessage()
{
var message = ResourceLoaderInstance.ResourceLoader.GetString("AdvancedPasteCustomModelInferenceError");
return string.IsNullOrWhiteSpace(message) ? "The custom model failed to generate a response." : message;
} }
} }
} }

View File

@@ -237,7 +237,7 @@ public abstract class KernelServiceBase(
? $"Runs the \"{customAction.Name}\" custom action." ? $"Runs the \"{customAction.Name}\" custom action."
: customAction.Description; : customAction.Description;
return KernelFunctionFactory.CreateFromMethod( return KernelFunctionFactory.CreateFromMethod(
method: async (Kernel kernel) => await ExecuteCustomActionAsync(kernel, customAction.Prompt), method: async (Kernel kernel) => await ExecuteCustomActionAsync(kernel, customAction),
functionName: functionName, functionName: functionName,
description: description, description: description,
parameters: null, parameters: null,
@@ -286,14 +286,14 @@ public abstract class KernelServiceBase(
return string.IsNullOrEmpty(sanitized) ? "_CustomAction" : sanitized; return string.IsNullOrEmpty(sanitized) ? "_CustomAction" : sanitized;
} }
private Task<string> ExecuteCustomActionAsync(Kernel kernel, string fixedPrompt) => private Task<string> ExecuteCustomActionAsync(Kernel kernel, AdvancedPasteCustomAction customAction) =>
ExecuteTransformAsync( ExecuteTransformAsync(
kernel, kernel,
new ActionChainItem(PasteFormats.CustomTextTransformation, Arguments: new() { { PromptParameterName, fixedPrompt } }), new ActionChainItem(PasteFormats.CustomTextTransformation, Arguments: new() { { PromptParameterName, customAction?.Prompt ?? string.Empty } }),
async dataPackageView => async dataPackageView =>
{ {
var input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken()); var input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken());
var result = await _customActionTransformService.TransformTextAsync(fixedPrompt, input, kernel.GetCancellationToken(), kernel.GetProgress()); var result = await _customActionTransformService.TransformTextAsync(customAction, customAction?.Prompt ?? string.Empty, input, kernel.GetCancellationToken(), kernel.GetProgress());
return DataPackageHelpers.CreateFromText(result?.Content ?? string.Empty); return DataPackageHelpers.CreateFromText(result?.Content ?? string.Empty);
}); });
@@ -311,7 +311,7 @@ public abstract class KernelServiceBase(
private async Task<string> GetPromptBasedOutput(PasteFormats format, string prompt, string input, CancellationToken cancellationToken, IProgress<double> progress) => private async Task<string> GetPromptBasedOutput(PasteFormats format, string prompt, string input, CancellationToken cancellationToken, IProgress<double> progress) =>
format switch format switch
{ {
PasteFormats.CustomTextTransformation => (await _customActionTransformService.TransformTextAsync(prompt, input, cancellationToken, progress))?.Content ?? string.Empty, PasteFormats.CustomTextTransformation => (await _customActionTransformService.TransformTextAsync(null, prompt, input, cancellationToken, progress))?.Content ?? string.Empty,
_ => throw new ArgumentException($"Unsupported format {format} for prompt transform", nameof(format)), _ => throw new ArgumentException($"Unsupported format {format} for prompt transform", nameof(format)),
}; };

View File

@@ -37,7 +37,7 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
pasteFormat.Format switch pasteFormat.Format switch
{ {
PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress), PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress),
PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetClipboardTextOrThrowAsync(cancellationToken), cancellationToken, progress))?.Content ?? string.Empty), PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformTextAsync(pasteFormat.CustomAction, pasteFormat.Prompt, await clipboardData.GetClipboardTextOrThrowAsync(cancellationToken), cancellationToken, progress))?.Content ?? string.Empty),
_ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress), _ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress),
}); });
} }

View File

@@ -334,8 +334,8 @@ namespace AdvancedPaste.ViewModels
private PasteFormat CreateStandardPasteFormat(PasteFormats format) => private PasteFormat CreateStandardPasteFormat(PasteFormats format) =>
PasteFormat.CreateStandardFormat(format, AvailableClipboardFormats, IsCustomAIServiceEnabled, ResourceLoaderInstance.ResourceLoader.GetString); PasteFormat.CreateStandardFormat(format, AvailableClipboardFormats, IsCustomAIServiceEnabled, ResourceLoaderInstance.ResourceLoader.GetString);
private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery) => private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery, AdvancedPasteCustomAction customAction = null) =>
PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled); PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled, customAction);
private void UpdateAIProviderActiveFlags() private void UpdateAIProviderActiveFlags()
{ {
@@ -407,7 +407,7 @@ namespace AdvancedPaste.ViewModels
UpdateFormats( UpdateFormats(
CustomActionPasteFormats, CustomActionPasteFormats,
IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true)) : []); IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true, customAction)) : []);
} }
public void Dispose() public void Dispose()
@@ -732,7 +732,7 @@ namespace AdvancedPaste.ViewModels
if (customAction != null) if (customAction != null)
{ {
await ReadClipboardAsync(); await ReadClipboardAsync();
await ExecutePasteFormatAsync(CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true), source); await ExecutePasteFormatAsync(CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true, customAction), source);
} }
} }
@@ -741,7 +741,7 @@ namespace AdvancedPaste.ViewModels
var customAction = _userSettings.CustomActions var customAction = _userSettings.CustomActions
.FirstOrDefault(customAction => Models.KernelQueryCache.CacheKey.PromptComparer.Equals(customAction.Prompt, Query)); .FirstOrDefault(customAction => Models.KernelQueryCache.CacheKey.PromptComparer.Equals(customAction.Prompt, Query));
await ExecutePasteFormatAsync(CreateCustomAIPasteFormat(customAction?.Name ?? "Default", Query, isSavedQuery: customAction != null), triggerSource); await ExecutePasteFormatAsync(CreateCustomAIPasteFormat(customAction?.Name ?? "Default", Query, isSavedQuery: customAction != null, customAction), triggerSource);
} }
private void HideWindow() private void HideWindow()

View File

@@ -23,6 +23,8 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction
private bool _isValid; private bool _isValid;
private bool _hasConflict; private bool _hasConflict;
private string _tooltip; private string _tooltip;
private bool _useCustomModel;
private string _customModelPath = string.Empty;
[JsonPropertyName("id")] [JsonPropertyName("id")]
public int Id public int Id
@@ -122,6 +124,32 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction
set => Set(ref _tooltip, value); set => Set(ref _tooltip, value);
} }
[JsonPropertyName("useCustomModel")]
public bool UseCustomModel
{
get => _useCustomModel;
set
{
if (Set(ref _useCustomModel, value))
{
UpdateIsValid();
}
}
}
[JsonPropertyName("customModelPath")]
public string CustomModelPath
{
get => _customModelPath;
set
{
if (Set(ref _customModelPath, value ?? string.Empty))
{
UpdateIsValid();
}
}
}
[JsonIgnore] [JsonIgnore]
public IEnumerable<IAdvancedPasteAction> SubActions => []; public IEnumerable<IAdvancedPasteAction> SubActions => [];
@@ -144,6 +172,8 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction
CanMoveDown = other.CanMoveDown; CanMoveDown = other.CanMoveDown;
HasConflict = other.HasConflict; HasConflict = other.HasConflict;
Tooltip = other.Tooltip; Tooltip = other.Tooltip;
UseCustomModel = other.UseCustomModel;
CustomModelPath = other.CustomModelPath;
} }
private HotkeySettings GetShortcutClone() private HotkeySettings GetShortcutClone()
@@ -159,6 +189,10 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction
private void UpdateIsValid() private void UpdateIsValid()
{ {
IsValid = !string.IsNullOrWhiteSpace(Name) && !string.IsNullOrWhiteSpace(Prompt); bool hasPrompt = !string.IsNullOrWhiteSpace(Prompt);
bool hasName = !string.IsNullOrWhiteSpace(Name);
bool hasModel = !UseCustomModel || !string.IsNullOrWhiteSpace(CustomModelPath);
IsValid = hasName && hasPrompt && hasModel;
} }
} }

View File

@@ -2,7 +2,9 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@@ -28,6 +30,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
ShowCustomPreview = true; ShowCustomPreview = true;
CloseAfterLosingFocus = false; CloseAfterLosingFocus = false;
PasteAIConfiguration = new(); PasteAIConfiguration = new();
CustomModelStoragePath = GetDefaultCustomModelStoragePath();
} }
[JsonConverter(typeof(BoolPropertyJsonConverter))] [JsonConverter(typeof(BoolPropertyJsonConverter))]
@@ -87,7 +90,36 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[CmdConfigureIgnoreAttribute] [CmdConfigureIgnoreAttribute]
public PasteAIConfiguration PasteAIConfiguration { get; set; } public PasteAIConfiguration PasteAIConfiguration { get; set; }
[JsonPropertyName("custom-model-storage-path")]
[CmdConfigureIgnoreAttribute]
public string CustomModelStoragePath { get; set; }
public override string ToString() public override string ToString()
=> JsonSerializer.Serialize(this); => JsonSerializer.Serialize(this);
public static string GetDefaultCustomModelStoragePath()
{
try
{
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(userProfile))
{
userProfile = Environment.GetEnvironmentVariable("USERPROFILE");
}
if (string.IsNullOrWhiteSpace(userProfile))
{
var temp = Path.GetTempPath();
return Path.Combine(temp, "PowerToys", "Models");
}
return Path.Combine(userProfile, ".cache", "PowerToys");
}
catch (Exception)
{
var temp = Path.GetTempPath();
return Path.Combine(temp, "PowerToys", "Models");
}
}
} }
} }

View File

@@ -381,6 +381,20 @@
</DataTemplate> </DataTemplate>
</tkcontrols:SettingsExpander.ItemTemplate> </tkcontrols:SettingsExpander.ItemTemplate>
</tkcontrols:SettingsExpander> </tkcontrols:SettingsExpander>
<tkcontrols:SettingsCard
x:Uid="AdvancedPaste_CustomModelStoragePath"
HeaderIcon="{ui:FontIcon Glyph=&#xE8B7;}"
IsEnabled="{x:Bind ViewModel.IsAIEnabled, Mode=OneWay}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox
Width="320"
Text="{x:Bind ViewModel.CustomModelStoragePath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
x:Uid="AdvancedPaste_CustomModelStoragePath_BrowseButton"
Click="BrowseCustomModelStoragePath_Click"
Style="{ThemeResource AccentButtonStyle}" />
</StackPanel>
</tkcontrols:SettingsCard>
<InfoBar <InfoBar
x:Uid="AdvancedPaste_ShortcutWarning" x:Uid="AdvancedPaste_ShortcutWarning"
IsClosable="False" IsClosable="False"
@@ -423,6 +437,31 @@
AcceptsReturn="true" AcceptsReturn="true"
Text="{Binding Prompt, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Text="{Binding Prompt, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<ToggleSwitch
x:Uid="AdvancedPasteUI_CustomAction_UseCustomModel"
HorizontalAlignment="Left"
IsOn="{Binding UseCustomModel, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
OffContent=""
OnContent=""
Toggled="CustomActionUseCustomModel_Toggled" />
<StackPanel
Orientation="Vertical"
Spacing="8"
Visibility="{Binding UseCustomModel, Converter={StaticResource BoolToVisibilityConverter}}">
<ComboBox
x:Name="CustomActionModelComboBox"
x:Uid="AdvancedPasteUI_CustomAction_ModelPath"
Width="340"
HorizontalAlignment="Left"
ItemsSource="{x:Bind CustomModelOptions, Mode=OneWay}"
SelectedItem="{Binding CustomModelPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
ToolTipService.ToolTip="{Binding CustomModelPath, Mode=OneWay}" />
<Button
x:Uid="AdvancedPasteUI_CustomAction_SelectModelButton"
HorizontalAlignment="Left"
Click="BrowseCustomActionModel_Click"
Style="{ThemeResource AccentButtonStyle}" />
</StackPanel>
</StackPanel> </StackPanel>
</ContentDialog> </ContentDialog>

View File

@@ -6,6 +6,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
@@ -37,10 +38,14 @@ namespace Microsoft.PowerToys.Settings.UI.Views
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."; 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.";
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."; 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.";
private readonly ObservableCollection<string> _customModelOptions = new();
private AdvancedPasteViewModel ViewModel { get; set; } private AdvancedPasteViewModel ViewModel { get; set; }
public ICommand EnableAdvancedPasteAICommand => new RelayCommand(EnableAdvancedPasteAI); public ICommand EnableAdvancedPasteAICommand => new RelayCommand(EnableAdvancedPasteAI);
public ObservableCollection<string> CustomModelOptions => _customModelOptions;
public AdvancedPastePage() public AdvancedPastePage()
{ {
var settingsUtils = new SettingsUtils(); var settingsUtils = new SettingsUtils();
@@ -52,6 +57,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views
DataContext = ViewModel; DataContext = ViewModel;
InitializeComponent(); InitializeComponent();
ViewModel.PropertyChanged += AdvancedPasteViewModel_PropertyChanged;
if (FoundryLocalPicker is not null) if (FoundryLocalPicker is not null)
{ {
FoundryLocalPicker.CachedModels = _foundryCachedModels; FoundryLocalPicker.CachedModels = _foundryCachedModels;
@@ -133,6 +140,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
CustomActionDialog.Title = resourceLoader.GetString("AddCustomAction"); CustomActionDialog.Title = resourceLoader.GetString("AddCustomAction");
CustomActionDialog.DataContext = ViewModel.GetNewCustomAction(resourceLoader.GetString("AdvancedPasteUI_NewCustomActionPrefix")); CustomActionDialog.DataContext = ViewModel.GetNewCustomAction(resourceLoader.GetString("AdvancedPasteUI_NewCustomActionPrefix"));
CustomActionDialog.PrimaryButtonText = resourceLoader.GetString("CustomActionSave"); CustomActionDialog.PrimaryButtonText = resourceLoader.GetString("CustomActionSave");
RefreshCustomActionModelOptions(CustomActionDialog.DataContext as AdvancedPasteCustomAction);
await CustomActionDialog.ShowAsync(); await CustomActionDialog.ShowAsync();
} }
@@ -143,6 +151,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
CustomActionDialog.Title = resourceLoader.GetString("EditCustomAction"); CustomActionDialog.Title = resourceLoader.GetString("EditCustomAction");
CustomActionDialog.DataContext = GetBoundCustomAction(sender, e).Clone(); CustomActionDialog.DataContext = GetBoundCustomAction(sender, e).Clone();
CustomActionDialog.PrimaryButtonText = resourceLoader.GetString("CustomActionUpdate"); CustomActionDialog.PrimaryButtonText = resourceLoader.GetString("CustomActionUpdate");
RefreshCustomActionModelOptions(CustomActionDialog.DataContext as AdvancedPasteCustomAction);
await CustomActionDialog.ShowAsync(); await CustomActionDialog.ShowAsync();
} }
@@ -256,7 +265,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views
if (!string.IsNullOrEmpty(selectedFile)) if (!string.IsNullOrEmpty(selectedFile))
{ {
PasteAIModelPathTextBox.Text = selectedFile;
if (ViewModel?.PasteAIProviderDraft is not null) if (ViewModel?.PasteAIProviderDraft is not null)
{ {
ViewModel.PasteAIProviderDraft.ModelPath = selectedFile; ViewModel.PasteAIProviderDraft.ModelPath = selectedFile;
@@ -264,6 +272,37 @@ namespace Microsoft.PowerToys.Settings.UI.Views
} }
} }
private void BrowseCustomActionModel_Click(object sender, RoutedEventArgs e)
{
string selectedFile = PickFileDialog(
"Model Files\0*.onnx;*.zip;*.model\0All Files\0*.*\0",
"Select Model File",
ViewModel?.CustomModelStoragePath);
if (string.IsNullOrEmpty(selectedFile))
{
return;
}
if (CustomActionDialog?.DataContext is AdvancedPasteCustomAction action)
{
action.CustomModelPath = NormalizeCustomModelSelectionPath(selectedFile);
RefreshCustomActionModelOptions(action);
}
}
private void BrowseCustomModelStoragePath_Click(object sender, RoutedEventArgs e)
{
IntPtr hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow());
string selectedFolder = ShellGetFolder.GetFolderDialogWithFlags(hwnd, ShellGetFolder.FolderDialogFlags._BIF_NEWDIALOGSTYLE);
if (!string.IsNullOrEmpty(selectedFolder) && ViewModel is not null)
{
ViewModel.CustomModelStoragePath = selectedFolder;
RefreshCustomActionModelOptions(CustomActionDialog?.DataContext as AdvancedPasteCustomAction);
}
}
private static string PickFileDialog(string filter, string title, string initialDir = null, int initialFilter = 0) private static string PickFileDialog(string filter, string title, string initialDir = null, int initialFilter = 0)
{ {
// Use Win32 OpenFileName dialog as FileOpenPicker doesn't work with elevated permissions // Use Win32 OpenFileName dialog as FileOpenPicker doesn't work with elevated permissions
@@ -293,6 +332,121 @@ namespace Microsoft.PowerToys.Settings.UI.Views
return null; return null;
} }
private string NormalizeCustomModelSelectionPath(string selectedFile)
{
if (string.IsNullOrWhiteSpace(selectedFile))
{
return string.Empty;
}
try
{
var fullSelection = Path.GetFullPath(selectedFile);
var storagePath = ViewModel?.CustomModelStoragePath;
if (!string.IsNullOrWhiteSpace(storagePath))
{
var fullStorage = Path.GetFullPath(storagePath);
if (fullSelection.StartsWith(fullStorage, StringComparison.OrdinalIgnoreCase))
{
var relative = Path.GetRelativePath(fullStorage, fullSelection);
if (!string.IsNullOrEmpty(relative) && !relative.StartsWith("..", StringComparison.Ordinal))
{
return relative;
}
}
}
return fullSelection;
}
catch (Exception)
{
return selectedFile;
}
}
private void RefreshCustomActionModelOptions(AdvancedPasteCustomAction action)
{
var options = new List<string>();
var storageRoot = GetCustomModelStorageRoot();
if (!string.IsNullOrWhiteSpace(storageRoot) && Directory.Exists(storageRoot))
{
try
{
foreach (var file in Directory.EnumerateFiles(storageRoot, "*.onnx", SearchOption.AllDirectories))
{
var option = NormalizeCustomModelSelectionPath(file);
if (!string.IsNullOrWhiteSpace(option))
{
options.Add(option);
}
}
}
catch (Exception)
{
// Enumeration failures are non-fatal; leave the list empty and allow manual selection.
}
}
if (action?.UseCustomModel == true && !string.IsNullOrWhiteSpace(action.CustomModelPath))
{
options.Add(action.CustomModelPath);
}
var uniqueOptions = options
.Where(option => !string.IsNullOrWhiteSpace(option))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(option => option, StringComparer.OrdinalIgnoreCase)
.ToList();
_customModelOptions.Clear();
foreach (var option in uniqueOptions)
{
_customModelOptions.Add(option);
}
if (action?.UseCustomModel == true && string.IsNullOrWhiteSpace(action.CustomModelPath) && _customModelOptions.Count > 0)
{
action.CustomModelPath = _customModelOptions[0];
}
}
private string GetCustomModelStorageRoot()
{
var path = ViewModel?.CustomModelStoragePath;
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
try
{
path = Environment.ExpandEnvironmentVariables(path.Trim());
return Path.GetFullPath(path);
}
catch (Exception)
{
return path;
}
}
private void AdvancedPasteViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (string.Equals(e.PropertyName, nameof(AdvancedPasteViewModel.CustomModelStoragePath), StringComparison.Ordinal))
{
RefreshCustomActionModelOptions(CustomActionDialog?.DataContext as AdvancedPasteCustomAction);
}
}
private void CustomActionUseCustomModel_Toggled(object sender, RoutedEventArgs e)
{
if (CustomActionDialog?.DataContext is AdvancedPasteCustomAction action && action.UseCustomModel)
{
RefreshCustomActionModelOptions(action);
}
}
private void ShowApiKeySavedMessage(string configType) private void ShowApiKeySavedMessage(string configType)
{ {
// This would typically show a TeachingTip or InfoBar // This would typically show a TeachingTip or InfoBar
@@ -1002,7 +1156,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views
FoundryLocalPicker.LoadRequested -= FoundryLocalPicker_LoadRequested; FoundryLocalPicker.LoadRequested -= FoundryLocalPicker_LoadRequested;
} }
ViewModel?.Dispose(); if (ViewModel is not null)
{
ViewModel.PropertyChanged -= AdvancedPasteViewModel_PropertyChanged;
ViewModel.Dispose();
}
_disposed = true; _disposed = true;
GC.SuppressFinalize(this); GC.SuppressFinalize(this);

View File

@@ -662,6 +662,22 @@ Please review the placeholder content that represents the final terms and usage
<data name="AdvancedPaste_Additional_Actions_GroupSettings.Header" xml:space="preserve"> <data name="AdvancedPaste_Additional_Actions_GroupSettings.Header" xml:space="preserve">
<value>Custom actions</value> <value>Custom actions</value>
</data> </data>
<data name="AdvancedPaste_CustomModelStoragePath.Header" xml:space="preserve">
<value>Custom model storage path</value>
<comment>Header for the settings card that lets users choose where custom models are stored.</comment>
</data>
<data name="AdvancedPaste_CustomModelStoragePath.Description" xml:space="preserve">
<value>PowerToys looks in this folder when custom actions use locally downloaded models.</value>
</data>
<data name="AdvancedPaste_CustomModelStoragePath_BrowseButton.Content" xml:space="preserve">
<value>Browse</value>
</data>
<data name="AdvancedPasteCustomModelNotFound" xml:space="preserve">
<value>The custom model could not be found. Verify the path and try again.</value>
</data>
<data name="AdvancedPasteCustomModelInferenceError" xml:space="preserve">
<value>PowerToys couldn't generate a response using the selected custom model.</value>
</data>
<data name="AdvancedPaste_FoundryLocal_LegalDescription" xml:space="preserve"> <data name="AdvancedPaste_FoundryLocal_LegalDescription" xml:space="preserve">
<value>You're running local models directly on your device. Their behavior may vary or be unpredictable.</value> <value>You're running local models directly on your device. Their behavior may vary or be unpredictable.</value>
</data> </data>
@@ -2065,6 +2081,20 @@ Made with 💗 by Microsoft and the PowerToys community.</value>
<data name="AdvancedPasteUI_CustomAction_Prompt.Header" xml:space="preserve"> <data name="AdvancedPasteUI_CustomAction_Prompt.Header" xml:space="preserve">
<value>Prompt</value> <value>Prompt</value>
</data> </data>
<data name="AdvancedPasteUI_CustomAction_UseCustomModel.Header" xml:space="preserve">
<value>Use custom model</value>
<comment>Toggle allowing the user to choose a locally stored model for the custom action.</comment>
</data>
<data name="AdvancedPasteUI_CustomAction_ModelPath.Header" xml:space="preserve">
<value>Model path</value>
<comment>Label for the text box showing the selected custom model path.</comment>
</data>
<data name="AdvancedPasteUI_CustomAction_ModelPath.PlaceholderText" xml:space="preserve">
<value>Select a local model file</value>
</data>
<data name="AdvancedPasteUI_CustomAction_SelectModelButton.Content" xml:space="preserve">
<value>Choose model</value>
</data>
<data name="CustomActionDialog.SecondaryButtonText" xml:space="preserve"> <data name="CustomActionDialog.SecondaryButtonText" xml:space="preserve">
<value>Cancel</value> <value>Cancel</value>
</data> </data>

View File

@@ -8,6 +8,7 @@ using System.Collections.ObjectModel;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.ComponentModel; using System.ComponentModel;
using System.Globalization; using System.Globalization;
using System.IO;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
@@ -90,6 +91,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
SetupSettingsFileWatcher(); SetupSettingsFileWatcher();
InitializePasteAIProviderState(); InitializePasteAIProviderState();
EnsureCustomModelStoragePathInitialized();
InitializeEnabledValue(); InitializeEnabledValue();
MigrateLegacyAIEnablement(); MigrateLegacyAIEnablement();
@@ -471,6 +473,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
} }
} }
public string CustomModelStoragePath
{
get => _advancedPasteSettings.Properties.CustomModelStoragePath;
set
{
var normalized = NormalizeDirectoryPath(value);
var current = NormalizeDirectoryPath(_advancedPasteSettings.Properties.CustomModelStoragePath);
if (!string.Equals(current, normalized, StringComparison.OrdinalIgnoreCase))
{
_advancedPasteSettings.Properties.CustomModelStoragePath = normalized;
EnsureDirectoryExists(normalized);
OnPropertyChanged(nameof(CustomModelStoragePath));
SaveAndNotifySettings();
}
}
}
public bool IsConflictingCopyShortcut => public bool IsConflictingCopyShortcut =>
_customActions.Select(customAction => customAction.Shortcut) _customActions.Select(customAction => customAction.Shortcut)
.Concat([PasteAsPlainTextShortcut, AdvancedPasteUIShortcut, PasteAsMarkdownShortcut, PasteAsJsonShortcut]) .Concat([PasteAsPlainTextShortcut, AdvancedPasteUIShortcut, PasteAsMarkdownShortcut, PasteAsJsonShortcut])
@@ -1149,6 +1169,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
OnPropertyChanged(nameof(CloseAfterLosingFocus)); OnPropertyChanged(nameof(CloseAfterLosingFocus));
} }
if (!string.Equals(NormalizeDirectoryPath(target.CustomModelStoragePath), NormalizeDirectoryPath(source.CustomModelStoragePath), StringComparison.OrdinalIgnoreCase))
{
target.CustomModelStoragePath = NormalizeDirectoryPath(source.CustomModelStoragePath);
EnsureDirectoryExists(target.CustomModelStoragePath);
OnPropertyChanged(nameof(CustomModelStoragePath));
}
var incomingConfig = source.PasteAIConfiguration ?? new PasteAIConfiguration(); var incomingConfig = source.PasteAIConfiguration ?? new PasteAIConfiguration();
if (ShouldReplacePasteAIConfiguration(target.PasteAIConfiguration, incomingConfig)) if (ShouldReplacePasteAIConfiguration(target.PasteAIConfiguration, incomingConfig))
{ {
@@ -1382,5 +1409,59 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
customAction.CanMoveDown = index != _customActions.Count - 1; customAction.CanMoveDown = index != _customActions.Count - 1;
} }
} }
private void EnsureCustomModelStoragePathInitialized()
{
var path = NormalizeDirectoryPath(_advancedPasteSettings.Properties.CustomModelStoragePath);
if (string.IsNullOrWhiteSpace(path))
{
path = NormalizeDirectoryPath(AdvancedPasteProperties.GetDefaultCustomModelStoragePath());
_advancedPasteSettings.Properties.CustomModelStoragePath = path;
SaveAndNotifySettings();
}
EnsureDirectoryExists(path);
OnPropertyChanged(nameof(CustomModelStoragePath));
}
private static string NormalizeDirectoryPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
try
{
path = path.Trim();
if (path.Length == 0)
{
return string.Empty;
}
path = Environment.ExpandEnvironmentVariables(path);
return Path.GetFullPath(path);
}
catch (Exception)
{
return path;
}
}
private static void EnsureDirectoryExists(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
try
{
Directory.CreateDirectory(path);
}
catch (Exception)
{
}
}
} }
} }