diff --git a/app/MindWork AI Studio/Components/Pages/Settings.razor b/app/MindWork AI Studio/Components/Pages/Settings.razor index a8801e3..d8a3f89 100644 --- a/app/MindWork AI Studio/Components/Pages/Settings.razor +++ b/app/MindWork AI Studio/Components/Pages/Settings.razor @@ -4,7 +4,7 @@ Configured Providers - + @@ -22,17 +22,17 @@ @context.UsedProvider @context.InstanceName - + Edit - + Delete - @if(this.Providers.Count == 0) + @if(this.SettingsManager.ConfigurationData.Providers.Count == 0) { No providers configured yet. } diff --git a/app/MindWork AI Studio/Components/Pages/Settings.razor.cs b/app/MindWork AI Studio/Components/Pages/Settings.razor.cs index 3cc59e1..cabf3dd 100644 --- a/app/MindWork AI Studio/Components/Pages/Settings.razor.cs +++ b/app/MindWork AI Studio/Components/Pages/Settings.razor.cs @@ -1,5 +1,8 @@ +using AIStudio.Components.CommonDialogs; +using AIStudio.Provider; using AIStudio.Settings; using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; using MudBlazor; @@ -14,35 +17,83 @@ public partial class Settings : ComponentBase [Inject] public IDialogService DialogService { get; init; } = null!; + + [Inject] + public IJSRuntime JsRuntime { get; init; } = null!; - private List Providers { get; set; } = new(); + private static readonly DialogOptions DIALOG_OPTIONS = new() + { + CloseOnEscapeKey = true, + FullWidth = true, MaxWidth = MaxWidth.Medium, + }; #region Overrides of ComponentBase protected override async Task OnInitializedAsync() { - var settings = await this.SettingsManager.LoadSettings(); - this.Providers = settings.Providers; - + await this.SettingsManager.LoadSettings(); await base.OnInitializedAsync(); } #endregion - + private async Task AddProvider() { var dialogParameters = new DialogParameters { - { x => x.UsedInstanceNames, this.Providers.Select(x => x.InstanceName.ToLowerInvariant()).ToList() }, + { x => x.IsEditing, false }, }; - - var dialogOptions = new DialogOptions { CloseOnEscapeKey = true, FullWidth = true, MaxWidth = MaxWidth.Medium }; - var dialogReference = await this.DialogService.ShowAsync("Add Provider", dialogParameters, dialogOptions); + + var dialogReference = await this.DialogService.ShowAsync("Add Provider", dialogParameters, DIALOG_OPTIONS); var dialogResult = await dialogReference.Result; if (dialogResult.Canceled) return; var addedProvider = (AIStudio.Settings.Provider)dialogResult.Data; - this.Providers.Add(addedProvider); + this.SettingsManager.ConfigurationData.Providers.Add(addedProvider); + await this.SettingsManager.StoreSettings(); + } + + private async Task EditProvider(global::AIStudio.Settings.Provider provider) + { + var dialogParameters = new DialogParameters + { + { x => x.DataId, provider.Id }, + { x => x.DataInstanceName, provider.InstanceName }, + { x => x.DataProvider, provider.UsedProvider }, + { x => x.IsEditing, true }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Edit Provider", dialogParameters, DIALOG_OPTIONS); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + + var editedProvider = (AIStudio.Settings.Provider)dialogResult.Data; + this.SettingsManager.ConfigurationData.Providers[this.SettingsManager.ConfigurationData.Providers.IndexOf(provider)] = editedProvider; + await this.SettingsManager.StoreSettings(); + } + + private async Task DeleteProvider(global::AIStudio.Settings.Provider provider) + { + var dialogParameters = new DialogParameters + { + { "Message", $"Are you sure you want to delete the provider '{provider.InstanceName}'?" }, + }; + + var dialogReference = await this.DialogService.ShowAsync("Delete Provider", dialogParameters, DIALOG_OPTIONS); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + + var providerInstance = provider.UsedProvider.CreateProvider(); + providerInstance.InstanceName = provider.InstanceName; + + var deleteSecretResponse = await this.SettingsManager.DeleteAPIKey(this.JsRuntime, providerInstance); + if(deleteSecretResponse.Success) + { + this.SettingsManager.ConfigurationData.Providers.Remove(provider); + await this.SettingsManager.StoreSettings(); + } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/IProvider.cs b/app/MindWork AI Studio/Provider/IProvider.cs new file mode 100644 index 0000000..db138f7 --- /dev/null +++ b/app/MindWork AI Studio/Provider/IProvider.cs @@ -0,0 +1,15 @@ +using AIStudio.Settings; +using Microsoft.JSInterop; + +namespace AIStudio.Provider; + +public interface IProvider +{ + public string Id { get; } + + public string InstanceName { get; set; } + + public IAsyncEnumerable GetChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, Thread chatThread); + + public Task> GetModels(IJSRuntime jsRuntime, SettingsManager settings); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/NoProvider.cs b/app/MindWork AI Studio/Provider/NoProvider.cs new file mode 100644 index 0000000..fa3635e --- /dev/null +++ b/app/MindWork AI Studio/Provider/NoProvider.cs @@ -0,0 +1,20 @@ +using AIStudio.Settings; + +using Microsoft.JSInterop; + +namespace AIStudio.Provider; + +public class NoProvider : IProvider +{ + #region Implementation of IProvider + + public string Id => "none"; + + public string InstanceName { get; set; } = "None"; + + public IAsyncEnumerable GetChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, Thread chatThread) => throw new NotImplementedException(); + + public Task> GetModels(IJSRuntime jsRuntime, SettingsManager settings) => throw new NotImplementedException(); + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Providers.cs b/app/MindWork AI Studio/Provider/Providers.cs new file mode 100644 index 0000000..22219d4 --- /dev/null +++ b/app/MindWork AI Studio/Provider/Providers.cs @@ -0,0 +1,26 @@ +using AIStudio.Provider.OpenAI; + +namespace AIStudio.Provider; + +public enum Providers +{ + NONE, + OPEN_AI, +} + +public static class ExtensionsProvider +{ + public static string ToName(this Providers provider) => provider switch + { + Providers.OPEN_AI => "OpenAI", + + _ => "Unknown", + }; + + public static IProvider CreateProvider(this Providers provider) => provider switch + { + Providers.OPEN_AI => new ProviderOpenAI(), + + _ => new NoProvider(), + }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/ProviderDialog.razor b/app/MindWork AI Studio/Settings/ProviderDialog.razor index 2cdf817..ed5a048 100644 --- a/app/MindWork AI Studio/Settings/ProviderDialog.razor +++ b/app/MindWork AI Studio/Settings/ProviderDialog.razor @@ -7,23 +7,33 @@ @* ReSharper disable once CSharpWarnings::CS8974 *@ @* ReSharper disable once CSharpWarnings::CS8974 *@ - + @foreach (Providers provider in Enum.GetValues(typeof(Providers))) { @provider } + + @* ReSharper disable once CSharpWarnings::CS8974 *@ + @if (this.dataIssues.Any()) @@ -42,7 +52,16 @@ } - Cancel - Add + Cancel + + @if(this.IsEditing) + { + @:Update + } + else + { + @:Add + } + \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/ProviderDialog.razor.cs b/app/MindWork AI Studio/Settings/ProviderDialog.razor.cs index 45912c8..b8c5fa8 100644 --- a/app/MindWork AI Studio/Settings/ProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Settings/ProviderDialog.razor.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using AIStudio.Provider; using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; using MudBlazor; @@ -14,28 +15,98 @@ public partial class ProviderDialog : ComponentBase private MudDialogInstance MudDialog { get; set; } = null!; [Parameter] - public IList UsedInstanceNames { get; set; } = new List(); + public string DataId { get; set; } = Guid.NewGuid().ToString(); + + [Parameter] + public string DataInstanceName { get; set; } = string.Empty; + + [Parameter] + public Providers DataProvider { get; set; } = Providers.NONE; + [Parameter] + public bool IsEditing { get; init; } + + [Inject] + private SettingsManager SettingsManager { get; set; } = null!; + + [Inject] + private IJSRuntime JsRuntime { get; set; } = null!; + + private List usedInstanceNames { get; set; } = []; + private bool dataIsValid; private string[] dataIssues = []; - private string dataInstanceName = string.Empty; - private Providers dataProvider = Providers.NONE; + private string dataAPIKey = string.Empty; + private string dataAPIKeyStorageIssue = string.Empty; + private string dataEditingPreviousInstanceName = string.Empty; private MudForm form = null!; - private async Task Add() + #region Overrides of ComponentBase + + protected override async Task OnInitializedAsync() + { + this.usedInstanceNames = this.SettingsManager.ConfigurationData.Providers.Select(x => x.InstanceName.ToLowerInvariant()).ToList(); + + if(this.IsEditing) + { + this.dataEditingPreviousInstanceName = this.DataInstanceName.ToLowerInvariant(); + var provider = this.DataProvider.CreateProvider(); + if(provider is NoProvider) + return; + + provider.InstanceName = this.DataInstanceName; + + var requestedSecret = await this.SettingsManager.GetAPIKey(this.JsRuntime, provider); + if(requestedSecret.Success) + this.dataAPIKey = requestedSecret.Secret; + else + { + this.dataAPIKeyStorageIssue = $"Failed to load the API key from the operating system. The message was: {requestedSecret.Issue}. You might ignore this message and provide the API key again."; + await this.form.Validate(); + } + } + + await base.OnInitializedAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if(!this.IsEditing && firstRender) + this.form.ResetValidation(); + + await base.OnAfterRenderAsync(firstRender); + } + + #endregion + + private async Task Store() { await this.form.Validate(); + if (!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue)) + this.dataAPIKeyStorageIssue = string.Empty; + if (!this.dataIsValid) return; var addedProvider = new Provider { - Id = Guid.NewGuid().ToString(), - InstanceName = this.dataInstanceName, - UsedProvider = this.dataProvider, + Id = this.DataId, + InstanceName = this.DataInstanceName, + UsedProvider = this.DataProvider, }; + var provider = this.DataProvider.CreateProvider(); + provider.InstanceName = this.DataInstanceName; + + var storeResponse = await this.SettingsManager.SetAPIKey(this.JsRuntime, provider, this.dataAPIKey); + if (!storeResponse.Success) + { + this.dataAPIKeyStorageIssue = $"Failed to store the API key in the operating system. The message was: {storeResponse.Issue}. Please try again."; + await this.form.Validate(); + return; + } + this.MudDialog.Close(DialogResult.Ok(addedProvider)); } @@ -47,8 +118,14 @@ public partial class ProviderDialog : ComponentBase return null; } + [GeneratedRegex("^[a-zA-Z0-9 ]+$")] + private static partial Regex InstanceNameRegex(); + private string? ValidatingInstanceName(string instanceName) { + if(string.IsNullOrWhiteSpace(instanceName)) + return "Please enter an instance name."; + if(instanceName.StartsWith(' ')) return "The instance name must not start with a space."; @@ -63,14 +140,23 @@ public partial class ProviderDialog : ComponentBase return "The instance name must not contain consecutive spaces."; // The instance name must be unique: - if (this.UsedInstanceNames.Contains(instanceName.ToLowerInvariant())) + var lowerInstanceName = instanceName.ToLowerInvariant(); + if (lowerInstanceName != this.dataEditingPreviousInstanceName && this.usedInstanceNames.Contains(lowerInstanceName)) return "The instance name must be unique; the chosen name is already in use."; return null; } + + private string? ValidatingAPIKey(string apiKey) + { + if(!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue)) + return this.dataAPIKeyStorageIssue; + + if(string.IsNullOrWhiteSpace(apiKey)) + return "Please enter an API key."; + + return null; + } private void Cancel() => this.MudDialog.Cancel(); - - [GeneratedRegex("^[a-zA-Z0-9 ]+$")] - private static partial Regex InstanceNameRegex(); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/SettingsManager.cs b/app/MindWork AI Studio/Settings/SettingsManager.cs index ed2f9e2..3936e4c 100644 --- a/app/MindWork AI Studio/Settings/SettingsManager.cs +++ b/app/MindWork AI Studio/Settings/SettingsManager.cs @@ -2,6 +2,8 @@ using System.Text.Json; using AIStudio.Provider; using Microsoft.JSInterop; +// ReSharper disable NotAccessedPositionalProperty.Local + namespace AIStudio.Settings; public sealed class SettingsManager @@ -11,37 +13,60 @@ public sealed class SettingsManager public static string? ConfigDirectory { get; set; } public static string? DataDirectory { get; set; } - - public bool IsSetUp => !string.IsNullOrWhiteSpace(ConfigDirectory) && !string.IsNullOrWhiteSpace(DataDirectory); - private readonly record struct GetSecretRequest(string Destination, string UserName); + public Data ConfigurationData { get; private set; } = new(); + + private bool IsSetUp => !string.IsNullOrWhiteSpace(ConfigDirectory) && !string.IsNullOrWhiteSpace(DataDirectory); + + #region API Key Handling - public async Task GetAPIKey(IJSRuntime jsRuntime, IProvider provider) => await jsRuntime.InvokeAsync("window.__TAURI__.invoke", "get_secret", new GetSecretRequest($"provider::{provider.Id}::{provider.InstanceName}::api_key", Environment.UserName)); + private readonly record struct GetSecretRequest(string Destination, string UserName); + + public readonly record struct RequestedSecret(bool Success, string Secret, string Issue); + + public async Task GetAPIKey(IJSRuntime jsRuntime, IProvider provider) => await jsRuntime.InvokeAsync("window.__TAURI__.invoke", "get_secret", new GetSecretRequest($"provider::{provider.Id}::{provider.InstanceName}::api_key", Environment.UserName)); private readonly record struct StoreSecretRequest(string Destination, string UserName, string Secret); - public async Task SetAPIKey(IJSRuntime jsRuntime, IProvider provider, string key) => await jsRuntime.InvokeVoidAsync("window.__TAURI__.invoke", "store_secret", new StoreSecretRequest($"provider::{provider.Id}::{provider.InstanceName}::api_key", Environment.UserName, key)); - - public async Task LoadSettings() - { - if(!this.IsSetUp) - return new Data(); - - var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME); - if(!File.Exists(settingsPath)) - return new Data(); - - var settingsJson = await File.ReadAllTextAsync(settingsPath); - return JsonSerializer.Deserialize(settingsJson) ?? new Data(); - } + public readonly record struct StoreSecretResponse(bool Success, string Issue); - public async Task StoreSettings(Data data) + public async Task SetAPIKey(IJSRuntime jsRuntime, IProvider provider, string key) => await jsRuntime.InvokeAsync("window.__TAURI__.invoke", "store_secret", new StoreSecretRequest($"provider::{provider.Id}::{provider.InstanceName}::api_key", Environment.UserName, key)); + + private readonly record struct DeleteSecretRequest(string Destination, string UserName); + + public readonly record struct DeleteSecretResponse(bool Success, string Issue); + + public async Task DeleteAPIKey(IJSRuntime jsRuntime, IProvider provider) => await jsRuntime.InvokeAsync("window.__TAURI__.invoke", "delete_secret", new DeleteSecretRequest($"provider::{provider.Id}::{provider.InstanceName}::api_key", Environment.UserName)); + + #endregion + + public async Task LoadSettings() { if(!this.IsSetUp) return; var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME); - var settingsJson = JsonSerializer.Serialize(data); + if(!File.Exists(settingsPath)) + return; + + var settingsJson = await File.ReadAllTextAsync(settingsPath); + var loadedConfiguration = JsonSerializer.Deserialize(settingsJson); + if(loadedConfiguration is null) + return; + + this.ConfigurationData = loadedConfiguration; + } + + public async Task StoreSettings() + { + if(!this.IsSetUp) + return; + + var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME); + if(!Directory.Exists(ConfigDirectory)) + Directory.CreateDirectory(ConfigDirectory!); + + var settingsJson = JsonSerializer.Serialize(this.ConfigurationData); await File.WriteAllTextAsync(settingsPath, settingsJson); } } \ No newline at end of file