diff --git a/app/MindWork AI Studio.sln.DotSettings b/app/MindWork AI Studio.sln.DotSettings index 550cf7cb..ac41b88e 100644 --- a/app/MindWork AI Studio.sln.DotSettings +++ b/app/MindWork AI Studio.sln.DotSettings @@ -1,2 +1,4 @@  - AI \ No newline at end of file + AI + MSG + True \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Blocks/Changelog.Logs.cs b/app/MindWork AI Studio/Components/Blocks/Changelog.Logs.cs index f6430f2d..664b8367 100644 --- a/app/MindWork AI Studio/Components/Blocks/Changelog.Logs.cs +++ b/app/MindWork AI Studio/Components/Blocks/Changelog.Logs.cs @@ -13,8 +13,9 @@ public partial class Changelog public static readonly Log[] LOGS = [ + new (156, "v0.6.0, build 156 (2024-06-30 12:49 UTC)", "v0.6.0.md"), + new (155, "v0.5.2, build 155 (2024-06-25 18:07 UTC)", "v0.5.2.md"), new (154, "v0.5.1, build 154 (2024-06-25 15:35 UTC)", "v0.5.1.md"), - new (154, "v0.5.2, build 154 (2024-06-25 15:35 UTC)", "v0.5.2.md"), new (149, "v0.5.0, build 149 (2024-06-02 18:51 UTC)", "v0.5.0.md"), new (138, "v0.4.0, build 138 (2024-05-26 13:26 UTC)", "v0.4.0.md"), new (120, "v0.3.0, build 120 (2024-05-18 21:57 UTC)", "v0.3.0.md"), diff --git a/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor b/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor index fbd9f58f..24c2ab78 100644 --- a/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor +++ b/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor @@ -1,3 +1,5 @@ +@inherits AIStudio.Tools.MSGComponentBase +
@this.ChildContent diff --git a/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs b/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs index f9d7ed7c..4dee88c7 100644 --- a/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs +++ b/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs @@ -1,12 +1,15 @@ +using AIStudio.Components.Layout; +using AIStudio.Tools; + using Microsoft.AspNetCore.Components; namespace AIStudio.Components.Blocks; -public partial class InnerScrolling : ComponentBase +public partial class InnerScrolling : MSGComponentBase { /// /// Set the height of anything above the scrolling content; usually a header. - /// What we do is calc(100vh - THIS). Means, you can use multiple measures like + /// What we do is calc(100vh - HeaderHeight). Means, you can use multiple measures like /// 230px - 3em. Default is 3em. /// [Parameter] @@ -21,5 +24,34 @@ public partial class InnerScrolling : ComponentBase [Parameter] public RenderFragment? FooterContent { get; set; } - private string Height => $"height: calc(100vh - {this.HeaderHeight});"; + [CascadingParameter] + private MainLayout MainLayout { get; set; } = null!; + + #region Overrides of ComponentBase + + protected override async Task OnInitializedAsync() + { + this.ApplyFilters([], [ Event.STATE_HAS_CHANGED ]); + await base.OnInitializedAsync(); + } + + #endregion + + #region Overrides of MSGComponentBase + + public override Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default + { + switch (triggeredEvent) + { + case Event.STATE_HAS_CHANGED: + this.StateHasChanged(); + break; + } + + return Task.CompletedTask; + } + + #endregion + + private string Height => $"height: calc(100vh - {this.HeaderHeight} - {this.MainLayout.AdditionalHeight});"; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/CommonDialogs/DialogOptions.cs b/app/MindWork AI Studio/Components/CommonDialogs/DialogOptions.cs new file mode 100644 index 00000000..02a0e8c5 --- /dev/null +++ b/app/MindWork AI Studio/Components/CommonDialogs/DialogOptions.cs @@ -0,0 +1,17 @@ +namespace AIStudio.Components.CommonDialogs; + +public static class DialogOptions +{ + public static readonly MudBlazor.DialogOptions FULLSCREEN = new() + { + CloseOnEscapeKey = true, + FullWidth = true, MaxWidth = MaxWidth.Medium, + }; + + public static readonly MudBlazor.DialogOptions FULLSCREEN_NO_HEADER = new() + { + NoHeader = true, + CloseOnEscapeKey = true, + FullWidth = true, MaxWidth = MaxWidth.Medium, + }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor b/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor new file mode 100644 index 00000000..80544496 --- /dev/null +++ b/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor @@ -0,0 +1,14 @@ +@using AIStudio.Tools + + + + + Update from v@(META_DATA.Version) to v@(this.UpdateResponse.NewVersion) + + + + + Install later + Install now + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor.cs b/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor.cs new file mode 100644 index 00000000..340788d4 --- /dev/null +++ b/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor.cs @@ -0,0 +1,26 @@ +using System.Reflection; + +using AIStudio.Tools; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components.CommonDialogs; + +/// +/// The update dialog that is used to inform the user about an available update. +/// +public partial class UpdateDialog : ComponentBase +{ + private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly(); + private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute()!; + + [CascadingParameter] + private MudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public UpdateResponse UpdateResponse { get; set; } + + private void Cancel() => this.MudDialog.Cancel(); + + private void Confirm() => this.MudDialog.Close(DialogResult.Ok(true)); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ConfigurationSelectData.cs b/app/MindWork AI Studio/Components/ConfigurationSelectData.cs index 1fb9a573..1e77a1d4 100644 --- a/app/MindWork AI Studio/Components/ConfigurationSelectData.cs +++ b/app/MindWork AI Studio/Components/ConfigurationSelectData.cs @@ -21,4 +21,13 @@ public static class ConfigurationSelectDataFactory yield return new("Modifier key + enter is sending the input", SendBehavior.MODIFER_ENTER_IS_SENDING); yield return new("Enter is sending the input", SendBehavior.ENTER_IS_SENDING); } + + public static IEnumerable> GetUpdateBehaviorData() + { + yield return new("No automatic update checks", UpdateBehavior.NO_CHECK); + yield return new("Once at startup", UpdateBehavior.ONCE_STARTUP); + yield return new("Check every hour", UpdateBehavior.HOURLY); + yield return new("Check every day", UpdateBehavior.DAILY); + yield return new ("Check every week", UpdateBehavior.WEEKLY); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Layout/MainLayout.razor b/app/MindWork AI Studio/Components/Layout/MainLayout.razor index 8a0b6a42..8843c671 100644 --- a/app/MindWork AI Studio/Components/Layout/MainLayout.razor +++ b/app/MindWork AI Studio/Components/Layout/MainLayout.razor @@ -2,31 +2,57 @@ - - - - - Home - - - Chats - - - Supporters - - - About - - - Settings - - - - + @if (!this.performingUpdate) + { + + + + + Home + + + Chats + + + Supporters + + + About + + + Settings + + + + + } - @this.Body + @if (!this.performingUpdate && this.IsUpdateAlertVisible) + { + +
+ + An update to version @this.updateToVersion is available. + + Show details + +
+
+ } + + @if (!this.performingUpdate) + { + + @this.Body + + } + + + Please wait for the update to complete... + +
diff --git a/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs index de25ecd7..609f8b95 100644 --- a/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs @@ -1,12 +1,37 @@ +using AIStudio.Components.CommonDialogs; using AIStudio.Settings; +using AIStudio.Tools; + using Microsoft.AspNetCore.Components; +using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions; + namespace AIStudio.Components.Layout; -public partial class MainLayout : LayoutComponentBase +public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver { [Inject] - private IJSRuntime JsRuntime { get; set; } = null!; + private IJSRuntime JsRuntime { get; init; } = null!; + + [Inject] + private SettingsManager SettingsManager { get; init; } = null!; + + [Inject] + private MessageBus MessageBus { get; init; } = null!; + + [Inject] + public IDialogService DialogService { get; init; } = null!; + + [Inject] + public Rust Rust { get; init; } = null!; + + public string AdditionalHeight { get; private set; } = "0em"; + + private bool isUpdateAvailable; + private bool performingUpdate; + private bool userDismissedUpdate; + private string updateToVersion = string.Empty; + private UpdateResponse? currentUpdateResponse; #region Overrides of ComponentBase @@ -23,8 +48,98 @@ public partial class MainLayout : LayoutComponentBase SettingsManager.ConfigDirectory = configDir; SettingsManager.DataDirectory = dataDir; + // Ensure that all settings are loaded: + await this.SettingsManager.LoadSettings(); + + // Register this component with the message bus: + this.MessageBus.RegisterComponent(this); + this.MessageBus.ApplyFilters(this, [], [ Event.UPDATE_AVAILABLE, Event.USER_SEARCH_FOR_UPDATE ]); + + // Set the js runtime for the update service: + UpdateService.SetJsRuntime(this.JsRuntime); + await base.OnInitializedAsync(); } #endregion + + #region Implementation of IMessageBusReceiver + + public async Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) + { + switch (triggeredEvent) + { + case Event.USER_SEARCH_FOR_UPDATE: + this.userDismissedUpdate = false; + break; + + case Event.UPDATE_AVAILABLE: + if (data is UpdateResponse updateResponse) + { + this.currentUpdateResponse = updateResponse; + this.isUpdateAvailable = updateResponse.UpdateIsAvailable; + this.updateToVersion = updateResponse.NewVersion; + + await this.InvokeAsync(this.StateHasChanged); + await this.SendMessage(Event.STATE_HAS_CHANGED); + } + + break; + } + } + + #endregion + + private async Task DismissUpdate() + { + this.userDismissedUpdate = true; + this.AdditionalHeight = "0em"; + + await this.SendMessage(Event.STATE_HAS_CHANGED); + } + + private bool IsUpdateAlertVisible + { + get + { + var state = this.isUpdateAvailable && !this.userDismissedUpdate; + this.AdditionalHeight = state ? "3em" : "0em"; + + return state; + } + } + + private async Task ShowUpdateDialog() + { + if(this.currentUpdateResponse is null) + return; + + // + // Replace the fir line with `# Changelog`: + // + var changelog = this.currentUpdateResponse.Value.Changelog; + if (!string.IsNullOrWhiteSpace(changelog)) + { + var lines = changelog.Split('\n'); + if (lines.Length > 0) + lines[0] = "# Changelog"; + + changelog = string.Join('\n', lines); + } + + var updatedResponse = this.currentUpdateResponse.Value with { Changelog = changelog }; + var dialogParameters = new DialogParameters + { + { x => x.UpdateResponse, updatedResponse } + }; + + var dialogReference = await this.DialogService.ShowAsync("Update", dialogParameters, DialogOptions.FULLSCREEN_NO_HEADER); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + + this.performingUpdate = true; + this.StateHasChanged(); + await this.Rust.InstallUpdate(this.JsRuntime); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Pages/About.razor b/app/MindWork AI Studio/Components/Pages/About.razor index fbd31df5..a5cef1ae 100644 --- a/app/MindWork AI Studio/Components/Pages/About.razor +++ b/app/MindWork AI Studio/Components/Pages/About.razor @@ -18,6 +18,9 @@ + + Check for updates + diff --git a/app/MindWork AI Studio/Components/Pages/About.razor.cs b/app/MindWork AI Studio/Components/Pages/About.razor.cs index d1bfc3c5..e9f74fea 100644 --- a/app/MindWork AI Studio/Components/Pages/About.razor.cs +++ b/app/MindWork AI Studio/Components/Pages/About.razor.cs @@ -1,11 +1,16 @@ using System.Reflection; +using AIStudio.Tools; + using Microsoft.AspNetCore.Components; namespace AIStudio.Components.Pages; public partial class About : ComponentBase { + [Inject] + private MessageBus MessageBus { get; init; } = null!; + private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly(); private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute()!; @@ -135,4 +140,9 @@ public partial class About : ComponentBase OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """; + + private async Task CheckForUpdate() + { + await this.MessageBus.SendMessage(this, Event.USER_SEARCH_FOR_UPDATE); + } } diff --git a/app/MindWork AI Studio/Components/Pages/Chat.razor.cs b/app/MindWork AI Studio/Components/Pages/Chat.razor.cs index 738b4353..53c22aa0 100644 --- a/app/MindWork AI Studio/Components/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Components/Pages/Chat.razor.cs @@ -37,9 +37,6 @@ public partial class Chat : ComponentBase protected override async Task OnInitializedAsync() { - // Ensure that the settings are loaded: - await this.SettingsManager.LoadSettings(); - // Configure the spellchecking for the user input: this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES); diff --git a/app/MindWork AI Studio/Components/Pages/Settings.razor b/app/MindWork AI Studio/Components/Pages/Settings.razor index 667f4d49..fdc51e4b 100644 --- a/app/MindWork AI Studio/Components/Pages/Settings.razor +++ b/app/MindWork AI Studio/Components/Pages/Settings.razor @@ -52,5 +52,6 @@ +
\ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Pages/Settings.razor.cs b/app/MindWork AI Studio/Components/Pages/Settings.razor.cs index ee23e07f..13b06844 100644 --- a/app/MindWork AI Studio/Components/Pages/Settings.razor.cs +++ b/app/MindWork AI Studio/Components/Pages/Settings.razor.cs @@ -3,6 +3,8 @@ using AIStudio.Provider; using AIStudio.Settings; using Microsoft.AspNetCore.Components; +using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions; + // ReSharper disable ClassNeverInstantiated.Global namespace AIStudio.Components.Pages; @@ -18,22 +20,6 @@ public partial class Settings : ComponentBase [Inject] public IJSRuntime JsRuntime { get; init; } = null!; - private static readonly DialogOptions DIALOG_OPTIONS = new() - { - CloseOnEscapeKey = true, - FullWidth = true, MaxWidth = MaxWidth.Medium, - }; - - #region Overrides of ComponentBase - - protected override async Task OnInitializedAsync() - { - await this.SettingsManager.LoadSettings(); - await base.OnInitializedAsync(); - } - - #endregion - #region Provider related private async Task AddProvider() @@ -43,7 +29,7 @@ public partial class Settings : ComponentBase { x => x.IsEditing, false }, }; - var dialogReference = await this.DialogService.ShowAsync("Add Provider", dialogParameters, DIALOG_OPTIONS); + var dialogReference = await this.DialogService.ShowAsync("Add Provider", dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult.Canceled) return; @@ -67,7 +53,7 @@ public partial class Settings : ComponentBase { x => x.IsEditing, true }, }; - var dialogReference = await this.DialogService.ShowAsync("Edit Provider", dialogParameters, DIALOG_OPTIONS); + var dialogReference = await this.DialogService.ShowAsync("Edit Provider", dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult.Canceled) return; @@ -90,7 +76,7 @@ public partial class Settings : ComponentBase { "Message", $"Are you sure you want to delete the provider '{provider.InstanceName}'?" }, }; - var dialogReference = await this.DialogService.ShowAsync("Delete Provider", dialogParameters, DIALOG_OPTIONS); + var dialogReference = await this.DialogService.ShowAsync("Delete Provider", dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult.Canceled) return; diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 5d54b793..4c1ad4a2 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -25,10 +25,12 @@ builder.Services.AddMudServices(config => }); builder.Services.AddMudMarkdownServices(); +builder.Services.AddSingleton(MessageBus.INSTANCE); builder.Services.AddSingleton(); builder.Services.AddMudMarkdownClipboardService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddHubOptions(options => diff --git a/app/MindWork AI Studio/Settings/Data.cs b/app/MindWork AI Studio/Settings/Data.cs index 1c629b80..8052c271 100644 --- a/app/MindWork AI Studio/Settings/Data.cs +++ b/app/MindWork AI Studio/Settings/Data.cs @@ -6,7 +6,7 @@ namespace AIStudio.Settings; public sealed class Data { /// - /// The version of the settings file. Allows us to upgrade the settings, + /// The version of the settings file. Allows us to upgrade the settings /// when a new version is available. /// public Version Version { get; init; } = Version.V1; @@ -36,4 +36,9 @@ public sealed class Data /// Should we enable spellchecking for all input fields? /// public bool EnableSpellchecking { get; set; } + + /// + /// If and when we should look for updates. + /// + public UpdateBehavior UpdateBehavior { get; set; } = UpdateBehavior.ONCE_STARTUP; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/UpdateBehavior.cs b/app/MindWork AI Studio/Settings/UpdateBehavior.cs new file mode 100644 index 00000000..30579f76 --- /dev/null +++ b/app/MindWork AI Studio/Settings/UpdateBehavior.cs @@ -0,0 +1,10 @@ +namespace AIStudio.Settings; + +public enum UpdateBehavior +{ + NO_CHECK, + ONCE_STARTUP, + HOURLY, + DAILY, + WEEKLY, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs new file mode 100644 index 00000000..2b259f4a --- /dev/null +++ b/app/MindWork AI Studio/Tools/Event.cs @@ -0,0 +1,13 @@ +namespace AIStudio.Tools; + +public enum Event +{ + NONE, + + // Common events: + STATE_HAS_CHANGED, + + // Update events: + USER_SEARCH_FOR_UPDATE, + UPDATE_AVAILABLE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/IMessageBusReceiver.cs b/app/MindWork AI Studio/Tools/IMessageBusReceiver.cs new file mode 100644 index 00000000..401a2118 --- /dev/null +++ b/app/MindWork AI Studio/Tools/IMessageBusReceiver.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Tools; + +public interface IMessageBusReceiver +{ + public Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MSGComponentBase.cs b/app/MindWork AI Studio/Tools/MSGComponentBase.cs new file mode 100644 index 00000000..b8ccaf82 --- /dev/null +++ b/app/MindWork AI Studio/Tools/MSGComponentBase.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Tools; + +public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBusReceiver +{ + [Inject] + protected MessageBus MessageBus { get; init; } = null!; + + #region Overrides of ComponentBase + + protected override void OnInitialized() + { + this.MessageBus.RegisterComponent(this); + base.OnInitialized(); + } + + #endregion + + #region Implementation of IMessageBusReceiver + + public abstract Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data); + + #endregion + + #region Implementation of IDisposable + + public void Dispose() + { + this.MessageBus.Unregister(this); + } + + #endregion + + protected async Task SendMessage(Event triggeredEvent, T? data = default) + { + await this.MessageBus.SendMessage(this, triggeredEvent, data); + } + + protected void ApplyFilters(ComponentBase[] components, Event[] events) + { + this.MessageBus.ApplyFilters(this, components, events); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MessageBus.cs b/app/MindWork AI Studio/Tools/MessageBus.cs new file mode 100644 index 00000000..53b215c7 --- /dev/null +++ b/app/MindWork AI Studio/Tools/MessageBus.cs @@ -0,0 +1,67 @@ +using System.Collections.Concurrent; + +using Microsoft.AspNetCore.Components; +// ReSharper disable RedundantRecordClassKeyword + +namespace AIStudio.Tools; + +public sealed class MessageBus +{ + public static readonly MessageBus INSTANCE = new(); + + private readonly ConcurrentDictionary componentFilters = new(); + private readonly ConcurrentDictionary componentEvents = new(); + private readonly ConcurrentQueue messageQueue = new(); + private readonly SemaphoreSlim sendingSemaphore = new(1, 1); + + private MessageBus() + { + } + + public void ApplyFilters(IMessageBusReceiver receiver, ComponentBase[] components, Event[] events) + { + this.componentFilters[receiver] = components; + this.componentEvents[receiver] = events; + } + + public void RegisterComponent(IMessageBusReceiver receiver) + { + this.componentFilters.TryAdd(receiver, []); + this.componentEvents.TryAdd(receiver, []); + } + + public void Unregister(IMessageBusReceiver receiver) + { + this.componentFilters.TryRemove(receiver, out _); + this.componentEvents.TryRemove(receiver, out _); + } + + private record class Message(ComponentBase? SendingComponent, Event TriggeredEvent, object? Data); + + public async Task SendMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data = default) + { + this.messageQueue.Enqueue(new Message(sendingComponent, triggeredEvent, data)); + + try + { + await this.sendingSemaphore.WaitAsync(); + while (this.messageQueue.TryDequeue(out var message)) + { + foreach (var (receiver, componentFilter) in this.componentFilters) + { + if (componentFilter.Length > 0 && sendingComponent is not null && !componentFilter.Contains(sendingComponent)) + continue; + + var eventFilter = this.componentEvents[receiver]; + if (eventFilter.Length == 0 || eventFilter.Contains(triggeredEvent)) + // We don't await the task here because we don't want to block the message bus: + _ = receiver.ProcessMessage(message.SendingComponent, message.TriggeredEvent, message.Data); + } + } + } + finally + { + this.sendingSemaphore.Release(); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MessageBusExtensions.cs b/app/MindWork AI Studio/Tools/MessageBusExtensions.cs new file mode 100644 index 00000000..7956c27e --- /dev/null +++ b/app/MindWork AI Studio/Tools/MessageBusExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Tools; + +public static class MessageBusExtensions +{ + public static async Task SendMessage(this ComponentBase component, Event triggeredEvent, T? data = default) + { + await MessageBus.INSTANCE.SendMessage(component, triggeredEvent, data); + } + + public static void ApplyFilters(this IMessageBusReceiver component, ComponentBase[] components, Event[] events) + { + MessageBus.INSTANCE.ApplyFilters(component, components, events); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust.cs b/app/MindWork AI Studio/Tools/Rust.cs index 9763b39d..af85765d 100644 --- a/app/MindWork AI Studio/Tools/Rust.cs +++ b/app/MindWork AI Studio/Tools/Rust.cs @@ -39,4 +39,16 @@ public sealed class Rust }; }); } + + 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/SetClipboardResponse.cs b/app/MindWork AI Studio/Tools/SetClipboardResponse.cs index 7a65caa9..e7a8fcc3 100644 --- a/app/MindWork AI Studio/Tools/SetClipboardResponse.cs +++ b/app/MindWork AI Studio/Tools/SetClipboardResponse.cs @@ -3,6 +3,6 @@ namespace AIStudio.Tools; /// /// The response from the set clipboard operation. /// -/// True when the operation was successful. -/// The issue that occurred during the operation, empty when successful. +/// True, when the operation was successful. +/// The issues, which occurred during the operation, empty when successful. public readonly record struct SetClipboardResponse(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/UpdateResponse.cs new file mode 100644 index 00000000..5a5e3e2b --- /dev/null +++ b/app/MindWork AI Studio/Tools/UpdateResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Tools; + +/// +/// The response of the update check. +/// +/// True if an update is available. +/// 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, + string Changelog +); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/UpdateService.cs b/app/MindWork AI Studio/Tools/UpdateService.cs new file mode 100644 index 00000000..4b7143df --- /dev/null +++ b/app/MindWork AI Studio/Tools/UpdateService.cs @@ -0,0 +1,105 @@ +using AIStudio.Settings; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Tools; + +public sealed class UpdateService : BackgroundService, IMessageBusReceiver +{ + // We cannot inject IJSRuntime into our service. This is due to the fact that + // the service is not a Blaozor component. We need to pass the IJSRuntime from + // the MainLayout component to the service. + private static IJSRuntime? JS_RUNTIME; + private static bool IS_INITALIZED; + + private readonly SettingsManager settingsManager; + private readonly TimeSpan updateInterval; + private readonly MessageBus messageBus; + private readonly Rust rust; + + public UpdateService(MessageBus messageBus, SettingsManager settingsManager, Rust rust) + { + this.settingsManager = settingsManager; + this.messageBus = messageBus; + this.rust = rust; + + this.messageBus.RegisterComponent(this); + this.ApplyFilters([], [ Event.USER_SEARCH_FOR_UPDATE ]); + + this.updateInterval = settingsManager.ConfigurationData.UpdateBehavior switch + { + UpdateBehavior.NO_CHECK => Timeout.InfiniteTimeSpan, + UpdateBehavior.ONCE_STARTUP => Timeout.InfiniteTimeSpan, + + UpdateBehavior.HOURLY => TimeSpan.FromHours(1), + UpdateBehavior.DAILY => TimeSpan.FromDays(1), + UpdateBehavior.WEEKLY => TimeSpan.FromDays(7), + + _ => TimeSpan.FromHours(1) + }; + } + + #region Overrides of BackgroundService + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested && !IS_INITALIZED) + { + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + } + + await this.settingsManager.LoadSettings(); + if(this.settingsManager.ConfigurationData.UpdateBehavior != UpdateBehavior.NO_CHECK) + await this.CheckForUpdate(); + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(this.updateInterval, stoppingToken); + await this.CheckForUpdate(); + } + } + + #endregion + + #region Implementation of IMessageBusReceiver + + public async Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) + { + switch (triggeredEvent) + { + case Event.USER_SEARCH_FOR_UPDATE: + await this.CheckForUpdate(); + break; + } + } + + #endregion + + #region Overrides of BackgroundService + + public override async Task StopAsync(CancellationToken cancellationToken) + { + this.messageBus.Unregister(this); + await base.StopAsync(cancellationToken); + } + + #endregion + + private async Task CheckForUpdate() + { + if(!IS_INITALIZED) + return; + + var response = await this.rust.CheckForUpdate(JS_RUNTIME!); + if (response.UpdateIsAvailable) + { + await this.messageBus.SendMessage(null, Event.UPDATE_AVAILABLE, response); + } + } + + public static void SetJsRuntime(IJSRuntime jsRuntime) + { + JS_RUNTIME = jsRuntime; + IS_INITALIZED = true; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v0.6.0.md b/app/MindWork AI Studio/wwwroot/changelog/v0.6.0.md new file mode 100644 index 00000000..ba2a6e1b --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/changelog/v0.6.0.md @@ -0,0 +1,8 @@ +# v0.6.0, build 156 (2024-06-30 12:49 UTC) +- Added a setting to determine whether and how often to check for updates +- Added a bidirectional message bus for inter-component communication +- Added an update dialog for the app +- Added an update banner to show when a new version is available +- Added an option to manually check for updates to the about page +- Fixed an issue with previous automatic updates on Windows, where background processes were not terminated +- Disabled Tauri's built-in update dialog \ No newline at end of file diff --git a/metadata.txt b/metadata.txt index 3f294abf..3a6cb4b2 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,9 +1,9 @@ -0.5.2 -2024-06-25 18:07:06 UTC -155 +0.6.0 +2024-06-30 12:49:38 UTC +156 8.0.206 (commit bb12410699) 8.0.6 (commit 3b8b000a0e) 1.79.0 (commit 129f3b996) 6.20.0 1.6.1 -2818aa93411, release +dab121a7217, release diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 3d46acbe..afe5286c 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -2313,12 +2313,13 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mindwork-ai-studio" -version = "0.5.2" +version = "0.6.0" dependencies = [ "arboard", "flexi_logger", "keyring", "log", + "once_cell", "reqwest 0.12.4", "serde", "serde_json", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index e2bb8483..44b47690 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mindwork-ai-studio" -version = "0.5.2" +version = "0.6.0" edition = "2021" description = "MindWork AI Studio" authors = ["Thorsten Sommer"] @@ -18,6 +18,7 @@ arboard = "3.4.0" tokio = "1.37.0" flexi_logger = "0.28" log = "0.4" +once_cell = "1.19.0" [target.'cfg(target_os = "linux")'.dependencies] # See issue https://github.com/tauri-apps/tauri/issues/4470 diff --git a/runtime/src/main.rs b/runtime/src/main.rs index 0c63bc7a..57d9a4e7 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -5,6 +5,7 @@ extern crate core; use std::net::TcpListener; use std::sync::{Arc, Mutex}; +use once_cell::sync::Lazy; use arboard::Clipboard; use keyring::Entry; @@ -15,6 +16,11 @@ use tauri::utils::config::AppUrl; use tokio::time; use flexi_logger::{AdaptiveFormat, Logger}; use log::{debug, error, info, warn}; +use tauri::updater::UpdateResponse; + +static SERVER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(None))); +static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); +static CHECK_UPDATE_RESPONSE: Lazy>>> = Lazy::new(|| Mutex::new(None)); fn main() { @@ -30,7 +36,14 @@ fn main() { let tauri_version = metadata_lines.next().unwrap(); let app_commit_hash = metadata_lines.next().unwrap(); - Logger::try_with_str("debug").expect("Cannot create logging") + // 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", + }; + + Logger::try_with_str(log_level).expect("Cannot create logging") .log_to_stdout() .adaptive_format_for_stdout(AdaptiveFormat::Detailed) .start().expect("Cannot start logging"); @@ -69,8 +82,7 @@ fn main() { info!("Try to start the .NET server on {app_url_log}..."); // Arc for the server process to stop it later: - let server: Arc>> = Arc::new(Mutex::new(None)); - let server_spawn_clone = server.clone(); + let server_spawn_clone = SERVER.clone(); // Channel to communicate with the server process: let (sender, mut receiver) = tauri::async_runtime::channel(100); @@ -114,9 +126,8 @@ fn main() { warn!("Running in development mode, no .NET server will be started."); } - let main_window: Arc>> = Arc::new(Mutex::new(None)); - let main_window_spawn_clone = main_window.clone(); - let server_receive_clone = server.clone(); + let main_window_spawn_clone = &MAIN_WINDOW; + let server_receive_clone = SERVER.clone(); // Create a thread to handle server events: tauri::async_runtime::spawn(async move { @@ -181,29 +192,93 @@ fn main() { }); info!("Starting Tauri app..."); - tauri::Builder::default() + let app = tauri::Builder::default() .setup(move |app| { let window = app.get_window("main").expect("Failed to get main window."); - *main_window.lock().unwrap() = Some(window); + *MAIN_WINDOW.lock().unwrap() = Some(window); Ok(()) }) .plugin(tauri_plugin_window_state::Builder::default().build()) - .invoke_handler(tauri::generate_handler![store_secret, get_secret, delete_secret, set_clipboard]) - .run(tauri::generate_context!()) + .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"); + + app.run(|app_handle, event| match event { + + tauri::RunEvent::WindowEvent { event, label, .. } => { + match event { + tauri::WindowEvent::CloseRequested { .. } => { + warn!("Window '{label}': close was requested."); + } + + tauri::WindowEvent::Destroyed => { + warn!("Window '{label}': was destroyed."); + } + + tauri::WindowEvent::FileDrop(files) => { + info!("Window '{label}': files were dropped: {files:?}"); + } + + _ => (), + } + } + + tauri::RunEvent::Updater(updater_event) => { + match updater_event { + + tauri::UpdaterEvent::UpdateAvailable { body, date, version } => { + let body_len = body.len(); + info!("Updater: update available: body size={body_len} time={date:?} version={version}"); + } + + tauri::UpdaterEvent::Pending => { + info!("Updater: update is pending!"); + } + + tauri::UpdaterEvent::DownloadProgress { chunk_length, content_length } => { + info!("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(); + } + + tauri::UpdaterEvent::Updated => { + info!("Updater: app has been updated"); + warn!("Try to restart the app now..."); + app_handle.restart(); + } + + tauri::UpdaterEvent::AlreadyUpToDate => { + info!("Updater: app is already up to date"); + } + + tauri::UpdaterEvent::Error(error) => { + warn!("Updater: failed to update: {error}"); + } + } + } + + tauri::RunEvent::ExitRequested { .. } => { + warn!("Run event: exit was requested."); + } + + tauri::RunEvent::Ready => { + info!("Run event: Tauri app is ready."); + } + + _ => {} + }); info!("Tauri app was stopped."); if is_prod() { info!("Try to stop the .NET server as well..."); - if let Some(server_process) = 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."); - } + stop_server(); } } @@ -230,6 +305,87 @@ fn get_available_port() -> Option { .ok() } +fn stop_server() { + if let Some(server_process) = 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."); + } +} + +#[tauri::command] +async fn check_for_update() -> CheckUpdateResponse { + 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(""), + } + }, + }, + + Err(e) => { + warn!("Failed to check updater: {e}."); + CheckUpdateResponse { + update_is_available: false, + error: true, + new_version: String::from(""), + changelog: String::from(""), + } + }, + } + }).await.unwrap() +} + +#[derive(Serialize)] +struct CheckUpdateResponse { + update_is_available: bool, + error: bool, + new_version: String, + changelog: String, +} + +#[tauri::command] +async fn install_update() { + let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone(); + match cloned_response_option { + Some(update_response) => { + update_response.download_and_install().await.unwrap(); + }, + + None => { + error!("Update installer: 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); diff --git a/runtime/tauri.conf.json b/runtime/tauri.conf.json index 1c7b2212..9a841f09 100644 --- a/runtime/tauri.conf.json +++ b/runtime/tauri.conf.json @@ -6,7 +6,7 @@ }, "package": { "productName": "MindWork AI Studio", - "version": "0.5.2" + "version": "0.6.0" }, "tauri": { "allowlist": { @@ -76,7 +76,7 @@ "endpoints": [ "https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json" ], - "dialog": true, + "dialog": false, "windows": { "installMode": "passive" },