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"
},