From fc64c0b5056cd3c449741502a92a90e82859e041 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 1 Sep 2024 20:10:03 +0200 Subject: [PATCH] Improve ipc (#102) --- .github/workflows/build-and-release.yml | 10 +- app/MindWork AI Studio/Agents/AgentBase.cs | 9 +- .../Agents/AgentTextContentCleaner.cs | 3 +- .../Agenda/AssistantAgenda.razor.cs | 1 - .../Assistants/AssistantBase.razor.cs | 14 +- .../Coding/AssistantCoding.razor.cs | 2 - .../Assistants/EMail/AssistantEMail.razor.cs | 1 - .../AssistantGrammarSpelling.razor.cs | 1 - .../IconFinder/AssistantIconFinder.razor.cs | 2 - .../LegalCheck/AssistantLegalCheck.razor.cs | 2 - .../AssistantRewriteImprove.razor.cs | 1 - .../AssistantTextSummarizer.razor.cs | 1 - .../Translation/AssistantTranslation.razor.cs | 1 - .../Chat/ContentBlockComponent.razor.cs | 11 +- app/MindWork AI Studio/Chat/ContentImage.cs | 2 +- app/MindWork AI Studio/Chat/ContentText.cs | 10 +- app/MindWork AI Studio/Chat/IContent.cs | 2 +- .../Components/Changelog.Logs.cs | 1 + .../Components/ConfigurationBase.razor.cs | 1 - .../ConfigurationProviderSelection.razor.cs | 1 - .../Components/InnerScrolling.razor.cs | 1 - .../Components/MSGComponentBase.cs | 2 - .../Components/ProcessComponent.razor.cs | 2 - .../Components/ReadWebContent.razor.cs | 1 - .../Components/Workspaces.razor.cs | 6 +- .../ProviderDialog.razor | 2 - .../ProviderDialog.razor.cs | 30 +- .../Dialogs/UpdateDialog.razor.cs | 2 +- app/MindWork AI Studio/GlobalUsings.cs | 2 + .../Layout/MainLayout.razor.cs | 26 +- app/MindWork AI Studio/Pages/About.razor | 17 +- app/MindWork AI Studio/Pages/About.razor.cs | 2 - app/MindWork AI Studio/Pages/Chat.razor.cs | 13 +- .../Pages/Settings.razor.cs | 17 +- app/MindWork AI Studio/Program.cs | 214 +++- .../Provider/Anthropic/ProviderAnthropic.cs | 15 +- .../Provider/BaseProvider.cs | 22 +- .../Provider/Fireworks/ProviderFireworks.cs | 15 +- app/MindWork AI Studio/Provider/IProvider.cs | 17 +- .../Provider/Mistral/ProviderMistral.cs | 19 +- app/MindWork AI Studio/Provider/NoProvider.cs | 9 +- .../Provider/OpenAI/ProviderOpenAI.cs | 25 +- app/MindWork AI Studio/Provider/Providers.cs | 61 - .../Provider/ProvidersExtensions.cs | 60 + .../Provider/SelfHosted/ProviderSelfHosted.cs | 14 +- .../Settings/ConfigurationSelectData.cs | 1 - .../Settings/DataModel/DataAgenda.cs | 1 - .../Settings/DataModel/DataEMail.cs | 1 - .../Settings/DataModel/DataGrammarSpelling.cs | 2 - .../Settings/DataModel/DataRewriteImprove.cs | 1 - .../Settings/DataModel/DataTextSummarizer.cs | 1 - .../Settings/DataModel/DataTranslation.cs | 2 - .../DataModel/PreviousModels/DataV1V3.cs | 1 - .../Settings/SettingsManager.cs | 91 +- .../Settings/SettingsMigrations.cs | 36 +- app/MindWork AI Studio/Tools/EncryptedText.cs | 13 + .../Tools/EncryptedTextExtensions.cs | 8 + .../Tools/EncryptedTextJsonConverter.cs | 27 + app/MindWork AI Studio/Tools/Encryption.cs | 142 +++ .../Tools/HttpRequestHeadersExtensions.cs | 18 + app/MindWork AI Studio/Tools/Rust.cs | 54 - .../Tools/Rust/DeleteSecretResponse.cs | 9 + .../Tools/Rust/RequestedSecret.cs | 9 + .../Tools/Rust/SelectSecretRequest.cs | 3 + .../Tools/{ => Rust}/SetClipboardResponse.cs | 2 +- .../Tools/Rust/StoreSecretRequest.cs | 3 + .../Tools/Rust/StoreSecretResponse.cs | 8 + .../Tools/{ => Rust}/UpdateResponse.cs | 10 +- app/MindWork AI Studio/Tools/RustService.cs | 331 ++++++ .../Services/MarkdownClipboardService.cs | 10 +- .../Tools/Services/TemporaryChatService.cs | 18 +- .../Tools/Services/UpdateService.cs | 13 +- .../Tools/SetClipboardText.cs | 7 - .../Tools/TerminalLogger.cs | 23 + .../wwwroot/changelog/v0.8.13.md | 2 - .../wwwroot/changelog/v0.9.0.md | 4 + metadata.txt | 8 +- runtime/Cargo.lock | 603 +++++++++- runtime/Cargo.toml | 25 +- runtime/src/main.rs | 1034 +++++++++++++---- runtime/tauri.conf.json | 26 +- 81 files changed, 2413 insertions(+), 804 deletions(-) rename app/MindWork AI Studio/{Settings => Dialogs}/ProviderDialog.razor (99%) rename app/MindWork AI Studio/{Settings => Dialogs}/ProviderDialog.razor.cs (93%) create mode 100644 app/MindWork AI Studio/Provider/ProvidersExtensions.cs create mode 100644 app/MindWork AI Studio/Tools/EncryptedText.cs create mode 100644 app/MindWork AI Studio/Tools/EncryptedTextExtensions.cs create mode 100644 app/MindWork AI Studio/Tools/EncryptedTextJsonConverter.cs create mode 100644 app/MindWork AI Studio/Tools/Encryption.cs create mode 100644 app/MindWork AI Studio/Tools/HttpRequestHeadersExtensions.cs delete mode 100644 app/MindWork AI Studio/Tools/Rust.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/DeleteSecretResponse.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/RequestedSecret.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/SelectSecretRequest.cs rename app/MindWork AI Studio/Tools/{ => Rust}/SetClipboardResponse.cs (91%) create mode 100644 app/MindWork AI Studio/Tools/Rust/StoreSecretRequest.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/StoreSecretResponse.cs rename app/MindWork AI Studio/Tools/{ => Rust}/UpdateResponse.cs (58%) create mode 100644 app/MindWork AI Studio/Tools/RustService.cs delete mode 100644 app/MindWork AI Studio/Tools/SetClipboardText.cs create mode 100644 app/MindWork AI Studio/Tools/TerminalLogger.cs delete mode 100644 app/MindWork AI Studio/wwwroot/changelog/v0.8.13.md create mode 100644 app/MindWork AI Studio/wwwroot/changelog/v0.9.0.md diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 07a1afa6..60293cd3 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -489,7 +489,7 @@ jobs: uses: actions/cache@v4 id: linux_arm_cache with: - path: $RUNNER_TEMP/linux_arm_qemu_cache.img + path: ${{ runner.temp }}/linux_arm_qemu_cache.img # When the entire key matches, Rust might just create the bundles using the current .NET build: key: target-linux-arm64-rust-${{ env.RUST_VERSION }}-dependencies-${{ env.CARGO_LOCK_HASH }} @@ -544,7 +544,7 @@ jobs: - name: Add the built runner image to the cache if: ${{ steps.linux_arm_cache.outputs.cache-hit != 'true' && env.SKIP != 'true' }} run: | - mv ${{ steps.build-linux-arm-runner.outputs.image }} $RUNNER_TEMP/linux_arm_qemu_cache.img + mv ${{ steps.build-linux-arm-runner.outputs.image }} ${{ runner.temp }}/linux_arm_qemu_cache.img - name: Build Tauri project if: ${{ env.SKIP != 'true' }} @@ -552,7 +552,7 @@ jobs: id: build-linux-arm with: - base_image: file://$RUNNER_TEMP/linux_arm_qemu_cache.img + base_image: file://${{ runner.temp }}/linux_arm_qemu_cache.img cpu: cortex-a53 optimize_image: false copy_artifact_path: runtime @@ -845,8 +845,8 @@ jobs: - name: Create release uses: softprops/action-gh-release@v2 with: - prerelease: false - draft: false + prerelease: true + draft: true make_latest: true body: ${{ env.CHANGELOG }} name: "Release ${{ env.FORMATTED_VERSION }}" diff --git a/app/MindWork AI Studio/Agents/AgentBase.cs b/app/MindWork AI Studio/Agents/AgentBase.cs index 191ca271..8028a864 100644 --- a/app/MindWork AI Studio/Agents/AgentBase.cs +++ b/app/MindWork AI Studio/Agents/AgentBase.cs @@ -1,19 +1,18 @@ using AIStudio.Chat; using AIStudio.Provider; using AIStudio.Settings; -using AIStudio.Tools; // ReSharper disable MemberCanBePrivate.Global namespace AIStudio.Agents; -public abstract class AgentBase(SettingsManager settingsManager, IJSRuntime jsRuntime, ThreadSafeRandom rng) : IAgent +public abstract class AgentBase(ILogger logger, SettingsManager settingsManager, ThreadSafeRandom rng) : IAgent { protected SettingsManager SettingsManager { get; init; } = settingsManager; - protected IJSRuntime JsRuntime { get; init; } = jsRuntime; - protected ThreadSafeRandom RNG { get; init; } = rng; + + protected ILogger Logger { get; init; } = logger; /// /// Represents the type or category of this agent. @@ -104,6 +103,6 @@ protected async Task AddAIResponseAsync(ChatThread thread, DateTimeOffset time) // Use the selected provider to get the AI response. // By awaiting this line, we wait for the entire // content to be streamed. - await aiText.CreateFromProviderAsync(providerSettings.CreateProvider(), this.JsRuntime, this.SettingsManager, providerSettings.Model, thread); + await aiText.CreateFromProviderAsync(providerSettings.CreateProvider(this.Logger), this.SettingsManager, providerSettings.Model, thread); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Agents/AgentTextContentCleaner.cs b/app/MindWork AI Studio/Agents/AgentTextContentCleaner.cs index 83fe48ff..f98b80eb 100644 --- a/app/MindWork AI Studio/Agents/AgentTextContentCleaner.cs +++ b/app/MindWork AI Studio/Agents/AgentTextContentCleaner.cs @@ -1,10 +1,9 @@ using AIStudio.Chat; using AIStudio.Settings; -using AIStudio.Tools; namespace AIStudio.Agents; -public sealed class AgentTextContentCleaner(SettingsManager settingsManager, IJSRuntime jsRuntime, ThreadSafeRandom rng) : AgentBase(settingsManager, jsRuntime, rng) +public sealed class AgentTextContentCleaner(ILogger logger, SettingsManager settingsManager, ThreadSafeRandom rng) : AgentBase(logger, settingsManager, rng) { private static readonly ContentBlock EMPTY_BLOCK = new() { diff --git a/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs b/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs index b9f95a87..16e1f7e0 100644 --- a/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs +++ b/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs @@ -1,7 +1,6 @@ using System.Text; using AIStudio.Chat; -using AIStudio.Tools; namespace AIStudio.Assistants.Agenda; diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index 496b3b52..a12376b3 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -1,16 +1,17 @@ using AIStudio.Chat; using AIStudio.Provider; using AIStudio.Settings; -using AIStudio.Tools; using Microsoft.AspNetCore.Components; +using RustService = AIStudio.Tools.RustService; + namespace AIStudio.Assistants; public abstract partial class AssistantBase : ComponentBase { [Inject] - protected SettingsManager SettingsManager { get; set; } = null!; + protected SettingsManager SettingsManager { get; init; } = null!; [Inject] protected IJSRuntime JsRuntime { get; init; } = null!; @@ -22,11 +23,14 @@ public abstract partial class AssistantBase : ComponentBase protected ISnackbar Snackbar { get; init; } = null!; [Inject] - protected Rust Rust { get; init; } = null!; + protected RustService RustService { get; init; } = null!; [Inject] protected NavigationManager NavigationManager { get; init; } = null!; + [Inject] + protected ILogger Logger { get; init; } = null!; + internal const string AFTER_RESULT_DIV_ID = "afterAssistantResult"; internal const string RESULT_DIV_ID = "assistantResult"; @@ -151,7 +155,7 @@ protected async Task AddAIResponseAsync(DateTimeOffset time) // Use the selected provider to get the AI response. // By awaiting this line, we wait for the entire // content to be streamed. - await aiText.CreateFromProviderAsync(this.providerSettings.CreateProvider(), this.JsRuntime, this.SettingsManager, this.providerSettings.Model, this.chatThread); + await aiText.CreateFromProviderAsync(this.providerSettings.CreateProvider(this.Logger), this.SettingsManager, this.providerSettings.Model, this.chatThread); this.isProcessing = false; this.StateHasChanged(); @@ -162,7 +166,7 @@ protected async Task AddAIResponseAsync(DateTimeOffset time) protected async Task CopyToClipboard() { - await this.Rust.CopyText2Clipboard(this.JsRuntime, this.Snackbar, this.Result2Copy()); + await this.RustService.CopyText2Clipboard(this.Snackbar, this.Result2Copy()); } private static string? GetButtonIcon(string icon) diff --git a/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs b/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs index 52c10e40..4f9ba4bc 100644 --- a/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs +++ b/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor.cs @@ -1,7 +1,5 @@ using System.Text; -using AIStudio.Tools; - namespace AIStudio.Assistants.Coding; public partial class AssistantCoding : AssistantBaseCore diff --git a/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs b/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs index 686b9282..23151e94 100644 --- a/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs +++ b/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs @@ -1,7 +1,6 @@ using System.Text; using AIStudio.Chat; -using AIStudio.Tools; namespace AIStudio.Assistants.EMail; diff --git a/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs b/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs index 1821aa37..7669be27 100644 --- a/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs +++ b/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs @@ -1,5 +1,4 @@ using AIStudio.Chat; -using AIStudio.Tools; namespace AIStudio.Assistants.GrammarSpelling; diff --git a/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs b/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs index c45dce41..458c5f0c 100644 --- a/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs +++ b/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs @@ -1,5 +1,3 @@ -using AIStudio.Tools; - namespace AIStudio.Assistants.IconFinder; public partial class AssistantIconFinder : AssistantBaseCore diff --git a/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs b/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs index af2138e5..e87fb624 100644 --- a/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs +++ b/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs @@ -1,5 +1,3 @@ -using AIStudio.Tools; - namespace AIStudio.Assistants.LegalCheck; public partial class AssistantLegalCheck : AssistantBaseCore diff --git a/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs b/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs index 303a127a..bbadd4e8 100644 --- a/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs +++ b/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs @@ -1,5 +1,4 @@ using AIStudio.Chat; -using AIStudio.Tools; namespace AIStudio.Assistants.RewriteImprove; diff --git a/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs b/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs index 5d120c86..d2d363ac 100644 --- a/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs +++ b/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs @@ -1,5 +1,4 @@ using AIStudio.Chat; -using AIStudio.Tools; namespace AIStudio.Assistants.TextSummarizer; diff --git a/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs b/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs index 97b3ed3b..653dba94 100644 --- a/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs +++ b/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs @@ -1,5 +1,4 @@ using AIStudio.Chat; -using AIStudio.Tools; namespace AIStudio.Assistants.Translation; diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index f411b075..756d3a61 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -1,7 +1,7 @@ -using AIStudio.Tools; - using Microsoft.AspNetCore.Components; +using RustService = AIStudio.Tools.RustService; + namespace AIStudio.Chat; /// @@ -40,10 +40,7 @@ public partial class ContentBlockComponent : ComponentBase public string Class { get; set; } = string.Empty; [Inject] - private Rust Rust { get; init; } = null!; - - [Inject] - private IJSRuntime JsRuntime { get; init; } = null!; + private RustService RustService { get; init; } = null!; [Inject] private ISnackbar Snackbar { get; init; } = null!; @@ -100,7 +97,7 @@ private async Task CopyToClipboard() { case ContentType.TEXT: var textContent = (ContentText) this.Content; - await this.Rust.CopyText2Clipboard(this.JsRuntime, this.Snackbar, textContent.Text); + await this.RustService.CopyText2Clipboard(this.Snackbar, textContent.Text); break; default: diff --git a/app/MindWork AI Studio/Chat/ContentImage.cs b/app/MindWork AI Studio/Chat/ContentImage.cs index 9afe4476..314ba93c 100644 --- a/app/MindWork AI Studio/Chat/ContentImage.cs +++ b/app/MindWork AI Studio/Chat/ContentImage.cs @@ -29,7 +29,7 @@ public sealed class ContentImage : IContent public Func StreamingEvent { get; set; } = () => Task.CompletedTask; /// - public Task CreateFromProviderAsync(IProvider provider, IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatChatThread, CancellationToken token = default) + public Task CreateFromProviderAsync(IProvider provider, SettingsManager settings, Model chatModel, ChatThread chatChatThread, CancellationToken token = default) { throw new NotImplementedException(); } diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index 0354c756..bbf65065 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -35,18 +35,18 @@ public sealed class ContentText : IContent public Func StreamingEvent { get; set; } = () => Task.CompletedTask; /// - public async Task CreateFromProviderAsync(IProvider provider, IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread? chatThread, CancellationToken token = default) + public async Task CreateFromProviderAsync(IProvider provider, SettingsManager settings, Model chatModel, ChatThread? chatThread, CancellationToken token = default) { if(chatThread is null) return; - // Store the last time we got a response. We use this later, + // Store the last time we got a response. We use this ater // to determine whether we should notify the UI about the // new content or not. Depends on the energy saving mode // the user chose. var last = DateTimeOffset.Now; - // Start another thread by using a task, to uncouple + // Start another thread by using a task to uncouple // the UI thread from the AI processing: await Task.Run(async () => { @@ -54,7 +54,7 @@ await Task.Run(async () => this.InitialRemoteWait = true; // Iterate over the responses from the AI: - await foreach (var deltaText in provider.StreamChatCompletion(jsRuntime, settings, chatModel, chatThread, token)) + await foreach (var deltaText in provider.StreamChatCompletion(chatModel, chatThread, token)) { // When the user cancels the request, we stop the loop: if (token.IsCancellationRequested) @@ -89,7 +89,7 @@ await Task.Run(async () => } // Stop the waiting animation (in case the loop - // was stopped or no content was received): + // was stopped, or no content was received): this.InitialRemoteWait = false; this.IsStreaming = false; }, token); diff --git a/app/MindWork AI Studio/Chat/IContent.cs b/app/MindWork AI Studio/Chat/IContent.cs index 8f6bc0ad..1feea520 100644 --- a/app/MindWork AI Studio/Chat/IContent.cs +++ b/app/MindWork AI Studio/Chat/IContent.cs @@ -42,5 +42,5 @@ public interface IContent /// /// Uses the provider to create the content. /// - public Task CreateFromProviderAsync(IProvider provider, IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatChatThread, CancellationToken token = default); + public Task CreateFromProviderAsync(IProvider provider, SettingsManager settings, Model chatModel, ChatThread chatChatThread, CancellationToken token = default); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Changelog.Logs.cs b/app/MindWork AI Studio/Components/Changelog.Logs.cs index 0efbb2ad..18b680a9 100644 --- a/app/MindWork AI Studio/Components/Changelog.Logs.cs +++ b/app/MindWork AI Studio/Components/Changelog.Logs.cs @@ -13,6 +13,7 @@ public readonly record struct Log(int Build, string Display, string Filename) public static readonly Log[] LOGS = [ + new (175, "v0.9.0, build 175 (2024-09-01 18:04 UTC)", "v0.9.0.md"), new (174, "v0.8.12, build 174 (2024-08-24 08:30 UTC)", "v0.8.12.md"), new (173, "v0.8.11, build 173 (2024-08-21 07:03 UTC)", "v0.8.11.md"), new (172, "v0.8.10, build 172 (2024-08-18 19:44 UTC)", "v0.8.10.md"), diff --git a/app/MindWork AI Studio/Components/ConfigurationBase.razor.cs b/app/MindWork AI Studio/Components/ConfigurationBase.razor.cs index bad94f67..6951c5e0 100644 --- a/app/MindWork AI Studio/Components/ConfigurationBase.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationBase.razor.cs @@ -1,5 +1,4 @@ using AIStudio.Settings; -using AIStudio.Tools; using Microsoft.AspNetCore.Components; diff --git a/app/MindWork AI Studio/Components/ConfigurationProviderSelection.razor.cs b/app/MindWork AI Studio/Components/ConfigurationProviderSelection.razor.cs index 59213204..5c0ca716 100644 --- a/app/MindWork AI Studio/Components/ConfigurationProviderSelection.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationProviderSelection.razor.cs @@ -1,5 +1,4 @@ using AIStudio.Settings; -using AIStudio.Tools; using Microsoft.AspNetCore.Components; diff --git a/app/MindWork AI Studio/Components/InnerScrolling.razor.cs b/app/MindWork AI Studio/Components/InnerScrolling.razor.cs index 42d75a06..6a3c057b 100644 --- a/app/MindWork AI Studio/Components/InnerScrolling.razor.cs +++ b/app/MindWork AI Studio/Components/InnerScrolling.razor.cs @@ -1,5 +1,4 @@ using AIStudio.Layout; -using AIStudio.Tools; using Microsoft.AspNetCore.Components; diff --git a/app/MindWork AI Studio/Components/MSGComponentBase.cs b/app/MindWork AI Studio/Components/MSGComponentBase.cs index c1606ba8..ae88bbd5 100644 --- a/app/MindWork AI Studio/Components/MSGComponentBase.cs +++ b/app/MindWork AI Studio/Components/MSGComponentBase.cs @@ -1,5 +1,3 @@ -using AIStudio.Tools; - using Microsoft.AspNetCore.Components; namespace AIStudio.Components; diff --git a/app/MindWork AI Studio/Components/ProcessComponent.razor.cs b/app/MindWork AI Studio/Components/ProcessComponent.razor.cs index bbcd6e5d..297cdbca 100644 --- a/app/MindWork AI Studio/Components/ProcessComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ProcessComponent.razor.cs @@ -1,5 +1,3 @@ -using AIStudio.Tools; - using Microsoft.AspNetCore.Components; namespace AIStudio.Components; diff --git a/app/MindWork AI Studio/Components/ReadWebContent.razor.cs b/app/MindWork AI Studio/Components/ReadWebContent.razor.cs index c681dc12..b1511b3f 100644 --- a/app/MindWork AI Studio/Components/ReadWebContent.razor.cs +++ b/app/MindWork AI Studio/Components/ReadWebContent.razor.cs @@ -1,7 +1,6 @@ using AIStudio.Agents; using AIStudio.Chat; using AIStudio.Settings; -using AIStudio.Tools; using Microsoft.AspNetCore.Components; diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index ee33e64c..edf0d280 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -5,7 +5,6 @@ using AIStudio.Chat; using AIStudio.Dialogs; using AIStudio.Settings; -using AIStudio.Tools; using Microsoft.AspNetCore.Components; @@ -24,6 +23,9 @@ public partial class Workspaces : ComponentBase [Inject] private ThreadSafeRandom RNG { get; init; } = null!; + [Inject] + private ILogger Logger { get; init; } = null!; + [Parameter] public ChatThread? CurrentChatThread { get; set; } @@ -309,7 +311,7 @@ public async Task StoreChat(ChatThread chat) } catch (Exception e) { - Console.WriteLine(e); + this.Logger.LogError($"Failed to load chat from '{chatPath}': {e.Message}"); } return null; diff --git a/app/MindWork AI Studio/Settings/ProviderDialog.razor b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor similarity index 99% rename from app/MindWork AI Studio/Settings/ProviderDialog.razor rename to app/MindWork AI Studio/Dialogs/ProviderDialog.razor index 3a64b92e..c6fd8dc8 100644 --- a/app/MindWork AI Studio/Settings/ProviderDialog.razor +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor @@ -1,7 +1,5 @@ -@using AIStudio.Components @using AIStudio.Provider @using AIStudio.Provider.SelfHosted -@using MudBlazor diff --git a/app/MindWork AI Studio/Settings/ProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs similarity index 93% rename from app/MindWork AI Studio/Settings/ProviderDialog.razor.cs rename to app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs index 4d688a0b..02d29d22 100644 --- a/app/MindWork AI Studio/Settings/ProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs @@ -1,12 +1,14 @@ using System.Text.RegularExpressions; using AIStudio.Provider; +using AIStudio.Settings; using Microsoft.AspNetCore.Components; using Host = AIStudio.Provider.SelfHosted.Host; +using RustService = AIStudio.Tools.RustService; -namespace AIStudio.Settings; +namespace AIStudio.Dialogs; /// /// The provider settings dialog. @@ -71,10 +73,13 @@ public partial class ProviderDialog : ComponentBase public bool IsEditing { get; init; } [Inject] - private SettingsManager SettingsManager { get; set; } = null!; + private SettingsManager SettingsManager { get; init; } = null!; [Inject] - private IJSRuntime JsRuntime { get; set; } = null!; + private ILogger Logger { get; init; } = null!; + + [Inject] + private RustService RustService { get; init; } = null!; private static readonly Dictionary SPELLCHECK_ATTRIBUTES = new(); @@ -94,8 +99,9 @@ public partial class ProviderDialog : ComponentBase private MudForm form = null!; private readonly List availableModels = new(); - - private Provider CreateProviderSettings() => new() + private readonly Encryption encryption = Program.ENCRYPTION; + + private Settings.Provider CreateProviderSettings() => new() { Num = this.DataNum, Id = this.DataId, @@ -133,7 +139,7 @@ protected override async Task OnInitializedAsync() } var loadedProviderSettings = this.CreateProviderSettings(); - var provider = loadedProviderSettings.CreateProvider(); + var provider = loadedProviderSettings.CreateProvider(this.Logger); if(provider is NoProvider) { await base.OnInitializedAsync(); @@ -141,10 +147,10 @@ protected override async Task OnInitializedAsync() } // Load the API key: - var requestedSecret = await this.SettingsManager.GetAPIKey(this.JsRuntime, provider); + var requestedSecret = await this.RustService.GetAPIKey(provider); if(requestedSecret.Success) { - this.dataAPIKey = requestedSecret.Secret; + this.dataAPIKey = await requestedSecret.Secret.Decrypt(this.encryption); // Now, we try to load the list of available models: await this.ReloadModels(); @@ -187,10 +193,10 @@ private async Task Store() if (addedProviderSettings.UsedProvider != Providers.SELF_HOSTED) { // We need to instantiate the provider to store the API key: - var provider = addedProviderSettings.CreateProvider(); + var provider = addedProviderSettings.CreateProvider(this.Logger); // Store the API key in the OS secure storage: - var storeResponse = await this.SettingsManager.SetAPIKey(this.JsRuntime, provider, this.dataAPIKey); + var storeResponse = await this.RustService.SetAPIKey(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."; @@ -318,11 +324,11 @@ private async Task Store() private async Task ReloadModels() { var currentProviderSettings = this.CreateProviderSettings(); - var provider = currentProviderSettings.CreateProvider(); + var provider = currentProviderSettings.CreateProvider(this.Logger); if(provider is NoProvider) return; - var models = await provider.GetTextModels(this.JsRuntime, this.SettingsManager, this.dataAPIKey); + var models = await provider.GetTextModels(this.dataAPIKey); // Order descending by ID means that the newest models probably come first: var orderedModels = models.OrderByDescending(n => n.Id); diff --git a/app/MindWork AI Studio/Dialogs/UpdateDialog.razor.cs b/app/MindWork AI Studio/Dialogs/UpdateDialog.razor.cs index dccad89c..aefd5518 100644 --- a/app/MindWork AI Studio/Dialogs/UpdateDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/UpdateDialog.razor.cs @@ -1,6 +1,6 @@ using System.Reflection; -using AIStudio.Tools; +using AIStudio.Tools.Rust; using Microsoft.AspNetCore.Components; diff --git a/app/MindWork AI Studio/GlobalUsings.cs b/app/MindWork AI Studio/GlobalUsings.cs index 8cd95b6e..fe809fa7 100644 --- a/app/MindWork AI Studio/GlobalUsings.cs +++ b/app/MindWork AI Studio/GlobalUsings.cs @@ -1,5 +1,7 @@ // Global using directives +global using AIStudio.Tools; + global using Microsoft.JSInterop; global using MudBlazor; \ No newline at end of file diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index 11fa7248..18961fe9 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -1,21 +1,19 @@ using AIStudio.Dialogs; using AIStudio.Settings; using AIStudio.Settings.DataModel; -using AIStudio.Tools; +using AIStudio.Tools.Rust; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; using DialogOptions = AIStudio.Dialogs.DialogOptions; +using RustService = AIStudio.Tools.RustService; namespace AIStudio.Layout; public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, IDisposable { - [Inject] - private IJSRuntime JsRuntime { get; init; } = null!; - [Inject] private SettingsManager SettingsManager { get; init; } = null!; @@ -26,13 +24,16 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, IDis private IDialogService DialogService { get; init; } = null!; [Inject] - private Rust Rust { get; init; } = null!; + private RustService RustService { get; init; } = null!; [Inject] private ISnackbar Snackbar { get; init; } = null!; [Inject] private NavigationManager NavigationManager { get; init; } = null!; + + [Inject] + private ILogger Logger { get; init; } = null!; public string AdditionalHeight { get; private set; } = "0em"; @@ -70,12 +71,15 @@ protected override async Task OnInitializedAsync() // We use the Tauri API (Rust) to get the data and config directories // for this app. // - var dataDir = await this.JsRuntime.InvokeAsync("window.__TAURI__.path.appLocalDataDir"); - var configDir = await this.JsRuntime.InvokeAsync("window.__TAURI__.path.appConfigDir"); + var dataDir = await this.RustService.GetDataDirectory(); + var configDir = await this.RustService.GetConfigDirectory(); + + this.Logger.LogInformation($"The data directory is: '{dataDir}'"); + this.Logger.LogInformation($"The config directory is: '{configDir}'"); // Store the directories in the settings manager: SettingsManager.ConfigDirectory = configDir; - SettingsManager.DataDirectory = Path.Join(dataDir, "data"); + SettingsManager.DataDirectory = dataDir; Directory.CreateDirectory(SettingsManager.DataDirectory); // Ensure that all settings are loaded: @@ -85,8 +89,8 @@ protected override async Task OnInitializedAsync() this.MessageBus.RegisterComponent(this); this.MessageBus.ApplyFilters(this, [], [ Event.UPDATE_AVAILABLE, Event.USER_SEARCH_FOR_UPDATE, Event.CONFIGURATION_CHANGED ]); - // Set the js runtime for the update service: - UpdateService.SetBlazorDependencies(this.JsRuntime, this.Snackbar); + // Set the snackbar for the update service: + UpdateService.SetBlazorDependencies(this.Snackbar); TemporaryChatService.Initialize(); // Should the navigation bar be open by default? @@ -189,7 +193,7 @@ private async Task ShowUpdateDialog() this.performingUpdate = true; this.StateHasChanged(); - await this.Rust.InstallUpdate(this.JsRuntime); + await this.RustService.InstallUpdate(); } private async ValueTask OnLocationChanging(LocationChangingContext context) diff --git a/app/MindWork AI Studio/Pages/About.razor b/app/MindWork AI Studio/Pages/About.razor index 5f4f6d13..046704fd 100644 --- a/app/MindWork AI Studio/Pages/About.razor +++ b/app/MindWork AI Studio/Pages/About.razor @@ -39,13 +39,18 @@ - - - - - + + + + + + - + + + + + diff --git a/app/MindWork AI Studio/Pages/About.razor.cs b/app/MindWork AI Studio/Pages/About.razor.cs index 5c31a69a..96b4f9ab 100644 --- a/app/MindWork AI Studio/Pages/About.razor.cs +++ b/app/MindWork AI Studio/Pages/About.razor.cs @@ -1,7 +1,5 @@ using System.Reflection; -using AIStudio.Tools; - using Microsoft.AspNetCore.Components; namespace AIStudio.Pages; diff --git a/app/MindWork AI Studio/Pages/Chat.razor.cs b/app/MindWork AI Studio/Pages/Chat.razor.cs index b9b1c3f7..e277c79c 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Pages/Chat.razor.cs @@ -4,7 +4,6 @@ using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; -using AIStudio.Tools; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; @@ -19,17 +18,17 @@ namespace AIStudio.Pages; public partial class Chat : MSGComponentBase, IAsyncDisposable { [Inject] - private SettingsManager SettingsManager { get; set; } = null!; - - [Inject] - public IJSRuntime JsRuntime { get; init; } = null!; + private SettingsManager SettingsManager { get; init; } = null!; [Inject] private ThreadSafeRandom RNG { get; init; } = null!; [Inject] - public IDialogService DialogService { get; set; } = null!; + private IDialogService DialogService { get; init; } = null!; + [Inject] + private ILogger Logger { get; init; } = null!; + private InnerScrolling scrollingArea = null!; private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Bottom; @@ -189,7 +188,7 @@ private async Task SendMessage() // Use the selected provider to get the AI response. // By awaiting this line, we wait for the entire // content to be streamed. - await aiText.CreateFromProviderAsync(this.providerSettings.CreateProvider(), this.JsRuntime, this.SettingsManager, this.providerSettings.Model, this.chatThread); + await aiText.CreateFromProviderAsync(this.providerSettings.CreateProvider(this.Logger), this.SettingsManager, this.providerSettings.Model, this.chatThread); // Save the chat: if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) diff --git a/app/MindWork AI Studio/Pages/Settings.razor.cs b/app/MindWork AI Studio/Pages/Settings.razor.cs index fee38abb..daa216b4 100644 --- a/app/MindWork AI Studio/Pages/Settings.razor.cs +++ b/app/MindWork AI Studio/Pages/Settings.razor.cs @@ -1,11 +1,11 @@ using AIStudio.Dialogs; using AIStudio.Provider; using AIStudio.Settings; -using AIStudio.Tools; using Microsoft.AspNetCore.Components; using DialogOptions = AIStudio.Dialogs.DialogOptions; +using RustService = AIStudio.Tools.RustService; // ReSharper disable ClassNeverInstantiated.Global @@ -14,16 +14,19 @@ namespace AIStudio.Pages; public partial class Settings : ComponentBase, IMessageBusReceiver, IDisposable { [Inject] - public SettingsManager SettingsManager { get; init; } = null!; + private SettingsManager SettingsManager { get; init; } = null!; [Inject] - public IDialogService DialogService { get; init; } = null!; + private IDialogService DialogService { get; init; } = null!; [Inject] - public IJSRuntime JsRuntime { get; init; } = null!; + private MessageBus MessageBus { get; init; } = null!; [Inject] - protected MessageBus MessageBus { get; init; } = null!; + private ILogger Logger { get; init; } = null!; + + [Inject] + private RustService RustService { get; init; } = null!; private readonly List> availableProviders = new(); @@ -111,8 +114,8 @@ private async Task DeleteProvider(AIStudio.Settings.Provider provider) if (dialogResult is null || dialogResult.Canceled) return; - var providerInstance = provider.CreateProvider(); - var deleteSecretResponse = await this.SettingsManager.DeleteAPIKey(this.JsRuntime, providerInstance); + var providerInstance = provider.CreateProvider(this.Logger); + var deleteSecretResponse = await this.RustService.DeleteAPIKey(providerInstance); if(deleteSecretResponse.Success) { this.SettingsManager.ConfigurationData.Providers.Remove(provider); diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 8e8441bc..a68a9339 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -1,9 +1,10 @@ -using AIStudio; using AIStudio.Agents; using AIStudio.Settings; -using AIStudio.Tools; using AIStudio.Tools.Services; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Logging.Console; + using MudBlazor.Services; #if !DEBUG @@ -11,73 +12,168 @@ using Microsoft.Extensions.FileProviders; #endif -var port = args.Length > 0 ? args[0] : "5000"; -var builder = WebApplication.CreateBuilder(); -builder.Services.AddMudServices(config => +namespace AIStudio; + +internal sealed class Program { - config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomLeft; - config.SnackbarConfiguration.PreventDuplicates = false; - config.SnackbarConfiguration.NewestOnTop = false; - config.SnackbarConfiguration.ShowCloseIcon = true; - config.SnackbarConfiguration.VisibleStateDuration = 6_000; //milliseconds aka 6 seconds - config.SnackbarConfiguration.HideTransitionDuration = 500; - config.SnackbarConfiguration.ShowTransitionDuration = 500; - config.SnackbarConfiguration.SnackbarVariant = Variant.Outlined; -}); - -builder.Services.AddMudMarkdownServices(); -builder.Services.AddSingleton(MessageBus.INSTANCE); -builder.Services.AddSingleton(); -builder.Services.AddMudMarkdownClipboardService(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddTransient(); -builder.Services.AddTransient(); -builder.Services.AddHostedService(); -builder.Services.AddHostedService(); -builder.Services.AddRazorComponents() - .AddInteractiveServerComponents() - .AddHubOptions(options => + public static RustService RUST_SERVICE = null!; + public static Encryption ENCRYPTION = null!; + public static string API_TOKEN = null!; + + public static async Task Main(string[] args) { - options.MaximumReceiveMessageSize = null; - options.ClientTimeoutInterval = TimeSpan.FromSeconds(1_200); - options.HandshakeTimeout = TimeSpan.FromSeconds(30); - }); + if(args.Length == 0) + { + Console.WriteLine("Error: Please provide the port of the runtime API."); + return; + } -builder.Services.AddSingleton(new HttpClient -{ - BaseAddress = new Uri($"http://localhost:{port}") -}); + // Read the secret key for the IPC from the AI_STUDIO_SECRET_KEY environment variable: + var secretPasswordEncoded = Environment.GetEnvironmentVariable("AI_STUDIO_SECRET_PASSWORD"); + if(string.IsNullOrWhiteSpace(secretPasswordEncoded)) + { + Console.WriteLine("Error: The AI_STUDIO_SECRET_PASSWORD environment variable is not set."); + return; + } -builder.WebHost.UseUrls($"http://localhost:{port}"); + var secretPassword = Convert.FromBase64String(secretPasswordEncoded); + var secretKeySaltEncoded = Environment.GetEnvironmentVariable("AI_STUDIO_SECRET_KEY_SALT"); + if(string.IsNullOrWhiteSpace(secretKeySaltEncoded)) + { + Console.WriteLine("Error: The AI_STUDIO_SECRET_KEY_SALT environment variable is not set."); + return; + } -#if DEBUG -builder.WebHost.UseWebRoot("wwwroot"); -builder.WebHost.UseStaticWebAssets(); -#endif + var secretKeySalt = Convert.FromBase64String(secretKeySaltEncoded); + + var certificateFingerprint = Environment.GetEnvironmentVariable("AI_STUDIO_CERTIFICATE_FINGERPRINT"); + if(string.IsNullOrWhiteSpace(certificateFingerprint)) + { + Console.WriteLine("Error: The AI_STUDIO_CERTIFICATE_FINGERPRINT environment variable is not set."); + return; + } + + var apiToken = Environment.GetEnvironmentVariable("AI_STUDIO_API_TOKEN"); + if(string.IsNullOrWhiteSpace(apiToken)) + { + Console.WriteLine("Error: The AI_STUDIO_API_TOKEN environment variable is not set."); + return; + } + + API_TOKEN = apiToken; + + var rustApiPort = args[0]; + using var rust = new RustService(rustApiPort, certificateFingerprint); + var appPort = await rust.GetAppPort(); + if(appPort == 0) + { + Console.WriteLine("Error: Failed to get the app port from Rust."); + return; + } + + var builder = WebApplication.CreateBuilder(); + + builder.WebHost.ConfigureKestrel(kestrelServerOptions => + { + kestrelServerOptions.ConfigureEndpointDefaults(listenOptions => + { + listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; + }); + }); + + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Debug); + builder.Logging.AddFilter("Microsoft", LogLevel.Information); + builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); + builder.Logging.AddFilter("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogLevel.Warning); + builder.Logging.AddFilter("Microsoft.AspNetCore.StaticFiles", LogLevel.Warning); + builder.Logging.AddFilter("MudBlazor", LogLevel.Information); + builder.Logging.AddConsole(options => + { + options.FormatterName = TerminalLogger.FORMATTER_NAME; + }).AddConsoleFormatter(); -var app = builder.Build(); -app.Use(Redirect.HandlerContentAsync); + builder.Services.AddMudServices(config => + { + config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomLeft; + config.SnackbarConfiguration.PreventDuplicates = false; + config.SnackbarConfiguration.NewestOnTop = false; + config.SnackbarConfiguration.ShowCloseIcon = true; + config.SnackbarConfiguration.VisibleStateDuration = 6_000; //milliseconds aka 6 seconds + config.SnackbarConfiguration.HideTransitionDuration = 500; + config.SnackbarConfiguration.ShowTransitionDuration = 500; + config.SnackbarConfiguration.SnackbarVariant = Variant.Outlined; + }); -#if DEBUG -app.UseStaticFiles(); -app.UseDeveloperExceptionPage(); -#else + builder.Services.AddMudMarkdownServices(); + builder.Services.AddSingleton(MessageBus.INSTANCE); + builder.Services.AddSingleton(rust); + builder.Services.AddMudMarkdownClipboardService(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddHubOptions(options => + { + options.MaximumReceiveMessageSize = null; + options.ClientTimeoutInterval = TimeSpan.FromSeconds(1_200); + options.HandshakeTimeout = TimeSpan.FromSeconds(30); + }); -var fileProvider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "wwwroot"); -app.UseStaticFiles(new StaticFileOptions -{ - FileProvider = fileProvider, - RequestPath = string.Empty, -}); + builder.Services.AddSingleton(new HttpClient + { + BaseAddress = new Uri($"http://localhost:{appPort}") + }); + + builder.WebHost.UseUrls($"http://localhost:{appPort}"); + #if DEBUG + builder.WebHost.UseWebRoot("wwwroot"); + builder.WebHost.UseStaticWebAssets(); + #endif + + // Execute the builder to get the app: + var app = builder.Build(); + + // Initialize the encryption service: + var encryptionLogger = app.Services.GetRequiredService>(); + var encryption = new Encryption(encryptionLogger, secretPassword, secretKeySalt); + var encryptionInitializer = encryption.Initialize(); + + // Set the logger for the Rust service: + var rustLogger = app.Services.GetRequiredService>(); + rust.SetLogger(rustLogger); + rust.SetEncryptor(encryption); + + RUST_SERVICE = rust; + ENCRYPTION = encryption; + + app.Use(Redirect.HandlerContentAsync); + +#if DEBUG + app.UseStaticFiles(); + app.UseDeveloperExceptionPage(); +#else + var fileProvider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "wwwroot"); + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = fileProvider, + RequestPath = string.Empty, + }); #endif -app.UseAntiforgery(); -app.MapRazorComponents() - .AddInteractiveServerRenderMode(); + app.UseAntiforgery(); + app.MapRazorComponents() + .AddInteractiveServerRenderMode(); -var serverTask = app.RunAsync(); + var serverTask = app.RunAsync(); -Console.WriteLine("RUST/TAURI SERVER STARTED"); -await serverTask; \ No newline at end of file + await encryptionInitializer; + await rust.AppIsReady(); + await serverTask; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs index ffeb2559..88dc493c 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs @@ -4,11 +4,10 @@ using AIStudio.Chat; using AIStudio.Provider.OpenAI; -using AIStudio.Settings; namespace AIStudio.Provider.Anthropic; -public sealed class ProviderAnthropic() : BaseProvider("https://api.anthropic.com/v1/"), IProvider +public sealed class ProviderAnthropic(ILogger logger) : BaseProvider("https://api.anthropic.com/v1/", logger), IProvider { private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new() { @@ -22,10 +21,10 @@ public sealed class ProviderAnthropic() : BaseProvider("https://api.anthropic.co public string InstanceName { get; set; } = "Anthropic"; /// - public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default) + public async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default) { // Get the API key: - var requestedSecret = await settings.GetAPIKey(jsRuntime, this); + var requestedSecret = await RUST_SERVICE.GetAPIKey(this); if(!requestedSecret.Success) yield break; @@ -64,7 +63,7 @@ public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, var request = new HttpRequestMessage(HttpMethod.Post, "messages"); // Set the authorization header: - request.Headers.Add("x-api-key", requestedSecret.Secret); + request.Headers.Add("x-api-key", await requestedSecret.Secret.Decrypt(ENCRYPTION)); // Set the Anthropic version: request.Headers.Add("anthropic-version", "2023-06-01"); @@ -137,14 +136,14 @@ public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously /// - public async IAsyncEnumerable StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) + public async IAsyncEnumerable StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) { yield break; } #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public Task> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) + public Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { return Task.FromResult(new[] { @@ -157,7 +156,7 @@ public Task> GetTextModels(IJSRuntime jsRuntime, SettingsMana #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously /// - public Task> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) + public Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { return Task.FromResult(Enumerable.Empty()); } diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index d6ce8a54..7e66fc13 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1,3 +1,5 @@ +using RustService = AIStudio.Tools.RustService; + namespace AIStudio.Provider; /// @@ -9,13 +11,31 @@ public abstract class BaseProvider /// The HTTP client to use for all requests. /// protected readonly HttpClient httpClient = new(); + + /// + /// The logger to use. + /// + protected readonly ILogger logger; + + static BaseProvider() + { + RUST_SERVICE = Program.RUST_SERVICE; + ENCRYPTION = Program.ENCRYPTION; + } + + protected static readonly RustService RUST_SERVICE; + + protected static readonly Encryption ENCRYPTION; /// /// Constructor for the base provider. /// /// The base URL for the provider. - protected BaseProvider(string url) + /// The logger service to use. + protected BaseProvider(string url, ILogger loggerService) { + this.logger = loggerService; + // Set the base URL: this.httpClient.BaseAddress = new(url); } diff --git a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs index cf29a6df..a48582be 100644 --- a/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs +++ b/app/MindWork AI Studio/Provider/Fireworks/ProviderFireworks.cs @@ -4,11 +4,10 @@ using System.Text.Json; using AIStudio.Chat; -using AIStudio.Settings; namespace AIStudio.Provider.Fireworks; -public class ProviderFireworks() : BaseProvider("https://api.fireworks.ai/inference/v1/"), IProvider +public class ProviderFireworks(ILogger logger) : BaseProvider("https://api.fireworks.ai/inference/v1/", logger), IProvider { private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new() { @@ -24,10 +23,10 @@ public class ProviderFireworks() : BaseProvider("https://api.fireworks.ai/infere public string InstanceName { get; set; } = "Fireworks.ai"; /// - public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default) + public async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default) { // Get the API key: - var requestedSecret = await settings.GetAPIKey(jsRuntime, this); + var requestedSecret = await RUST_SERVICE.GetAPIKey(this); if(!requestedSecret.Success) yield break; @@ -73,7 +72,7 @@ public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", requestedSecret.Secret); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); // Set the content: request.Content = new StringContent(fireworksChatRequest, Encoding.UTF8, "application/json"); @@ -139,20 +138,20 @@ public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously /// - public async IAsyncEnumerable StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) + public async IAsyncEnumerable StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) { yield break; } #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public Task> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) + public Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { return Task.FromResult(Enumerable.Empty()); } /// - public Task> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) + public Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { return Task.FromResult(Enumerable.Empty()); } diff --git a/app/MindWork AI Studio/Provider/IProvider.cs b/app/MindWork AI Studio/Provider/IProvider.cs index 776d981b..ef3214bb 100644 --- a/app/MindWork AI Studio/Provider/IProvider.cs +++ b/app/MindWork AI Studio/Provider/IProvider.cs @@ -1,5 +1,4 @@ using AIStudio.Chat; -using AIStudio.Settings; namespace AIStudio.Provider; @@ -22,44 +21,36 @@ public interface IProvider /// /// Starts a chat completion stream. /// - /// The JS runtime to access the Rust code. - /// The settings manager to access the API key. /// The model to use for chat completion. /// The chat thread to continue. /// The cancellation token. /// The chat completion stream. - public IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatThread, CancellationToken token = default); + public IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, CancellationToken token = default); /// /// Starts an image completion stream. /// - /// The JS runtime to access the Rust code. - /// The settings manager to access the API key. /// The model to use for image completion. /// The positive prompt. /// The negative prompt. /// The reference image URL. /// The cancellation token. /// The image completion stream. - public IAsyncEnumerable StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, CancellationToken token = default); + public IAsyncEnumerable StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, CancellationToken token = default); /// /// Load all possible text models that can be used with this provider. /// - /// The JS runtime to access the Rust code. - /// The settings manager to access the API key. /// The provisional API key to use. Useful when the user is adding a new provider. When null, the stored API key is used. /// The cancellation token. /// The list of text models. - public Task> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default); + public Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default); /// /// Load all possible image models that can be used with this provider. /// - /// The JS runtime to access the Rust code. - /// The settings manager to access the API key. /// The provisional API key to use. Useful when the user is adding a new provider. When null, the stored API key is used. /// The cancellation token. /// The list of image models. - public Task> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default); + public Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs index c3510811..86d94e85 100644 --- a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs +++ b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs @@ -5,11 +5,10 @@ using AIStudio.Chat; using AIStudio.Provider.OpenAI; -using AIStudio.Settings; namespace AIStudio.Provider.Mistral; -public sealed class ProviderMistral() : BaseProvider("https://api.mistral.ai/v1/"), IProvider +public sealed class ProviderMistral(ILogger logger) : BaseProvider("https://api.mistral.ai/v1/", logger), IProvider { private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new() { @@ -23,10 +22,10 @@ public sealed class ProviderMistral() : BaseProvider("https://api.mistral.ai/v1/ public string InstanceName { get; set; } = "Mistral"; /// - public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default) + public async IAsyncEnumerable StreamChatCompletion(Provider.Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default) { // Get the API key: - var requestedSecret = await settings.GetAPIKey(jsRuntime, this); + var requestedSecret = await RUST_SERVICE.GetAPIKey(this); if(!requestedSecret.Success) yield break; @@ -75,7 +74,7 @@ public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", requestedSecret.Secret); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); // Set the content: request.Content = new StringContent(mistralChatRequest, Encoding.UTF8, "application/json"); @@ -141,21 +140,21 @@ public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously /// - public async IAsyncEnumerable StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) + public async IAsyncEnumerable StreamImageCompletion(Provider.Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) { yield break; } #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public async Task> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) + public async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { var secretKey = apiKeyProvisional switch { not null => apiKeyProvisional, - _ => await settings.GetAPIKey(jsRuntime, this) switch + _ => await RUST_SERVICE.GetAPIKey(this) switch { - { Success: true } result => result.Secret, + { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), _ => null, } }; @@ -179,7 +178,7 @@ public async IAsyncEnumerable StreamImageCompletion(IJSRuntime jsRunti #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously /// - public Task> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) + public Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { return Task.FromResult(Enumerable.Empty()); } diff --git a/app/MindWork AI Studio/Provider/NoProvider.cs b/app/MindWork AI Studio/Provider/NoProvider.cs index b5b9b2fe..f6a6079b 100644 --- a/app/MindWork AI Studio/Provider/NoProvider.cs +++ b/app/MindWork AI Studio/Provider/NoProvider.cs @@ -1,7 +1,6 @@ using System.Runtime.CompilerServices; using AIStudio.Chat; -using AIStudio.Settings; namespace AIStudio.Provider; @@ -13,17 +12,17 @@ public class NoProvider : IProvider public string InstanceName { get; set; } = "None"; - public Task> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult>([]); + public Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult>([]); - public Task> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult>([]); + public Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) => Task.FromResult>([]); - public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatChatThread, [EnumeratorCancellation] CancellationToken token = default) + public async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatChatThread, [EnumeratorCancellation] CancellationToken token = default) { await Task.FromResult(0); yield break; } - public async IAsyncEnumerable StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) + public async IAsyncEnumerable StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) { await Task.FromResult(0); yield break; diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs index 6e4af600..70b80f2e 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs @@ -4,14 +4,13 @@ using System.Text.Json; using AIStudio.Chat; -using AIStudio.Settings; namespace AIStudio.Provider.OpenAI; /// /// The OpenAI provider. /// -public sealed class ProviderOpenAI() : BaseProvider("https://api.openai.com/v1/"), IProvider +public sealed class ProviderOpenAI(ILogger logger) : BaseProvider("https://api.openai.com/v1/", logger), IProvider { private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new() { @@ -27,10 +26,10 @@ public sealed class ProviderOpenAI() : BaseProvider("https://api.openai.com/v1/" public string InstanceName { get; set; } = "OpenAI"; /// - public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default) + public async IAsyncEnumerable StreamChatCompletion(Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default) { // Get the API key: - var requestedSecret = await settings.GetAPIKey(jsRuntime, this); + var requestedSecret = await RUST_SERVICE.GetAPIKey(this); if(!requestedSecret.Success) yield break; @@ -79,7 +78,7 @@ public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); // Set the authorization header: - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", requestedSecret.Secret); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(ENCRYPTION)); // Set the content: request.Content = new StringContent(openAIChatRequest, Encoding.UTF8, "application/json"); @@ -145,34 +144,34 @@ public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously /// - public async IAsyncEnumerable StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) + public async IAsyncEnumerable StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) { yield break; } #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// - public Task> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) + public Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModels(jsRuntime, settings, "gpt-", token, apiKeyProvisional); + return this.LoadModels("gpt-", token, apiKeyProvisional); } /// - public Task> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) + public Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModels(jsRuntime, settings, "dall-e-", token, apiKeyProvisional); + return this.LoadModels("dall-e-", token, apiKeyProvisional); } #endregion - private async Task> LoadModels(IJSRuntime jsRuntime, SettingsManager settings, string prefix, CancellationToken token, string? apiKeyProvisional = null) + private async Task> LoadModels(string prefix, CancellationToken token, string? apiKeyProvisional = null) { var secretKey = apiKeyProvisional switch { not null => apiKeyProvisional, - _ => await settings.GetAPIKey(jsRuntime, this) switch + _ => await RUST_SERVICE.GetAPIKey(this) switch { - { Success: true } result => result.Secret, + { Success: true } result => await result.Secret.Decrypt(ENCRYPTION), _ => null, } }; diff --git a/app/MindWork AI Studio/Provider/Providers.cs b/app/MindWork AI Studio/Provider/Providers.cs index 530d0237..2d5cd295 100644 --- a/app/MindWork AI Studio/Provider/Providers.cs +++ b/app/MindWork AI Studio/Provider/Providers.cs @@ -1,9 +1,3 @@ -using AIStudio.Provider.Anthropic; -using AIStudio.Provider.Fireworks; -using AIStudio.Provider.Mistral; -using AIStudio.Provider.OpenAI; -using AIStudio.Provider.SelfHosted; - namespace AIStudio.Provider; /// @@ -20,59 +14,4 @@ public enum Providers FIREWORKS = 5, SELF_HOSTED = 4, -} - -/// -/// Extension methods for the provider enum. -/// -public static class ExtensionsProvider -{ - /// - /// Returns the human-readable name of the provider. - /// - /// The provider. - /// The human-readable name of the provider. - public static string ToName(this Providers provider) => provider switch - { - Providers.NONE => "No provider selected", - - Providers.OPEN_AI => "OpenAI", - Providers.ANTHROPIC => "Anthropic", - Providers.MISTRAL => "Mistral", - - Providers.FIREWORKS => "Fireworks.ai", - - Providers.SELF_HOSTED => "Self-hosted", - - _ => "Unknown", - }; - - /// - /// Creates a new provider instance based on the provider value. - /// - /// The provider settings. - /// The provider instance. - public static IProvider CreateProvider(this Settings.Provider providerSettings) - { - try - { - return providerSettings.UsedProvider switch - { - Providers.OPEN_AI => new ProviderOpenAI { InstanceName = providerSettings.InstanceName }, - Providers.ANTHROPIC => new ProviderAnthropic { InstanceName = providerSettings.InstanceName }, - Providers.MISTRAL => new ProviderMistral { InstanceName = providerSettings.InstanceName }, - - Providers.FIREWORKS => new ProviderFireworks { InstanceName = providerSettings.InstanceName }, - - Providers.SELF_HOSTED => new ProviderSelfHosted(providerSettings) { InstanceName = providerSettings.InstanceName }, - - _ => new NoProvider(), - }; - } - catch (Exception e) - { - Console.WriteLine($"Failed to create provider: {e.Message}"); - return new NoProvider(); - } - } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/ProvidersExtensions.cs b/app/MindWork AI Studio/Provider/ProvidersExtensions.cs new file mode 100644 index 00000000..806c3bb8 --- /dev/null +++ b/app/MindWork AI Studio/Provider/ProvidersExtensions.cs @@ -0,0 +1,60 @@ +using AIStudio.Provider.Anthropic; +using AIStudio.Provider.Fireworks; +using AIStudio.Provider.Mistral; +using AIStudio.Provider.OpenAI; +using AIStudio.Provider.SelfHosted; + +namespace AIStudio.Provider; + +public static class ProvidersExtensions +{ + /// + /// Returns the human-readable name of the provider. + /// + /// The provider. + /// The human-readable name of the provider. + public static string ToName(this Providers provider) => provider switch + { + Providers.NONE => "No provider selected", + + Providers.OPEN_AI => "OpenAI", + Providers.ANTHROPIC => "Anthropic", + Providers.MISTRAL => "Mistral", + + Providers.FIREWORKS => "Fireworks.ai", + + Providers.SELF_HOSTED => "Self-hosted", + + _ => "Unknown", + }; + + /// + /// Creates a new provider instance based on the provider value. + /// + /// The provider settings. + /// The logger to use. + /// The provider instance. + public static IProvider CreateProvider(this Settings.Provider providerSettings, ILogger logger) + { + try + { + return providerSettings.UsedProvider switch + { + Providers.OPEN_AI => new ProviderOpenAI(logger) { InstanceName = providerSettings.InstanceName }, + Providers.ANTHROPIC => new ProviderAnthropic(logger) { InstanceName = providerSettings.InstanceName }, + Providers.MISTRAL => new ProviderMistral(logger) { InstanceName = providerSettings.InstanceName }, + + Providers.FIREWORKS => new ProviderFireworks(logger) { InstanceName = providerSettings.InstanceName }, + + Providers.SELF_HOSTED => new ProviderSelfHosted(logger, providerSettings) { InstanceName = providerSettings.InstanceName }, + + _ => new NoProvider(), + }; + } + catch (Exception e) + { + logger.LogError($"Failed to create provider: {e.Message}"); + return new NoProvider(); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index 623c3b17..2f979543 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -4,11 +4,10 @@ using AIStudio.Chat; using AIStudio.Provider.OpenAI; -using AIStudio.Settings; namespace AIStudio.Provider.SelfHosted; -public sealed class ProviderSelfHosted(Settings.Provider provider) : BaseProvider($"{provider.Hostname}{provider.Host.BaseURL()}"), IProvider +public sealed class ProviderSelfHosted(ILogger logger, Settings.Provider provider) : BaseProvider($"{provider.Hostname}{provider.Host.BaseURL()}", logger), IProvider { private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new() { @@ -21,7 +20,8 @@ public sealed class ProviderSelfHosted(Settings.Provider provider) : BaseProvide public string InstanceName { get; set; } = "Self-hosted"; - public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default) + /// + public async IAsyncEnumerable StreamChatCompletion(Provider.Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default) { // Prepare the system prompt: var systemPrompt = new Message @@ -129,14 +129,14 @@ public async IAsyncEnumerable StreamChatCompletion(IJSRuntime jsRuntime, #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously /// - public async IAsyncEnumerable StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) + public async IAsyncEnumerable StreamImageCompletion(Provider.Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default) { yield break; } #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously - public async Task> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) + public async Task> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { try { @@ -162,14 +162,14 @@ public async IAsyncEnumerable StreamImageCompletion(IJSRuntime jsRunti } catch(Exception e) { - Console.WriteLine($"Failed to load text models from self-hosted provider: {e.Message}"); + this.logger.LogError($"Failed to load text models from self-hosted provider: {e.Message}"); return []; } } #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously /// - public Task> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default) + public Task> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { return Task.FromResult(Enumerable.Empty()); } diff --git a/app/MindWork AI Studio/Settings/ConfigurationSelectData.cs b/app/MindWork AI Studio/Settings/ConfigurationSelectData.cs index d62c7770..c7cdc887 100644 --- a/app/MindWork AI Studio/Settings/ConfigurationSelectData.cs +++ b/app/MindWork AI Studio/Settings/ConfigurationSelectData.cs @@ -5,7 +5,6 @@ using AIStudio.Assistants.TextSummarizer; using AIStudio.Assistants.EMail; using AIStudio.Settings.DataModel; -using AIStudio.Tools; using WritingStylesRewrite = AIStudio.Assistants.RewriteImprove.WritingStyles; using WritingStylesEMail = AIStudio.Assistants.EMail.WritingStyles; diff --git a/app/MindWork AI Studio/Settings/DataModel/DataAgenda.cs b/app/MindWork AI Studio/Settings/DataModel/DataAgenda.cs index aa59871d..46ef668f 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataAgenda.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataAgenda.cs @@ -1,5 +1,4 @@ using AIStudio.Assistants.Agenda; -using AIStudio.Tools; namespace AIStudio.Settings.DataModel; diff --git a/app/MindWork AI Studio/Settings/DataModel/DataEMail.cs b/app/MindWork AI Studio/Settings/DataModel/DataEMail.cs index 710507f4..ab659fcf 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataEMail.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataEMail.cs @@ -1,5 +1,4 @@ using AIStudio.Assistants.EMail; -using AIStudio.Tools; namespace AIStudio.Settings.DataModel; diff --git a/app/MindWork AI Studio/Settings/DataModel/DataGrammarSpelling.cs b/app/MindWork AI Studio/Settings/DataModel/DataGrammarSpelling.cs index cd74c0b4..01cc0f63 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataGrammarSpelling.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataGrammarSpelling.cs @@ -1,5 +1,3 @@ -using AIStudio.Tools; - namespace AIStudio.Settings.DataModel; public sealed class DataGrammarSpelling diff --git a/app/MindWork AI Studio/Settings/DataModel/DataRewriteImprove.cs b/app/MindWork AI Studio/Settings/DataModel/DataRewriteImprove.cs index 48742de7..738d63a4 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataRewriteImprove.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataRewriteImprove.cs @@ -1,5 +1,4 @@ using AIStudio.Assistants.RewriteImprove; -using AIStudio.Tools; namespace AIStudio.Settings.DataModel; diff --git a/app/MindWork AI Studio/Settings/DataModel/DataTextSummarizer.cs b/app/MindWork AI Studio/Settings/DataModel/DataTextSummarizer.cs index b3ad839d..6074a77f 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataTextSummarizer.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataTextSummarizer.cs @@ -1,5 +1,4 @@ using AIStudio.Assistants.TextSummarizer; -using AIStudio.Tools; namespace AIStudio.Settings.DataModel; diff --git a/app/MindWork AI Studio/Settings/DataModel/DataTranslation.cs b/app/MindWork AI Studio/Settings/DataModel/DataTranslation.cs index 26c5aba9..a861dca8 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataTranslation.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataTranslation.cs @@ -1,5 +1,3 @@ -using AIStudio.Tools; - namespace AIStudio.Settings.DataModel; public sealed class DataTranslation diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviousModels/DataV1V3.cs b/app/MindWork AI Studio/Settings/DataModel/PreviousModels/DataV1V3.cs index 3419ccb3..0b1c7883 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviousModels/DataV1V3.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviousModels/DataV1V3.cs @@ -1,7 +1,6 @@ using AIStudio.Assistants.Coding; using AIStudio.Assistants.IconFinder; using AIStudio.Assistants.TextSummarizer; -using AIStudio.Tools; namespace AIStudio.Settings.DataModel.PreviousModels; diff --git a/app/MindWork AI Studio/Settings/SettingsManager.cs b/app/MindWork AI Studio/Settings/SettingsManager.cs index c4cbc443..5f647460 100644 --- a/app/MindWork AI Studio/Settings/SettingsManager.cs +++ b/app/MindWork AI Studio/Settings/SettingsManager.cs @@ -1,7 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; -using AIStudio.Provider; using AIStudio.Settings.DataModel; // ReSharper disable NotAccessedPositionalProperty.Local @@ -11,7 +10,7 @@ namespace AIStudio.Settings; /// /// The settings manager. /// -public sealed class SettingsManager +public sealed class SettingsManager(ILogger logger) { private const string SETTINGS_FILENAME = "settings.json"; @@ -20,6 +19,8 @@ public sealed class SettingsManager WriteIndented = true, Converters = { new JsonStringEnumConverter() }, }; + + private ILogger logger = logger; /// /// The directory where the configuration files are stored. @@ -37,64 +38,6 @@ public sealed class SettingsManager public Data ConfigurationData { get; private set; } = new(); private bool IsSetUp => !string.IsNullOrWhiteSpace(ConfigDirectory) && !string.IsNullOrWhiteSpace(DataDirectory); - - #region API Key Handling - - private readonly record struct GetSecretRequest(string Destination, string UserName); - - /// - /// Data structure for any requested secret. - /// - /// True, when the secret was successfully retrieved. - /// The secret, e.g., API key. - /// The issue, when the secret could not be retrieved. - public readonly record struct RequestedSecret(bool Success, string Secret, string Issue); - - /// - /// Try to get the API key for the given provider. - /// - /// The JS runtime to access the Rust code. - /// The provider to get the API key for. - /// The requested secret. - 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); - - /// - /// Data structure for storing a secret response. - /// - /// True, when the secret was successfully stored. - /// The issue, when the secret could not be stored. - public readonly record struct StoreSecretResponse(bool Success, string Issue); - - /// - /// Try to store the API key for the given provider. - /// - /// The JS runtime to access the Rust code. - /// The provider to store the API key for. - /// The API key to store. - /// The store secret response. - 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); - - /// - /// Data structure for deleting a secret response. - /// - /// True, when the secret was successfully deleted or not found. - /// The issue, when the secret could not be deleted. - /// True, when the entry was found and deleted. - public readonly record struct DeleteSecretResponse(bool Success, string Issue, bool WasEntryFound); - - /// - /// Tries to delete the API key for the given provider. - /// - /// The JS runtime to access the Rust code. - /// The provider to delete the API key for. - /// The delete secret response. - 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 /// /// Loads the settings from the file system. @@ -102,12 +45,18 @@ public sealed class SettingsManager public async Task LoadSettings() { if(!this.IsSetUp) + { + this.logger.LogWarning("Cannot load settings, because the configuration is not set up yet."); return; - + } + var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME); if(!File.Exists(settingsPath)) + { + this.logger.LogWarning("Cannot load settings, because the settings file does not exist."); return; - + } + // We read the `"Version": "V3"` line to determine the version of the settings file: await foreach (var line in File.ReadLinesAsync(settingsPath)) { @@ -123,16 +72,16 @@ public async Task LoadSettings() Enum.TryParse(settingsVersionText, out Version settingsVersion); if(settingsVersion is Version.UNKNOWN) { - Console.WriteLine("Error: Unknown version of the settings file."); + this.logger.LogError("Unknown version of the settings file found."); this.ConfigurationData = new(); return; } - this.ConfigurationData = SettingsMigrations.Migrate(settingsVersion, await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS); + this.ConfigurationData = SettingsMigrations.Migrate(this.logger, settingsVersion, await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS); return; } - Console.WriteLine("Error: Failed to read the version of the settings file."); + this.logger.LogError("Failed to read the version of the settings file."); this.ConfigurationData = new(); } @@ -142,14 +91,22 @@ public async Task LoadSettings() public async Task StoreSettings() { if(!this.IsSetUp) + { + this.logger.LogWarning("Cannot store settings, because the configuration is not set up yet."); return; - + } + var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME); if(!Directory.Exists(ConfigDirectory)) + { + this.logger.LogInformation("Creating the configuration directory."); Directory.CreateDirectory(ConfigDirectory!); - + } + var settingsJson = JsonSerializer.Serialize(this.ConfigurationData, JSON_OPTIONS); await File.WriteAllTextAsync(settingsPath, settingsJson); + + this.logger.LogInformation("Stored the settings to the file system."); } public void InjectSpellchecking(Dictionary attributes) => attributes["spellcheck"] = this.ConfigurationData.App.EnableSpellchecking ? "true" : "false"; diff --git a/app/MindWork AI Studio/Settings/SettingsMigrations.cs b/app/MindWork AI Studio/Settings/SettingsMigrations.cs index 121d96c9..9c5bd3a8 100644 --- a/app/MindWork AI Studio/Settings/SettingsMigrations.cs +++ b/app/MindWork AI Studio/Settings/SettingsMigrations.cs @@ -9,7 +9,7 @@ namespace AIStudio.Settings; public static class SettingsMigrations { - public static Data Migrate(Version previousVersion, string configData, JsonSerializerOptions jsonOptions) + public static Data Migrate(ILogger logger, Version previousVersion, string configData, JsonSerializerOptions jsonOptions) { switch (previousVersion) { @@ -17,41 +17,41 @@ public static Data Migrate(Version previousVersion, string configData, JsonSeria var configV1 = JsonSerializer.Deserialize(configData, jsonOptions); if (configV1 is null) { - Console.WriteLine("Error: failed to parse the configuration. Using default values."); + logger.LogError("Failed to parse the v1 configuration. Using default values."); return new(); } - configV1 = MigrateV1ToV2(configV1); - configV1 = MigrateV2ToV3(configV1); - return MigrateV3ToV4(configV1); + configV1 = MigrateV1ToV2(logger, configV1); + configV1 = MigrateV2ToV3(logger, configV1); + return MigrateV3ToV4(logger, configV1); case Version.V2: var configV2 = JsonSerializer.Deserialize(configData, jsonOptions); if (configV2 is null) { - Console.WriteLine("Error: failed to parse the configuration. Using default values."); + logger.LogError("Failed to parse the v2 configuration. Using default values."); return new(); } - configV2 = MigrateV2ToV3(configV2); - return MigrateV3ToV4(configV2); + configV2 = MigrateV2ToV3(logger, configV2); + return MigrateV3ToV4(logger, configV2); case Version.V3: var configV3 = JsonSerializer.Deserialize(configData, jsonOptions); if (configV3 is null) { - Console.WriteLine("Error: failed to parse the configuration. Using default values."); + logger.LogError("Failed to parse the v3 configuration. Using default values."); return new(); } - return MigrateV3ToV4(configV3); + return MigrateV3ToV4(logger, configV3); default: - Console.WriteLine("No configuration migration needed."); + logger.LogInformation("No configuration migration is needed."); var configV4 = JsonSerializer.Deserialize(configData, jsonOptions); if (configV4 is null) { - Console.WriteLine("Error: failed to parse the configuration. Using default values."); + logger.LogError("Failed to parse the v4 configuration. Using default values."); return new(); } @@ -59,14 +59,14 @@ public static Data Migrate(Version previousVersion, string configData, JsonSeria } } - private static DataV1V3 MigrateV1ToV2(DataV1V3 previousData) + private static DataV1V3 MigrateV1ToV2(ILogger logger, DataV1V3 previousData) { // // Summary: // In v1 we had no self-hosted providers. Thus, we had no hostnames. // - Console.WriteLine("Migrating from v1 to v2..."); + logger.LogInformation("Migrating from v1 to v2..."); return new() { Version = Version.V2, @@ -81,14 +81,14 @@ private static DataV1V3 MigrateV1ToV2(DataV1V3 previousData) }; } - private static DataV1V3 MigrateV2ToV3(DataV1V3 previousData) + private static DataV1V3 MigrateV2ToV3(ILogger logger, DataV1V3 previousData) { // // Summary: // In v2, self-hosted providers had no host (LM Studio, llama.cpp, ollama, etc.) // - Console.WriteLine("Migrating from v2 to v3..."); + logger.LogInformation("Migrating from v2 to v3..."); return new() { Version = Version.V3, @@ -110,14 +110,14 @@ private static DataV1V3 MigrateV2ToV3(DataV1V3 previousData) }; } - private static Data MigrateV3ToV4(DataV1V3 previousConfig) + private static Data MigrateV3ToV4(ILogger logger, DataV1V3 previousConfig) { // // Summary: // We grouped the settings into different categories. // - Console.WriteLine("Migrating from v3 to v4..."); + logger.LogInformation("Migrating from v3 to v4..."); return new() { Version = Version.V4, diff --git a/app/MindWork AI Studio/Tools/EncryptedText.cs b/app/MindWork AI Studio/Tools/EncryptedText.cs new file mode 100644 index 00000000..1c1c2ab1 --- /dev/null +++ b/app/MindWork AI Studio/Tools/EncryptedText.cs @@ -0,0 +1,13 @@ +using System.Security; +using System.Text.Json.Serialization; + +namespace AIStudio.Tools; + +[JsonConverter(typeof(EncryptedTextJsonConverter))] +public readonly record struct EncryptedText(string EncryptedData) +{ + public EncryptedText() : this(string.Empty) + { + throw new SecurityException("Please provide the encrypted data."); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/EncryptedTextExtensions.cs b/app/MindWork AI Studio/Tools/EncryptedTextExtensions.cs new file mode 100644 index 00000000..8e77f8f8 --- /dev/null +++ b/app/MindWork AI Studio/Tools/EncryptedTextExtensions.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools; + +public static class EncryptedTextExtensions +{ + public static async Task Encrypt(this string data, Encryption encryption) => await encryption.Encrypt(data); + + public static async Task Decrypt(this EncryptedText encryptedText, Encryption encryption) => await encryption.Decrypt(encryptedText); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/EncryptedTextJsonConverter.cs b/app/MindWork AI Studio/Tools/EncryptedTextJsonConverter.cs new file mode 100644 index 00000000..43ddc681 --- /dev/null +++ b/app/MindWork AI Studio/Tools/EncryptedTextJsonConverter.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AIStudio.Tools; + +public sealed class EncryptedTextJsonConverter : JsonConverter +{ + #region Overrides of JsonConverter + + public override EncryptedText Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.String) + { + var value = reader.GetString()!; + return new EncryptedText(value); + } + + throw new JsonException($"Unexpected token type when parsing EncryptedText. Expected {JsonTokenType.String}, but got {reader.TokenType}."); + } + + public override void Write(Utf8JsonWriter writer, EncryptedText value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.EncryptedData); + } + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Encryption.cs b/app/MindWork AI Studio/Tools/Encryption.cs new file mode 100644 index 00000000..91cc71ae --- /dev/null +++ b/app/MindWork AI Studio/Tools/Encryption.cs @@ -0,0 +1,142 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace AIStudio.Tools; + +public sealed class Encryption(ILogger logger, byte[] secretPassword, byte[] secretKeySalt) +{ + /// + /// The number of iterations to derive the key and IV from the password. For a password manager + /// where the user has to enter their primary password, 100 iterations would be too few and + /// insecure. Here, the use case is different: We generate a 512-byte long and cryptographically + /// secure password at every start. This password already contains enough entropy. In our case, + /// we need key and IV primarily because AES, with the algorithms we chose, requires a fixed key + /// length, and our password is too long. + /// + private const int ITERATIONS = 100; + + private readonly byte[] key = new byte[32]; + private readonly byte[] iv = new byte[16]; + + public async Task Initialize() + { + logger.LogInformation("Initializing encryption service..."); + var stopwatch = Stopwatch.StartNew(); + + if (secretPassword.Length != 512) + { + logger.LogError($"The secret password must be 512 bytes long. It was {secretPassword.Length} bytes long."); + throw new CryptographicException("The secret password must be 512 bytes long."); + } + + if(secretKeySalt.Length != 16) + { + logger.LogError($"The salt data must be 16 bytes long. It was {secretKeySalt.Length} bytes long."); + throw new CryptographicException("The salt data must be 16 bytes long."); + } + + // Derive key and iv vector: the operations take several seconds. Thus, using a task: + await Task.Run(() => + { + using var keyVectorObj = new Rfc2898DeriveBytes(secretPassword, secretKeySalt, ITERATIONS, HashAlgorithmName.SHA512); + var keyBytes = keyVectorObj.GetBytes(32); // the max valid key length = 256 bit = 32 bytes + var ivBytes = keyVectorObj.GetBytes(16); // the only valid block size = 128 bit = 16 bytes + + Array.Copy(keyBytes, this.key, this.key.Length); + Array.Copy(ivBytes, this.iv, this.iv.Length); + }); + + var initDuration = stopwatch.Elapsed; + + stopwatch.Stop(); + logger.LogInformation($"Encryption service initialized in {initDuration.TotalMilliseconds} milliseconds."); + } + + public async Task Encrypt(string data) + { + // Create AES encryption: + using var aes = Aes.Create(); + aes.Padding = PaddingMode.PKCS7; + aes.Key = this.key; + aes.IV = this.iv; + aes.Mode = CipherMode.CBC; + + using var encryption = aes.CreateEncryptor(); + + // Copy the given string data into a memory stream: + await using var plainDataStream = new MemoryStream(Encoding.UTF8.GetBytes(data)); + + // A memory stream for the final, encrypted data: + await using var encryptedAndEncodedData = new MemoryStream(); + + // A base64 stream for the encoding: + await using var base64Stream = new CryptoStream(encryptedAndEncodedData, new ToBase64Transform(), CryptoStreamMode.Write); + + // Write the salt into the base64 stream: + await base64Stream.WriteAsync(secretKeySalt); + + // Create the encryption stream: + await using var cryptoStream = new CryptoStream(base64Stream, encryption, CryptoStreamMode.Write); + + // Write the payload into the encryption stream: + await plainDataStream.CopyToAsync(cryptoStream); + + // Flush the final block. Please note that it is not enough to call the regular flush method. + await cryptoStream.FlushFinalBlockAsync(); + + // Convert the base64 encoded data back into a string. Uses GetBuffer due to the advantage that + // it does not create another copy of the data. ToArray would create another copy of the data. + return new EncryptedText(Encoding.ASCII.GetString(encryptedAndEncodedData.GetBuffer()[..(int)encryptedAndEncodedData.Length])); + } + + public async Task Decrypt(EncryptedText encryptedData) + { + // Build a memory stream to access the given base64 encoded data: + await using var encodedEncryptedStream = new MemoryStream(Encoding.ASCII.GetBytes(encryptedData.EncryptedData)); + + // Wrap around the base64 decoder stream: + await using var base64Stream = new CryptoStream(encodedEncryptedStream, new FromBase64Transform(), CryptoStreamMode.Read); + + // A buffer for the salt's bytes: + var readSaltBytes = new byte[16]; // 16 bytes = Guid + + // Read the salt's bytes out of the stream: + var readBytes = 0; + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + while(readBytes < readSaltBytes.Length && !cts.Token.IsCancellationRequested) + { + readBytes += await base64Stream.ReadAsync(readSaltBytes, readBytes, readSaltBytes.Length - readBytes, cts.Token); + await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token); + } + + // Check the salt bytes: + if(!readSaltBytes.SequenceEqual(secretKeySalt)) + { + logger.LogError("The salt bytes do not match. The data is corrupted or tampered."); + throw new CryptographicException("The salt bytes do not match. The data is corrupted or tampered."); + } + + // Create AES decryption: + using var aes = Aes.Create(); + aes.Padding = PaddingMode.PKCS7; + aes.Key = this.key; + aes.IV = this.iv; + + using var decryption = aes.CreateDecryptor(); + + // A memory stream for the final, decrypted data: + await using var decryptedData = new MemoryStream(); + + // The crypto stream: + await using var cryptoStream = new CryptoStream(base64Stream, decryption, CryptoStreamMode.Read); + + // Reads all remaining data through the decrypt stream. Note that this operation + // starts at the current position, i.e., after the salt bytes: + await cryptoStream.CopyToAsync(decryptedData); + + // Convert the decrypted data back into a string. Uses GetBuffer due to the advantage that + // it does not create another copy of the data. ToArray would create another copy of the data. + return Encoding.UTF8.GetString(decryptedData.GetBuffer()[..(int)decryptedData.Length]); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/HttpRequestHeadersExtensions.cs b/app/MindWork AI Studio/Tools/HttpRequestHeadersExtensions.cs new file mode 100644 index 00000000..7a3fc122 --- /dev/null +++ b/app/MindWork AI Studio/Tools/HttpRequestHeadersExtensions.cs @@ -0,0 +1,18 @@ +using System.Net.Http.Headers; + +namespace AIStudio.Tools; + +public static class HttpRequestHeadersExtensions +{ + private static readonly string API_TOKEN; + + static HttpRequestHeadersExtensions() + { + API_TOKEN = Program.API_TOKEN; + } + + public static void AddApiToken(this HttpRequestHeaders headers) + { + headers.Add("token", API_TOKEN); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust.cs b/app/MindWork AI Studio/Tools/Rust.cs deleted file mode 100644 index af85765d..00000000 --- a/app/MindWork AI Studio/Tools/Rust.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace AIStudio.Tools; - -/// -/// Calling Rust functions. -/// -public sealed class Rust -{ - /// - /// Tries to copy the given text to the clipboard. - /// - /// The JS runtime to access the Rust code. - /// The snackbar to show the result. - /// The text to copy to the clipboard. - public async Task CopyText2Clipboard(IJSRuntime jsRuntime, ISnackbar snackbar, string text) - { - var response = await jsRuntime.InvokeAsync("window.__TAURI__.invoke", "set_clipboard", new SetClipboardText(text)); - var msg = response.Success switch - { - true => "Successfully copied text to clipboard!", - false => $"Failed to copy text to clipboard: {response.Issue}", - }; - - var severity = response.Success switch - { - true => Severity.Success, - false => Severity.Error, - }; - - snackbar.Add(msg, severity, config => - { - config.Icon = Icons.Material.Filled.ContentCopy; - config.IconSize = Size.Large; - config.IconColor = severity switch - { - Severity.Success => Color.Success, - Severity.Error => Color.Error, - - _ => Color.Default, - }; - }); - } - - public async Task CheckForUpdate(IJSRuntime jsRuntime) - { - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(16)); - return await jsRuntime.InvokeAsync("window.__TAURI__.invoke", cts.Token, "check_for_update"); - } - - public async Task InstallUpdate(IJSRuntime jsRuntime) - { - var cts = new CancellationTokenSource(); - await jsRuntime.InvokeVoidAsync("window.__TAURI__.invoke", cts.Token, "install_update"); - } -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/DeleteSecretResponse.cs b/app/MindWork AI Studio/Tools/Rust/DeleteSecretResponse.cs new file mode 100644 index 00000000..634dc012 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/DeleteSecretResponse.cs @@ -0,0 +1,9 @@ +namespace AIStudio.Tools.Rust; + +/// +/// Data structure for deleting a secret response. +/// +/// True, when the secret was successfully deleted or not found. +/// The issue, when the secret could not be deleted. +/// True, when the entry was found and deleted. +public readonly record struct DeleteSecretResponse(bool Success, string Issue, bool WasEntryFound); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/RequestedSecret.cs b/app/MindWork AI Studio/Tools/Rust/RequestedSecret.cs new file mode 100644 index 00000000..ce55a784 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/RequestedSecret.cs @@ -0,0 +1,9 @@ +namespace AIStudio.Tools.Rust; + +/// +/// Data structure for any requested secret. +/// +/// True, when the secret was successfully retrieved. +/// The secret, e.g., API key. +/// The issue, when the secret could not be retrieved. +public readonly record struct RequestedSecret(bool Success, EncryptedText Secret, string Issue); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/SelectSecretRequest.cs b/app/MindWork AI Studio/Tools/Rust/SelectSecretRequest.cs new file mode 100644 index 00000000..d1596709 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/SelectSecretRequest.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools.Rust; + +public readonly record struct SelectSecretRequest(string Destination, string UserName); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/SetClipboardResponse.cs b/app/MindWork AI Studio/Tools/Rust/SetClipboardResponse.cs similarity index 91% rename from app/MindWork AI Studio/Tools/SetClipboardResponse.cs rename to app/MindWork AI Studio/Tools/Rust/SetClipboardResponse.cs index e7a8fcc3..c6d256c8 100644 --- a/app/MindWork AI Studio/Tools/SetClipboardResponse.cs +++ b/app/MindWork AI Studio/Tools/Rust/SetClipboardResponse.cs @@ -1,4 +1,4 @@ -namespace AIStudio.Tools; +namespace AIStudio.Tools.Rust; /// /// The response from the set clipboard operation. diff --git a/app/MindWork AI Studio/Tools/Rust/StoreSecretRequest.cs b/app/MindWork AI Studio/Tools/Rust/StoreSecretRequest.cs new file mode 100644 index 00000000..a970c275 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/StoreSecretRequest.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools.Rust; + +public readonly record struct StoreSecretRequest(string Destination, string UserName, EncryptedText Secret); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/StoreSecretResponse.cs b/app/MindWork AI Studio/Tools/Rust/StoreSecretResponse.cs new file mode 100644 index 00000000..04860469 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/StoreSecretResponse.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools.Rust; + +/// +/// Data structure for storing a secret response. +/// +/// True, when the secret was successfully stored. +/// The issue, when the secret could not be stored. +public readonly record struct StoreSecretResponse(bool Success, string Issue); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/UpdateResponse.cs b/app/MindWork AI Studio/Tools/Rust/UpdateResponse.cs similarity index 58% rename from app/MindWork AI Studio/Tools/UpdateResponse.cs rename to app/MindWork AI Studio/Tools/Rust/UpdateResponse.cs index 5a5e3e2b..352fbece 100644 --- a/app/MindWork AI Studio/Tools/UpdateResponse.cs +++ b/app/MindWork AI Studio/Tools/Rust/UpdateResponse.cs @@ -1,6 +1,4 @@ -using System.Text.Json.Serialization; - -namespace AIStudio.Tools; +namespace AIStudio.Tools.Rust; /// /// The response of the update check. @@ -9,8 +7,8 @@ namespace AIStudio.Tools; /// The new version, when available. /// The changelog of the new version, when available. public readonly record struct UpdateResponse( - [property:JsonPropertyName("update_is_available")] bool UpdateIsAvailable, - [property:JsonPropertyName("error")] bool Error, - [property:JsonPropertyName("new_version")] string NewVersion, + bool UpdateIsAvailable, + bool Error, + string NewVersion, string Changelog ); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/RustService.cs b/app/MindWork AI Studio/Tools/RustService.cs new file mode 100644 index 00000000..1acd7de9 --- /dev/null +++ b/app/MindWork AI Studio/Tools/RustService.cs @@ -0,0 +1,331 @@ +using System.Security.Cryptography; +using System.Text.Json; + +using AIStudio.Provider; +using AIStudio.Tools.Rust; + +// ReSharper disable NotAccessedPositionalProperty.Local + +namespace AIStudio.Tools; + +/// +/// Calling Rust functions. +/// +public sealed class RustService : IDisposable +{ + private readonly HttpClient http; + + private readonly JsonSerializerOptions jsonRustSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + private ILogger? logger; + private Encryption? encryptor; + + private readonly string apiPort; + private readonly string certificateFingerprint; + + public RustService(string apiPort, string certificateFingerprint) + { + this.apiPort = apiPort; + this.certificateFingerprint = certificateFingerprint; + var certificateValidationHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, certificate, _, _) => + { + if(certificate is null) + return false; + + var currentCertificateFingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256); + return currentCertificateFingerprint == certificateFingerprint; + }, + }; + + this.http = new HttpClient(certificateValidationHandler) + { + BaseAddress = new Uri($"https://127.0.0.1:{apiPort}"), + DefaultRequestVersion = Version.Parse("2.0"), + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher, + }; + + this.http.DefaultRequestHeaders.AddApiToken(); + } + + public void SetLogger(ILogger logService) + { + this.logger = logService; + } + + public void SetEncryptor(Encryption encryptionService) + { + this.encryptor = encryptionService; + } + + public async Task GetAppPort() + { + Console.WriteLine("Trying to get app port from Rust runtime..."); + + // + // Note I: In the production environment, the Rust runtime is already running + // and listening on the given port. In the development environment, the IDE + // starts the Rust runtime in parallel with the .NET runtime. Since the + // Rust runtime needs some time to start, we have to wait for it to be ready. + // + const int MAX_TRIES = 160; + var tris = 0; + var wait4Try = TimeSpan.FromMilliseconds(250); + var url = new Uri($"https://127.0.0.1:{this.apiPort}/system/dotnet/port"); + while (tris++ < MAX_TRIES) + { + // + // Note II: We use a new HttpClient instance for each try to avoid + // .NET is caching the result. When we use the same HttpClient + // instance, we would always get the same result (403 forbidden), + // without even trying to connect to the Rust server. + // + + using var initialHttp = new HttpClient(new HttpClientHandler + { + // + // Note III: We have to create also a new HttpClientHandler instance + // for each try to avoid .NET is caching the result. This is necessary + // because it gets disposed when the HttpClient instance gets disposed. + // + ServerCertificateCustomValidationCallback = (_, certificate, _, _) => + { + if(certificate is null) + return false; + + var currentCertificateFingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256); + return currentCertificateFingerprint == this.certificateFingerprint; + } + }); + + initialHttp.DefaultRequestVersion = Version.Parse("2.0"); + initialHttp.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher; + initialHttp.DefaultRequestHeaders.AddApiToken(); + + try + { + var response = await initialHttp.GetAsync(url); + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"Try {tris}/{MAX_TRIES} to get the app port from Rust runtime"); + await Task.Delay(wait4Try); + continue; + } + + var appPortContent = await response.Content.ReadAsStringAsync(); + var appPort = int.Parse(appPortContent); + Console.WriteLine($"Received app port from Rust runtime: '{appPort}'"); + return appPort; + } + catch (Exception e) + { + Console.WriteLine($"Error: Was not able to get the app port from Rust runtime: '{e.Message}'"); + Console.WriteLine(e.InnerException); + throw; + } + } + + Console.WriteLine("Failed to receive the app port from Rust runtime."); + return 0; + } + + public async Task AppIsReady() + { + const string URL = "/system/dotnet/ready"; + this.logger!.LogInformation("Notifying Rust runtime that the app is ready."); + try + { + var response = await this.http.GetAsync(URL); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to notify Rust runtime that the app is ready: '{response.StatusCode}'"); + } + } + catch (Exception e) + { + this.logger!.LogError(e, "Failed to notify the Rust runtime that the app is ready."); + throw; + } + } + + public async Task GetConfigDirectory() + { + var response = await this.http.GetAsync("/system/directories/config"); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to get the config directory from Rust: '{response.StatusCode}'"); + return string.Empty; + } + + return await response.Content.ReadAsStringAsync(); + } + + public async Task GetDataDirectory() + { + var response = await this.http.GetAsync("/system/directories/data"); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to get the data directory from Rust: '{response.StatusCode}'"); + return string.Empty; + } + + return await response.Content.ReadAsStringAsync(); + } + + /// + /// Tries to copy the given text to the clipboard. + /// + /// The snackbar to show the result. + /// The text to copy to the clipboard. + public async Task CopyText2Clipboard(ISnackbar snackbar, string text) + { + var message = "Successfully copied the text to your clipboard"; + var iconColor = Color.Error; + var severity = Severity.Error; + try + { + var encryptedText = await text.Encrypt(this.encryptor!); + var response = await this.http.PostAsync("/clipboard/set", new StringContent(encryptedText.EncryptedData)); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to copy the text to the clipboard due to an network error: '{response.StatusCode}'"); + message = "Failed to copy the text to your clipboard."; + return; + } + + var state = await response.Content.ReadFromJsonAsync(); + if (!state.Success) + { + this.logger!.LogError("Failed to copy the text to the clipboard."); + message = "Failed to copy the text to your clipboard."; + return; + } + + iconColor = Color.Success; + severity = Severity.Success; + this.logger!.LogDebug("Successfully copied the text to the clipboard."); + } + finally + { + snackbar.Add(message, severity, config => + { + config.Icon = Icons.Material.Filled.ContentCopy; + config.IconSize = Size.Large; + config.IconColor = iconColor; + }); + } + } + + public async Task CheckForUpdate() + { + try + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(16)); + return await this.http.GetFromJsonAsync("/updates/check", cts.Token); + } + catch (Exception e) + { + this.logger!.LogError(e, "Failed to check for an update."); + return new UpdateResponse + { + Error = true, + UpdateIsAvailable = false, + }; + } + } + + public async Task InstallUpdate() + { + try + { + var cts = new CancellationTokenSource(); + await this.http.GetAsync("/updates/install", cts.Token); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + + /// + /// Try to get the API key for the given provider. + /// + /// The provider to get the API key for. + /// The requested secret. + public async Task GetAPIKey(IProvider provider) + { + var secretRequest = new SelectSecretRequest($"provider::{provider.Id}::{provider.InstanceName}::api_key", Environment.UserName); + var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions); + if (!result.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to get the API key for provider '{provider.Id}' due to an API issue: '{result.StatusCode}'"); + return new RequestedSecret(false, new EncryptedText(string.Empty), "Failed to get the API key due to an API issue."); + } + + var secret = await result.Content.ReadFromJsonAsync(); + if (!secret.Success) + this.logger!.LogError($"Failed to get the API key for provider '{provider.Id}': '{secret.Issue}'"); + + return secret; + } + + /// + /// Try to store the API key for the given provider. + /// + /// The provider to store the API key for. + /// The API key to store. + /// The store secret response. + public async Task SetAPIKey(IProvider provider, string key) + { + var encryptedKey = await this.encryptor!.Encrypt(key); + var request = new StoreSecretRequest($"provider::{provider.Id}::{provider.InstanceName}::api_key", Environment.UserName, encryptedKey); + var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions); + if (!result.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to store the API key for provider '{provider.Id}' due to an API issue: '{result.StatusCode}'"); + return new StoreSecretResponse(false, "Failed to get the API key due to an API issue."); + } + + var state = await result.Content.ReadFromJsonAsync(); + if (!state.Success) + this.logger!.LogError($"Failed to store the API key for provider '{provider.Id}': '{state.Issue}'"); + + return state; + } + + /// + /// Tries to delete the API key for the given provider. + /// + /// The provider to delete the API key for. + /// The delete secret response. + public async Task DeleteAPIKey(IProvider provider) + { + var request = new SelectSecretRequest($"provider::{provider.Id}::{provider.InstanceName}::api_key", Environment.UserName); + var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions); + if (!result.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to delete the API key for provider '{provider.Id}' due to an API issue: '{result.StatusCode}'"); + return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = "Failed to delete the API key due to an API issue."}; + } + + var state = await result.Content.ReadFromJsonAsync(); + if (!state.Success) + this.logger!.LogError($"Failed to delete the API key for provider '{provider.Id}': '{state.Issue}'"); + + return state; + } + + #region IDisposable + + public void Dispose() + { + this.http.Dispose(); + } + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/MarkdownClipboardService.cs b/app/MindWork AI Studio/Tools/Services/MarkdownClipboardService.cs index 126c13f7..b597aa5b 100644 --- a/app/MindWork AI Studio/Tools/Services/MarkdownClipboardService.cs +++ b/app/MindWork AI Studio/Tools/Services/MarkdownClipboardService.cs @@ -6,17 +6,15 @@ namespace AIStudio.Tools.Services; /// Wire up the clipboard service to copy Markdown to the clipboard. /// We use our own Rust-based clipboard service for this. /// -public sealed class MarkdownClipboardService(Rust rust, IJSRuntime jsRuntime, ISnackbar snackbar) : IMudMarkdownClipboardService +public sealed class MarkdownClipboardService(RustService rust, ISnackbar snackbar) : IMudMarkdownClipboardService { - private IJSRuntime JsRuntime { get; } = jsRuntime; - private ISnackbar Snackbar { get; } = snackbar; - private Rust Rust { get; } = rust; + private RustService Rust { get; } = rust; /// - /// Gets called when the user wants to copy the markdown to the clipboard. + /// Gets called when the user wants to copy the Markdown to the clipboard. /// /// The Markdown text to copy. - public async ValueTask CopyToClipboardAsync(string text) => await this.Rust.CopyText2Clipboard(this.JsRuntime, this.Snackbar, text); + public async ValueTask CopyToClipboardAsync(string text) => await this.Rust.CopyText2Clipboard(this.Snackbar, text); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/TemporaryChatService.cs b/app/MindWork AI Studio/Tools/Services/TemporaryChatService.cs index 0290298f..f1e49d7f 100644 --- a/app/MindWork AI Studio/Tools/Services/TemporaryChatService.cs +++ b/app/MindWork AI Studio/Tools/Services/TemporaryChatService.cs @@ -3,11 +3,13 @@ namespace AIStudio.Tools.Services; -public class TemporaryChatService(SettingsManager settingsManager) : BackgroundService +public class TemporaryChatService(ILogger logger, SettingsManager settingsManager) : BackgroundService { private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromDays(1); private static bool IS_INITIALIZED; + private readonly ILogger logger = logger; + #region Overrides of BackgroundService protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -15,10 +17,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) while (!stoppingToken.IsCancellationRequested && !IS_INITIALIZED) await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); + this.logger.LogInformation("The temporary chat maintenance service was initialized."); + await settingsManager.LoadSettings(); if(settingsManager.ConfigurationData.Workspace.StorageTemporaryMaintenancePolicy is WorkspaceStorageTemporaryMaintenancePolicy.NO_AUTOMATIC_MAINTENANCE) { - Console.WriteLine("Automatic maintenance of temporary chat storage is disabled. Exiting maintenance service."); + this.logger.LogWarning("Automatic maintenance of temporary chat storage is disabled. Exiting maintenance service."); return; } @@ -34,10 +38,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) private Task StartMaintenance() { + this.logger.LogInformation("Starting maintenance of temporary chat storage."); var temporaryDirectories = Path.Join(SettingsManager.DataDirectory, "tempChats"); if(!Directory.Exists(temporaryDirectories)) + { + this.logger.LogWarning("Temporary chat storage directory does not exist. End maintenance."); return Task.CompletedTask; - + } + foreach (var tempChatDirPath in Directory.EnumerateDirectories(temporaryDirectories)) { var chatPath = Path.Join(tempChatDirPath, "thread.json"); @@ -59,9 +67,13 @@ private Task StartMaintenance() }; if(deleteChat) + { + this.logger.LogInformation($"Deleting temporary chat storage directory '{tempChatDirPath}' due to maintenance policy."); Directory.Delete(tempChatDirPath, true); + } } + this.logger.LogInformation("Finished maintenance of temporary chat storage."); return Task.CompletedTask; } diff --git a/app/MindWork AI Studio/Tools/Services/UpdateService.cs b/app/MindWork AI Studio/Tools/Services/UpdateService.cs index c8cfef68..8de0690d 100644 --- a/app/MindWork AI Studio/Tools/Services/UpdateService.cs +++ b/app/MindWork AI Studio/Tools/Services/UpdateService.cs @@ -7,20 +7,16 @@ namespace AIStudio.Tools.Services; public sealed class UpdateService : BackgroundService, IMessageBusReceiver { - // We cannot inject IJSRuntime into our service. This is because - // the service is not a Blazor component. We need to pass the IJSRuntime from - // the MainLayout component to the service. - private static IJSRuntime? JS_RUNTIME; private static bool IS_INITIALIZED; private static ISnackbar? SNACKBAR; private readonly SettingsManager settingsManager; private readonly MessageBus messageBus; - private readonly Rust rust; + private readonly RustService rust; private TimeSpan updateInterval; - public UpdateService(MessageBus messageBus, SettingsManager settingsManager, Rust rust) + public UpdateService(MessageBus messageBus, SettingsManager settingsManager, RustService rust) { this.settingsManager = settingsManager; this.messageBus = messageBus; @@ -96,7 +92,7 @@ private async Task CheckForUpdate(bool notifyUserWhenNoUpdate = false) if(!IS_INITIALIZED) return; - var response = await this.rust.CheckForUpdate(JS_RUNTIME!); + var response = await this.rust.CheckForUpdate(); if (response.UpdateIsAvailable) { await this.messageBus.SendMessage(null, Event.UPDATE_AVAILABLE, response); @@ -115,10 +111,9 @@ private async Task CheckForUpdate(bool notifyUserWhenNoUpdate = false) } } - public static void SetBlazorDependencies(IJSRuntime jsRuntime, ISnackbar snackbar) + public static void SetBlazorDependencies(ISnackbar snackbar) { SNACKBAR = snackbar; - JS_RUNTIME = jsRuntime; IS_INITIALIZED = true; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/SetClipboardText.cs b/app/MindWork AI Studio/Tools/SetClipboardText.cs deleted file mode 100644 index 9776d29b..00000000 --- a/app/MindWork AI Studio/Tools/SetClipboardText.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace AIStudio.Tools; - -/// -/// Model for setting clipboard text. -/// -/// The text to set to the clipboard. -public record SetClipboardText(string Text); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/TerminalLogger.cs b/app/MindWork AI Studio/Tools/TerminalLogger.cs new file mode 100644 index 00000000..ce87feb4 --- /dev/null +++ b/app/MindWork AI Studio/Tools/TerminalLogger.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; + +namespace AIStudio.Tools; + +public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME) +{ + public const string FORMATTER_NAME = "AI Studio Terminal Logger"; + + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) + { + var message = logEntry.Formatter(logEntry.State, logEntry.Exception); + var timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff"); + var logLevel = logEntry.LogLevel.ToString(); + var category = logEntry.Category; + + textWriter.Write($"=> {timestamp} [{logLevel}] {category}: {message}"); + if (logEntry.Exception is not null) + textWriter.Write($" Exception was = {logEntry.Exception}"); + + textWriter.WriteLine(); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v0.8.13.md b/app/MindWork AI Studio/wwwroot/changelog/v0.8.13.md deleted file mode 100644 index 085d053a..00000000 --- a/app/MindWork AI Studio/wwwroot/changelog/v0.8.13.md +++ /dev/null @@ -1,2 +0,0 @@ -# v0.8.13, build 175 -- Upgraded `keyring` dependency to v3.2.0. \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v0.9.0.md b/app/MindWork AI Studio/wwwroot/changelog/v0.9.0.md new file mode 100644 index 00000000..9c0d5964 --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v0.9.0.md @@ -0,0 +1,4 @@ +# v0.9.0, build 175 (2024-09-01 18:04 UTC) +- Upgraded `keyring` dependency to v3.2.0. +- Refactored the interprocess communication (IPC) to use a new runtime API for better performance, stability, extensibility, maintainability, and security. This is the foundation for upcoming features and improvements, such as performant RAG and scripting support. +- Refactored the logging system. Now, the logging of all systems is unified. \ No newline at end of file diff --git a/metadata.txt b/metadata.txt index 6a21edab..422f00a1 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,9 +1,9 @@ -0.8.12 -2024-08-24 08:30:01 UTC -174 +0.9.0 +2024-09-01 18:04:01 UTC +175 8.0.108 (commit 665a05cea7) 8.0.8 (commit 08338fcaa5) 1.80.1 (commit 3f5fd8dd4) 7.6.0 1.7.1 -2d82fcc5e16, release +a1446103d70, release diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index de65e24f..7cead009 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -17,6 +17,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -80,6 +91,39 @@ dependencies = [ "x11rb", ] +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.72", +] + +[[package]] +name = "async-trait" +version = "0.1.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.72", +] + [[package]] name = "atk" version = "0.15.1" @@ -104,6 +148,21 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -149,6 +208,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "binascii" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" + [[package]] name = "bincode" version = "1.3.3" @@ -185,6 +250,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -292,6 +366,15 @@ dependencies = [ "toml 0.7.8", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.1.6" @@ -353,6 +436,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.0" @@ -414,6 +507,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -648,6 +752,39 @@ dependencies = [ "syn 2.0.72", ] +[[package]] +name = "devise" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" +dependencies = [ + "devise_codegen", + "devise_core", +] + +[[package]] +name = "devise_codegen" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" +dependencies = [ + "devise_core", + "quote", +] + +[[package]] +name = "devise_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" +dependencies = [ + "bitflags 2.6.0", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.72", +] + [[package]] name = "digest" version = "0.10.7" @@ -656,6 +793,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -706,6 +844,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "embed-resource" version = "2.4.3" @@ -782,6 +926,20 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.0", + "pear", + "serde", + "toml 0.8.16", + "uncased", + "version_check", +] + [[package]] name = "filetime" version = "0.2.23" @@ -806,9 +964,9 @@ dependencies = [ [[package]] name = "flexi_logger" -version = "0.28.5" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca927478b3747ba47f98af6ba0ac0daea4f12d12f55e9104071b3dc00276310" +checksum = "a250587a211932896a131f214a4f64c047b826ce072d2018764e5ff5141df8fa" dependencies = [ "chrono", "glob", @@ -885,6 +1043,20 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.30" @@ -892,6 +1064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -946,6 +1119,7 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1350,12 +1524,27 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.26.0" @@ -1488,23 +1677,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" -dependencies = [ - "futures-util", - "http 1.1.0", - "hyper 1.4.1", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - [[package]] name = "hyper-tls" version = "0.5.0" @@ -1675,6 +1847,22 @@ dependencies = [ "cfb", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -1690,6 +1878,17 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi 0.4.0", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "itoa" version = "0.4.8" @@ -1931,16 +2130,27 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mindwork-ai-studio" -version = "0.8.12" +version = "0.9.0" dependencies = [ + "aes", "arboard", + "base64 0.22.1", + "cbc", + "cipher", "flexi_logger", + "hmac", "keyring", "log", "once_cell", - "reqwest 0.12.5", + "pbkdf2", + "rand 0.8.5", + "rand_chacha 0.3.1", + "rcgen", + "reqwest 0.12.4", + "rocket", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-window-state", @@ -1969,12 +2179,31 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4569e456d394deccd22ce1c1913e6ea0e54519f577285001215d33557431afe4" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.1.0", + "httparse", + "memchr", + "mime", + "spin", + "tokio", + "tokio-util", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.12" @@ -2130,6 +2359,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi 0.3.9", + "libc", +] + [[package]] name = "num_enum" version = "0.5.11" @@ -2438,6 +2677,49 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.72", +] + +[[package]] +name = "pem" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +dependencies = [ + "base64 0.22.1", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2709,6 +2991,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.72", + "version_check", + "yansi", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -2814,6 +3109,19 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" +[[package]] +name = "rcgen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54077e1872c46788540de1ea3d7f4ccb1983d12f9aa909b234468676c1a36779" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2843,6 +3151,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ref-cast" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf0a6f84d5f1d581da8b41b47ec8600871962f2a528115b542b362d4b744931" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.72", +] + [[package]] name = "regex" version = "1.10.5" @@ -2915,7 +3243,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 0.1.2", + "sync_wrapper", "system-configuration", "tokio", "tokio-native-tls", @@ -2931,9 +3259,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.5" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10" dependencies = [ "base64 0.22.1", "bytes", @@ -2945,7 +3273,6 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.4.1", - "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", "ipnet", @@ -2960,7 +3287,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.1", + "sync_wrapper", "system-configuration", "tokio", "tokio-native-tls", @@ -3011,6 +3338,91 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rocket" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a516907296a31df7dc04310e7043b61d71954d703b603cc6867a026d7e72d73f" +dependencies = [ + "async-stream", + "async-trait", + "atomic 0.5.3", + "binascii", + "bytes", + "either", + "figment", + "futures", + "indexmap 2.2.6", + "log", + "memchr", + "multer", + "num_cpus", + "parking_lot", + "pin-project-lite", + "rand 0.8.5", + "ref-cast", + "rocket_codegen", + "rocket_http", + "serde", + "serde_json", + "state 0.6.0", + "tempfile", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "ubyte", + "version_check", + "yansi", +] + +[[package]] +name = "rocket_codegen" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" +dependencies = [ + "devise", + "glob", + "indexmap 2.2.6", + "proc-macro2", + "quote", + "rocket_http", + "syn 2.0.72", + "unicode-xid", + "version_check", +] + +[[package]] +name = "rocket_http" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e274915a20ee3065f611c044bd63c40757396b6dbc057d6046aec27f14f882b9" +dependencies = [ + "cookie", + "either", + "futures", + "http 0.2.12", + "hyper 0.14.30", + "indexmap 2.2.6", + "log", + "memchr", + "pear", + "percent-encoding", + "pin-project-lite", + "ref-cast", + "rustls", + "rustls-pemfile 1.0.4", + "serde", + "smallvec", + "stable-pattern", + "state 0.6.0", + "time", + "tokio", + "tokio-rustls", + "uncased", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -3041,15 +3453,14 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.12" +version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ - "once_cell", - "rustls-pki-types", + "log", + "ring", "rustls-webpki", - "subtle", - "zeroize", + "sct", ] [[package]] @@ -3079,12 +3490,11 @@ checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" [[package]] name = "rustls-webpki" -version = "0.102.6" +version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "rustls-pki-types", "untrusted", ] @@ -3130,6 +3540,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3338,6 +3758,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -3409,6 +3838,15 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "stable-pattern" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" +dependencies = [ + "memchr", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3424,6 +3862,15 @@ dependencies = [ "loom", ] +[[package]] +name = "state" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8c4a4445d81357df8b1a650d0d0d6fbbbfe99d064aa5e02f3e4022061476d8" +dependencies = [ + "loom", +] + [[package]] name = "string_cache" version = "0.8.7" @@ -3458,9 +3905,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" -version = "2.6.1" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" @@ -3490,12 +3937,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" -[[package]] -name = "sync_wrapper" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" - [[package]] name = "system-configuration" version = "0.5.1" @@ -3659,7 +4100,7 @@ dependencies = [ "serde_repr", "serialize-to-javascript", "shared_child", - "state", + "state 0.5.3", "tar", "tauri-macros", "tauri-runtime", @@ -3958,10 +4399,23 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.72", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -3974,12 +4428,22 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ "rustls", - "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +dependencies = [ + "futures-core", + "pin-project-lite", "tokio", ] @@ -4164,6 +4628,25 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ubyte" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" +dependencies = [ + "serde", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "serde", + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -4191,6 +4674,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +[[package]] +name = "unicode-xid" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229730647fbc343e3a80e463c1db7f78f3855d3f3739bee0dda773c9a037c90a" + [[package]] name = "untrusted" version = "0.9.0" @@ -4993,10 +5482,22 @@ dependencies = [ ] [[package]] -name = "zeroize" -version = "1.8.1" +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "yasna" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] [[package]] name = "zip" diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index fcbd67fc..231ae6c3 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mindwork-ai-studio" -version = "0.8.12" +version = "0.9.0" edition = "2021" description = "MindWork AI Studio" authors = ["Thorsten Sommer"] @@ -9,22 +9,31 @@ authors = ["Thorsten Sommer"] tauri-build = { version = "1.5", features = [] } [dependencies] -tauri = { version = "1.7.1", features = [ "updater", "http-all", "shell-sidecar", "path-all", "shell-open"] } +tauri = { version = "1.7.1", features = [ "updater", "shell-sidecar", "shell-open"] } tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -keyring = { version = "3.2.0", features = ["apple-native", "windows-native", "sync-secret-service"] } +keyring = { version = "3.2", features = ["apple-native", "windows-native", "sync-secret-service"] } arboard = "3.4.0" -tokio = "1.39" -flexi_logger = "0.28" -log = "0.4" +tokio = { version = "1.39", features = ["rt", "rt-multi-thread", "macros"] } +flexi_logger = "0.29" +log = { version = "0.4", features = ["kv"] } once_cell = "1.19.0" +rocket = { version = "0.5", features = ["json", "tls"] } +rand = "0.8" +rand_chacha = "0.3.1" +base64 = "0.22.1" +cipher = { version = "0.4.4", features = ["std"] } +aes = "0.8.4" +cbc = "0.1.2" +pbkdf2 = "0.12.2" +hmac = "0.12.1" +sha2 = "0.10.8" +rcgen = { version = "0.13.1", features = ["pem"] } [target.'cfg(target_os = "linux")'.dependencies] # See issue https://github.com/tauri-apps/tauri/issues/4470 reqwest = { version = "0.12", features = ["native-tls-vendored"] } [features] -# this feature is used for production builds or when `devPath` points to the filesystem -# DO NOT REMOVE!! custom-protocol = ["tauri/custom-protocol"] diff --git a/runtime/src/main.rs b/runtime/src/main.rs index d2f5c9b3..8d7c5c0d 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -1,29 +1,114 @@ // Prevents an additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +extern crate rocket; extern crate core; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt; use std::net::TcpListener; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use once_cell::sync::Lazy; use arboard::Clipboard; +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; use keyring::Entry; -use serde::Serialize; -use tauri::{Manager, Url, Window, WindowUrl}; +use serde::{Deserialize, Serialize}; +use tauri::{Manager, Url, Window}; use tauri::api::process::{Command, CommandChild, CommandEvent}; -use tauri::utils::config::AppUrl; use tokio::time; -use flexi_logger::{AdaptiveFormat, Logger}; +use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger}; +use flexi_logger::writers::FileLogWriter; +use hmac::Hmac; use keyring::error::Error::NoEntry; -use log::{debug, error, info, warn}; +use log::{debug, error, info, kv, warn}; +use log::kv::{Key, Value, VisitSource}; +use pbkdf2::pbkdf2; +use rand::{RngCore, SeedableRng}; +use rcgen::generate_simple_self_signed; +use rocket::figment::Figment; +use rocket::{data, get, post, routes, Data, Request}; +use rocket::config::{Shutdown}; +use rocket::data::{ToByteUnit}; +use rocket::http::Status; +use rocket::request::{FromRequest}; +use rocket::serde::json::Json; +use sha2::{Sha256, Sha512, Digest}; use tauri::updater::UpdateResponse; +use tokio::io::AsyncReadExt; -static SERVER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(None))); +type Aes256CbcEnc = cbc::Encryptor; + +type Aes256CbcDec = cbc::Decryptor; + +type DataOutcome<'r, T> = data::Outcome<'r, T>; + +type RequestOutcome = rocket::request::Outcome; + +// The .NET server is started in a separate process and communicates with this +// runtime process via IPC. However, we do net start the .NET server in +// the development environment. +static DOTNET_SERVER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(None))); + +// The .NET server port is relevant for the production environment only, sine we +// do not start the server in the development environment. +static DOTNET_SERVER_PORT: Lazy = Lazy::new(|| get_available_port().unwrap()); + +// The port used for the runtime API server. In the development environment, we use a fixed +// port, in the production environment we use the next available port. This differentiation +// is necessary because we cannot communicate the port to the .NET server in the development +// environment. +static API_SERVER_PORT: Lazy = Lazy::new(|| { + if is_dev() { + 5000 + } else { + get_available_port().unwrap() + } +}); + +// The Tauri main window. static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); + +// The update response coming from the Tauri updater. static CHECK_UPDATE_RESPONSE: Lazy>>> = Lazy::new(|| Mutex::new(None)); -fn main() { +static ENCRYPTION: Lazy = Lazy::new(|| { + // + // Generate a secret key & salt for the AES encryption for the IPC channel: + // + let mut secret_key = [0u8; 512]; // 512 bytes = 4096 bits + let mut secret_key_salt = [0u8; 16]; // 16 bytes = 128 bits + + // We use a cryptographically secure pseudo-random number generator + // to generate the secret password & salt. ChaCha20Rng is the algorithm + // of our choice: + let mut rng = rand_chacha::ChaChaRng::from_entropy(); + + // Fill the secret key & salt with random bytes: + rng.fill_bytes(&mut secret_key); + rng.fill_bytes(&mut secret_key_salt); + + Encryption::new(&secret_key, &secret_key_salt).unwrap() +}); + +static API_TOKEN: Lazy = Lazy::new(|| { + let mut token = [0u8; 32]; + let mut rng = rand_chacha::ChaChaRng::from_entropy(); + rng.fill_bytes(&mut token); + APIToken::from_bytes(token.to_vec()) +}); + +static DATA_DIRECTORY: OnceLock = OnceLock::new(); + +static CONFIG_DIRECTORY: OnceLock = OnceLock::new(); + +static DOTNET_INITIALIZED: Lazy> = Lazy::new(|| Mutex::new(false)); + +#[tokio::main] +async fn main() { let metadata = include_str!("../../metadata.txt"); let mut metadata_lines = metadata.lines(); @@ -37,16 +122,36 @@ fn main() { let tauri_version = metadata_lines.next().unwrap(); let app_commit_hash = metadata_lines.next().unwrap(); - // Set the log level according to the environment: - // In debug mode, the log level is set to debug, in release mode to info. - let log_level = match is_dev() { - true => "debug", - false => "info", + // + // Configure the logger: + // + let mut log_config = String::new(); + + // Set the log level depending on the environment: + match is_dev() { + true => log_config.push_str("debug, "), + false => log_config.push_str("info, "), }; - Logger::try_with_str(log_level).expect("Cannot create logging") - .log_to_stdout() - .adaptive_format_for_stdout(AdaptiveFormat::Detailed) + // Set the log level for the Rocket library: + log_config.push_str("rocket=info, "); + + // Set the log level for the Rocket server: + log_config.push_str("rocket::server=warn, "); + + // Set the log level for the Reqwest library: + log_config.push_str("reqwest::async_impl::client=info"); + + let logger = Logger::try_with_str(log_config).expect("Cannot create logging") + .log_to_file(FileSpec::default() + .basename("AI Studio Events") + .suppress_timestamp() + .suffix("log")) + .duplicate_to_stdout(Duplicate::All) + .use_utc() + .format_for_files(file_logger_format) + .format_for_stderr(terminal_colored_logger_format) + .format_for_stdout(terminal_colored_logger_format) .start().expect("Cannot start logging"); info!("Starting MindWork AI Studio:"); @@ -64,130 +169,168 @@ fn main() { info!("Running in production mode."); } - let port = match is_dev() { - true => 5000, - false => get_available_port().unwrap(), - }; + info!("Try to generate a TLS certificate for the runtime API server..."); - let url = match Url::parse(format!("http://localhost:{port}").as_str()) - { - Ok(url) => url, - Err(msg) => { - error!("Error while parsing URL: {msg}"); - return; - } - }; + let subject_alt_names = vec!["localhost".to_string()]; + let certificate_data = generate_simple_self_signed(subject_alt_names).unwrap(); + let certificate_binary_data = certificate_data.cert.der().to_vec(); + let certificate_fingerprint = Sha256::digest(certificate_binary_data).to_vec(); + let certificate_fingerprint = certificate_fingerprint.iter().fold(String::new(), |mut result, byte| { + result.push_str(&format!("{:02x}", byte)); + result + }); + let certificate_fingerprint = certificate_fingerprint.to_uppercase(); + info!("Certificate fingerprint: '{certificate_fingerprint}'."); + info!("Done generating certificate for the runtime API server."); - let app_url = AppUrl::Url(WindowUrl::External(url.clone())); - let app_url_log = app_url.clone(); - info!("Try to start the .NET server on {app_url_log}..."); + let api_port = *API_SERVER_PORT; + info!("Try to start the API server on 'http://localhost:{api_port}'..."); - // Arc for the server process to stop it later: - let server_spawn_clone = SERVER.clone(); + // Configure the runtime API server: + let figment = Figment::from(rocket::Config::release_default()) - // Channel to communicate with the server process: - let (sender, mut receiver) = tauri::async_runtime::channel(100); + // We use the next available port which was determined before: + .merge(("port", api_port)) - if is_prod() { - tauri::async_runtime::spawn(async move { - let (mut rx, child) = Command::new_sidecar("mindworkAIStudioServer") - .expect("Failed to create sidecar") - .args([format!("{port}").as_str()]) - .spawn() - .expect("Failed to spawn .NET server process."); - - let server_pid = child.pid(); - debug!(".NET server process started with PID={server_pid}."); - - // Save the server process to stop it later: - *server_spawn_clone.lock().unwrap() = Some(child); - - info!("Waiting for .NET server to boot..."); - while let Some(CommandEvent::Stdout(line)) = rx.recv().await { - let line_lower = line.to_lowercase(); - let line_cleared = line_lower.trim(); - match line_cleared - { - "rust/tauri server started" => _ = sender.send(ServerEvent::Started).await, - - _ if line_cleared.contains("fail") || line_cleared.contains("error") || line_cleared.contains("exception") => _ = sender.send(ServerEvent::Error(line)).await, - _ if line_cleared.contains("warn") => _ = sender.send(ServerEvent::Warning(line)).await, - _ if line_cleared.contains("404") => _ = sender.send(ServerEvent::NotFound(line)).await, - _ => (), - } - } + // The runtime API server should be accessible only from the local machine: + .merge(("address", "127.0.0.1")) - let sending_stop_result = sender.send(ServerEvent::Stopped).await; - match sending_stop_result { - Ok(_) => (), - Err(e) => error!("Was not able to send the server stop message: {e}."), - } - }); - } else { - warn!("Running in development mode, no .NET server will be started."); - } + // We do not want to use the Ctrl+C signal to stop the server: + .merge(("ctrlc", false)) - let main_window_spawn_clone = &MAIN_WINDOW; - let server_receive_clone = SERVER.clone(); + // Set a name for the server: + .merge(("ident", "AI Studio Runtime API")) + + // Set the maximum number of workers and blocking threads: + .merge(("workers", 3)) + .merge(("max_blocking", 12)) + + // No colors and emojis in the log output: + .merge(("cli_colors", false)) + + // Read the TLS certificate and key from the generated certificate data in-memory: + .merge(("tls.certs", certificate_data.cert.pem().as_bytes())) + .merge(("tls.key", certificate_data.key_pair.serialize_pem().as_bytes())) + + // Set the shutdown configuration: + .merge(("shutdown", Shutdown { + + // Again, we do not want to use the Ctrl+C signal to stop the server: + ctrlc: false, - // Create a thread to handle server events: + // We do not want to use the termination signal to stop the server: + signals: HashSet::new(), + + // Everything else is set to default: + ..Shutdown::default() + })); + + // + // Start the runtime API server in a separate thread. This is necessary + // because the server is blocking, and we need to run the Tauri app in + // parallel: + // tauri::async_runtime::spawn(async move { - info!("Start listening for server events..."); - loop { - match receiver.recv().await { - Some(ServerEvent::Started) => { - info!("The .NET server was booted successfully."); - - // Try to get the main window. If it is not available yet, wait for it: - let mut main_window_ready = false; - let mut main_window_status_reported = false; - while !main_window_ready - { - main_window_ready = { - let main_window = main_window_spawn_clone.lock().unwrap(); - main_window.is_some() - }; - - if !main_window_ready { - if !main_window_status_reported { - info!("Waiting for main window to be ready, because .NET was faster than Tauri."); - main_window_status_reported = true; - } - - time::sleep(time::Duration::from_millis(100)).await; - } - } - - let main_window = main_window_spawn_clone.lock().unwrap(); - let js_location_change = format!("window.location = '{url}';"); - let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str()); - match location_change_result { - Ok(_) => info!("Location was changed to {url}."), - Err(e) => error!("Failed to change location to {url}: {e}."), - } - }, + _ = rocket::custom(figment) + .mount("/", routes![ + dotnet_port, dotnet_ready, set_clipboard, check_for_update, install_update, + get_secret, store_secret, delete_secret, get_data_directory, get_config_directory, + ]) + .ignite().await.unwrap() + .launch().await.unwrap(); + }); - Some(ServerEvent::NotFound(line)) => { - warn!("The .NET server issued a 404 error: {line}."); - }, + // Get the secret password & salt and convert it to a base64 string: + let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password); + let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt); - Some(ServerEvent::Warning(line)) => { - warn!("The .NET server issued a warning: {line}."); - }, + let dotnet_server_environment = HashMap::from_iter([ + (String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password), + (String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt), + (String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), certificate_fingerprint), + (String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()), + ]); - Some(ServerEvent::Error(line)) => { - error!("The .NET server issued an error: {line}."); - }, + info!("Secret password for the IPC channel was generated successfully."); + info!("Try to start the .NET server..."); + let server_spawn_clone = DOTNET_SERVER.clone(); + tauri::async_runtime::spawn(async move { + let api_port = *API_SERVER_PORT; + + let (mut rx, child) = match is_dev() { + true => { + // We are in the development environment, so we try to start a process + // with `dotnet run` in the `../app/MindWork AI Studio` directory. But + // we cannot issue a sidecar because we cannot use any command for the + // sidecar (see Tauri configuration). Thus, we use a standard Rust process: + warn!(Source = "Bootloader .NET"; "Development environment detected; start .NET server using 'dotnet run'."); + Command::new("dotnet") + + // Start the .NET server in the `../app/MindWork AI Studio` directory. + // We provide the runtime API server port to the .NET server: + .args(["run", "--project", "../app/MindWork AI Studio", "--", format!("{api_port}").as_str()]) + + .envs(dotnet_server_environment) + .spawn() + .expect("Failed to spawn .NET server process.") + } - Some(ServerEvent::Stopped) => { - warn!("The .NET server was stopped."); - *server_receive_clone.lock().unwrap() = None; - }, + false => { + Command::new_sidecar("mindworkAIStudioServer") + .expect("Failed to create sidecar") - None => { - debug!("Server event channel was closed."); - break; - }, + // Provide the runtime API server port to the .NET server: + .args([format!("{api_port}").as_str()]) + + .envs(dotnet_server_environment) + .spawn() + .expect("Failed to spawn .NET server process.") + } + }; + + let server_pid = child.pid(); + info!(Source = "Bootloader .NET"; "The .NET server process started with PID={server_pid}."); + + // Save the server process to stop it later: + *server_spawn_clone.lock().unwrap() = Some(child); + + // Log the output of the .NET server: + while let Some(CommandEvent::Stdout(line)) = rx.recv().await { + + // Remove newline characters from the end: + let line = line.trim_end(); + + // Starts the line with '=>'? + if line.starts_with("=>") { + // Yes. This means that the line is a log message from the .NET server. + // The format is: ' [] : '. + // We try to parse this line and log it with the correct log level: + let line = line.trim_start_matches("=>").trim(); + let parts = line.split_once(": ").unwrap(); + let left_part = parts.0.trim(); + let message = parts.1.trim(); + let parts = left_part.split_once("] ").unwrap(); + let level = parts.0.split_once("[").unwrap().1.trim(); + let source = parts.1.trim(); + match level { + "Trace" => debug!(Source = ".NET Server", Comp = source; "{message}"), + "Debug" => debug!(Source = ".NET Server", Comp = source; "{message}"), + "Information" => info!(Source = ".NET Server", Comp = source; "{message}"), + "Warning" => warn!(Source = ".NET Server", Comp = source; "{message}"), + "Error" => error!(Source = ".NET Server", Comp = source; "{message}"), + "Critical" => error!(Source = ".NET Server", Comp = source; "{message}"), + + _ => error!(Source = ".NET Server", Comp = source; "{message} (unknown log level '{level}')"), + } + } else { + let lower_line = line.to_lowercase(); + if lower_line.contains("error") { + error!(Source = ".NET Server"; "{line}"); + } else if lower_line.contains("warning") { + warn!(Source = ".NET Server"; "{line}"); + } else { + info!(Source = ".NET Server"; "{line}"); + } } } }); @@ -197,13 +340,25 @@ fn main() { .setup(move |app| { let window = app.get_window("main").expect("Failed to get main window."); *MAIN_WINDOW.lock().unwrap() = Some(window); + + info!(Source = "Bootloader Tauri"; "Setup is running."); + let logger_path = app.path_resolver().app_local_data_dir().unwrap(); + let logger_path = logger_path.join("data"); + + DATA_DIRECTORY.set(logger_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not abe to set the data directory.")).unwrap(); + CONFIG_DIRECTORY.set(app.path_resolver().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap(); + + info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {logger_path:?}"); + logger.reset_flw(&FileLogWriter::builder( + FileSpec::default() + .directory(logger_path) + .basename("events") + .suppress_timestamp() + .suffix("log")))?; + Ok(()) }) .plugin(tauri_plugin_window_state::Builder::default().build()) - .invoke_handler(tauri::generate_handler![ - store_secret, get_secret, delete_secret, set_clipboard, - check_for_update, install_update - ]) .build(tauri::generate_context!()) .expect("Error while running Tauri application"); @@ -212,15 +367,15 @@ fn main() { tauri::RunEvent::WindowEvent { event, label, .. } => { match event { tauri::WindowEvent::CloseRequested { .. } => { - warn!("Window '{label}': close was requested."); + warn!(Source = "Tauri"; "Window '{label}': close was requested."); } tauri::WindowEvent::Destroyed => { - warn!("Window '{label}': was destroyed."); + warn!(Source = "Tauri"; "Window '{label}': was destroyed."); } tauri::WindowEvent::FileDrop(files) => { - info!("Window '{label}': files were dropped: {files:?}"); + info!(Source = "Tauri"; "Window '{label}': files were dropped: {files:?}"); } _ => (), @@ -232,64 +387,393 @@ fn main() { tauri::UpdaterEvent::UpdateAvailable { body, date, version } => { let body_len = body.len(); - info!("Updater: update available: body size={body_len} time={date:?} version={version}"); + info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={version}"); } tauri::UpdaterEvent::Pending => { - info!("Updater: update is pending!"); + info!(Source = "Tauri"; "Updater: update is pending!"); } tauri::UpdaterEvent::DownloadProgress { chunk_length, content_length } => { - info!("Updater: downloaded {} of {:?}", chunk_length, content_length); + info!(Source = "Tauri"; "Updater: downloaded {} of {:?}", chunk_length, content_length); } tauri::UpdaterEvent::Downloaded => { - info!("Updater: update has been downloaded!"); - warn!("Try to stop the .NET server now..."); - stop_server(); + info!(Source = "Tauri"; "Updater: update has been downloaded!"); + warn!(Source = "Tauri"; "Try to stop the .NET server now..."); + stop_servers(); } tauri::UpdaterEvent::Updated => { - info!("Updater: app has been updated"); - warn!("Try to restart the app now..."); + info!(Source = "Tauri"; "Updater: app has been updated"); + warn!(Source = "Tauri"; "Try to restart the app now..."); app_handle.restart(); } tauri::UpdaterEvent::AlreadyUpToDate => { - info!("Updater: app is already up to date"); + info!(Source = "Tauri"; "Updater: app is already up to date"); } tauri::UpdaterEvent::Error(error) => { - warn!("Updater: failed to update: {error}"); + warn!(Source = "Tauri"; "Updater: failed to update: {error}"); } } } tauri::RunEvent::ExitRequested { .. } => { - warn!("Run event: exit was requested."); + warn!(Source = "Tauri"; "Run event: exit was requested."); } tauri::RunEvent::Ready => { - info!("Run event: Tauri app is ready."); + info!(Source = "Tauri"; "Run event: Tauri app is ready."); } _ => {} }); - info!("Tauri app was stopped."); + warn!(Source = "Tauri"; "Tauri app was stopped."); if is_prod() { - info!("Try to stop the .NET server as well..."); - stop_server(); + warn!("Try to stop the .NET server as well..."); + stop_servers(); + } +} + +struct APIToken{ + hex_text: String, +} + +impl APIToken { + fn from_bytes(bytes: Vec) -> Self { + APIToken { + hex_text: bytes.iter().fold(String::new(), |mut result, byte| { + result.push_str(&format!("{:02x}", byte)); + result + }), + } + } + + fn from_hex_text(hex_text: &str) -> Self { + APIToken { + hex_text: hex_text.to_string(), + } + } + + fn to_hex_text(&self) -> &str { + self.hex_text.as_str() + } + + fn validate(&self, received_token: &Self) -> bool { + received_token.to_hex_text() == self.to_hex_text() + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for APIToken { + type Error = APITokenError; + + async fn from_request(request: &'r Request<'_>) -> RequestOutcome { + let token = request.headers().get_one("token"); + match token { + Some(token) => { + let received_token = APIToken::from_hex_text(token); + if API_TOKEN.validate(&received_token) { + RequestOutcome::Success(received_token) + } else { + RequestOutcome::Error((Status::Unauthorized, APITokenError::Invalid)) + } + } + + None => RequestOutcome::Error((Status::Unauthorized, APITokenError::Missing)), + } } } -// Enum for server events: -enum ServerEvent { - Started, - NotFound(String), - Warning(String), - Error(String), - Stopped, +#[derive(Debug)] +enum APITokenError { + Missing, + Invalid, +} + +// +// Data structure for iterating over key-value pairs of log messages. +// +struct LogKVCollect<'kvs>(BTreeMap, Value<'kvs>>); + +impl<'kvs> VisitSource<'kvs> for LogKVCollect<'kvs> { + fn visit_pair(&mut self, key: Key<'kvs>, value: Value<'kvs>) -> Result<(), kv::Error> { + self.0.insert(key, value); + Ok(()) + } +} + +pub fn write_kv_pairs(w: &mut dyn std::io::Write, record: &log::Record) -> Result<(), std::io::Error> { + if record.key_values().count() > 0 { + let mut visitor = LogKVCollect(BTreeMap::new()); + record.key_values().visit(&mut visitor).unwrap(); + write!(w, "[")?; + let mut index = 0; + for (key, value) in visitor.0 { + index += 1; + if index > 1 { + write!(w, ", ")?; + } + + write!(w, "{} = {}", key, value)?; + } + write!(w, "] ")?; + } + + Ok(()) +} + +// Custom logger format for the terminal: +pub fn terminal_colored_logger_format( + w: &mut dyn std::io::Write, + now: &mut DeferredNow, + record: &log::Record, +) -> Result<(), std::io::Error> { + let level = record.level(); + + // Write the timestamp, log level, and module path: + write!( + w, + "[{}] {} [{}] ", + flexi_logger::style(level).paint(now.format(flexi_logger::TS_DASHES_BLANK_COLONS_DOT_BLANK).to_string()), + flexi_logger::style(level).paint(record.level().to_string()), + record.module_path().unwrap_or(""), + )?; + + // Write all key-value pairs: + write_kv_pairs(w, record)?; + + // Write the log message: + write!(w, "{}", flexi_logger::style(level).paint(record.args().to_string())) +} + +// Custom logger format for the log files: +pub fn file_logger_format( + w: &mut dyn std::io::Write, + now: &mut DeferredNow, + record: &log::Record, +) -> Result<(), std::io::Error> { + + // Write the timestamp, log level, and module path: + write!( + w, + "[{}] {} [{}] ", + now.format(flexi_logger::TS_DASHES_BLANK_COLONS_DOT_BLANK), + record.level(), + record.module_path().unwrap_or(""), + )?; + + // Write all key-value pairs: + write_kv_pairs(w, record)?; + + // Write the log message: + write!(w, "{}", &record.args()) +} + +pub struct Encryption { + key: [u8; 32], + iv: [u8; 16], + + secret_password: [u8; 512], + secret_key_salt: [u8; 16], +} + +impl Encryption { + // The number of iterations to derive the key and IV from the password. For a password + // manager where the user has to enter their primary password, 100 iterations would be + // too few and insecure. Here, the use case is different: We generate a 512-byte long + // and cryptographically secure password at every start. This password already contains + // enough entropy. In our case, we need key and IV primarily because AES, with the + // algorithms we chose, requires a fixed key length, and our password is too long. + const ITERATIONS: u32 = 100; + + pub fn new(secret_password: &[u8], secret_key_salt: &[u8]) -> Result { + if secret_password.len() != 512 { + return Err("The secret password must be 512 bytes long.".to_string()); + } + + if secret_key_salt.len() != 16 { + return Err("The salt must be 16 bytes long.".to_string()); + } + + info!(Source = "Encryption"; "Initializing encryption..."); + let mut encryption = Encryption { + key: [0u8; 32], + iv: [0u8; 16], + + secret_password: [0u8; 512], + secret_key_salt: [0u8; 16], + }; + + encryption.secret_password.copy_from_slice(secret_password); + encryption.secret_key_salt.copy_from_slice(secret_key_salt); + + let start = Instant::now(); + let mut key_iv = [0u8; 48]; + pbkdf2::>(secret_password, secret_key_salt, Self::ITERATIONS, &mut key_iv).map_err(|e| format!("Error while generating key and IV: {e}"))?; + encryption.key.copy_from_slice(&key_iv[0..32]); + encryption.iv.copy_from_slice(&key_iv[32..48]); + + let duration = start.elapsed(); + let duration = duration.as_millis(); + info!(Source = "Encryption"; "Encryption initialized in {duration} milliseconds.", ); + + Ok(encryption) + } + + pub fn encrypt(&self, data: &str) -> Result { + let cipher = Aes256CbcEnc::new(&self.key.into(), &self.iv.into()); + let encrypted = cipher.encrypt_padded_vec_mut::(data.as_bytes()); + let mut result = BASE64_STANDARD.encode(self.secret_key_salt); + result.push_str(&BASE64_STANDARD.encode(&encrypted)); + Ok(EncryptedText::new(result)) + } + + pub fn decrypt(&self, encrypted_data: &EncryptedText) -> Result { + let decoded = BASE64_STANDARD.decode(encrypted_data.get_encrypted()).map_err(|e| format!("Error decoding base64: {e}"))?; + + if decoded.len() < 16 { + return Err("Encrypted data is too short.".to_string()); + } + + let (salt, encrypted) = decoded.split_at(16); + if salt != self.secret_key_salt { + return Err("The salt bytes do not match. The data is corrupted or tampered.".to_string()); + } + + let cipher = Aes256CbcDec::new(&self.key.into(), &self.iv.into()); + let decrypted = cipher.decrypt_padded_vec_mut::(encrypted).map_err(|e| format!("Error decrypting data: {e}"))?; + + String::from_utf8(decrypted).map_err(|e| format!("Error converting decrypted data to string: {}", e)) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct EncryptedText(String); + +impl EncryptedText { + pub fn new(encrypted_data: String) -> Self { + EncryptedText(encrypted_data) + } + + pub fn get_encrypted(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for EncryptedText { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "EncryptedText(**********)") + } +} + +impl fmt::Display for EncryptedText { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "**********") + } +} + +// Use Case: When we receive encrypted text from the client as body (e.g., in a POST request). +// We must interpret the body as EncryptedText. +#[rocket::async_trait] +impl<'r> data::FromData<'r> for EncryptedText { + type Error = String; + async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> DataOutcome<'r, Self> { + let content_type = req.content_type(); + if content_type.map_or(true, |ct| !ct.is_text()) { + return DataOutcome::Forward((data, Status::Ok)); + } + + let mut stream = data.open(2.mebibytes()); + let mut body = String::new(); + if let Err(e) = stream.read_to_string(&mut body).await { + return DataOutcome::Error((Status::InternalServerError, format!("Failed to read data: {}", e))); + } + + DataOutcome::Success(EncryptedText(body)) + } +} + +#[get("/system/dotnet/port")] +fn dotnet_port(_token: APIToken) -> String { + let dotnet_server_port = *DOTNET_SERVER_PORT; + format!("{dotnet_server_port}") +} + +#[get("/system/directories/data")] +fn get_data_directory(_token: APIToken) -> String { + match DATA_DIRECTORY.get() { + Some(data_directory) => data_directory.clone(), + None => String::from(""), + } +} + +#[get("/system/directories/config")] +fn get_config_directory(_token: APIToken) -> String { + match CONFIG_DIRECTORY.get() { + Some(config_directory) => config_directory.clone(), + None => String::from(""), + } +} + +#[get("/system/dotnet/ready")] +async fn dotnet_ready(_token: APIToken) { + + // We create a manual scope for the lock to be released as soon as possible. + // This is necessary because we cannot await any function while the lock is + // held. + { + let mut initialized = DOTNET_INITIALIZED.lock().unwrap(); + if *initialized { + error!("Anyone tried to initialize the runtime twice. This is not intended."); + return; + } + + info!("The .NET server was booted successfully."); + *initialized = true; + } + + // Try to get the main window. If it is not available yet, wait for it: + let mut main_window_ready = false; + let mut main_window_status_reported = false; + let main_window_spawn_clone = &MAIN_WINDOW; + while !main_window_ready + { + main_window_ready = { + let main_window = main_window_spawn_clone.lock().unwrap(); + main_window.is_some() + }; + + if !main_window_ready { + if !main_window_status_reported { + info!("Waiting for main window to be ready, because .NET was faster than Tauri."); + main_window_status_reported = true; + } + + time::sleep(Duration::from_millis(100)).await; + } + } + + let main_window = main_window_spawn_clone.lock().unwrap(); + let dotnet_server_port = *DOTNET_SERVER_PORT; + let url = match Url::parse(format!("http://localhost:{dotnet_server_port}").as_str()) + { + Ok(url) => url, + Err(msg) => { + error!("Error while parsing URL for navigating to the app: {msg}"); + return; + } + }; + + let js_location_change = format!("window.location = '{url}';"); + let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str()); + match location_change_result { + Ok(_) => info!("The app location was changed to {url}."), + Err(e) => error!("Failed to change the app location to {url}: {e}."), + } } pub fn is_dev() -> bool { @@ -306,63 +790,61 @@ fn get_available_port() -> Option { .ok() } -fn stop_server() { - if let Some(server_process) = SERVER.lock().unwrap().take() { +fn stop_servers() { + if let Some(server_process) = DOTNET_SERVER.lock().unwrap().take() { let server_kill_result = server_process.kill(); match server_kill_result { Ok(_) => info!("The .NET server process was stopped."), Err(e) => error!("Failed to stop the .NET server process: {e}."), } } else { - warn!("The .NET server process was not started or already stopped."); + warn!("The .NET server process was not started or is already stopped."); } } -#[tauri::command] -async fn check_for_update() -> CheckUpdateResponse { +#[get("/updates/check")] +async fn check_for_update(_token: APIToken) -> Json { let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().unwrap().app_handle(); - tauri::async_runtime::spawn(async move { - let response = app_handle.updater().check().await; - match response { - Ok(update_response) => match update_response.is_update_available() { - true => { - *CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update_response.clone()); - let new_version = update_response.latest_version(); - info!("Updater: update to version '{new_version}' is available."); - let changelog = update_response.body(); - CheckUpdateResponse { - update_is_available: true, - error: false, - new_version: new_version.to_string(), - changelog: match changelog { - Some(c) => c.to_string(), - None => String::from(""), - }, - } - }, - - false => { - info!("Updater: no updates available."); - CheckUpdateResponse { - update_is_available: false, - error: false, - new_version: String::from(""), - changelog: String::from(""), - } - }, + let response = app_handle.updater().check().await; + match response { + Ok(update_response) => match update_response.is_update_available() { + true => { + *CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update_response.clone()); + let new_version = update_response.latest_version(); + info!(Source = "Updater"; "An update to version '{new_version}' is available."); + let changelog = update_response.body(); + Json(CheckUpdateResponse { + update_is_available: true, + error: false, + new_version: new_version.to_string(), + changelog: match changelog { + Some(c) => c.to_string(), + None => String::from(""), + }, + }) }, - Err(e) => { - warn!("Failed to check updater: {e}."); - CheckUpdateResponse { + false => { + info!(Source = "Updater"; "No updates are available."); + Json(CheckUpdateResponse { update_is_available: false, - error: true, + error: false, new_version: String::from(""), changelog: String::from(""), - } + }) }, - } - }).await.unwrap() + }, + + Err(e) => { + warn!(Source = "Updater"; "Failed to check for updates: {e}."); + Json(CheckUpdateResponse { + update_is_available: false, + error: true, + new_version: String::from(""), + changelog: String::from(""), + }) + }, + } } #[derive(Serialize)] @@ -373,8 +855,8 @@ struct CheckUpdateResponse { changelog: String, } -#[tauri::command] -async fn install_update() { +#[get("/updates/install")] +async fn install_update(_token: APIToken) { let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone(); match cloned_response_option { Some(update_response) => { @@ -382,106 +864,147 @@ async fn install_update() { }, None => { - error!("Update installer: no update available to install. Did you check for updates first?"); + error!(Source = "Updater"; "No update available to install. Did you check for updates first?"); }, } } -#[tauri::command] -fn store_secret(destination: String, user_name: String, secret: String) -> StoreSecretResponse { - let service = format!("mindwork-ai-studio::{}", destination); - let entry = Entry::new(service.as_str(), user_name.as_str()).unwrap(); - let result = entry.set_password(secret.as_str()); +#[post("/secrets/store", data = "")] +fn store_secret(_token: APIToken, request: Json) -> Json { + let user_name = request.user_name.as_str(); + let decrypted_text = match ENCRYPTION.decrypt(&request.secret) { + Ok(text) => text, + Err(e) => { + error!(Source = "Secret Store"; "Failed to decrypt the text: {e}."); + return Json(StoreSecretResponse { + success: false, + issue: format!("Failed to decrypt the text: {e}"), + }) + }, + }; + + let service = format!("mindwork-ai-studio::{}", request.destination); + let entry = Entry::new(service.as_str(), user_name).unwrap(); + let result = entry.set_password(decrypted_text.as_str()); match result { Ok(_) => { - info!("Secret for {service} and user {user_name} was stored successfully."); - StoreSecretResponse { + info!(Source = "Secret Store"; "Secret for {service} and user {user_name} was stored successfully."); + Json(StoreSecretResponse { success: true, issue: String::from(""), - } + }) }, Err(e) => { - error!("Failed to store secret for {service} and user {user_name}: {e}."); - StoreSecretResponse { + error!(Source = "Secret Store"; "Failed to store secret for {service} and user {user_name}: {e}."); + Json(StoreSecretResponse { success: false, issue: e.to_string(), - } + }) }, } } +#[derive(Deserialize)] +struct StoreSecret { + destination: String, + user_name: String, + secret: EncryptedText, +} + #[derive(Serialize)] struct StoreSecretResponse { success: bool, issue: String, } -#[tauri::command] -fn get_secret(destination: String, user_name: String) -> RequestedSecret { - let service = format!("mindwork-ai-studio::{}", destination); - let entry = Entry::new(service.as_str(), user_name.as_str()).unwrap(); +#[post("/secrets/get", data = "")] +fn get_secret(_token: APIToken, request: Json) -> Json { + let user_name = request.user_name.as_str(); + let service = format!("mindwork-ai-studio::{}", request.destination); + let entry = Entry::new(service.as_str(), user_name).unwrap(); let secret = entry.get_password(); match secret { Ok(s) => { - info!("Secret for {service} and user {user_name} was retrieved successfully."); - RequestedSecret { + info!(Source = "Secret Store"; "Secret for '{service}' and user '{user_name}' was retrieved successfully."); + + // Encrypt the secret: + let encrypted_secret = match ENCRYPTION.encrypt(s.as_str()) { + Ok(e) => e, + Err(e) => { + error!(Source = "Secret Store"; "Failed to encrypt the secret: {e}."); + return Json(RequestedSecret { + success: false, + secret: EncryptedText::new(String::from("")), + issue: format!("Failed to encrypt the secret: {e}"), + }); + }, + }; + + Json(RequestedSecret { success: true, - secret: s, + secret: encrypted_secret, issue: String::from(""), - } + }) }, Err(e) => { - error!("Failed to retrieve secret for {service} and user {user_name}: {e}."); - RequestedSecret { + error!(Source = "Secret Store"; "Failed to retrieve secret for '{service}' and user '{user_name}': {e}."); + Json(RequestedSecret { success: false, - secret: String::from(""), - issue: e.to_string(), - } + secret: EncryptedText::new(String::from("")), + issue: format!("Failed to retrieve secret for '{service}' and user '{user_name}': {e}"), + }) }, } } +#[derive(Deserialize)] +struct RequestSecret { + destination: String, + user_name: String, +} + #[derive(Serialize)] struct RequestedSecret { success: bool, - secret: String, + secret: EncryptedText, issue: String, } -#[tauri::command] -fn delete_secret(destination: String, user_name: String) -> DeleteSecretResponse { - let service = format!("mindwork-ai-studio::{}", destination); - let entry = Entry::new(service.as_str(), user_name.as_str()).unwrap(); +#[post("/secrets/delete", data = "")] +fn delete_secret(_token: APIToken, request: Json) -> Json { + let user_name = request.user_name.as_str(); + let service = format!("mindwork-ai-studio::{}", request.destination); + let entry = Entry::new(service.as_str(), user_name).unwrap(); let result = entry.delete_credential(); match result { Ok(_) => { - warn!("Secret for {service} and user {user_name} was deleted successfully."); - DeleteSecretResponse { + warn!(Source = "Secret Store"; "Secret for {service} and user {user_name} was deleted successfully."); + Json(DeleteSecretResponse { success: true, was_entry_found: true, issue: String::from(""), - } + }) }, Err(NoEntry) => { - warn!("No secret for {service} and user {user_name} was found."); - DeleteSecretResponse { + warn!(Source = "Secret Store"; "No secret for {service} and user {user_name} was found."); + Json(DeleteSecretResponse { success: true, was_entry_found: false, issue: String::from(""), - } + }) } Err(e) => { - error!("Failed to delete secret for {service} and user {user_name}: {e}."); - DeleteSecretResponse { + error!(Source = "Secret Store"; "Failed to delete secret for {service} and user {user_name}: {e}."); + Json(DeleteSecretResponse { success: false, was_entry_found: false, issue: e.to_string(), - } + }) }, } } @@ -493,36 +1016,49 @@ struct DeleteSecretResponse { issue: String, } -#[tauri::command] -fn set_clipboard(text: String) -> SetClipboardResponse { +#[post("/clipboard/set", data = "")] +fn set_clipboard(_token: APIToken, encrypted_text: EncryptedText) -> Json { + + // Decrypt this text first: + let decrypted_text = match ENCRYPTION.decrypt(&encrypted_text) { + Ok(text) => text, + Err(e) => { + error!(Source = "Clipboard"; "Failed to decrypt the text: {e}."); + return Json(SetClipboardResponse { + success: false, + issue: e, + }) + }, + }; + let clipboard_result = Clipboard::new(); let mut clipboard = match clipboard_result { Ok(clipboard) => clipboard, Err(e) => { - error!("Failed to get the clipboard instance: {e}."); - return SetClipboardResponse { + error!(Source = "Clipboard"; "Failed to get the clipboard instance: {e}."); + return Json(SetClipboardResponse { success: false, issue: e.to_string(), - } + }) }, }; - let set_text_result = clipboard.set_text(text); + let set_text_result = clipboard.set_text(decrypted_text); match set_text_result { Ok(_) => { - debug!("Text was set to the clipboard successfully."); - SetClipboardResponse { + debug!(Source = "Clipboard"; "Text was set to the clipboard successfully."); + Json(SetClipboardResponse { success: true, issue: String::from(""), - } + }) }, Err(e) => { - error!("Failed to set text to the clipboard: {e}."); - SetClipboardResponse { + error!(Source = "Clipboard"; "Failed to set text to the clipboard: {e}."); + Json(SetClipboardResponse { success: false, issue: e.to_string(), - } + }) }, } } diff --git a/runtime/tauri.conf.json b/runtime/tauri.conf.json index aab42db5..8d2bf732 100644 --- a/runtime/tauri.conf.json +++ b/runtime/tauri.conf.json @@ -1,12 +1,12 @@ { "build": { - "devPath": "http://localhost:5000", + "devPath": "ui/", "distDir": "ui/", - "withGlobalTauri": true + "withGlobalTauri": false }, "package": { "productName": "MindWork AI Studio", - "version": "0.8.12" + "version": "0.9.0" }, "tauri": { "allowlist": { @@ -22,16 +22,6 @@ "args": true } ] - }, - "path": { - "all": true - }, - "http" : { - "all": true, - "request": true, - "scope": [ - "http://localhost" - ] } }, "windows": [ @@ -43,16 +33,6 @@ "height": 1080 } ], - "security": { - "csp": null, - "dangerousRemoteDomainIpcAccess": [ - { - "domain": "localhost", - "windows": ["main"], - "enableTauriAPI": true - } - ] - }, "bundle": { "active": true, "targets": "all",