From 508c7f9701a3e340d2990441a7505a0c918c7bc0 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Fri, 3 Jul 2026 15:02:20 +0200 Subject: [PATCH 1/9] Allow assistants to run in the background (#828) --- .../Agenda/AssistantAgenda.razor.cs | 80 ++++ .../Assistants/AssistantBase.razor | 14 +- .../Assistants/AssistantBase.razor.cs | 409 ++++++++++++++++-- .../Assistants/AssistantLowerBase.cs | 24 + .../BiasDay/BiasOfTheDayAssistant.razor.cs | 23 +- .../Coding/AssistantCoding.razor.cs | 23 + .../DocumentAnalysisAssistant.razor.cs | 52 ++- .../Dynamic/AssistantDynamic.razor.cs | 61 +++ .../Assistants/EMail/AssistantEMail.razor.cs | 41 ++ .../Assistants/ERI/AssistantERI.razor | 62 +-- .../Assistants/ERI/AssistantERI.razor.cs | 190 +++++++- .../AssistantGrammarSpelling.razor.cs | 32 +- .../Assistants/I18N/AssistantI18N.razor | 10 +- .../Assistants/I18N/AssistantI18N.razor.cs | 194 ++++++--- .../Assistants/I18N/allTexts.lua | 21 + .../IconFinder/AssistantIconFinder.razor.cs | 17 + .../JobPosting/AssistantJobPostings.razor.cs | 44 ++ .../LegalCheck/AssistantLegalCheck.razor.cs | 26 ++ .../MyTasks/AssistantMyTasks.razor.cs | 20 + .../AssistantPromptOptimizer.razor.cs | 62 +++ .../AssistantRewriteImprove.razor.cs | 38 +- .../SlideBuilder/SlideAssistant.razor.cs | 53 +++ .../Synonym/AssistantSynonyms.razor.cs | 23 + .../AssistantTextSummarizer.razor.cs | 38 ++ .../Translation/AssistantTranslation.razor.cs | 35 ++ .../Components/AssistantBlock.razor | 12 +- .../Components/AssistantBlock.razor.cs | 63 ++- .../Components/EnumSelection.razor | 4 +- .../Components/EnumSelection.razor.cs | 6 + .../Components/ProviderSelection.razor | 2 +- .../Components/ProviderSelection.razor.cs | 6 + .../Layout/MainLayout.razor.cs | 30 +- app/MindWork AI Studio/Layout/NavBarItem.cs | 5 + app/MindWork AI Studio/Pages/Assistants.razor | 1 + .../plugin.lua | 21 + .../plugin.lua | 21 + app/MindWork AI Studio/Program.cs | 2 + .../AssistantSessions/AssistantSessionKey.cs | 34 ++ .../AssistantSessionService.cs | 365 ++++++++++++++++ .../AssistantSessionSnapshot.cs | 68 +++ .../AssistantSessionSnapshotField.cs | 45 ++ .../AssistantSessionStateKey.cs | 28 ++ .../AssistantSessionStateReader.cs | 111 +++++ .../AssistantSessionStateWriter.cs | 78 ++++ .../AssistantSessionStatus.cs | 37 ++ .../IAssistantSessionSnapshotField.cs | 20 + app/MindWork AI Studio/Tools/Event.cs | 10 + .../Tools/MudThemeExtensions.cs | 10 + .../Assistants/DataModel/AssistantState.cs | 43 ++ app/MindWork AI Studio/wwwroot/app.js | 12 + .../wwwroot/changelog/v26.6.3.md | 3 + .../UsageAnalyzers/ThisUsageAnalyzer.cs | 3 + 52 files changed, 2464 insertions(+), 168 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionKey.cs create mode 100644 app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionService.cs create mode 100644 app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshot.cs create mode 100644 app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshotField.cs create mode 100644 app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateKey.cs create mode 100644 app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateReader.cs create mode 100644 app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateWriter.cs create mode 100644 app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStatus.cs create mode 100644 app/MindWork AI Studio/Tools/AssistantSessions/IAssistantSessionSnapshotField.cs diff --git a/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs b/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs index c6513cc2..b31bd188 100644 --- a/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs +++ b/app/MindWork AI Studio/Assistants/Agenda/AssistantAgenda.razor.cs @@ -1,6 +1,7 @@ using System.Text; using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.Agenda; @@ -185,6 +186,85 @@ public partial class AssistantAgenda : AssistantBaseCore private string inputWhoIsPresenting = string.Empty; private readonly List contentLines = []; + private static readonly AssistantSessionStateKey INPUT_TOPIC_STATE_KEY = new(nameof(inputTopic)); + private static readonly AssistantSessionStateKey INPUT_NAME_STATE_KEY = new(nameof(inputName)); + private static readonly AssistantSessionStateKey INPUT_CONTENT_STATE_KEY = new(nameof(inputContent)); + private static readonly AssistantSessionStateKey INPUT_DURATION_STATE_KEY = new(nameof(inputDuration)); + private static readonly AssistantSessionStateKey INPUT_START_TIME_STATE_KEY = new(nameof(inputStartTime)); + private static readonly AssistantSessionStateKey> SELECTED_FOCI_STATE_KEY = new(nameof(selectedFoci)); + private static readonly AssistantSessionStateKey> JUST_BRIEFLY_STATE_KEY = new(nameof(justBriefly)); + private static readonly AssistantSessionStateKey INPUT_OBJECTIVE_STATE_KEY = new(nameof(inputObjective)); + private static readonly AssistantSessionStateKey INPUT_MODERATOR_STATE_KEY = new(nameof(inputModerator)); + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + private static readonly AssistantSessionStateKey INTRODUCE_PARTICIPANTS_STATE_KEY = new(nameof(introduceParticipants)); + private static readonly AssistantSessionStateKey IS_MEETING_VIRTUAL_STATE_KEY = new(nameof(isMeetingVirtual)); + private static readonly AssistantSessionStateKey INPUT_LOCATION_STATE_KEY = new(nameof(inputLocation)); + private static readonly AssistantSessionStateKey GOING_TO_DINNER_STATE_KEY = new(nameof(goingToDinner)); + private static readonly AssistantSessionStateKey DOING_SOCIAL_ACTIVITY_STATE_KEY = new(nameof(doingSocialActivity)); + private static readonly AssistantSessionStateKey NEED_TO_ARRIVE_AND_DEPART_STATE_KEY = new(nameof(needToArriveAndDepart)); + private static readonly AssistantSessionStateKey DURATION_LUNCH_BREAK_STATE_KEY = new(nameof(durationLunchBreak)); + private static readonly AssistantSessionStateKey DURATION_BREAKS_STATE_KEY = new(nameof(durationBreaks)); + private static readonly AssistantSessionStateKey ACTIVE_PARTICIPATION_STATE_KEY = new(nameof(activeParticipation)); + private static readonly AssistantSessionStateKey NUMBER_PARTICIPANTS_STATE_KEY = new(nameof(numberParticipants)); + private static readonly AssistantSessionStateKey INPUT_WHO_IS_PRESENTING_STATE_KEY = new(nameof(inputWhoIsPresenting)); + private static readonly AssistantSessionStateKey> CONTENT_LINES_STATE_KEY = new(nameof(contentLines)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(INPUT_TOPIC_STATE_KEY, this.inputTopic); + state.Set(INPUT_NAME_STATE_KEY, this.inputName); + state.Set(INPUT_CONTENT_STATE_KEY, this.inputContent); + state.Set(INPUT_DURATION_STATE_KEY, this.inputDuration); + state.Set(INPUT_START_TIME_STATE_KEY, this.inputStartTime); + state.SetHashSet(SELECTED_FOCI_STATE_KEY, this.selectedFoci); + state.SetHashSet(JUST_BRIEFLY_STATE_KEY, this.justBriefly); + state.Set(INPUT_OBJECTIVE_STATE_KEY, this.inputObjective); + state.Set(INPUT_MODERATOR_STATE_KEY, this.inputModerator); + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + state.Set(INTRODUCE_PARTICIPANTS_STATE_KEY, this.introduceParticipants); + state.Set(IS_MEETING_VIRTUAL_STATE_KEY, this.isMeetingVirtual); + state.Set(INPUT_LOCATION_STATE_KEY, this.inputLocation); + state.Set(GOING_TO_DINNER_STATE_KEY, this.goingToDinner); + state.Set(DOING_SOCIAL_ACTIVITY_STATE_KEY, this.doingSocialActivity); + state.Set(NEED_TO_ARRIVE_AND_DEPART_STATE_KEY, this.needToArriveAndDepart); + state.Set(DURATION_LUNCH_BREAK_STATE_KEY, this.durationLunchBreak); + state.Set(DURATION_BREAKS_STATE_KEY, this.durationBreaks); + state.Set(ACTIVE_PARTICIPATION_STATE_KEY, this.activeParticipation); + state.Set(NUMBER_PARTICIPANTS_STATE_KEY, this.numberParticipants); + state.Set(INPUT_WHO_IS_PRESENTING_STATE_KEY, this.inputWhoIsPresenting); + state.SetList(CONTENT_LINES_STATE_KEY, this.contentLines); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(INPUT_TOPIC_STATE_KEY, value => this.inputTopic = value); + state.Restore(INPUT_NAME_STATE_KEY, value => this.inputName = value); + state.Restore(INPUT_CONTENT_STATE_KEY, value => this.inputContent = value); + state.Restore(INPUT_DURATION_STATE_KEY, value => this.inputDuration = value); + state.Restore(INPUT_START_TIME_STATE_KEY, value => this.inputStartTime = value); + state.Restore(SELECTED_FOCI_STATE_KEY, value => this.selectedFoci = value); + state.Restore(JUST_BRIEFLY_STATE_KEY, value => this.justBriefly = value); + state.Restore(INPUT_OBJECTIVE_STATE_KEY, value => this.inputObjective = value); + state.Restore(INPUT_MODERATOR_STATE_KEY, value => this.inputModerator = value); + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + state.Restore(INTRODUCE_PARTICIPANTS_STATE_KEY, value => this.introduceParticipants = value); + state.Restore(IS_MEETING_VIRTUAL_STATE_KEY, value => this.isMeetingVirtual = value); + state.Restore(INPUT_LOCATION_STATE_KEY, value => this.inputLocation = value); + state.Restore(GOING_TO_DINNER_STATE_KEY, value => this.goingToDinner = value); + state.Restore(DOING_SOCIAL_ACTIVITY_STATE_KEY, value => this.doingSocialActivity = value); + state.Restore(NEED_TO_ARRIVE_AND_DEPART_STATE_KEY, value => this.needToArriveAndDepart = value); + state.Restore(DURATION_LUNCH_BREAK_STATE_KEY, value => this.durationLunchBreak = value); + state.Restore(DURATION_BREAKS_STATE_KEY, value => this.durationBreaks = value); + state.Restore(ACTIVE_PARTICIPATION_STATE_KEY, value => this.activeParticipation = value); + state.Restore(NUMBER_PARTICIPANTS_STATE_KEY, value => this.numberParticipants = value); + state.Restore(INPUT_WHO_IS_PRESENTING_STATE_KEY, value => this.inputWhoIsPresenting = value); + state.RestoreList(CONTENT_LINES_STATE_KEY, this.contentLines); + } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor b/app/MindWork AI Studio/Assistants/AssistantBase.razor index 796de962..599ba1cb 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor @@ -24,7 +24,7 @@ - + @this.Description @@ -38,10 +38,10 @@ - + @this.SubmitText - @if (this.isProcessing && this.CancellationTokenSource is not null) + @if (this.IsProcessing) { @@ -50,9 +50,9 @@ } - + - @if (this.ShowDedicatedProgress && this.isProcessing) + @if (this.ShowDedicatedProgress && this.IsProcessing) { } @@ -63,9 +63,9 @@
- @if (this.ShowResult && !this.ShowEntireChatThread && this.resultingContentBlock is not null && this.resultingContentBlock.Content is not null) + @if (this.ShowResult && !this.ShowEntireChatThread && this.ResultingContentBlock is not null && this.ResultingContentBlock.Content is not null) { - + } @if(this.ShowResult && this.ShowEntireChatThread && this.ChatThread is not null) diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index 79f650bb..11e83a02 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -2,6 +2,8 @@ using AIStudio.Chat; using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AIJobs; +using AIStudio.Tools.AssistantSessions; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -36,6 +38,15 @@ public abstract partial class AssistantBase : AssistantLowerBase wher [Inject] private MudTheme ColorTheme { get; init; } = null!; + + [Inject] + protected AssistantSessionService AssistantSessionService { get; init; } = null!; + + /// + /// Gets the job service used to run assistant-created chats independently from the assistant UI. + /// + [Inject] + protected AIJobService AIJobService { get; init; } = null!; protected abstract string Title { get; } @@ -45,7 +56,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher protected abstract Tools.Components Component { get; } - protected virtual Func Result2Copy => () => this.resultingContentBlock is null ? string.Empty : this.resultingContentBlock.Content switch + protected virtual Func Result2Copy => () => this.ResultingContentBlock is null ? string.Empty : this.ResultingContentBlock.Content switch { ContentText textBlock => textBlock.Text, _ => string.Empty, @@ -111,20 +122,29 @@ public abstract partial class AssistantBase : AssistantLowerBase wher protected virtual bool HasSettingsPanel => typeof(TSettings) != typeof(NoSettingsPanel); - protected AIStudio.Settings.Provider ProviderSettings = Settings.Provider.NONE; - protected MudForm? Form; - protected bool InputIsValid; - protected Profile CurrentProfile = Profile.NO_PROFILE; - protected ChatTemplate CurrentChatTemplate = ChatTemplate.NO_CHAT_TEMPLATE; - protected ChatThread? ChatThread; - protected IContent? LastUserPrompt; - protected CancellationTokenSource? CancellationTokenSource; - private readonly Timer formChangeTimer = new(TimeSpan.FromSeconds(1.6)); + + protected MudForm? Form; + protected CancellationTokenSource? CancellationTokenSource; + private bool isDisposed; + private AssistantSessionKey assistantSessionKey; + private Guid? assistantSessionId; + private AssistantSessionSnapshot? pendingRenderedAssistantSessionSnapshot; - private ContentBlock? resultingContentBlock; - private string[] inputIssues = []; - private bool isProcessing; + /// + /// Gets whether the Blazor component instance has already been disposed. + /// + protected bool IsAssistantComponentDisposed => this.isDisposed; + + /// + /// Gets whether this component has attached an assistant session snapshot. + /// + protected bool HasAssistantSession => this.assistantSessionId is not null; + + /// + /// Gets the assistant-specific identifier used to distinguish session slots. + /// + protected virtual string AssistantSessionInstanceId => this.GetType().FullName ?? this.Component.ToString(); #region Overrides of ComponentBase @@ -150,6 +170,8 @@ public abstract partial class AssistantBase : AssistantLowerBase wher this.ProviderSettings = this.SettingsManager.GetPreselectedProvider(this.Component); this.CurrentProfile = this.SettingsManager.GetPreselectedProfile(this.Component); this.CurrentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(this.Component); + this.assistantSessionKey = new(this.Component, this.AssistantSessionInstanceId); + await this.AttachAssistantSessionIfAvailable(); } protected override async Task OnParametersSetAsync() @@ -166,6 +188,12 @@ public abstract partial class AssistantBase : AssistantLowerBase wher // We don't want to show validation errors when the user opens the dialog. if(firstRender) this.Form?.ResetValidation(); + + if (this.pendingRenderedAssistantSessionSnapshot is { } snapshot) + { + this.pendingRenderedAssistantSessionSnapshot = null; + await this.OnAssistantSessionRenderedAsync(snapshot); + } await base.OnAfterRenderAsync(firstRender); } @@ -191,12 +219,67 @@ public abstract partial class AssistantBase : AssistantLowerBase wher private async Task Start() { - using (this.CancellationTokenSource = new()) + var activeSession = this.AssistantSessionService.TryGetSnapshot(this.assistantSessionKey); + if (activeSession?.IsActive ?? false) + { + await this.AttachAssistantSession(activeSession, restoreClientOnlyContent: true); + return; + } + + this.CancellationTokenSource = new(); + this.IsProcessing = true; + var startedSession = await this.AssistantSessionService.TryBeginAsync(this.assistantSessionKey, this.Title, this.CancellationTokenSource, this.ChatThread, this.CaptureAssistantSessionState(), this); + if (startedSession.IsActive is not true || startedSession.Key != this.assistantSessionKey) + { + this.CancellationTokenSource.Dispose(); + this.CancellationTokenSource = null; + return; + } + + this.assistantSessionId = startedSession.SessionId; + await this.RefreshAssistantUIAsync(); + + var sessionStatus = AssistantSessionStatus.COMPLETED; + var errorMessage = string.Empty; + try { await this.SubmitAction(); + + if (this.CancellationTokenSource?.IsCancellationRequested ?? false) + sessionStatus = AssistantSessionStatus.CANCELED; + } + catch (OperationCanceledException) + { + sessionStatus = AssistantSessionStatus.CANCELED; + } + catch (ProviderRequestException e) + { + sessionStatus = AssistantSessionStatus.FAILED; + errorMessage = e.UserMessage; + this.Logger.LogError(e, "The provider request failed for assistant '{AssistantTitle}'. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'", this.Title, e.StatusCode, e.ReasonPhrase, e.ResponseBody); + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, e.UserMessage)); + } + catch (Exception e) + { + sessionStatus = AssistantSessionStatus.FAILED; + errorMessage = e.Message; + this.Logger.LogError(e, "The assistant session '{AssistantTitle}' failed.", this.Title); + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(this.TB("The assistant failed. The message is: '{0}'"), e.Message))); + } + finally + { + this.IsProcessing = false; + var sessionCancellationTokenSource = this.CancellationTokenSource; + this.CancellationTokenSource = null; + if (this.assistantSessionId is { } sessionId) + { + await this.AssistantSessionService.CompleteAsync(this.assistantSessionKey, sessionId, sessionStatus, errorMessage, this.ChatThread, this.CaptureAssistantSessionState(), this); + if (!this.isDisposed) + _ = this.AssistantSessionService.TryTakeInactiveSnapshot(this.assistantSessionKey); + } + sessionCancellationTokenSource?.Dispose(); + await this.RefreshAssistantUIAsync(); } - - this.CancellationTokenSource = null; } private void TriggerFormChange(FormFieldChangedEventArgs _) @@ -221,10 +304,10 @@ public abstract partial class AssistantBase : AssistantLowerBase wher /// The issue to add. protected void AddInputIssue(string issue) { - Array.Resize(ref this.inputIssues, this.inputIssues.Length + 1); - this.inputIssues[^1] = issue; + Array.Resize(ref this.InputIssues, this.InputIssues.Length + 1); + this.InputIssues[^1] = issue; this.InputIsValid = false; - this.StateHasChanged(); + _ = this.RefreshAssistantUIAsync(); } /// @@ -232,9 +315,9 @@ public abstract partial class AssistantBase : AssistantLowerBase wher /// protected void ClearInputIssues() { - this.inputIssues = []; + this.InputIssues = []; this.InputIsValid = true; - this.StateHasChanged(); + _ = this.RefreshAssistantUIAsync(); } protected void CreateChatThread() @@ -310,7 +393,19 @@ public abstract partial class AssistantBase : AssistantLowerBase wher InitialRemoteWait = true, }; - this.resultingContentBlock = new ContentBlock + aiText.StreamingEvent = async () => + { + await this.CheckpointAssistantSession(); + await this.RefreshAssistantUIAsync(); + }; + + aiText.StreamingDone = async () => + { + await this.CheckpointAssistantSession(); + await this.RefreshAssistantUIAsync(); + }; + + this.ResultingContentBlock = new ContentBlock { Time = time, ContentType = ContentType.TEXT, @@ -321,12 +416,13 @@ public abstract partial class AssistantBase : AssistantLowerBase wher if (this.ChatThread is not null) { - this.ChatThread.Blocks.Add(this.resultingContentBlock); + this.ChatThread.Blocks.Add(this.ResultingContentBlock); this.ChatThread.SelectedProvider = this.ProviderSettings.Id; } - this.isProcessing = true; - this.StateHasChanged(); + this.IsProcessing = true; + await this.CheckpointAssistantSession(); + await this.RefreshAssistantUIAsync(); try { @@ -343,18 +439,19 @@ public abstract partial class AssistantBase : AssistantLowerBase wher this.Logger.LogError(e, "The provider request failed for assistant '{AssistantTitle}'. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'", this.Title, e.StatusCode, e.ReasonPhrase, e.ResponseBody); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, e.UserMessage)); - if (this.resultingContentBlock is not null && string.IsNullOrWhiteSpace(aiText.Text)) + if (this.ResultingContentBlock is not null && string.IsNullOrWhiteSpace(aiText.Text)) { - this.ChatThread?.Blocks.Remove(this.resultingContentBlock); - this.resultingContentBlock = null; + this.ChatThread?.Blocks.Remove(this.ResultingContentBlock); + this.ResultingContentBlock = null; } return string.Empty; } finally { - this.isProcessing = false; - this.StateHasChanged(); + this.IsProcessing = this.assistantSessionId is not null && (this.AssistantSessionService.TryGetSnapshot(this.assistantSessionKey)?.IsActive ?? false); + await this.CheckpointAssistantSession(); + await this.RefreshAssistantUIAsync(); if(manageCancellationLocally) { @@ -363,12 +460,54 @@ public abstract partial class AssistantBase : AssistantLowerBase wher } } } + + /// + /// Starts the current assistant chat thread as a regular background-capable chat generation job. + /// + /// + /// Use this when an assistant creates a chat and hands it over to the chat page instead of + /// rendering the answer inside the assistant UI. + /// + /// The timestamp to use for the AI response block. + /// Whether the AI response block should be hidden from the user. + /// Whether the chat job should start as the current foreground job. + /// A task that completes after the chat job was registered. + protected async Task StartChatGenerationJobAsync(DateTimeOffset time, bool hideContentFromUser = false, bool isForeground = true) + { + if (this.ChatThread is null) + return; + + var aiText = new ContentText + { + InitialRemoteWait = true, + }; + + this.ResultingContentBlock = new ContentBlock + { + Time = time, + ContentType = ContentType.TEXT, + Role = ChatRole.AI, + Content = aiText, + HideFromUser = hideContentFromUser, + }; + + this.ChatThread.Blocks.Add(this.ResultingContentBlock); + this.ChatThread.SelectedProvider = this.ProviderSettings.Id; + + await this.CheckpointAssistantSession(); + await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest + { + ChatThread = this.ChatThread, + AIText = aiText, + LastUserPrompt = this.LastUserPrompt, + ProviderSettings = this.ProviderSettings, + IsForeground = isForeground, + }); + } private async Task CancelStreaming() { - if (this.CancellationTokenSource is not null) - if(!this.CancellationTokenSource.IsCancellationRequested) - await this.CancellationTokenSource.CancelAsync(); + await this.AssistantSessionService.CancelAsync(this.assistantSessionKey, this); } protected async Task CopyToClipboard() @@ -434,15 +573,15 @@ public abstract partial class AssistantBase : AssistantLowerBase wher await this.DialogService.ShowAsync(null, dialogParameters, DialogOptions.FULLSCREEN); } - protected Task SendToAssistant(Tools.Components destination, SendToButton sendToButton) + protected async Task SendToAssistant(Tools.Components destination, SendToButton sendToButton) { if (!this.CanSendToAssistant(destination)) - return Task.CompletedTask; + return; var contentToSend = sendToButton == default ? string.Empty : sendToButton.UseResultingContentBlockData switch { false => sendToButton.GetText(), - true => this.resultingContentBlock?.Content switch + true => this.ResultingContentBlock?.Content switch { ContentText textBlock => textBlock.Text, _ => string.Empty, @@ -450,6 +589,16 @@ public abstract partial class AssistantBase : AssistantLowerBase wher }; var sendToData = destination.GetData(); + if (destination is not Tools.Components.CHAT && this.AssistantSessionService.GetSnapshots().Any(snapshot => snapshot.IsActive && snapshot.Key.Component == destination)) + { + await this.MessageBus.SendWarning(new(Icons.Material.Filled.Apps, this.TB("This assistant is already running. AI Studio opens the running session instead."))); + this.NavigationManager.NavigateTo(sendToData.Route); + return; + } + + if (destination is not Tools.Components.CHAT) + await this.AssistantSessionService.ClearInactiveSessionsForComponentAsync(destination); + switch (destination) { case Tools.Components.CHAT: @@ -469,7 +618,6 @@ public abstract partial class AssistantBase : AssistantLowerBase wher } this.NavigationManager.NavigateTo(sendToData.Route); - return Task.CompletedTask; } private bool CanSendToAssistant(Tools.Components component) @@ -482,7 +630,12 @@ public abstract partial class AssistantBase : AssistantLowerBase wher private async Task InnerResetForm() { - this.resultingContentBlock = null; + if (this.AssistantSessionService.TryGetSnapshot(this.assistantSessionKey)?.IsActive ?? false) + return; + + await this.AssistantSessionService.ClearAsync(this.assistantSessionKey); + this.assistantSessionId = null; + this.ResultingContentBlock = null; this.ProviderSettings = Settings.Provider.NONE; await this.JsRuntime.ClearDiv(RESULT_DIV_ID); @@ -492,10 +645,10 @@ public abstract partial class AssistantBase : AssistantLowerBase wher this.ResetProviderAndProfileSelection(); this.InputIsValid = false; - this.inputIssues = []; + this.InputIssues = []; this.Form?.ResetValidation(); - this.StateHasChanged(); + await this.RefreshAssistantUIAsync(); this.Form?.ResetValidation(); } @@ -515,6 +668,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher protected override void DisposeResources() { + this.isDisposed = true; try { this.formChangeTimer.Stop(); @@ -529,4 +683,177 @@ public abstract partial class AssistantBase : AssistantLowerBase wher } #endregion -} + + #region Assistant sessions + + /// + /// Stores the current assistant UI and chat state in the active assistant session. + /// + /// A task that completes after the checkpoint was stored and published. + private Task CheckpointAssistantSession() + { + if (this.assistantSessionId is null) + return Task.CompletedTask; + + return this.AssistantSessionService.CheckpointAsync(this.assistantSessionKey, this.assistantSessionId.Value, this.Title, this.ChatThread, this.CaptureAssistantSessionState(), this); + } + + /// + /// Allows derived assistants to restore client-only UI after a session was attached. + /// + /// The assistant session snapshot that was attached. + /// A task that completes after derived UI restore work has finished. + protected virtual Task OnAssistantSessionAttachedAsync(AssistantSessionSnapshot snapshot) => Task.CompletedTask; + + /// + /// Allows derived assistants to restore DOM-dependent client-only UI after an attached session was rendered. + /// + /// The assistant session snapshot that was rendered. + /// A task that completes after derived UI restore work has finished. + protected virtual Task OnAssistantSessionRenderedAsync(AssistantSessionSnapshot snapshot) => Task.CompletedTask; + + /// + /// Handles assistant session change events for the current assistant instance. + /// + /// The message payload type. + /// The component that sent the message, if any. + /// The event that was triggered. + /// The message payload. + /// A task that completes after the message was processed. + protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default + { + if (ReferenceEquals(sendingComponent, this)) + return; + + switch (triggeredEvent) + { + case Event.ASSISTANT_SESSION_CHANGED: + case Event.ASSISTANT_SESSION_FINISHED: + if (data is AssistantSessionSnapshot snapshot && snapshot.Key == this.assistantSessionKey) + { + await this.AttachAssistantSession(snapshot, restoreClientOnlyContent: triggeredEvent is Event.ASSISTANT_SESSION_FINISHED); + if (triggeredEvent is Event.ASSISTANT_SESSION_FINISHED) + _ = this.AssistantSessionService.TryTakeInactiveSnapshot(this.assistantSessionKey); + } + break; + } + } + + /// + /// Attaches the component to an existing assistant session if one is available. + /// + /// A task that completes after the session was attached. + private async Task AttachAssistantSessionIfAvailable() + { + var snapshot = this.AssistantSessionService.TryGetSnapshot(this.assistantSessionKey); + if (snapshot?.IsActive ?? false) + { + await this.AttachAssistantSession(snapshot, restoreClientOnlyContent: true); + return; + } + + snapshot = this.AssistantSessionService.TryTakeInactiveSnapshot(this.assistantSessionKey); + if (snapshot is null) + return; + + await this.AttachAssistantSession(snapshot, restoreClientOnlyContent: true); + } + + /// + /// Applies an assistant session snapshot to this component instance. + /// + /// The snapshot to attach. + /// Whether derived assistants should restore client-only UI state. + /// A task that completes after the component was refreshed. + private async Task AttachAssistantSession(AssistantSessionSnapshot snapshot, bool restoreClientOnlyContent) + { + this.assistantSessionId = snapshot.SessionId; + this.ImportAssistantSessionState(snapshot.State); + this.ChatThread = snapshot.ChatThread ?? this.ChatThread; + this.IsProcessing = snapshot.IsActive; + + if (!snapshot.IsActive) + this.CancellationTokenSource = null; + + if (restoreClientOnlyContent) + await this.OnAssistantSessionAttachedAsync(snapshot); + + if (restoreClientOnlyContent) + this.pendingRenderedAssistantSessionSnapshot = snapshot; + + await this.RefreshAssistantUIAsync(); + } + + /// + /// Refreshes the component when it is still mounted. + /// + /// A task that completes after the renderer was notified. + private async Task RefreshAssistantUIAsync() + { + if (this.isDisposed) + return; + + try + { + await this.InvokeAsync(this.StateHasChanged); + } + catch (InvalidOperationException) + { + // The component may already have left the renderer while a background session is finishing. + } + } + + /// + /// Captures the base assistant state and assistant-specific typed state values for session restore. + /// + /// A dictionary containing the current assistant state. + private Dictionary CaptureAssistantSessionState() + { + var state = new AssistantSessionStateWriter(); + state.Set(PROVIDER_SETTINGS_STATE_KEY, this.ProviderSettings); + state.Set(INPUT_IS_VALID_STATE_KEY, this.InputIsValid); + state.Set(CURRENT_PROFILE_STATE_KEY, this.CurrentProfile); + state.Set(CURRENT_CHAT_TEMPLATE_STATE_KEY, this.CurrentChatTemplate); + state.Set(CHAT_THREAD_STATE_KEY, this.ChatThread); + state.Set(LAST_USER_PROMPT_STATE_KEY, this.LastUserPrompt); + state.Set(RESULTING_CONTENT_BLOCK_STATE_KEY, this.ResultingContentBlock); + state.Set(INPUT_ISSUES_STATE_KEY, this.InputIssues); + state.Set(IS_PROCESSING_STATE_KEY, this.IsProcessing); + this.CaptureCustomAssistantSessionState(state); + + return state.ToDictionary(); + } + + /// + /// Captures assistant-specific state values. + /// + /// The typed state writer to update. + protected virtual void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) { } + + /// + /// Restores the base assistant state and assistant-specific typed state values from a session snapshot. + /// + /// The captured assistant state to import. + private void ImportAssistantSessionState(IReadOnlyDictionary state) + { + var reader = new AssistantSessionStateReader(state, this.Title); + reader.Restore(PROVIDER_SETTINGS_STATE_KEY, value => this.ProviderSettings = value); + reader.Restore(INPUT_IS_VALID_STATE_KEY, value => this.InputIsValid = value); + reader.Restore(CURRENT_PROFILE_STATE_KEY, value => this.CurrentProfile = value); + reader.Restore(CURRENT_CHAT_TEMPLATE_STATE_KEY, value => this.CurrentChatTemplate = value); + reader.Restore(CHAT_THREAD_STATE_KEY, value => this.ChatThread = value); + reader.Restore(LAST_USER_PROMPT_STATE_KEY, value => this.LastUserPrompt = value); + reader.Restore(RESULTING_CONTENT_BLOCK_STATE_KEY, value => this.ResultingContentBlock = value); + reader.Restore(INPUT_ISSUES_STATE_KEY, value => this.InputIssues = value); + reader.Restore(IS_PROCESSING_STATE_KEY, value => this.IsProcessing = value); + this.RestoreCustomAssistantSessionState(reader); + } + + /// + /// Restores assistant-specific state values. + /// + /// The typed state reader to read from. + protected virtual void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) { } + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/AssistantLowerBase.cs b/app/MindWork AI Studio/Assistants/AssistantLowerBase.cs index 2f9e804f..cc1f35e8 100644 --- a/app/MindWork AI Studio/Assistants/AssistantLowerBase.cs +++ b/app/MindWork AI Studio/Assistants/AssistantLowerBase.cs @@ -1,4 +1,7 @@ +using AIStudio.Chat; using AIStudio.Components; +using AIStudio.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants; @@ -9,4 +12,25 @@ public abstract class AssistantLowerBase : MSGComponentBase internal const string RESULT_DIV_ID = "assistantResult"; internal const string BEFORE_RESULT_DIV_ID = "beforeAssistantResult"; internal const string AFTER_RESULT_DIV_ID = "afterAssistantResult"; + + protected static readonly AssistantSessionStateKey PROVIDER_SETTINGS_STATE_KEY = new(nameof(ProviderSettings)); + protected static readonly AssistantSessionStateKey INPUT_IS_VALID_STATE_KEY = new(nameof(InputIsValid)); + protected static readonly AssistantSessionStateKey CURRENT_PROFILE_STATE_KEY = new(nameof(CurrentProfile)); + protected static readonly AssistantSessionStateKey CURRENT_CHAT_TEMPLATE_STATE_KEY = new(nameof(CurrentChatTemplate)); + protected static readonly AssistantSessionStateKey CHAT_THREAD_STATE_KEY = new(nameof(ChatThread)); + protected static readonly AssistantSessionStateKey LAST_USER_PROMPT_STATE_KEY = new(nameof(LastUserPrompt)); + protected static readonly AssistantSessionStateKey RESULTING_CONTENT_BLOCK_STATE_KEY = new(nameof(ResultingContentBlock)); + protected static readonly AssistantSessionStateKey INPUT_ISSUES_STATE_KEY = new(nameof(InputIssues)); + protected static readonly AssistantSessionStateKey IS_PROCESSING_STATE_KEY = new(nameof(IsProcessing)); + + protected AIStudio.Settings.Provider ProviderSettings = Settings.Provider.NONE; + protected bool InputIsValid; + protected Profile CurrentProfile = Profile.NO_PROFILE; + protected ChatTemplate CurrentChatTemplate = ChatTemplate.NO_CHAT_TEMPLATE; + protected ChatThread? ChatThread; + protected IContent? LastUserPrompt; + + protected ContentBlock? ResultingContentBlock; + protected string[] InputIssues = []; + protected bool IsProcessing; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BiasDay/BiasOfTheDayAssistant.razor.cs b/app/MindWork AI Studio/Assistants/BiasDay/BiasOfTheDayAssistant.razor.cs index d1930b8e..e9313c6f 100644 --- a/app/MindWork AI Studio/Assistants/BiasDay/BiasOfTheDayAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/BiasDay/BiasOfTheDayAssistant.razor.cs @@ -3,6 +3,7 @@ using System.Text; using AIStudio.Chat; using AIStudio.Dialogs.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.BiasDay; @@ -66,6 +67,25 @@ public partial class BiasOfTheDayAssistant : AssistantBaseCore BIAS_OF_THE_DAY_STATE_KEY = new(nameof(biasOfTheDay)); + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(BIAS_OF_THE_DAY_STATE_KEY, this.biasOfTheDay); + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(BIAS_OF_THE_DAY_STATE_KEY, value => this.biasOfTheDay = value); + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + } private string? ValidateTargetLanguage(CommonLanguages language) { @@ -149,8 +169,7 @@ public partial class BiasOfTheDayAssistant : AssistantBaseCore private bool provideCompilerMessages; private string compilerMessages = string.Empty; private string questions = string.Empty; + private static readonly AssistantSessionStateKey> CODING_CONTEXTS_STATE_KEY = new(nameof(codingContexts)); + private static readonly AssistantSessionStateKey PROVIDE_COMPILER_MESSAGES_STATE_KEY = new(nameof(provideCompilerMessages)); + private static readonly AssistantSessionStateKey COMPILER_MESSAGES_STATE_KEY = new(nameof(compilerMessages)); + private static readonly AssistantSessionStateKey QUESTIONS_STATE_KEY = new(nameof(questions)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.SetList(CODING_CONTEXTS_STATE_KEY, this.codingContexts); + state.Set(PROVIDE_COMPILER_MESSAGES_STATE_KEY, this.provideCompilerMessages); + state.Set(COMPILER_MESSAGES_STATE_KEY, this.compilerMessages); + state.Set(QUESTIONS_STATE_KEY, this.questions); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.RestoreList(CODING_CONTEXTS_STATE_KEY, this.codingContexts); + state.Restore(PROVIDE_COMPILER_MESSAGES_STATE_KEY, value => this.provideCompilerMessages = value); + state.Restore(COMPILER_MESSAGES_STATE_KEY, value => this.compilerMessages = value); + state.Restore(QUESTIONS_STATE_KEY, value => this.questions = value); + } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index 987c9f5c..436c5c4d 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -7,6 +7,7 @@ using AIStudio.Dialogs.Settings; using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.AssistantSessions; using Microsoft.AspNetCore.Components; @@ -279,6 +280,55 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore loadedDocumentPaths = []; private readonly List> availableLLMProviders = new(); + private static readonly AssistantSessionStateKey SELECTED_POLICY_STATE_KEY = new(nameof(selectedPolicy)); + private static readonly AssistantSessionStateKey POLICY_IS_PROTECTED_STATE_KEY = new(nameof(policyIsProtected)); + private static readonly AssistantSessionStateKey POLICY_HIDE_POLICY_DEFINITION_STATE_KEY = new(nameof(policyHidePolicyDefinition)); + private static readonly AssistantSessionStateKey POLICY_DEFINITION_EXPANDED_STATE_KEY = new(nameof(policyDefinitionExpanded)); + private static readonly AssistantSessionStateKey POLICY_NAME_STATE_KEY = new(nameof(policyName)); + private static readonly AssistantSessionStateKey POLICY_DESCRIPTION_STATE_KEY = new(nameof(policyDescription)); + private static readonly AssistantSessionStateKey POLICY_ANALYSIS_RULES_STATE_KEY = new(nameof(policyAnalysisRules)); + private static readonly AssistantSessionStateKey POLICY_OUTPUT_RULES_STATE_KEY = new(nameof(policyOutputRules)); + private static readonly AssistantSessionStateKey POLICY_MINIMUM_PROVIDER_CONFIDENCE_STATE_KEY = new(nameof(policyMinimumProviderConfidence)); + private static readonly AssistantSessionStateKey POLICY_PRESELECTED_PROVIDER_ID_STATE_KEY = new(nameof(policyPreselectedProviderId)); + private static readonly AssistantSessionStateKey POLICY_PRESELECTED_PROFILE_STATE_KEY = new(nameof(policyPreselectedProfile)); + private static readonly AssistantSessionStateKey> LOADED_DOCUMENT_PATHS_STATE_KEY = new(nameof(loadedDocumentPaths)); + private static readonly AssistantSessionStateKey>> AVAILABLE_LLM_PROVIDERS_STATE_KEY = new(nameof(availableLLMProviders)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(SELECTED_POLICY_STATE_KEY, this.selectedPolicy); + state.Set(POLICY_IS_PROTECTED_STATE_KEY, this.policyIsProtected); + state.Set(POLICY_HIDE_POLICY_DEFINITION_STATE_KEY, this.policyHidePolicyDefinition); + state.Set(POLICY_DEFINITION_EXPANDED_STATE_KEY, this.policyDefinitionExpanded); + state.Set(POLICY_NAME_STATE_KEY, this.policyName); + state.Set(POLICY_DESCRIPTION_STATE_KEY, this.policyDescription); + state.Set(POLICY_ANALYSIS_RULES_STATE_KEY, this.policyAnalysisRules); + state.Set(POLICY_OUTPUT_RULES_STATE_KEY, this.policyOutputRules); + state.Set(POLICY_MINIMUM_PROVIDER_CONFIDENCE_STATE_KEY, this.policyMinimumProviderConfidence); + state.Set(POLICY_PRESELECTED_PROVIDER_ID_STATE_KEY, this.policyPreselectedProviderId); + state.Set(POLICY_PRESELECTED_PROFILE_STATE_KEY, this.policyPreselectedProfile); + state.SetHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths); + state.SetList(AVAILABLE_LLM_PROVIDERS_STATE_KEY, this.availableLLMProviders); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(SELECTED_POLICY_STATE_KEY, value => this.selectedPolicy = value); + state.Restore(POLICY_IS_PROTECTED_STATE_KEY, value => this.policyIsProtected = value); + state.Restore(POLICY_HIDE_POLICY_DEFINITION_STATE_KEY, value => this.policyHidePolicyDefinition = value); + state.Restore(POLICY_DEFINITION_EXPANDED_STATE_KEY, value => this.policyDefinitionExpanded = value); + state.Restore(POLICY_NAME_STATE_KEY, value => this.policyName = value); + state.Restore(POLICY_DESCRIPTION_STATE_KEY, value => this.policyDescription = value); + state.Restore(POLICY_ANALYSIS_RULES_STATE_KEY, value => this.policyAnalysisRules = value); + state.Restore(POLICY_OUTPUT_RULES_STATE_KEY, value => this.policyOutputRules = value); + state.Restore(POLICY_MINIMUM_PROVIDER_CONFIDENCE_STATE_KEY, value => this.policyMinimumProviderConfidence = value); + state.Restore(POLICY_PRESELECTED_PROVIDER_ID_STATE_KEY, value => this.policyPreselectedProviderId = value); + state.Restore(POLICY_PRESELECTED_PROFILE_STATE_KEY, value => this.policyPreselectedProfile = value); + state.RestoreHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths); + state.RestoreList(AVAILABLE_LLM_PROVIDERS_STATE_KEY, this.availableLLMProviders); + } private bool IsNoPolicySelectedOrProtected => this.selectedPolicy is null || this.selectedPolicy.IsProtected; @@ -515,7 +565,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore // Reuse chat-level provider filtering/preselection instead of NONE. protected override Tools.Components Component => Tools.Components.CHAT; + /// + /// Gets the plugin ID as the assistant session instance ID. + /// + protected override string AssistantSessionInstanceId => this.assistantPlugin is null ? base.AssistantSessionInstanceId : this.assistantPlugin.Id.ToString(); + private string title = string.Empty; private string description = string.Empty; private string systemPrompt = string.Empty; @@ -44,6 +50,61 @@ public partial class AssistantDynamic : AssistantBaseCore private string securityMessage = string.Empty; private bool isSecurityBlocked; private const string ASSISTANT_QUERY_KEY = "assistantId"; + private static readonly AssistantSessionStateKey TITLE_STATE_KEY = new(nameof(title)); + private static readonly AssistantSessionStateKey DESCRIPTION_STATE_KEY = new(nameof(description)); + private static readonly AssistantSessionStateKey SYSTEM_PROMPT_STATE_KEY = new(nameof(systemPrompt)); + private static readonly AssistantSessionStateKey ALLOW_PROFILES_STATE_KEY = new(nameof(allowProfiles)); + private static readonly AssistantSessionStateKey SUBMIT_TEXT_STATE_KEY = new(nameof(submitText)); + private static readonly AssistantSessionStateKey SHOW_FOOTER_PROFILE_SELECTION_STATE_KEY = new(nameof(showFooterProfileSelection)); + private static readonly AssistantSessionStateKey ASSISTANT_PLUGIN_STATE_KEY = new(nameof(assistantPlugin)); + private static readonly AssistantSessionStateKey ASSISTANT_STATE_STATE_KEY = new(nameof(assistantState)); + private static readonly AssistantSessionStateKey> IMAGE_CACHE_STATE_KEY = new(nameof(imageCache)); + private static readonly AssistantSessionStateKey> EXECUTING_BUTTON_ACTIONS_STATE_KEY = new(nameof(executingButtonActions)); + private static readonly AssistantSessionStateKey> EXECUTING_SWITCH_ACTIONS_STATE_KEY = new(nameof(executingSwitchActions)); + private static readonly AssistantSessionStateKey PLUGIN_PATH_STATE_KEY = new(nameof(pluginPath)); + private static readonly AssistantSessionStateKey AUDIT_STATE_KEY = new(nameof(audit)); + private static readonly AssistantSessionStateKey SECURITY_MESSAGE_STATE_KEY = new(nameof(securityMessage)); + private static readonly AssistantSessionStateKey IS_SECURITY_BLOCKED_STATE_KEY = new(nameof(isSecurityBlocked)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(TITLE_STATE_KEY, this.title); + state.Set(DESCRIPTION_STATE_KEY, this.description); + state.Set(SYSTEM_PROMPT_STATE_KEY, this.systemPrompt); + state.Set(ALLOW_PROFILES_STATE_KEY, this.allowProfiles); + state.Set(SUBMIT_TEXT_STATE_KEY, this.submitText); + state.Set(SHOW_FOOTER_PROFILE_SELECTION_STATE_KEY, this.showFooterProfileSelection); + state.Set(ASSISTANT_PLUGIN_STATE_KEY, this.assistantPlugin); + state.Set(ASSISTANT_STATE_STATE_KEY, this.assistantState.Clone()); + state.SetDictionary(IMAGE_CACHE_STATE_KEY, this.imageCache); + state.SetHashSet(EXECUTING_BUTTON_ACTIONS_STATE_KEY, this.executingButtonActions); + state.SetHashSet(EXECUTING_SWITCH_ACTIONS_STATE_KEY, this.executingSwitchActions); + state.Set(PLUGIN_PATH_STATE_KEY, this.pluginPath); + state.Set(AUDIT_STATE_KEY, this.audit); + state.Set(SECURITY_MESSAGE_STATE_KEY, this.securityMessage); + state.Set(IS_SECURITY_BLOCKED_STATE_KEY, this.isSecurityBlocked); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(TITLE_STATE_KEY, value => this.title = value); + state.Restore(DESCRIPTION_STATE_KEY, value => this.description = value); + state.Restore(SYSTEM_PROMPT_STATE_KEY, value => this.systemPrompt = value); + state.Restore(ALLOW_PROFILES_STATE_KEY, value => this.allowProfiles = value); + state.Restore(SUBMIT_TEXT_STATE_KEY, value => this.submitText = value); + state.Restore(SHOW_FOOTER_PROFILE_SELECTION_STATE_KEY, value => this.showFooterProfileSelection = value); + state.Restore(ASSISTANT_PLUGIN_STATE_KEY, value => this.assistantPlugin = value); + state.Restore(ASSISTANT_STATE_STATE_KEY, value => this.assistantState.CopyFrom(value)); + state.RestoreDictionary(IMAGE_CACHE_STATE_KEY, this.imageCache); + state.RestoreHashSet(EXECUTING_BUTTON_ACTIONS_STATE_KEY, this.executingButtonActions); + state.RestoreHashSet(EXECUTING_SWITCH_ACTIONS_STATE_KEY, this.executingSwitchActions); + state.Restore(PLUGIN_PATH_STATE_KEY, value => this.pluginPath = value); + state.Restore(AUDIT_STATE_KEY, value => this.audit = value); + state.Restore(SECURITY_MESSAGE_STATE_KEY, value => this.securityMessage = value); + state.Restore(IS_SECURITY_BLOCKED_STATE_KEY, value => this.isSecurityBlocked = value); + } #region Implementation of AssistantBase diff --git a/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs b/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs index 4c1e1158..ee5d233a 100644 --- a/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs +++ b/app/MindWork AI Studio/Assistants/EMail/AssistantEMail.razor.cs @@ -1,6 +1,7 @@ using System.Text; using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.EMail; @@ -78,6 +79,46 @@ public partial class AssistantEMail : AssistantBaseCore SELECTED_WRITING_STYLE_STATE_KEY = new(nameof(selectedWritingStyle)); + private static readonly AssistantSessionStateKey INPUT_GREETING_STATE_KEY = new(nameof(inputGreeting)); + private static readonly AssistantSessionStateKey INPUT_BULLET_POINTS_STATE_KEY = new(nameof(inputBulletPoints)); + private static readonly AssistantSessionStateKey> BULLET_POINTS_LINES_STATE_KEY = new(nameof(bulletPointsLines)); + private static readonly AssistantSessionStateKey> SELECTED_FOCI_STATE_KEY = new(nameof(selectedFoci)); + private static readonly AssistantSessionStateKey INPUT_NAME_STATE_KEY = new(nameof(inputName)); + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + private static readonly AssistantSessionStateKey PROVIDE_HISTORY_STATE_KEY = new(nameof(provideHistory)); + private static readonly AssistantSessionStateKey INPUT_HISTORY_STATE_KEY = new(nameof(inputHistory)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(SELECTED_WRITING_STYLE_STATE_KEY, this.selectedWritingStyle); + state.Set(INPUT_GREETING_STATE_KEY, this.inputGreeting); + state.Set(INPUT_BULLET_POINTS_STATE_KEY, this.inputBulletPoints); + state.SetList(BULLET_POINTS_LINES_STATE_KEY, this.bulletPointsLines); + state.SetHashSet(SELECTED_FOCI_STATE_KEY, this.selectedFoci); + state.Set(INPUT_NAME_STATE_KEY, this.inputName); + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + state.Set(PROVIDE_HISTORY_STATE_KEY, this.provideHistory); + state.Set(INPUT_HISTORY_STATE_KEY, this.inputHistory); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(SELECTED_WRITING_STYLE_STATE_KEY, value => this.selectedWritingStyle = value); + state.Restore(INPUT_GREETING_STATE_KEY, value => this.inputGreeting = value); + state.Restore(INPUT_BULLET_POINTS_STATE_KEY, value => this.inputBulletPoints = value); + state.RestoreList(BULLET_POINTS_LINES_STATE_KEY, this.bulletPointsLines); + state.Restore(SELECTED_FOCI_STATE_KEY, value => this.selectedFoci = value); + state.Restore(INPUT_NAME_STATE_KEY, value => this.inputName = value); + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + state.Restore(PROVIDE_HISTORY_STATE_KEY, value => this.provideHistory = value); + state.Restore(INPUT_HISTORY_STATE_KEY, value => this.inputHistory = value); + } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor b/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor index 9f19942d..e1973b8a 100644 --- a/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor +++ b/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor @@ -41,7 +41,7 @@ } else { - + @foreach (var server in this.SettingsManager.ConfigurationData.ERI.ERIServers) { @@ -52,10 +52,10 @@ else } - + @T("Add ERI server preset") - + @T("Delete this server preset") @@ -82,18 +82,18 @@ else } - +
@T("Common ERI server settings") - - + + - + @foreach (var language in Enum.GetValues()) { @@ -103,12 +103,12 @@ else @if (this.selectedProgrammingLanguage is ProgrammingLanguages.OTHER) { - + } - + @foreach (var version in Enum.GetValues()) { @@ -116,7 +116,7 @@ else } - + @T("Download specification") @@ -126,7 +126,7 @@ else - + @foreach (var dataSource in Enum.GetValues()) { @@ -136,21 +136,21 @@ else @if (this.selectedDataSource is DataSources.CUSTOM) { - + } @if(this.selectedDataSource > DataSources.FILE_SYSTEM) { - + } @if (this.NeedHostnamePort()) {
- - + + @if (this.dataSourcePort < 1024) { @@ -168,7 +168,7 @@ else } - + @if (this.selectedAuthenticationMethods.Contains(Auth.KERBEROS)) { - + @foreach (var os in Enum.GetValues()) { @@ -204,7 +204,7 @@ else @T("Data protection settings") - + @foreach (var option in Enum.GetValues()) { @@ -227,7 +227,7 @@ else @if (!this.IsNoneERIServerSelected) { - + @@ -243,10 +243,10 @@ else @context.EmbeddingType - + @T("Edit") - + @T("Delete") @@ -262,7 +262,7 @@ else } } - + @T("Add Embedding Method") @@ -276,7 +276,7 @@ else @if (!this.IsNoneERIServerSelected) { - + @@ -289,10 +289,10 @@ else @context.Name - + @T("Edit") - + @T("Delete") @@ -308,7 +308,7 @@ else } } - + @T("Add Retrieval Process") @@ -316,7 +316,7 @@ else @T("You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected.") - + @T("Provider selection for generation") @@ -330,7 +330,7 @@ else @T("Important:") @T("The LLM may need to generate many files. This reaches the request limit of most providers. Typically, only a certain number of requests can be made per minute, and only a maximum number of tokens can be generated per minute. AI Studio automatically considers this.") @T("However, generating all the files takes a certain amount of time.") @T("Local or self-hosted models may work without these limitations and can generate responses faster. AI Studio dynamically adapts its behavior and always tries to achieve the fastest possible data processing.") - + @T("Write code to file system") @@ -344,5 +344,5 @@ else @T("When you rebuild / re-generate the ERI server code, AI Studio proceeds as follows: All files generated last time will be deleted. All other files you have created remain. Then, the AI generates the new files.") @T("But beware:") @T("It may happen that the AI generates a file this time that you manually created last time. In this case, your manually created file will then be overwritten. Therefore, you should always create a Git repository and commit or revert all changes before using this assistant. With a diff visualization, you can immediately see where the AI has made changes. It is best to use an IDE suitable for your selected language for this purpose.") - - + + diff --git a/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs b/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs index a4c204c9..c6725c33 100644 --- a/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs +++ b/app/MindWork AI Studio/Assistants/ERI/AssistantERI.razor.cs @@ -5,6 +5,7 @@ using AIStudio.Chat; using AIStudio.Dialogs; using AIStudio.Dialogs.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.AssistantSessions; using Microsoft.AspNetCore.Components; @@ -291,7 +292,17 @@ public partial class AssistantERI : AssistantBaseCore } } - protected override IReadOnlyList FooterButtons => []; + protected override IReadOnlyList FooterButtons => + [ + new ButtonData + { + Text = T("Open in chat"), + Icon = Icons.Material.Filled.Chat, + Color = Color.Default, + AsyncAction = this.OpenInChat, + DisabledActionParam = () => !this.CanOpenInChat, + }, + ]; protected override bool ShowEntireChatThread => true; @@ -307,6 +318,22 @@ public partial class AssistantERI : AssistantBaseCore { SystemPrompt = this.SystemPrompt, }; + + /// + /// Indicates whether the generated ERI conversation can be opened in the chat view. + /// + private bool CanOpenInChat => !this.IsProcessing && this.ChatThread is { Blocks.Count: > 0 }; + + /// + /// Opens the generated ERI conversation in the chat view when a finished conversation is available. + /// + private async Task OpenInChat() + { + if (!this.CanOpenInChat) + return; + + await this.SendToAssistant(Tools.Components.CHAT, default); + } protected override void ResetForm() { @@ -449,17 +476,110 @@ public partial class AssistantERI : AssistantBaseCore private bool writeToFilesystem; private string baseDirectory = string.Empty; private List previouslyGeneratedFiles = new(); + private static readonly AssistantSessionStateKey SELECTED_ERI_SERVER_STATE_KEY = new(nameof(selectedERIServer)); + private static readonly AssistantSessionStateKey AUTO_SAVE_STATE_KEY = new(nameof(autoSave)); + private static readonly AssistantSessionStateKey SERVER_NAME_STATE_KEY = new(nameof(serverName)); + private static readonly AssistantSessionStateKey SERVER_DESCRIPTION_STATE_KEY = new(nameof(serverDescription)); + private static readonly AssistantSessionStateKey SELECTED_ERI_VERSION_STATE_KEY = new(nameof(selectedERIVersion)); + private static readonly AssistantSessionStateKey ERI_SPECIFICATION_STATE_KEY = new(nameof(eriSpecification)); + private static readonly AssistantSessionStateKey SELECTED_PROGRAMMING_LANGUAGE_STATE_KEY = new(nameof(selectedProgrammingLanguage)); + private static readonly AssistantSessionStateKey OTHER_PROGRAMMING_LANGUAGE_STATE_KEY = new(nameof(otherProgrammingLanguage)); + private static readonly AssistantSessionStateKey SELECTED_DATA_SOURCE_STATE_KEY = new(nameof(selectedDataSource)); + private static readonly AssistantSessionStateKey OTHER_DATA_SOURCE_STATE_KEY = new(nameof(otherDataSource)); + private static readonly AssistantSessionStateKey DATA_SOURCE_PRODUCT_NAME_STATE_KEY = new(nameof(dataSourceProductName)); + private static readonly AssistantSessionStateKey DATA_SOURCE_HOSTNAME_STATE_KEY = new(nameof(dataSourceHostname)); + private static readonly AssistantSessionStateKey DATA_SOURCE_PORT_STATE_KEY = new(nameof(dataSourcePort)); + private static readonly AssistantSessionStateKey USER_TYPED_PORT_STATE_KEY = new(nameof(userTypedPort)); + private static readonly AssistantSessionStateKey> SELECTED_AUTHENTICATION_METHODS_STATE_KEY = new(nameof(selectedAuthenticationMethods)); + private static readonly AssistantSessionStateKey AUTH_DESCRIPTION_STATE_KEY = new(nameof(authDescription)); + private static readonly AssistantSessionStateKey SELECTED_OPERATING_SYSTEM_STATE_KEY = new(nameof(selectedOperatingSystem)); + private static readonly AssistantSessionStateKey ALLOWED_LLM_PROVIDERS_STATE_KEY = new(nameof(allowedLLMProviders)); + private static readonly AssistantSessionStateKey> EMBEDDINGS_STATE_KEY = new(nameof(embeddings)); + private static readonly AssistantSessionStateKey> RETRIEVAL_PROCESSES_STATE_KEY = new(nameof(retrievalProcesses)); + private static readonly AssistantSessionStateKey ADDITIONAL_LIBRARIES_STATE_KEY = new(nameof(additionalLibraries)); + private static readonly AssistantSessionStateKey WRITE_TO_FILESYSTEM_STATE_KEY = new(nameof(writeToFilesystem)); + private static readonly AssistantSessionStateKey BASE_DIRECTORY_STATE_KEY = new(nameof(baseDirectory)); + private static readonly AssistantSessionStateKey> PREVIOUSLY_GENERATED_FILES_STATE_KEY = new(nameof(previouslyGeneratedFiles)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(SELECTED_ERI_SERVER_STATE_KEY, this.selectedERIServer); + state.Set(AUTO_SAVE_STATE_KEY, this.autoSave); + state.Set(SERVER_NAME_STATE_KEY, this.serverName); + state.Set(SERVER_DESCRIPTION_STATE_KEY, this.serverDescription); + state.Set(SELECTED_ERI_VERSION_STATE_KEY, this.selectedERIVersion); + state.Set(ERI_SPECIFICATION_STATE_KEY, this.eriSpecification); + state.Set(SELECTED_PROGRAMMING_LANGUAGE_STATE_KEY, this.selectedProgrammingLanguage); + state.Set(OTHER_PROGRAMMING_LANGUAGE_STATE_KEY, this.otherProgrammingLanguage); + state.Set(SELECTED_DATA_SOURCE_STATE_KEY, this.selectedDataSource); + state.Set(OTHER_DATA_SOURCE_STATE_KEY, this.otherDataSource); + state.Set(DATA_SOURCE_PRODUCT_NAME_STATE_KEY, this.dataSourceProductName); + state.Set(DATA_SOURCE_HOSTNAME_STATE_KEY, this.dataSourceHostname); + state.Set(DATA_SOURCE_PORT_STATE_KEY, this.dataSourcePort); + state.Set(USER_TYPED_PORT_STATE_KEY, this.userTypedPort); + state.SetHashSet(SELECTED_AUTHENTICATION_METHODS_STATE_KEY, this.selectedAuthenticationMethods); + state.Set(AUTH_DESCRIPTION_STATE_KEY, this.authDescription); + state.Set(SELECTED_OPERATING_SYSTEM_STATE_KEY, this.selectedOperatingSystem); + state.Set(ALLOWED_LLM_PROVIDERS_STATE_KEY, this.allowedLLMProviders); + state.SetList(EMBEDDINGS_STATE_KEY, this.embeddings); + state.SetList(RETRIEVAL_PROCESSES_STATE_KEY, this.retrievalProcesses); + state.Set(ADDITIONAL_LIBRARIES_STATE_KEY, this.additionalLibraries); + state.Set(WRITE_TO_FILESYSTEM_STATE_KEY, this.writeToFilesystem); + state.Set(BASE_DIRECTORY_STATE_KEY, this.baseDirectory); + state.SetList(PREVIOUSLY_GENERATED_FILES_STATE_KEY, this.previouslyGeneratedFiles); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(SELECTED_ERI_SERVER_STATE_KEY, value => this.selectedERIServer = value); + state.Restore(AUTO_SAVE_STATE_KEY, value => this.autoSave = value); + state.Restore(SERVER_NAME_STATE_KEY, value => this.serverName = value); + state.Restore(SERVER_DESCRIPTION_STATE_KEY, value => this.serverDescription = value); + state.Restore(SELECTED_ERI_VERSION_STATE_KEY, value => this.selectedERIVersion = value); + state.Restore(ERI_SPECIFICATION_STATE_KEY, value => this.eriSpecification = value); + state.Restore(SELECTED_PROGRAMMING_LANGUAGE_STATE_KEY, value => this.selectedProgrammingLanguage = value); + state.Restore(OTHER_PROGRAMMING_LANGUAGE_STATE_KEY, value => this.otherProgrammingLanguage = value); + state.Restore(SELECTED_DATA_SOURCE_STATE_KEY, value => this.selectedDataSource = value); + state.Restore(OTHER_DATA_SOURCE_STATE_KEY, value => this.otherDataSource = value); + state.Restore(DATA_SOURCE_PRODUCT_NAME_STATE_KEY, value => this.dataSourceProductName = value); + state.Restore(DATA_SOURCE_HOSTNAME_STATE_KEY, value => this.dataSourceHostname = value); + state.Restore(DATA_SOURCE_PORT_STATE_KEY, value => this.dataSourcePort = value); + state.Restore(USER_TYPED_PORT_STATE_KEY, value => this.userTypedPort = value); + state.Restore(SELECTED_AUTHENTICATION_METHODS_STATE_KEY, value => this.selectedAuthenticationMethods = value); + state.Restore(AUTH_DESCRIPTION_STATE_KEY, value => this.authDescription = value); + state.Restore(SELECTED_OPERATING_SYSTEM_STATE_KEY, value => this.selectedOperatingSystem = value); + state.Restore(ALLOWED_LLM_PROVIDERS_STATE_KEY, value => this.allowedLLMProviders = value); + state.RestoreList(EMBEDDINGS_STATE_KEY, this.embeddings); + state.RestoreList(RETRIEVAL_PROCESSES_STATE_KEY, this.retrievalProcesses); + state.Restore(ADDITIONAL_LIBRARIES_STATE_KEY, value => this.additionalLibraries = value); + state.Restore(WRITE_TO_FILESYSTEM_STATE_KEY, value => this.writeToFilesystem = value); + state.Restore(BASE_DIRECTORY_STATE_KEY, value => this.baseDirectory = value); + state.RestoreList(PREVIOUSLY_GENERATED_FILES_STATE_KEY, this.previouslyGeneratedFiles); + } private bool AreServerPresetsBlocked => !this.SettingsManager.ConfigurationData.ERI.PreselectOptions; + + /// + /// Gets whether ERI server preset controls should be disabled. + /// + private bool AreServerPresetControlsDisabled => this.AreServerPresetsBlocked || this.IsProcessing; private void SelectedERIServerChanged(DataERIServer? server) { + if (this.IsProcessing) + return; + this.selectedERIServer = server; this.ResetForm(); } private async Task AddERIServer() { + if (this.IsProcessing) + return; + this.SettingsManager.ConfigurationData.ERI.ERIServers.Add(new () { ServerName = string.Format(T("ERI Server {0}"), DateTimeOffset.UtcNow), @@ -470,6 +590,9 @@ public partial class AssistantERI : AssistantBaseCore private async Task RemoveERIServer() { + if (this.IsProcessing) + return; + if(this.selectedERIServer is null) return; @@ -493,6 +616,31 @@ public partial class AssistantERI : AssistantBaseCore private bool IsNoneERIServerSelected => this.selectedERIServer is null; + /// + /// Gets whether ERI configuration input controls should be disabled. + /// + private bool IsERIInputDisabled => this.IsNoneERIServerSelected || this.IsProcessing; + + /// + /// Gets whether the selected ERI specification cannot be downloaded. + /// + private bool IsSpecificationDownloadDisabled => !this.selectedERIVersion.WasSpecificationSelected() || this.IsERIInputDisabled; + + /// + /// Gets whether the generated-code target directory selection should be disabled. + /// + private bool IsBaseDirectorySelectionDisabled => this.IsERIInputDisabled || !this.writeToFilesystem; + + /// + /// Gets a stable row snapshot for the embedding-method table. + /// + private EmbeddingInfo[] EmbeddingRows => this.embeddings.ToArray(); + + /// + /// Gets a stable row snapshot for the retrieval-process table. + /// + private RetrievalInfo[] RetrievalProcessRows => this.retrievalProcesses.ToArray(); + /// /// Gets called when the server name was changed by typing. /// @@ -780,6 +928,9 @@ public partial class AssistantERI : AssistantBaseCore private async Task AddEmbedding() { + if (this.IsProcessing) + return; + var dialogParameters = new DialogParameters { { x => x.IsEditing, false }, @@ -798,6 +949,9 @@ public partial class AssistantERI : AssistantBaseCore private async Task EditEmbedding(EmbeddingInfo embeddingInfo) { + if (this.IsProcessing) + return; + var dialogParameters = new DialogParameters { { x => x.DataEmbeddingName, embeddingInfo.EmbeddingName }, @@ -823,6 +977,9 @@ public partial class AssistantERI : AssistantBaseCore private async Task DeleteEmbedding(EmbeddingInfo embeddingInfo) { + if (this.IsProcessing) + return; + var message = this.retrievalProcesses.Any(n => n.Embeddings?.Contains(embeddingInfo) is true) ? string.Format(T("The embedding '{0}' is used in one or more retrieval processes. Are you sure you want to delete it?"), embeddingInfo.EmbeddingName) : string.Format(T("Are you sure you want to delete the embedding '{0}'?"), embeddingInfo.EmbeddingName); @@ -845,6 +1002,9 @@ public partial class AssistantERI : AssistantBaseCore private async Task AddRetrievalProcess() { + if (this.IsProcessing) + return; + var dialogParameters = new DialogParameters { { x => x.IsEditing, false }, @@ -864,6 +1024,9 @@ public partial class AssistantERI : AssistantBaseCore private async Task EditRetrievalProcess(RetrievalInfo retrievalInfo) { + if (this.IsProcessing) + return; + var dialogParameters = new DialogParameters { { x => x.DataName, retrievalInfo.Name }, @@ -890,6 +1053,9 @@ public partial class AssistantERI : AssistantBaseCore private async Task DeleteRetrievalProcess(RetrievalInfo retrievalInfo) { + if (this.IsProcessing) + return; + var dialogParameters = new DialogParameters { { x => x.Message, string.Format(T("Are you sure you want to delete the retrieval process '{0}'?"), retrievalInfo.Name) }, @@ -949,6 +1115,10 @@ public partial class AssistantERI : AssistantBaseCore this.AddInputIssue(T("Please describe at least one retrieval process.")); return; } + + var writeToFilesystemSnapshot = this.writeToFilesystem; + var baseDirectorySnapshot = this.baseDirectory; + var previouslyGeneratedFilesSnapshot = this.previouslyGeneratedFiles.ToArray(); this.eriSpecification = await this.selectedERIVersion.ReadSpecification(this.HttpClient); if (string.IsNullOrWhiteSpace(this.eriSpecification)) @@ -990,9 +1160,9 @@ public partial class AssistantERI : AssistantBaseCore var fileListAnswer = await this.AddAIResponseAsync(time, true); // Is this an update of the ERI server? If so, we need to delete the previously generated files: - if (this.writeToFilesystem && this.previouslyGeneratedFiles.Count > 0 && !string.IsNullOrWhiteSpace(fileListAnswer)) + if (writeToFilesystemSnapshot && previouslyGeneratedFilesSnapshot.Length > 0 && !string.IsNullOrWhiteSpace(fileListAnswer)) { - foreach (var file in this.previouslyGeneratedFiles) + foreach (var file in previouslyGeneratedFilesSnapshot) { try { @@ -1014,7 +1184,8 @@ public partial class AssistantERI : AssistantBaseCore } var generatedFiles = new List(); - foreach (var file in this.ExtractFiles(fileListAnswer)) + var filesToGenerate = this.ExtractFiles(fileListAnswer).ToArray(); + foreach (var file in filesToGenerate) { this.Logger.LogInformation($"The LLM want to create the file: '{file}'"); @@ -1034,15 +1205,15 @@ public partial class AssistantERI : AssistantBaseCore ``` """, true); var generatedCodeMarkdown = await this.AddAIResponseAsync(time); - if (this.writeToFilesystem) + if (writeToFilesystemSnapshot) { - var desiredFilePath = Path.Join(this.baseDirectory, file); + var desiredFilePath = Path.Join(baseDirectorySnapshot, file); // Security check: ensure that the desired file path is inside the base directory. // We cannot trust the beginning of the file path because it would be possible // to escape by using `..` in the file path. - if (!desiredFilePath.StartsWith(this.baseDirectory, StringComparison.InvariantCultureIgnoreCase) || desiredFilePath.Contains("..")) - this.Logger.LogWarning($"The file path '{desiredFilePath}' is may not inside the base directory '{this.baseDirectory}'."); + if (!desiredFilePath.StartsWith(baseDirectorySnapshot, StringComparison.InvariantCultureIgnoreCase) || desiredFilePath.Contains("..")) + this.Logger.LogWarning($"The file path '{desiredFilePath}' is may not inside the base directory '{baseDirectorySnapshot}'."); else { @@ -1077,7 +1248,7 @@ public partial class AssistantERI : AssistantBaseCore } } - if(this.writeToFilesystem) + if(writeToFilesystemSnapshot) { this.previouslyGeneratedFiles = generatedFiles; this.selectedERIServer!.PreviouslyGeneratedFiles = generatedFiles; @@ -1096,6 +1267,5 @@ public partial class AssistantERI : AssistantBaseCore like Docker. """, true); await this.AddAIResponseAsync(time); - await this.SendToAssistant(Tools.Components.CHAT, default); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs b/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs index 9f90a0fa..ea6b1077 100644 --- a/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs +++ b/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.GrammarSpelling; @@ -84,6 +85,28 @@ public partial class AssistantGrammarSpelling : AssistantBaseCore INPUT_TEXT_STATE_KEY = new(nameof(inputText)); + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + private static readonly AssistantSessionStateKey CORRECTED_TEXT_STATE_KEY = new(nameof(correctedText)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(INPUT_TEXT_STATE_KEY, this.inputText); + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + state.Set(CORRECTED_TEXT_STATE_KEY, this.correctedText); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(INPUT_TEXT_STATE_KEY, value => this.inputText = value); + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + state.Restore(CORRECTED_TEXT_STATE_KEY, value => this.correctedText = value); + } private string? ValidateText(string text) { @@ -127,6 +150,13 @@ public partial class AssistantGrammarSpelling : AssistantBaseCore - - + + @if (this.isLoading) { @@ -20,7 +20,7 @@ else if (!this.isLoading && string.IsNullOrWhiteSpace(this.loadingIssue)) @this.AddedContentText - + @@ -50,7 +50,7 @@ else if (!this.isLoading && string.IsNullOrWhiteSpace(this.loadingIssue)) @this.RemovedContentText - + @@ -94,7 +94,7 @@ else if (!this.isLoading && string.IsNullOrWhiteSpace(this.loadingIssue)) @this.LocalizedContentText - + diff --git a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs index cc69e796..49d34783 100644 --- a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs +++ b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Text; using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; using AIStudio.Tools.PluginSystem; using Microsoft.Extensions.FileProviders; @@ -117,32 +118,87 @@ public partial class AssistantI18N : AssistantBaseCore private Dictionary removedContent = []; private Dictionary localizedContent = []; private StringBuilder finalLuaCode = new(); + private string? activeSystemPromptLanguage; + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + private static readonly AssistantSessionStateKey IS_LOADING_STATE_KEY = new(nameof(isLoading)); + private static readonly AssistantSessionStateKey LOADING_ISSUE_STATE_KEY = new(nameof(loadingIssue)); + private static readonly AssistantSessionStateKey LOCALIZATION_POSSIBLE_STATE_KEY = new(nameof(localizationPossible)); + private static readonly AssistantSessionStateKey SEARCH_STRING_STATE_KEY = new(nameof(searchString)); + private static readonly AssistantSessionStateKey SELECTED_LANGUAGE_PLUGIN_ID_STATE_KEY = new(nameof(selectedLanguagePluginId)); + private static readonly AssistantSessionStateKey SELECTED_LANGUAGE_PLUGIN_STATE_KEY = new(nameof(selectedLanguagePlugin)); + private static readonly AssistantSessionStateKey> ADDED_CONTENT_STATE_KEY = new(nameof(addedContent)); + private static readonly AssistantSessionStateKey> REMOVED_CONTENT_STATE_KEY = new(nameof(removedContent)); + private static readonly AssistantSessionStateKey> LOCALIZED_CONTENT_STATE_KEY = new(nameof(localizedContent)); + private static readonly AssistantSessionStateKey FINAL_LUA_CODE_STATE_KEY = new(nameof(finalLuaCode)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + state.Set(IS_LOADING_STATE_KEY, this.isLoading); + state.Set(LOADING_ISSUE_STATE_KEY, this.loadingIssue); + state.Set(LOCALIZATION_POSSIBLE_STATE_KEY, this.localizationPossible); + state.Set(SEARCH_STRING_STATE_KEY, this.searchString); + state.Set(SELECTED_LANGUAGE_PLUGIN_ID_STATE_KEY, this.selectedLanguagePluginId); + state.Set(SELECTED_LANGUAGE_PLUGIN_STATE_KEY, this.selectedLanguagePlugin); + state.SetDictionary(ADDED_CONTENT_STATE_KEY, this.addedContent); + state.SetDictionary(REMOVED_CONTENT_STATE_KEY, this.removedContent); + state.SetDictionary(LOCALIZED_CONTENT_STATE_KEY, this.localizedContent); + state.SetStringBuilder(FINAL_LUA_CODE_STATE_KEY, this.finalLuaCode); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + state.Restore(IS_LOADING_STATE_KEY, value => this.isLoading = value); + state.Restore(LOADING_ISSUE_STATE_KEY, value => this.loadingIssue = value); + state.Restore(LOCALIZATION_POSSIBLE_STATE_KEY, value => this.localizationPossible = value); + state.Restore(SEARCH_STRING_STATE_KEY, value => this.searchString = value); + state.Restore(SELECTED_LANGUAGE_PLUGIN_ID_STATE_KEY, value => this.selectedLanguagePluginId = value); + state.Restore(SELECTED_LANGUAGE_PLUGIN_STATE_KEY, value => this.selectedLanguagePlugin = value); + state.RestoreDictionary(ADDED_CONTENT_STATE_KEY, this.addedContent); + state.RestoreDictionary(REMOVED_CONTENT_STATE_KEY, this.removedContent); + state.RestoreDictionary(LOCALIZED_CONTENT_STATE_KEY, this.localizedContent); + state.RestoreStringBuilder(FINAL_LUA_CODE_STATE_KEY, this.finalLuaCode); + } #region Overrides of AssistantBase protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); + if (this.HasAssistantSession) + return; + await this.OnLanguagePluginChanged(this.selectedLanguagePluginId); - await this.LoadData(); } #endregion - private string SystemPromptLanguage() => this.selectedTargetLanguage switch + private string SystemPromptLanguage() => this.activeSystemPromptLanguage ?? (this.selectedTargetLanguage switch { CommonLanguages.OTHER => this.customTargetLanguage, _ => $"{this.selectedTargetLanguage.Name()}", - }; + }); private async Task OnLanguagePluginChanged(Guid pluginId) { + if (this.IsProcessing) + return; + this.selectedLanguagePluginId = pluginId; await this.OnChangedLanguage(); } private async Task OnChangedLanguage() { + if (this.IsProcessing) + return; + this.finalLuaCode.Clear(); this.localizedContent.Clear(); this.localizationPossible = false; @@ -261,6 +317,21 @@ public partial class AssistantI18N : AssistantBaseCore private int NumTotalItems => (this.selectedLanguagePlugin?.Content.Count ?? 0) + this.addedContent.Count - this.removedContent.Count; + /// + /// Gets a stable row snapshot for the added-content table. + /// + private KeyValuePair[] AddedContentRows => this.addedContent.ToArray(); + + /// + /// Gets a stable row snapshot for the removed-content table. + /// + private KeyValuePair[] RemovedContentRows => this.removedContent.ToArray(); + + /// + /// Gets a stable row snapshot for the localized-content table. + /// + private KeyValuePair[] LocalizedContentRows => this.localizedContent.ToArray(); + private string AddedContentText => string.Format(T("Added Content ({0} entries)"), this.addedContent.Count); private string RemovedContentText => string.Format(T("Removed Content ({0} entries)"), this.removedContent.Count); @@ -279,68 +350,87 @@ public partial class AssistantI18N : AssistantBaseCore if (this.selectedLanguagePlugin.IETFTag != this.selectedTargetLanguage.ToIETFTag()) return; - this.localizedContent.Clear(); - if (this.selectedTargetLanguage is not CommonLanguages.EN_US) - { - // Phase 1: Translate added content - await this.Phase1TranslateAddedContent(); - } - else - { - // Case: no translation needed - this.localizedContent = this.addedContent.ToDictionary(); - } + var addedContentSnapshot = this.addedContent.ToArray(); + var removedContentSnapshot = this.removedContent.ToArray(); + var removedContentKeys = removedContentSnapshot.Select(keyValuePair => keyValuePair.Key).ToHashSet(StringComparer.Ordinal); + var selectedLanguageContentSnapshot = this.selectedLanguagePlugin.Content.ToArray(); + var baseLanguageContentSnapshot = PluginFactory.BaseLanguage.Content.ToArray(); - if(this.CancellationTokenSource!.IsCancellationRequested) - return; - - // - // Now, we have localized the added content. Next, we must merge - // the localized content with the existing content. However, we - // must skip the removed content. We use the localizedContent - // dictionary for the final result: - // - foreach (var keyValuePair in this.selectedLanguagePlugin.Content) + this.localizedContent.Clear(); + this.activeSystemPromptLanguage = this.SystemPromptLanguage(); + try { - if (this.CancellationTokenSource!.IsCancellationRequested) - break; + if (this.selectedTargetLanguage is not CommonLanguages.EN_US) + { + // Phase 1: Translate added content + await this.Phase1TranslateAddedContent(addedContentSnapshot); + } + else + { + // Case: no translation needed + this.localizedContent = addedContentSnapshot.ToDictionary(keyValuePair => keyValuePair.Key, keyValuePair => keyValuePair.Value, StringComparer.Ordinal); + } + + if(this.CancellationTokenSource!.IsCancellationRequested) + return; - if (this.localizedContent.ContainsKey(keyValuePair.Key)) - continue; + // + // Now, we have localized the added content. Next, we must merge + // the localized content with the existing content. However, we + // must skip the removed content. We use the localizedContent + // dictionary for the final result: + // + foreach (var keyValuePair in selectedLanguageContentSnapshot) + { + if (this.CancellationTokenSource!.IsCancellationRequested) + break; + + if (this.localizedContent.ContainsKey(keyValuePair.Key)) + continue; + + if (removedContentKeys.Contains(keyValuePair.Key)) + continue; + + this.localizedContent.Add(keyValuePair.Key, keyValuePair.Value); + } + + if(this.CancellationTokenSource!.IsCancellationRequested) + return; - if (this.removedContent.ContainsKey(keyValuePair.Key)) - continue; + // + // Phase 2: Create the Lua code. We want to use the base language + // for the comments, though: + // + var commentContent = addedContentSnapshot.ToDictionary(keyValuePair => keyValuePair.Key, keyValuePair => keyValuePair.Value, StringComparer.Ordinal); + foreach (var keyValuePair in baseLanguageContentSnapshot) + { + if (this.CancellationTokenSource!.IsCancellationRequested) + break; + + if (removedContentKeys.Contains(keyValuePair.Key)) + continue; + + commentContent.TryAdd(keyValuePair.Key, keyValuePair.Value); + } - this.localizedContent.Add(keyValuePair.Key, keyValuePair.Value); + this.Phase2CreateLuaCode(commentContent); } - - if(this.CancellationTokenSource!.IsCancellationRequested) - return; - - // - // Phase 2: Create the Lua code. We want to use the base language - // for the comments, though: - // - var commentContent = new Dictionary(this.addedContent); - foreach (var keyValuePair in PluginFactory.BaseLanguage.Content) + finally { - if (this.CancellationTokenSource!.IsCancellationRequested) - break; - - if (this.removedContent.ContainsKey(keyValuePair.Key)) - continue; - - commentContent.TryAdd(keyValuePair.Key, keyValuePair.Value); + this.activeSystemPromptLanguage = null; } - - this.Phase2CreateLuaCode(commentContent); } - private async Task Phase1TranslateAddedContent() + /// + /// Translates the added text content from a stable snapshot. + /// + /// The added text entries captured when the job started. + /// A task that completes when all added text entries were translated or cancellation was requested. + private async Task Phase1TranslateAddedContent(KeyValuePair[] addedContentSnapshot) { var stopwatch = new Stopwatch(); var minimumTime = TimeSpan.FromMilliseconds(500); - foreach (var keyValuePair in this.addedContent) + foreach (var keyValuePair in addedContentSnapshot) { if(this.CancellationTokenSource!.IsCancellationRequested) break; diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index bd5d8535..c61ce973 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -310,6 +310,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T180921696"] = "Reset" -- Please select a provider. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1809312323"] = "Please select a provider." +-- The assistant failed. The message is: '{0}' +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1833836281"] = "The assistant failed. The message is: '{0}'" + +-- This assistant is already running. AI Studio opens the running session instead. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T2575715765"] = "This assistant is already running. AI Studio opens the running session instead." + -- Assistant - {0} UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T3043922"] = "Assistant - {0}" @@ -748,6 +754,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T1565111217"] = "Relat -- Please select an ERI specification version for the ERI server. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T1612890554"] = "Please select an ERI specification version for the ERI server." +-- Open in chat +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T1664804142"] = "Open in chat" + -- Ports below 1024 are reserved for system services. Your ERI server need to run with elevated permissions (root user). UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T168780641"] = "Ports below 1024 are reserved for system services. Your ERI server need to run with elevated permissions (root user)." @@ -1981,6 +1990,18 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "The ima -- Open Settings UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Open Settings" +-- Assistant is still running. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "Assistant is still running." + +-- Assistant was canceled. Open it to review the result. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3390934621"] = "Assistant was canceled. Open it to review the result." + +-- Assistant failed. Open it to review the result. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3571008422"] = "Assistant failed. Open it to review the result." + +-- The result is ready. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T661906146"] = "The result is ready." + -- Show or hide the detailed security information. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1045105126"] = "Show or hide the detailed security information." diff --git a/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs b/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs index b6a3e5ad..1134c175 100644 --- a/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs +++ b/app/MindWork AI Studio/Assistants/IconFinder/AssistantIconFinder.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.IconFinder; @@ -56,6 +57,22 @@ public partial class AssistantIconFinder : AssistantBaseCore INPUT_CONTEXT_STATE_KEY = new(nameof(inputContext)); + private static readonly AssistantSessionStateKey SELECTED_ICON_SOURCE_STATE_KEY = new(nameof(selectedIconSource)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(INPUT_CONTEXT_STATE_KEY, this.inputContext); + state.Set(SELECTED_ICON_SOURCE_STATE_KEY, this.selectedIconSource); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(INPUT_CONTEXT_STATE_KEY, value => this.inputContext = value); + state.Restore(SELECTED_ICON_SOURCE_STATE_KEY, value => this.selectedIconSource = value); + } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs b/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs index 9d44eae7..d8826a8c 100644 --- a/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs +++ b/app/MindWork AI Studio/Assistants/JobPosting/AssistantJobPostings.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.JobPosting; @@ -128,6 +129,49 @@ public partial class AssistantJobPostings : AssistantBaseCore INPUT_MANDATORY_INFORMATION_STATE_KEY = new(nameof(inputMandatoryInformation)); + private static readonly AssistantSessionStateKey INPUT_JOB_DESCRIPTION_STATE_KEY = new(nameof(inputJobDescription)); + private static readonly AssistantSessionStateKey INPUT_QUALIFICATIONS_STATE_KEY = new(nameof(inputQualifications)); + private static readonly AssistantSessionStateKey INPUT_RESPONSIBILITIES_STATE_KEY = new(nameof(inputResponsibilities)); + private static readonly AssistantSessionStateKey INPUT_COMPANY_NAME_STATE_KEY = new(nameof(inputCompanyName)); + private static readonly AssistantSessionStateKey INPUT_ENTRY_DATE_STATE_KEY = new(nameof(inputEntryDate)); + private static readonly AssistantSessionStateKey INPUT_VALID_UNTIL_STATE_KEY = new(nameof(inputValidUntil)); + private static readonly AssistantSessionStateKey INPUT_WORK_LOCATION_STATE_KEY = new(nameof(inputWorkLocation)); + private static readonly AssistantSessionStateKey INPUT_COUNTRY_LEGAL_FRAMEWORK_STATE_KEY = new(nameof(inputCountryLegalFramework)); + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(INPUT_MANDATORY_INFORMATION_STATE_KEY, this.inputMandatoryInformation); + state.Set(INPUT_JOB_DESCRIPTION_STATE_KEY, this.inputJobDescription); + state.Set(INPUT_QUALIFICATIONS_STATE_KEY, this.inputQualifications); + state.Set(INPUT_RESPONSIBILITIES_STATE_KEY, this.inputResponsibilities); + state.Set(INPUT_COMPANY_NAME_STATE_KEY, this.inputCompanyName); + state.Set(INPUT_ENTRY_DATE_STATE_KEY, this.inputEntryDate); + state.Set(INPUT_VALID_UNTIL_STATE_KEY, this.inputValidUntil); + state.Set(INPUT_WORK_LOCATION_STATE_KEY, this.inputWorkLocation); + state.Set(INPUT_COUNTRY_LEGAL_FRAMEWORK_STATE_KEY, this.inputCountryLegalFramework); + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(INPUT_MANDATORY_INFORMATION_STATE_KEY, value => this.inputMandatoryInformation = value); + state.Restore(INPUT_JOB_DESCRIPTION_STATE_KEY, value => this.inputJobDescription = value); + state.Restore(INPUT_QUALIFICATIONS_STATE_KEY, value => this.inputQualifications = value); + state.Restore(INPUT_RESPONSIBILITIES_STATE_KEY, value => this.inputResponsibilities = value); + state.Restore(INPUT_COMPANY_NAME_STATE_KEY, value => this.inputCompanyName = value); + state.Restore(INPUT_ENTRY_DATE_STATE_KEY, value => this.inputEntryDate = value); + state.Restore(INPUT_VALID_UNTIL_STATE_KEY, value => this.inputValidUntil = value); + state.Restore(INPUT_WORK_LOCATION_STATE_KEY, value => this.inputWorkLocation = value); + state.Restore(INPUT_COUNTRY_LEGAL_FRAMEWORK_STATE_KEY, value => this.inputCountryLegalFramework = value); + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs b/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs index a7c01bca..7c9cce5e 100644 --- a/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs +++ b/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.LegalCheck; @@ -59,6 +60,31 @@ public partial class AssistantLegalCheck : AssistantBaseCore SHOW_WEB_CONTENT_READER_STATE_KEY = new(nameof(showWebContentReader)); + private static readonly AssistantSessionStateKey USE_CONTENT_CLEANER_AGENT_STATE_KEY = new(nameof(useContentCleanerAgent)); + private static readonly AssistantSessionStateKey IS_AGENT_RUNNING_STATE_KEY = new(nameof(isAgentRunning)); + private static readonly AssistantSessionStateKey INPUT_LEGAL_DOCUMENT_STATE_KEY = new(nameof(inputLegalDocument)); + private static readonly AssistantSessionStateKey INPUT_QUESTIONS_STATE_KEY = new(nameof(inputQuestions)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(SHOW_WEB_CONTENT_READER_STATE_KEY, this.showWebContentReader); + state.Set(USE_CONTENT_CLEANER_AGENT_STATE_KEY, this.useContentCleanerAgent); + state.Set(IS_AGENT_RUNNING_STATE_KEY, this.isAgentRunning); + state.Set(INPUT_LEGAL_DOCUMENT_STATE_KEY, this.inputLegalDocument); + state.Set(INPUT_QUESTIONS_STATE_KEY, this.inputQuestions); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(SHOW_WEB_CONTENT_READER_STATE_KEY, value => this.showWebContentReader = value); + state.Restore(USE_CONTENT_CLEANER_AGENT_STATE_KEY, value => this.useContentCleanerAgent = value); + state.Restore(IS_AGENT_RUNNING_STATE_KEY, value => this.isAgentRunning = value); + state.Restore(INPUT_LEGAL_DOCUMENT_STATE_KEY, value => this.inputLegalDocument = value); + state.Restore(INPUT_QUESTIONS_STATE_KEY, value => this.inputQuestions = value); + } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs b/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs index ff5ab87f..18b25880 100644 --- a/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs +++ b/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor.cs @@ -1,5 +1,6 @@ using AIStudio.Dialogs.Settings; using AIStudio.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.MyTasks; @@ -59,6 +60,25 @@ public partial class AssistantMyTasks : AssistantBaseCore private string inputText = string.Empty; private CommonLanguages selectedTargetLanguage = CommonLanguages.AS_IS; private string customTargetLanguage = string.Empty; + private static readonly AssistantSessionStateKey INPUT_TEXT_STATE_KEY = new(nameof(inputText)); + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(INPUT_TEXT_STATE_KEY, this.inputText); + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(INPUT_TEXT_STATE_KEY, value => this.inputText = value); + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs b/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs index b1df8944..7a5156b2 100644 --- a/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs +++ b/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using AIStudio.Chat; using AIStudio.Dialogs; using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; using Microsoft.AspNetCore.Components; #if !DEBUG @@ -176,6 +177,67 @@ public partial class AssistantPromptOptimizer : AssistantBaseCore INPUT_PROMPT_STATE_KEY = new(nameof(inputPrompt)); + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + private static readonly AssistantSessionStateKey IMPORTANT_ASPECTS_STATE_KEY = new(nameof(importantAspects)); + private static readonly AssistantSessionStateKey USE_CUSTOM_PROMPT_GUIDE_STATE_KEY = new(nameof(useCustomPromptGuide)); + private static readonly AssistantSessionStateKey> CUSTOM_PROMPT_GUIDE_FILES_STATE_KEY = new(nameof(customPromptGuideFiles)); + private static readonly AssistantSessionStateKey CURRENT_CUSTOM_PROMPT_GUIDE_PATH_STATE_KEY = new(nameof(currentCustomPromptGuidePath)); + private static readonly AssistantSessionStateKey CUSTOM_PROMPTING_GUIDELINE_CONTENT_STATE_KEY = new(nameof(customPromptingGuidelineContent)); + private static readonly AssistantSessionStateKey IS_LOADING_CUSTOM_PROMPT_GUIDE_STATE_KEY = new(nameof(isLoadingCustomPromptGuide)); + private static readonly AssistantSessionStateKey HAS_UPDATED_DEFAULT_RECOMMENDATIONS_STATE_KEY = new(nameof(hasUpdatedDefaultRecommendations)); + private static readonly AssistantSessionStateKey OPTIMIZED_PROMPT_STATE_KEY = new(nameof(optimizedPrompt)); + private static readonly AssistantSessionStateKey REC_CLARITY_DIRECTNESS_STATE_KEY = new(nameof(recClarityDirectness)); + private static readonly AssistantSessionStateKey REC_EXAMPLES_CONTEXT_STATE_KEY = new(nameof(recExamplesContext)); + private static readonly AssistantSessionStateKey REC_SEQUENTIAL_STEPS_STATE_KEY = new(nameof(recSequentialSteps)); + private static readonly AssistantSessionStateKey REC_STRUCTURE_MARKERS_STATE_KEY = new(nameof(recStructureMarkers)); + private static readonly AssistantSessionStateKey REC_ROLE_DEFINITION_STATE_KEY = new(nameof(recRoleDefinition)); + private static readonly AssistantSessionStateKey REC_LANGUAGE_CHOICE_STATE_KEY = new(nameof(recLanguageChoice)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(INPUT_PROMPT_STATE_KEY, this.inputPrompt); + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + state.Set(IMPORTANT_ASPECTS_STATE_KEY, this.importantAspects); + state.Set(USE_CUSTOM_PROMPT_GUIDE_STATE_KEY, this.useCustomPromptGuide); + state.SetHashSet(CUSTOM_PROMPT_GUIDE_FILES_STATE_KEY, this.customPromptGuideFiles); + state.Set(CURRENT_CUSTOM_PROMPT_GUIDE_PATH_STATE_KEY, this.currentCustomPromptGuidePath); + state.Set(CUSTOM_PROMPTING_GUIDELINE_CONTENT_STATE_KEY, this.customPromptingGuidelineContent); + state.Set(IS_LOADING_CUSTOM_PROMPT_GUIDE_STATE_KEY, this.isLoadingCustomPromptGuide); + state.Set(HAS_UPDATED_DEFAULT_RECOMMENDATIONS_STATE_KEY, this.hasUpdatedDefaultRecommendations); + state.Set(OPTIMIZED_PROMPT_STATE_KEY, this.optimizedPrompt); + state.Set(REC_CLARITY_DIRECTNESS_STATE_KEY, this.recClarityDirectness); + state.Set(REC_EXAMPLES_CONTEXT_STATE_KEY, this.recExamplesContext); + state.Set(REC_SEQUENTIAL_STEPS_STATE_KEY, this.recSequentialSteps); + state.Set(REC_STRUCTURE_MARKERS_STATE_KEY, this.recStructureMarkers); + state.Set(REC_ROLE_DEFINITION_STATE_KEY, this.recRoleDefinition); + state.Set(REC_LANGUAGE_CHOICE_STATE_KEY, this.recLanguageChoice); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(INPUT_PROMPT_STATE_KEY, value => this.inputPrompt = value); + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + state.Restore(IMPORTANT_ASPECTS_STATE_KEY, value => this.importantAspects = value); + state.Restore(USE_CUSTOM_PROMPT_GUIDE_STATE_KEY, value => this.useCustomPromptGuide = value); + state.RestoreHashSet(CUSTOM_PROMPT_GUIDE_FILES_STATE_KEY, this.customPromptGuideFiles); + state.Restore(CURRENT_CUSTOM_PROMPT_GUIDE_PATH_STATE_KEY, value => this.currentCustomPromptGuidePath = value); + state.Restore(CUSTOM_PROMPTING_GUIDELINE_CONTENT_STATE_KEY, value => this.customPromptingGuidelineContent = value); + state.Restore(IS_LOADING_CUSTOM_PROMPT_GUIDE_STATE_KEY, value => this.isLoadingCustomPromptGuide = value); + state.Restore(HAS_UPDATED_DEFAULT_RECOMMENDATIONS_STATE_KEY, value => this.hasUpdatedDefaultRecommendations = value); + state.Restore(OPTIMIZED_PROMPT_STATE_KEY, value => this.optimizedPrompt = value); + state.Restore(REC_CLARITY_DIRECTNESS_STATE_KEY, value => this.recClarityDirectness = value); + state.Restore(REC_EXAMPLES_CONTEXT_STATE_KEY, value => this.recExamplesContext = value); + state.Restore(REC_SEQUENTIAL_STEPS_STATE_KEY, value => this.recSequentialSteps = value); + state.Restore(REC_STRUCTURE_MARKERS_STATE_KEY, value => this.recStructureMarkers = value); + state.Restore(REC_ROLE_DEFINITION_STATE_KEY, value => this.recRoleDefinition = value); + state.Restore(REC_LANGUAGE_CHOICE_STATE_KEY, value => this.recLanguageChoice = value); + } private bool ShowUpdatedPromptGuidelinesIndicator => !this.useCustomPromptGuide && this.hasUpdatedDefaultRecommendations; private bool CanPreviewCustomPromptGuide => this.useCustomPromptGuide && this.customPromptGuideFiles.Count > 0; diff --git a/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs b/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs index 81eaa6b3..eb2cb493 100644 --- a/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs +++ b/app/MindWork AI Studio/Assistants/RewriteImprove/AssistantRewriteImprove.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.RewriteImprove; @@ -91,6 +92,34 @@ public partial class AssistantRewriteImprove : AssistantBaseCore INPUT_TEXT_STATE_KEY = new(nameof(inputText)); + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + private static readonly AssistantSessionStateKey REWRITTEN_TEXT_STATE_KEY = new(nameof(rewrittenText)); + private static readonly AssistantSessionStateKey SELECTED_WRITING_STYLE_STATE_KEY = new(nameof(selectedWritingStyle)); + private static readonly AssistantSessionStateKey SELECTED_SENTENCE_STRUCTURE_STATE_KEY = new(nameof(selectedSentenceStructure)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(INPUT_TEXT_STATE_KEY, this.inputText); + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + state.Set(REWRITTEN_TEXT_STATE_KEY, this.rewrittenText); + state.Set(SELECTED_WRITING_STYLE_STATE_KEY, this.selectedWritingStyle); + state.Set(SELECTED_SENTENCE_STRUCTURE_STATE_KEY, this.selectedSentenceStructure); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(INPUT_TEXT_STATE_KEY, value => this.inputText = value); + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + state.Restore(REWRITTEN_TEXT_STATE_KEY, value => this.rewrittenText = value); + state.Restore(SELECTED_WRITING_STYLE_STATE_KEY, value => this.selectedWritingStyle = value); + state.Restore(SELECTED_SENTENCE_STRUCTURE_STATE_KEY, value => this.selectedSentenceStructure = value); + } private string? ValidateText(string text) { @@ -134,6 +163,13 @@ public partial class AssistantRewriteImprove : AssistantBaseCore loadedDocumentPaths = []; + private static readonly AssistantSessionStateKey INPUT_TITLE_STATE_KEY = new(nameof(inputTitle)); + private static readonly AssistantSessionStateKey INPUT_CONTENT_STATE_KEY = new(nameof(inputContent)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + private static readonly AssistantSessionStateKey SELECTED_AUDIENCE_PROFILE_STATE_KEY = new(nameof(selectedAudienceProfile)); + private static readonly AssistantSessionStateKey SELECTED_AUDIENCE_AGE_GROUP_STATE_KEY = new(nameof(selectedAudienceAgeGroup)); + private static readonly AssistantSessionStateKey SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY = new(nameof(selectedAudienceOrganizationalLevel)); + private static readonly AssistantSessionStateKey SELECTED_AUDIENCE_EXPERTISE_STATE_KEY = new(nameof(selectedAudienceExpertise)); + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey NUMBER_OF_SHEETS_STATE_KEY = new(nameof(numberOfSheets)); + private static readonly AssistantSessionStateKey NUMBER_OF_BULLET_POINTS_STATE_KEY = new(nameof(numberOfBulletPoints)); + private static readonly AssistantSessionStateKey TIME_SPECIFICATION_STATE_KEY = new(nameof(timeSpecification)); + private static readonly AssistantSessionStateKey CALCULATED_NUMBER_OF_SLIDES_STATE_KEY = new(nameof(calculatedNumberOfSlides)); + private static readonly AssistantSessionStateKey IMPORTANT_ASPECTS_STATE_KEY = new(nameof(importantAspects)); + private static readonly AssistantSessionStateKey> LOADED_DOCUMENT_PATHS_STATE_KEY = new(nameof(loadedDocumentPaths)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(INPUT_TITLE_STATE_KEY, this.inputTitle); + state.Set(INPUT_CONTENT_STATE_KEY, this.inputContent); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + state.Set(SELECTED_AUDIENCE_PROFILE_STATE_KEY, this.selectedAudienceProfile); + state.Set(SELECTED_AUDIENCE_AGE_GROUP_STATE_KEY, this.selectedAudienceAgeGroup); + state.Set(SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY, this.selectedAudienceOrganizationalLevel); + state.Set(SELECTED_AUDIENCE_EXPERTISE_STATE_KEY, this.selectedAudienceExpertise); + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(NUMBER_OF_SHEETS_STATE_KEY, this.numberOfSheets); + state.Set(NUMBER_OF_BULLET_POINTS_STATE_KEY, this.numberOfBulletPoints); + state.Set(TIME_SPECIFICATION_STATE_KEY, this.timeSpecification); + state.Set(CALCULATED_NUMBER_OF_SLIDES_STATE_KEY, this.calculatedNumberOfSlides); + state.Set(IMPORTANT_ASPECTS_STATE_KEY, this.importantAspects); + state.SetHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(INPUT_TITLE_STATE_KEY, value => this.inputTitle = value); + state.Restore(INPUT_CONTENT_STATE_KEY, value => this.inputContent = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + state.Restore(SELECTED_AUDIENCE_PROFILE_STATE_KEY, value => this.selectedAudienceProfile = value); + state.Restore(SELECTED_AUDIENCE_AGE_GROUP_STATE_KEY, value => this.selectedAudienceAgeGroup = value); + state.Restore(SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY, value => this.selectedAudienceOrganizationalLevel = value); + state.Restore(SELECTED_AUDIENCE_EXPERTISE_STATE_KEY, value => this.selectedAudienceExpertise = value); + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(NUMBER_OF_SHEETS_STATE_KEY, value => this.numberOfSheets = value); + state.Restore(NUMBER_OF_BULLET_POINTS_STATE_KEY, value => this.numberOfBulletPoints = value); + state.Restore(TIME_SPECIFICATION_STATE_KEY, value => this.timeSpecification = value); + state.Restore(CALCULATED_NUMBER_OF_SLIDES_STATE_KEY, value => this.calculatedNumberOfSlides = value); + state.Restore(IMPORTANT_ASPECTS_STATE_KEY, value => this.importantAspects = value); + state.RestoreHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths); + } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs b/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs index d778d9a1..3acc0b08 100644 --- a/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs +++ b/app/MindWork AI Studio/Assistants/Synonym/AssistantSynonyms.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.Synonym; @@ -103,6 +104,28 @@ public partial class AssistantSynonyms : AssistantBaseCore INPUT_TEXT_STATE_KEY = new(nameof(inputText)); + private static readonly AssistantSessionStateKey INPUT_CONTEXT_STATE_KEY = new(nameof(inputContext)); + private static readonly AssistantSessionStateKey SELECTED_LANGUAGE_STATE_KEY = new(nameof(selectedLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(INPUT_TEXT_STATE_KEY, this.inputText); + state.Set(INPUT_CONTEXT_STATE_KEY, this.inputContext); + state.Set(SELECTED_LANGUAGE_STATE_KEY, this.selectedLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(INPUT_TEXT_STATE_KEY, value => this.inputText = value); + state.Restore(INPUT_CONTEXT_STATE_KEY, value => this.inputContext = value); + state.Restore(SELECTED_LANGUAGE_STATE_KEY, value => this.selectedLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs b/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs index 0c2097b1..62356f83 100644 --- a/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs +++ b/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.TextSummarizer; @@ -72,6 +73,43 @@ public partial class AssistantTextSummarizer : AssistantBaseCore SHOW_WEB_CONTENT_READER_STATE_KEY = new(nameof(showWebContentReader)); + private static readonly AssistantSessionStateKey USE_CONTENT_CLEANER_AGENT_STATE_KEY = new(nameof(useContentCleanerAgent)); + private static readonly AssistantSessionStateKey INPUT_TEXT_STATE_KEY = new(nameof(inputText)); + private static readonly AssistantSessionStateKey IS_AGENT_RUNNING_STATE_KEY = new(nameof(isAgentRunning)); + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + private static readonly AssistantSessionStateKey SELECTED_COMPLEXITY_STATE_KEY = new(nameof(selectedComplexity)); + private static readonly AssistantSessionStateKey EXPERT_IN_FIELD_STATE_KEY = new(nameof(expertInField)); + private static readonly AssistantSessionStateKey IMPORTANT_ASPECTS_STATE_KEY = new(nameof(importantAspects)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(SHOW_WEB_CONTENT_READER_STATE_KEY, this.showWebContentReader); + state.Set(USE_CONTENT_CLEANER_AGENT_STATE_KEY, this.useContentCleanerAgent); + state.Set(INPUT_TEXT_STATE_KEY, this.inputText); + state.Set(IS_AGENT_RUNNING_STATE_KEY, this.isAgentRunning); + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + state.Set(SELECTED_COMPLEXITY_STATE_KEY, this.selectedComplexity); + state.Set(EXPERT_IN_FIELD_STATE_KEY, this.expertInField); + state.Set(IMPORTANT_ASPECTS_STATE_KEY, this.importantAspects); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(SHOW_WEB_CONTENT_READER_STATE_KEY, value => this.showWebContentReader = value); + state.Restore(USE_CONTENT_CLEANER_AGENT_STATE_KEY, value => this.useContentCleanerAgent = value); + state.Restore(INPUT_TEXT_STATE_KEY, value => this.inputText = value); + state.Restore(IS_AGENT_RUNNING_STATE_KEY, value => this.isAgentRunning = value); + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + state.Restore(SELECTED_COMPLEXITY_STATE_KEY, value => this.selectedComplexity = value); + state.Restore(EXPERT_IN_FIELD_STATE_KEY, value => this.expertInField = value); + state.Restore(IMPORTANT_ASPECTS_STATE_KEY, value => this.importantAspects = value); + } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs b/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs index 690f8d21..b368f186 100644 --- a/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs +++ b/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Dialogs.Settings; +using AIStudio.Tools.AssistantSessions; namespace AIStudio.Assistants.Translation; @@ -79,6 +80,40 @@ public partial class AssistantTranslation : AssistantBaseCore SHOW_WEB_CONTENT_READER_STATE_KEY = new(nameof(showWebContentReader)); + private static readonly AssistantSessionStateKey USE_CONTENT_CLEANER_AGENT_STATE_KEY = new(nameof(useContentCleanerAgent)); + private static readonly AssistantSessionStateKey LIVE_TRANSLATION_STATE_KEY = new(nameof(liveTranslation)); + private static readonly AssistantSessionStateKey IS_AGENT_RUNNING_STATE_KEY = new(nameof(isAgentRunning)); + private static readonly AssistantSessionStateKey INPUT_TEXT_STATE_KEY = new(nameof(inputText)); + private static readonly AssistantSessionStateKey INPUT_TEXT_LAST_TRANSLATION_STATE_KEY = new(nameof(inputTextLastTranslation)); + private static readonly AssistantSessionStateKey SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage)); + private static readonly AssistantSessionStateKey CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage)); + + /// + protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state) + { + state.Set(SHOW_WEB_CONTENT_READER_STATE_KEY, this.showWebContentReader); + state.Set(USE_CONTENT_CLEANER_AGENT_STATE_KEY, this.useContentCleanerAgent); + state.Set(LIVE_TRANSLATION_STATE_KEY, this.liveTranslation); + state.Set(IS_AGENT_RUNNING_STATE_KEY, this.isAgentRunning); + state.Set(INPUT_TEXT_STATE_KEY, this.inputText); + state.Set(INPUT_TEXT_LAST_TRANSLATION_STATE_KEY, this.inputTextLastTranslation); + state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage); + state.Set(CUSTOM_TARGET_LANGUAGE_STATE_KEY, this.customTargetLanguage); + } + + /// + protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) + { + state.Restore(SHOW_WEB_CONTENT_READER_STATE_KEY, value => this.showWebContentReader = value); + state.Restore(USE_CONTENT_CLEANER_AGENT_STATE_KEY, value => this.useContentCleanerAgent = value); + state.Restore(LIVE_TRANSLATION_STATE_KEY, value => this.liveTranslation = value); + state.Restore(IS_AGENT_RUNNING_STATE_KEY, value => this.isAgentRunning = value); + state.Restore(INPUT_TEXT_STATE_KEY, value => this.inputText = value); + state.Restore(INPUT_TEXT_LAST_TRANSLATION_STATE_KEY, value => this.inputTextLastTranslation = value); + state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value); + state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); + } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Components/AssistantBlock.razor b/app/MindWork AI Studio/Components/AssistantBlock.razor index 973af871..b46711c5 100644 --- a/app/MindWork AI Studio/Components/AssistantBlock.razor +++ b/app/MindWork AI Studio/Components/AssistantBlock.razor @@ -7,7 +7,17 @@ - + + + @if (this.AssistantSessionIndicator is { } indicator) + { + + + + + + } + @this.Name diff --git a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs index dde37267..735b2974 100644 --- a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs +++ b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs @@ -1,5 +1,6 @@ using AIStudio.Dialogs.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.AssistantSessions; using Microsoft.AspNetCore.Components; using DialogOptions = AIStudio.Dialogs.DialogOptions; @@ -7,6 +8,14 @@ namespace AIStudio.Components; public partial class AssistantBlock : MSGComponentBase where TSettings : IComponent { + /// + /// Describes the assistant session indicator shown on top of the assistant icon. + /// + /// The icon that communicates the session status. + /// The color that communicates the session status. + /// The tooltip text that explains the session status. + private sealed record AssistantSessionIndicatorData(string Icon, Color Color, string Tooltip); + [Parameter] public string Name { get; set; } = string.Empty; @@ -31,6 +40,12 @@ public partial class AssistantBlock : MSGComponentBase where TSetting [Parameter] public Tools.Components Component { get; set; } = Tools.Components.NONE; + /// + /// Gets or sets the optional assistant session instance ID represented by this block. + /// + [Parameter] + public string AssistantSessionInstanceId { get; set; } = string.Empty; + [Parameter] public PreviewFeatures RequiredPreviewFeature { get; set; } = PreviewFeatures.NONE; @@ -39,6 +54,9 @@ public partial class AssistantBlock : MSGComponentBase where TSetting [Inject] private IDialogService DialogService { get; init; } = null!; + + [Inject] + private AssistantSessionService AssistantSessionService { get; init; } = null!; private async Task OpenSettingsDialog() { @@ -50,15 +68,50 @@ public partial class AssistantBlock : MSGComponentBase where TSetting await this.DialogService.ShowAsync(T("Open Settings"), dialogParameters, DialogOptions.FULLSCREEN); } - private string BorderColor => this.SettingsManager.IsDarkMode switch + private string BorderColor => this.AssistantSessionSnapshot?.IsActive is true ? this.ColorTheme.GetActivityIndicatorColor(this.SettingsManager) : this.SettingsManager.IsDarkMode switch { - true => this.ColorTheme.GetCurrentPalette(this.SettingsManager).GrayLight, - false => this.ColorTheme.GetCurrentPalette(this.SettingsManager).Primary.Value, + true => this.ColorTheme.GetCurrentPalette(this.SettingsManager).GrayDefault, + false => this.ColorTheme.GetCurrentPalette(this.SettingsManager).GrayDefault, }; - private string BlockStyle => $"border-width: 2px; border-color: {this.BorderColor}; border-radius: 12px; border-style: solid; max-width: 20em;"; + private string BlockStyle => $"border-width: 3px; border-color: {this.BorderColor}; border-radius: 12px; border-style: solid; max-width: 20em;"; private bool IsVisible => this.SettingsManager.IsAssistantVisible(this.Component, assistantName: this.Name, requiredPreviewFeature: this.RequiredPreviewFeature); private bool HasSettingsPanel => typeof(TSettings) != typeof(NoSettingsPanel); -} + + /// + /// Gets the newest assistant session snapshot represented by this block. + /// + private AssistantSessionSnapshot? AssistantSessionSnapshot => string.IsNullOrWhiteSpace(this.AssistantSessionInstanceId) + ? this.AssistantSessionService.GetSnapshots().FirstOrDefault(snapshot => snapshot.Key.Component == this.Component) + : this.AssistantSessionService.GetSnapshots().FirstOrDefault(snapshot => snapshot.Key.InstanceId == this.AssistantSessionInstanceId); + + /// + /// Gets the assistant session indicator shown on top of the assistant icon. + /// + private AssistantSessionIndicatorData? AssistantSessionIndicator => this.AssistantSessionSnapshot?.Status switch + { + AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING => new(Icons.Material.Filled.ChangeCircle, Color.Info, this.T("Assistant is still running.")), + AssistantSessionStatus.COMPLETED => new(Icons.Material.Filled.TaskAlt, Color.Success, this.T("The result is ready.")), + AssistantSessionStatus.FAILED => new(Icons.Material.Filled.Error, Color.Error, this.T("Assistant failed. Open it to review the result.")), + AssistantSessionStatus.CANCELED => new(Icons.Material.Filled.Cancel, Color.Warning, this.T("Assistant was canceled. Open it to review the result.")), + _ => null, + }; + + /// + /// Refreshes the block when assistant session activity changes. + /// + /// The message payload type. + /// The component that sent the message, if any. + /// The event that was triggered. + /// The message payload. + /// A task that completes after the message was processed. + protected override Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default + { + if (triggeredEvent is Event.ASSISTANT_SESSION_CHANGED or Event.ASSISTANT_SESSION_FINISHED) + this.StateHasChanged(); + + return base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/EnumSelection.razor b/app/MindWork AI Studio/Components/EnumSelection.razor index bd5bc08a..db25cae2 100644 --- a/app/MindWork AI Studio/Components/EnumSelection.razor +++ b/app/MindWork AI Studio/Components/EnumSelection.razor @@ -2,7 +2,7 @@ @inherits EnumSelectionBase - + @foreach (var value in Enum.GetValues()) { @@ -12,6 +12,6 @@ @if (this.AllowOther && this.Value.Equals(this.OtherValue)) { - + } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/EnumSelection.razor.cs b/app/MindWork AI Studio/Components/EnumSelection.razor.cs index a7d93dd9..0429c738 100644 --- a/app/MindWork AI Studio/Components/EnumSelection.razor.cs +++ b/app/MindWork AI Studio/Components/EnumSelection.razor.cs @@ -38,6 +38,12 @@ public partial class EnumSelection : EnumSelectionBase where T : struct, Enum [Parameter] public string Icon { get; set; } = Icons.Material.Filled.ArrowDropDown; + + /// + /// Gets or sets whether the selection controls are disabled. + /// + [Parameter] + public bool Disabled { get; set; } /// /// Gets or sets the custom name function for selecting the display name of an enum value. diff --git a/app/MindWork AI Studio/Components/ProviderSelection.razor b/app/MindWork AI Studio/Components/ProviderSelection.razor index 793f87c7..527ebde6 100644 --- a/app/MindWork AI Studio/Components/ProviderSelection.razor +++ b/app/MindWork AI Studio/Components/ProviderSelection.razor @@ -1,6 +1,6 @@ @using AIStudio.Settings @inherits MSGComponentBase - + @foreach (var provider in this.GetAvailableProviders()) { diff --git a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs index 74bd75c9..5a5375de 100644 --- a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs +++ b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs @@ -20,6 +20,12 @@ public partial class ProviderSelection : MSGComponentBase [Parameter] public Func ValidateProvider { get; set; } = _ => null; + /// + /// Gets or sets whether provider selection is disabled. + /// + [Parameter] + public bool Disabled { get; set; } + [Parameter] public ConfidenceLevel ExplicitMinimumConfidence { get; set; } = ConfidenceLevel.UNKNOWN; diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index 57dc70dc..b7f9aae1 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -2,6 +2,7 @@ using AIStudio.Dialogs; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.AIJobs; +using AIStudio.Tools.AssistantSessions; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; using AIStudio.Tools.Services; @@ -30,6 +31,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan [Inject] private AIJobService AIJobService { get; init; } = null!; + + [Inject] + private AssistantSessionService AssistantSessionService { get; init; } = null!; [Inject] private ISnackbar Snackbar { get; init; } = null!; @@ -102,7 +106,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan Event.UPDATE_AVAILABLE, Event.CONFIGURATION_CHANGED, Event.COLOR_THEME_CHANGED, Event.SHOW_ERROR, Event.SHOW_WARNING, Event.SHOW_SUCCESS, Event.STARTUP_PLUGIN_SYSTEM, Event.PLUGINS_RELOADED, Event.INSTALL_UPDATE, Event.STARTUP_COMPLETED, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, - Event.CHAT_GENERATION_CHANGED, + Event.CHAT_GENERATION_CHANGED, Event.ASSISTANT_SESSION_CHANGED, Event.ASSISTANT_SESSION_FINISHED, ]); // Set the snackbar for the update service: @@ -228,6 +232,8 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan case Event.AI_JOB_CHANGED: case Event.AI_JOB_FINISHED: case Event.CHAT_GENERATION_CHANGED: + case Event.ASSISTANT_SESSION_CHANGED: + case Event.ASSISTANT_SESSION_FINISHED: this.LoadNavItems(); this.StateHasChanged(); break; @@ -341,18 +347,26 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan private IEnumerable GetNavItems() { var palette = this.ColorTheme.GetCurrentPalette(this.SettingsManager); + var activityIndicatorLightColor = this.ColorTheme.GetActivityIndicatorLightColor(); + var activityIndicatorDarkColor = this.ColorTheme.GetActivityIndicatorDarkColor(); + var defaultLightColor = palette.DarkLighten; + var defaultDarkColor = palette.GrayLight; + var chatLightColor = this.AIJobService.HasActiveJobs ? activityIndicatorLightColor : defaultLightColor; + var chatDarkColor = this.AIJobService.HasActiveJobs ? activityIndicatorDarkColor : defaultDarkColor; + var assistantsLightColor = this.AssistantSessionService.HasActiveSessions ? activityIndicatorLightColor : defaultLightColor; + var assistantsDarkColor = this.AssistantSessionService.HasActiveSessions ? activityIndicatorDarkColor : defaultDarkColor; - yield return new(T("Home"), Icons.Material.Filled.Home, palette.DarkLighten, palette.GrayLight, Routes.HOME, true); - yield return new(T("Chat"), this.AIJobService.HasActiveJobs ? Icons.Material.Filled.Chat : Icons.Material.Outlined.Chat, palette.DarkLighten, palette.GrayLight, Routes.CHAT, false); - yield return new(T("Assistants"), Icons.Material.Filled.Apps, palette.DarkLighten, palette.GrayLight, Routes.ASSISTANTS, false); + yield return new(T("Home"), Icons.Material.Filled.Home, defaultLightColor, defaultDarkColor, Routes.HOME, true); + yield return new(T("Chat"), Icons.Material.Filled.Chat, chatLightColor, chatDarkColor, Routes.CHAT, false); + yield return new(T("Assistants"), Icons.Material.Filled.Apps, assistantsLightColor, assistantsDarkColor, Routes.ASSISTANTS, false); if (PreviewFeatures.PRE_WRITER_MODE_2024.IsEnabled(this.SettingsManager)) - yield return new(T("Writer"), Icons.Material.Filled.Create, palette.DarkLighten, palette.GrayLight, Routes.WRITER, false); + yield return new(T("Writer"), Icons.Material.Filled.Create, defaultLightColor, defaultDarkColor, Routes.WRITER, false); - yield return new(T("Plugins"), Icons.Material.TwoTone.Extension, palette.DarkLighten, palette.GrayLight, Routes.PLUGINS, false); + yield return new(T("Plugins"), Icons.Material.TwoTone.Extension, defaultLightColor, defaultDarkColor, Routes.PLUGINS, false); yield return new(T("Supporters"), Icons.Material.Filled.Favorite, palette.Error.Value, "#801a00", Routes.SUPPORTERS, false); - yield return new(T("Information"), Icons.Material.Filled.Info, palette.DarkLighten, palette.GrayLight, Routes.ABOUT, false); - yield return new(T("Settings"), Icons.Material.Filled.Settings, palette.DarkLighten, palette.GrayLight, Routes.SETTINGS, false); + yield return new(T("Information"), Icons.Material.Filled.Info, defaultLightColor, defaultDarkColor, Routes.ABOUT, false); + yield return new(T("Settings"), Icons.Material.Filled.Settings, defaultLightColor, defaultDarkColor, Routes.SETTINGS, false); } private async Task ShowUpdateDialog() diff --git a/app/MindWork AI Studio/Layout/NavBarItem.cs b/app/MindWork AI Studio/Layout/NavBarItem.cs index efa90cd2..1e7b49f2 100644 --- a/app/MindWork AI Studio/Layout/NavBarItem.cs +++ b/app/MindWork AI Studio/Layout/NavBarItem.cs @@ -4,5 +4,10 @@ namespace AIStudio.Layout; public record NavBarItem(string Name, string Icon, string IconLightColor, string IconDarkColor, string Path, bool MatchAll) { + /// + /// Gets the CSS style that applies the current theme-aware icon color. + /// + /// The settings manager used to read the current theme. + /// The CSS style for the nav item icon color. public string SetColorStyle(SettingsManager settingsManager) => $"--custom-icon-color: {(settingsManager.IsDarkMode ? this.IconDarkColor : this.IconLightColor)};"; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Pages/Assistants.razor b/app/MindWork AI Studio/Pages/Assistants.razor index cec6c561..feca92fc 100644 --- a/app/MindWork AI Studio/Pages/Assistants.razor +++ b/app/MindWork AI Studio/Pages/Assistants.razor @@ -47,6 +47,7 @@ Description="@T(assistantPlugin.Description)" Icon="@Icons.Material.Filled.FindInPage" Disabled="@(!securityState.CanStartAssistant)" + AssistantSessionInstanceId="@assistantPlugin.Id.ToString()" Link="@($"{Routes.ASSISTANT_DYNAMIC}?assistantId={assistantPlugin.Id}")"> diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index eecc3ce3..258fa49c 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -312,6 +312,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T180921696"] = "Zurückset -- Please select a provider. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1809312323"] = "Bitte wählen Sie einen Anbieter aus." +-- The assistant failed. The message is: '{0}' +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1833836281"] = "Der Assistent ist fehlgeschlagen. Die Meldung lautet: „{0}“" + +-- This assistant is already running. AI Studio opens the running session instead. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T2575715765"] = "Dieser Assistent läuft bereits. AI Studio öffnet stattdessen die laufende Sitzung." + -- Assistant - {0} UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T3043922"] = "Assistent – {0}" @@ -750,6 +756,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T1565111217"] = "Relev -- Please select an ERI specification version for the ERI server. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T1612890554"] = "Bitte wählen Sie eine Version der ERI-Spezifikation für den ERI-Server aus." +-- Open in chat +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T1664804142"] = "Im Chat öffnen" + -- Ports below 1024 are reserved for system services. Your ERI server need to run with elevated permissions (root user). UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T168780641"] = "Ports unterhalb von 1024 sind für Systemdienste reserviert. Ihr ERI-Server muss mit erhöhten Rechten (als Root-Benutzer) ausgeführt werden." @@ -1983,6 +1992,18 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "Das Bil -- Open Settings UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Einstellungen öffnen" +-- Assistant is still running. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "Assistent läuft noch." + +-- Assistant was canceled. Open it to review the result. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3390934621"] = "Assistent wurde abgebrochen. Öffnen Sie ihn, um das Ergebnis zu überprüfen." + +-- Assistant failed. Open it to review the result. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3571008422"] = "Assistent fehlgeschlagen. Öffnen Sie ihn, um das Ergebnis zu überprüfen." + +-- The result is ready. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T661906146"] = "Das Ergebnis ist fertig." + -- Show or hide the detailed security information. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1045105126"] = "Detaillierte Sicherheitsinformationen anzeigen oder ausblenden." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index c60b245a..59bde45f 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -312,6 +312,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T180921696"] = "Reset" -- Please select a provider. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1809312323"] = "Please select a provider." +-- The assistant failed. The message is: '{0}' +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1833836281"] = "The assistant failed. The message is: '{0}'" + +-- This assistant is already running. AI Studio opens the running session instead. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T2575715765"] = "This assistant is already running. AI Studio opens the running session instead." + -- Assistant - {0} UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T3043922"] = "Assistant - {0}" @@ -750,6 +756,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T1565111217"] = "Relat -- Please select an ERI specification version for the ERI server. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T1612890554"] = "Please select an ERI specification version for the ERI server." +-- Open in chat +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T1664804142"] = "Open in chat" + -- Ports below 1024 are reserved for system services. Your ERI server need to run with elevated permissions (root user). UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T168780641"] = "Ports below 1024 are reserved for system services. Your ERI server need to run with elevated permissions (root user)." @@ -1983,6 +1992,18 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "The ima -- Open Settings UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Open Settings" +-- Assistant is still running. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "Assistant is still running." + +-- Assistant was canceled. Open it to review the result. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3390934621"] = "Assistant was canceled. Open it to review the result." + +-- Assistant failed. Open it to review the result. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3571008422"] = "Assistant failed. Open it to review the result." + +-- The result is ready. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T661906146"] = "The result is ready." + -- Show or hide the detailed security information. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1045105126"] = "Show or hide the detailed security information." diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 95ba5490..2690e684 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -3,6 +3,7 @@ using AIStudio.Agents.AssistantAudit; using AIStudio.Settings; using AIStudio.Tools.Databases; using AIStudio.Tools.AIJobs; +using AIStudio.Tools.AssistantSessions; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.Services; @@ -130,6 +131,7 @@ internal sealed class Program builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionKey.cs b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionKey.cs new file mode 100644 index 00000000..677e3b43 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionKey.cs @@ -0,0 +1,34 @@ +namespace AIStudio.Tools.AssistantSessions; + +/// +/// Identifies one logical assistant session slot. +/// +public readonly record struct AssistantSessionKey +{ + /// + /// Initializes a new assistant session key. + /// + /// The application component the assistant belongs to. + /// The assistant-specific instance ID, such as a component type or plugin ID. + public AssistantSessionKey(Components component, string instanceId) + { + this.Component = component; + this.InstanceId = instanceId; + } + + /// + /// Gets the application component the assistant belongs to. + /// + public Components Component { get; init; } + + /// + /// Gets the assistant-specific instance ID, such as a component type or plugin ID. + /// + public string InstanceId { get; init; } + + /// + /// Converts the key into a compact diagnostic string. + /// + /// The component and instance ID joined by a colon. + public override string ToString() => $"{this.Component}:{this.InstanceId}"; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionService.cs b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionService.cs new file mode 100644 index 00000000..e3baa690 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionService.cs @@ -0,0 +1,365 @@ +using System.Collections.Concurrent; + +using AIStudio.Chat; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Tools.AssistantSessions; + +/// +/// Keeps assistant sessions alive while their Blazor components are not mounted. +/// +/// The message bus used to publish assistant session changes. +public sealed class AssistantSessionService(MessageBus messageBus) +{ + /// + /// Mutable runtime state owned exclusively by . + /// + /// + /// This type intentionally exists in addition to . + /// It holds runtime-only data such as the cancellation token source and lock, while + /// snapshots are copied DTOs for UI components and message bus payloads. + /// + private sealed class AssistantSessionState + { + /// + /// Identifies this concrete run of an assistant session. + /// + public required Guid SessionId { get; init; } + + /// + /// Identifies the assistant and logical session slot this run belongs to. + /// + public required AssistantSessionKey Key { get; init; } + + /// + /// Cancels the active assistant run. + /// + public required CancellationTokenSource CancellationTokenSource { get; init; } + + /// + /// Stores when the session run started. + /// + public required DateTimeOffset StartedAt { get; init; } + + /// + /// Stores when the session state was last changed. + /// + public DateTimeOffset UpdatedAt { get; set; } + + /// + /// Stores when the session reached a terminal state. + /// + public DateTimeOffset? FinishedAt { get; set; } + + /// + /// Stores the user-visible assistant title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Stores the current lifecycle state of the session. + /// + public AssistantSessionStatus Status { get; set; } + + /// + /// Stores the user-visible error message for failed sessions. + /// + public string ErrorMessage { get; set; } = string.Empty; + + /// + /// Stores the current assistant chat thread, including streamed output. + /// + public ChatThread? ChatThread { get; set; } + + /// + /// Stores the assistant component state captured from the running UI instance. + /// + public Dictionary State { get; set; } = new(StringComparer.Ordinal); + + /// + /// Guards mutable fields while snapshots are created or updates are applied. + /// + public readonly Lock SyncRoot = new(); + } + + /// + /// Stores one assistant session per session key. + /// + private readonly ConcurrentDictionary sessions = new(); + + /// + /// Gets whether at least one assistant session is still active. + /// + public bool HasActiveSessions => this.sessions.Values.Any(session => session.Status is AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING); + + /// + /// Gets copied snapshots for all known assistant sessions. + /// + /// Session snapshots ordered by newest update first. + public IReadOnlyCollection GetSnapshots() + { + return this.sessions.Values + .Select(CreateSnapshot) + .OrderByDescending(snapshot => snapshot.UpdatedAt) + .ToList(); + } + + /// + /// Tries to get the current snapshot for an assistant session key. + /// + /// The assistant session key to look up. + /// The current snapshot, or null when no session exists. + public AssistantSessionSnapshot? TryGetSnapshot(AssistantSessionKey key) + { + return this.sessions.TryGetValue(key, out var session) ? CreateSnapshot(session) : null; + } + + /// + /// Tries to get and remove an inactive assistant session snapshot. + /// + /// + /// This method intentionally does not publish a change event. It is used when + /// a UI instance consumes a finished session exactly once and should keep the + /// restored result locally until the user leaves or resets the assistant. + /// + /// The assistant session key to look up and remove. + /// The removed inactive snapshot, or null when no inactive session exists. + public AssistantSessionSnapshot? TryTakeInactiveSnapshot(AssistantSessionKey key) + { + if (!this.sessions.TryGetValue(key, out var session)) + return null; + + AssistantSessionSnapshot snapshot; + lock (session.SyncRoot) + { + if (session.Status is AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING) + return null; + + snapshot = CreateSnapshotWithoutLock(session); + } + + return ((ICollection>)this.sessions).Remove(new(key, session)) ? snapshot : null; + } + + /// + /// Starts a new assistant session when no active session exists for the key. + /// + /// The assistant session key. + /// The user-visible assistant title. + /// The cancellation token source owned by the new runtime session. + /// The current assistant chat thread, if one already exists. + /// The initial assistant component state. + /// The component that initiated the session start. + /// The new session snapshot, or the existing active session snapshot. + public async Task TryBeginAsync(AssistantSessionKey key, string title, CancellationTokenSource cancellationTokenSource, ChatThread? chatThread, Dictionary state, ComponentBase? sendingComponent = null) + { + if (this.sessions.TryGetValue(key, out var existing) && existing.Status is AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING) + return CreateSnapshot(existing); + + var now = DateTimeOffset.Now; + var session = new AssistantSessionState + { + SessionId = Guid.NewGuid(), + Key = key, + CancellationTokenSource = cancellationTokenSource, + StartedAt = now, + UpdatedAt = now, + Title = title, + Status = AssistantSessionStatus.RUNNING, + ChatThread = chatThread, + State = state, + }; + + this.sessions[key] = session; + var snapshot = CreateSnapshot(session); + await this.NotifyChangedAsync(session, sendingComponent); + return snapshot; + } + + /// + /// Updates a running assistant session with the latest UI and chat state. + /// + /// The assistant session key. + /// The concrete run ID that is allowed to write the checkpoint. + /// The current user-visible assistant title. + /// The current assistant chat thread. + /// The current assistant component state. + /// The component that initiated the checkpoint. + public async Task CheckpointAsync(AssistantSessionKey key, Guid sessionId, string title, ChatThread? chatThread, Dictionary state, ComponentBase? sendingComponent = null) + { + if (!this.sessions.TryGetValue(key, out var session)) + return; + + lock (session.SyncRoot) + { + if (session.SessionId != sessionId) + return; + + session.Title = title; + session.ChatThread = chatThread; + session.State = state; + session.UpdatedAt = DateTimeOffset.Now; + } + + await this.NotifyChangedAsync(session, sendingComponent); + } + + /// + /// Requests cancellation for an active assistant session. + /// + /// The assistant session key to cancel. + /// The component that initiated the cancellation. + public async Task CancelAsync(AssistantSessionKey key, ComponentBase? sendingComponent = null) + { + if (!this.sessions.TryGetValue(key, out var session)) + return; + + lock (session.SyncRoot) + { + if (session.Status is not AssistantSessionStatus.RUNNING) + return; + + session.Status = AssistantSessionStatus.CANCELING; + session.UpdatedAt = DateTimeOffset.Now; + } + + try + { + if (!session.CancellationTokenSource.IsCancellationRequested) + await session.CancellationTokenSource.CancelAsync(); + } + catch (ObjectDisposedException) + { + return; + } + + await this.NotifyChangedAsync(session, sendingComponent); + } + + /// + /// Moves an assistant session into a terminal state and publishes completion. + /// + /// The assistant session key. + /// The concrete run ID that is allowed to complete the session. + /// The terminal status to store. + /// The user-visible error message for failed sessions. + /// The final assistant chat thread. + /// The final assistant component state. + /// The component that initiated the completion. + public async Task CompleteAsync(AssistantSessionKey key, Guid sessionId, AssistantSessionStatus status, string errorMessage, ChatThread? chatThread, Dictionary state, ComponentBase? sendingComponent = null) + { + if (!this.sessions.TryGetValue(key, out var session)) + return; + + lock (session.SyncRoot) + { + if (session.SessionId != sessionId) + return; + + session.Status = status; + session.ErrorMessage = errorMessage; + session.ChatThread = chatThread; + session.State = state; + session.UpdatedAt = DateTimeOffset.Now; + session.FinishedAt = session.UpdatedAt; + } + + await this.NotifyChangedAsync(session, sendingComponent); + await messageBus.SendMessage(sendingComponent, Event.ASSISTANT_SESSION_FINISHED, CreateSnapshot(session)); + + try + { + session.CancellationTokenSource.Dispose(); + } + catch + { + // ignore + } + } + + /// + /// Clears an inactive assistant session. + /// + /// The assistant session key to clear. + public async Task ClearAsync(AssistantSessionKey key) + { + if (!this.sessions.TryGetValue(key, out var session)) + return; + + if (session.Status is AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING) + return; + + lock (session.SyncRoot) + { + session.Status = AssistantSessionStatus.NONE; + session.ChatThread = null; + session.State = new(StringComparer.Ordinal); + session.UpdatedAt = DateTimeOffset.Now; + session.FinishedAt = session.UpdatedAt; + } + + this.sessions.TryRemove(key, out _); + await messageBus.SendMessage(null, Event.ASSISTANT_SESSION_CHANGED, CreateSnapshot(session)); + } + + /// + /// Clears all inactive sessions for a component. + /// + /// The component whose inactive sessions should be cleared. + public async Task ClearInactiveSessionsForComponentAsync(Components component) + { + var matchingKeys = this.sessions + .Where(pair => pair.Key.Component == component && pair.Value.Status is not AssistantSessionStatus.RUNNING and not AssistantSessionStatus.CANCELING) + .Select(pair => pair.Key) + .ToList(); + + foreach (var key in matchingKeys) + await this.ClearAsync(key); + } + + /// + /// Publishes an assistant session change event. + /// + /// The runtime session whose copied snapshot should be published. + /// The component that initiated the session change. + private async Task NotifyChangedAsync(AssistantSessionState session, ComponentBase? sendingComponent = null) + { + await messageBus.SendMessage(sendingComponent, Event.ASSISTANT_SESSION_CHANGED, CreateSnapshot(session)); + } + + /// + /// Creates a copied, external snapshot from the internal runtime state. + /// + /// The runtime session to copy. + /// A snapshot safe to send to UI components. + private static AssistantSessionSnapshot CreateSnapshot(AssistantSessionState session) + { + lock (session.SyncRoot) + { + return CreateSnapshotWithoutLock(session); + } + } + + /// + /// Creates a copied, external snapshot while the caller already holds the session lock. + /// + /// The runtime session to copy. + /// A snapshot safe to send to UI components. + private static AssistantSessionSnapshot CreateSnapshotWithoutLock(AssistantSessionState session) + { + return new() + { + SessionId = session.SessionId, + Key = session.Key, + Title = session.Title, + Status = session.Status, + StartedAt = session.StartedAt, + UpdatedAt = session.UpdatedAt, + FinishedAt = session.FinishedAt, + ErrorMessage = session.ErrorMessage, + ChatThread = session.ChatThread, + State = new Dictionary(session.State, StringComparer.Ordinal), + }; + } +} diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshot.cs b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshot.cs new file mode 100644 index 00000000..b5676df7 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshot.cs @@ -0,0 +1,68 @@ +using AIStudio.Chat; + +namespace AIStudio.Tools.AssistantSessions; + +/// +/// Immutable-style view of an assistant session for UI consumers and message bus events. +/// +/// +/// The service creates snapshots by copying its internal runtime state. Consumers must +/// treat the contained chat thread and state objects as read-only views of the session. +/// +public sealed record AssistantSessionSnapshot +{ + /// + /// Identifies the concrete run represented by this snapshot. + /// + public required Guid SessionId { get; init; } + + /// + /// Identifies the assistant and logical session slot represented by this snapshot. + /// + public required AssistantSessionKey Key { get; init; } + + /// + /// Gets the user-visible assistant title. + /// + public required string Title { get; init; } + + /// + /// Gets the current lifecycle status. + /// + public required AssistantSessionStatus Status { get; init; } + + /// + /// Gets when the session run started. + /// + public required DateTimeOffset StartedAt { get; init; } + + /// + /// Gets when the session was last changed. + /// + public required DateTimeOffset UpdatedAt { get; init; } + + /// + /// Gets when the session reached a terminal state. + /// + public DateTimeOffset? FinishedAt { get; init; } + + /// + /// Gets the user-visible error message for failed sessions. + /// + public string ErrorMessage { get; init; } = string.Empty; + + /// + /// Gets the assistant chat thread captured for this session. + /// + public ChatThread? ChatThread { get; init; } + + /// + /// Gets the assistant component state captured for this session. + /// + public IReadOnlyDictionary State { get; init; } = new Dictionary(); + + /// + /// Gets whether the session is still running or canceling. + /// + public bool IsActive => this.Status is AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshotField.cs b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshotField.cs new file mode 100644 index 00000000..053bfb11 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshotField.cs @@ -0,0 +1,45 @@ +// ReSharper disable MemberCanBePrivate.Global +namespace AIStudio.Tools.AssistantSessions; + +/// +/// Stores one typed value in an assistant session snapshot. +/// +/// The captured value type. +public sealed record AssistantSessionSnapshotField : IAssistantSessionSnapshotField +{ + /// + /// Initializes a new typed snapshot field. + /// + /// The captured value. + public AssistantSessionSnapshotField(T value) + { + this.Value = value; + } + + /// + /// Gets the captured value. + /// + public T Value { get; } + + /// + public Type ValueType => typeof(T); + + /// + public bool TryRead(out TValue? value) + { + if (this.Value is TValue typedValue) + { + value = typedValue; + return true; + } + + if (this.Value is null && default(TValue) is null) + { + value = default; + return true; + } + + value = default; + return false; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateKey.cs b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateKey.cs new file mode 100644 index 00000000..8cab0f0c --- /dev/null +++ b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateKey.cs @@ -0,0 +1,28 @@ +namespace AIStudio.Tools.AssistantSessions; + +/// +/// Identifies a typed assistant session state value. +/// +/// The value type stored for this key. +public readonly record struct AssistantSessionStateKey +{ + /// + /// Initializes a new assistant session state key. + /// + /// The stable dictionary name used in assistant session snapshots. + public AssistantSessionStateKey(string name) + { + this.Name = name; + } + + /// + /// Gets the stable dictionary name used in assistant session snapshots. + /// + public string Name { get; } + + /// + /// Returns the stable dictionary name. + /// + /// The stable dictionary name. + public override string ToString() => this.Name; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateReader.cs b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateReader.cs new file mode 100644 index 00000000..4b826ed6 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateReader.cs @@ -0,0 +1,111 @@ +using System.Text; + +namespace AIStudio.Tools.AssistantSessions; + +/// +/// Restores typed assistant session state values from a snapshot. +/// +/// The captured snapshot fields. +/// The user-visible assistant title. +public sealed class AssistantSessionStateReader(IReadOnlyDictionary fields, string assistantTitle) +{ + private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(); + + /// + /// Restores a typed value when it exists in the snapshot. + /// + /// The value type. + /// The typed state key. + /// The action that applies the restored value. + public void Restore(AssistantSessionStateKey key, Action apply) + { + if (this.TryRead(key, out var value)) + apply(value!); + } + + /// + /// Restores a list into an existing list instance. + /// + /// The list item type. + /// The typed state key. + /// The existing list to update. + public void RestoreList(AssistantSessionStateKey> key, List target) + { + this.Restore(key, values => + { + target.Clear(); + target.AddRange(values); + }); + } + + /// + /// Restores a hash set into an existing hash set instance. + /// + /// The set item type. + /// The typed state key. + /// The existing hash set to update. + public void RestoreHashSet(AssistantSessionStateKey> key, HashSet target) + { + this.Restore(key, values => + { + target.Clear(); + target.UnionWith(values); + }); + } + + /// + /// Restores a dictionary into an existing dictionary instance. + /// + /// The dictionary key type. + /// The dictionary value type. + /// The typed state key. + /// The existing dictionary to update. + public void RestoreDictionary(AssistantSessionStateKey> key, Dictionary target) where TKey : notnull + { + this.Restore(key, values => + { + target.Clear(); + foreach (var (itemKey, itemValue) in values) + target[itemKey] = itemValue; + }); + } + + /// + /// Restores text into an existing string builder instance. + /// + /// The typed state key. + /// The existing string builder to update. + public void RestoreStringBuilder(AssistantSessionStateKey key, StringBuilder target) + { + this.Restore(key, value => + { + target.Clear(); + target.Append(value); + }); + } + + /// + /// Tries to read a typed value from the snapshot. + /// + /// The requested value type. + /// The typed state key. + /// The restored value when reading succeeds. + /// true when a value exists and matches the requested type; otherwise, false. + private bool TryRead(AssistantSessionStateKey key, out T? value) + { + value = default; + if (!fields.TryGetValue(key.Name, out var field)) + return false; + + if (field.TryRead(out value)) + return true; + + LOG.LogWarning( + "Could not restore assistant session field '{FieldStateKey}' for assistant '{AssistantTitle}'. ExpectedType='{ExpectedType}', CapturedValueType='{CapturedValueType}'. The current value is kept.", + key.Name, + assistantTitle, + typeof(T).FullName, + field.ValueType.FullName); + return false; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateWriter.cs b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateWriter.cs new file mode 100644 index 00000000..e57d3c6c --- /dev/null +++ b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStateWriter.cs @@ -0,0 +1,78 @@ +using System.Text; + +namespace AIStudio.Tools.AssistantSessions; + +/// +/// Collects typed assistant session state values for a snapshot. +/// +public sealed class AssistantSessionStateWriter +{ + /// + /// Stores captured fields by their stable dictionary names. + /// + private readonly Dictionary fields = new(StringComparer.Ordinal); + + /// + /// Stores a typed state value. + /// + /// The value type. + /// The typed state key. + /// The captured value. + public void Set(AssistantSessionStateKey key, T value) + { + this.fields[key.Name] = new AssistantSessionSnapshotField(value); + } + + /// + /// Stores a list copy. + /// + /// The list item type. + /// The typed state key. + /// The values to copy. + public void SetList(AssistantSessionStateKey> key, IEnumerable values) + { + this.Set(key, values.ToList()); + } + + /// + /// Stores a hash set copy. + /// + /// The set item type. + /// The typed state key. + /// The values to copy. + public void SetHashSet(AssistantSessionStateKey> key, IEnumerable values) + { + this.Set(key, values.ToHashSet()); + } + + /// + /// Stores a dictionary copy. + /// + /// The dictionary key type. + /// The dictionary value type. + /// The typed state key. + /// The values to copy. + public void SetDictionary(AssistantSessionStateKey> key, IDictionary values) where TKey : notnull + { + this.Set(key, new Dictionary(values)); + } + + /// + /// Stores the current text from a string builder. + /// + /// The typed state key. + /// The string builder to read. + public void SetStringBuilder(AssistantSessionStateKey key, StringBuilder value) + { + this.Set(key, value.ToString()); + } + + /// + /// Returns the captured fields as a dictionary. + /// + /// A copied dictionary containing the captured fields. + public Dictionary ToDictionary() + { + return new(this.fields, StringComparer.Ordinal); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStatus.cs b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStatus.cs new file mode 100644 index 00000000..96f3485f --- /dev/null +++ b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionStatus.cs @@ -0,0 +1,37 @@ +namespace AIStudio.Tools.AssistantSessions; + +/// +/// Describes the lifecycle state of an assistant session. +/// +public enum AssistantSessionStatus +{ + /// + /// No session state is available. + /// + NONE, + + /// + /// The assistant session is running. + /// + RUNNING, + + /// + /// Cancellation was requested and the assistant is shutting down. + /// + CANCELING, + + /// + /// The assistant session completed successfully. + /// + COMPLETED, + + /// + /// The assistant session was canceled. + /// + CANCELED, + + /// + /// The assistant session failed. + /// + FAILED, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/IAssistantSessionSnapshotField.cs b/app/MindWork AI Studio/Tools/AssistantSessions/IAssistantSessionSnapshotField.cs new file mode 100644 index 00000000..c64780a9 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AssistantSessions/IAssistantSessionSnapshotField.cs @@ -0,0 +1,20 @@ +namespace AIStudio.Tools.AssistantSessions; + +/// +/// Provides a typed value stored in an assistant session snapshot. +/// +public interface IAssistantSessionSnapshotField +{ + /// + /// Gets the type used when the value was captured. + /// + Type ValueType { get; } + + /// + /// Tries to read the captured value as the requested type. + /// + /// The requested value type. + /// The typed value when reading succeeds. + /// true when the captured value matches ; otherwise, false. + bool TryRead(out T? value); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs index 15ee6183..dbc737e4 100644 --- a/app/MindWork AI Studio/Tools/Event.cs +++ b/app/MindWork AI Studio/Tools/Event.cs @@ -144,6 +144,16 @@ public enum Event /// Notifies receivers that chat generation state changed. /// CHAT_GENERATION_CHANGED, + + /// + /// Notifies receivers that an assistant session changed. + /// + ASSISTANT_SESSION_CHANGED, + + /// + /// Notifies receivers that an assistant session finished. + /// + ASSISTANT_SESSION_FINISHED, // Workspace events: /// diff --git a/app/MindWork AI Studio/Tools/MudThemeExtensions.cs b/app/MindWork AI Studio/Tools/MudThemeExtensions.cs index 7a6108c7..6f70b105 100644 --- a/app/MindWork AI Studio/Tools/MudThemeExtensions.cs +++ b/app/MindWork AI Studio/Tools/MudThemeExtensions.cs @@ -9,4 +9,14 @@ public static class MudThemeExtensions true => theme.PaletteDark, false => theme.PaletteLight, }; + + public static string GetActivityIndicatorColor(this MudTheme theme, SettingsManager settingsManager) => settingsManager.IsDarkMode switch + { + true => theme.GetActivityIndicatorDarkColor(), + false => theme.GetActivityIndicatorLightColor(), + }; + + public static string GetActivityIndicatorLightColor(this MudTheme theme) => theme.PaletteLight.Info.Value; + + public static string GetActivityIndicatorDarkColor(this MudTheme theme) => theme.PaletteDark.InfoLighten; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantState.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantState.cs index be172190..23adc194 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantState.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/DataModel/AssistantState.cs @@ -30,6 +30,49 @@ public sealed class AssistantState this.Times.Clear(); } + /// + /// Copies all dynamic assistant state values from another state instance. + /// + /// The state instance to copy from. + public void CopyFrom(AssistantState other) + { + this.Clear(); + CopyDictionary(other.Text, this.Text); + CopyDictionary(other.SingleSelect, this.SingleSelect); + CopyDictionary(other.MultiSelect, this.MultiSelect); + CopyDictionary(other.Booleans, this.Booleans); + CopyDictionary(other.WebContent, this.WebContent); + CopyDictionary(other.FileContent, this.FileContent); + CopyDictionary(other.Colors, this.Colors); + CopyDictionary(other.Dates, this.Dates); + CopyDictionary(other.DateRanges, this.DateRanges); + CopyDictionary(other.Times, this.Times); + } + + /// + /// Creates a copy of the dynamic assistant state. + /// + /// A copied assistant state instance. + public AssistantState Clone() + { + var clone = new AssistantState(); + clone.CopyFrom(this); + return clone; + } + + /// + /// Copies all entries from one dictionary into another dictionary. + /// + /// The dictionary key type. + /// The dictionary value type. + /// The source dictionary. + /// The target dictionary. + private static void CopyDictionary(Dictionary source, Dictionary target) where TKey : notnull + { + foreach (var (key, value) in source) + target[key] = value; + } + public bool TryApplyValue(string fieldName, LuaValue value, out string expectedType) { expectedType = string.Empty; diff --git a/app/MindWork AI Studio/wwwroot/app.js b/app/MindWork AI Studio/wwwroot/app.js index c2845f76..0f2a49ec 100644 --- a/app/MindWork AI Studio/wwwroot/app.js +++ b/app/MindWork AI Studio/wwwroot/app.js @@ -1,10 +1,18 @@ window.generateDiff = function (text1, text2, divDiff, divLegend) { let wikEdDiff = new WikEdDiff(); let targetDiv = document.getElementById(divDiff) + if (!targetDiv) { + return; + } + targetDiv.innerHTML = wikEdDiff.diff(text1, text2); targetDiv.classList.add('mud-typography-body1', 'improvedDiff'); let legend = document.getElementById(divLegend); + if (!legend) { + return; + } + legend.innerHTML = `

Legend

@@ -20,6 +28,10 @@ window.generateDiff = function (text1, text2, divDiff, divLegend) { window.clearDiv = function (divName) { let targetDiv = document.getElementById(divName); + if (!targetDiv) { + return; + } + targetDiv.innerHTML = ''; } diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md index 435b1554..57497a85 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md @@ -1,2 +1,5 @@ # v26.6.3, build 243 (2026-06-xx xx:xx UTC) +- Improved all assistants, so running tasks can continue when you leave the assistant and return later. +- Improved the assistant overview so it shows which assistants are still running or have a result ready. +- Improved the activity indicators for running chats and assistants so they use a consistent blue highlight. - Improved the chat experience by automatically focusing the message composer again when it becomes available. Thanks, Dominic Neuburg (`donework`), for the contribution. \ No newline at end of file diff --git a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ThisUsageAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ThisUsageAnalyzer.cs index 1e601a6f..09f62743 100644 --- a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ThisUsageAnalyzer.cs +++ b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/ThisUsageAnalyzer.cs @@ -227,6 +227,9 @@ public sealed class ThisUsageAnalyzer : DiagnosticAnalyzer } // Also check for conditional access expressions (e.g., instance?.Member): + if (node.Parent is MemberBindingExpressionSyntax) + return true; + if (node.Parent is ConditionalAccessExpressionSyntax) return true; From 796855f0ef5027e3ead654741576bc1dfeae6e4d Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 4 Jul 2026 14:07:32 +0200 Subject: [PATCH 2/9] Upgraded Rust to v1.96.1 (#831) --- app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md | 3 ++- metadata.txt | 2 +- runtime/src/qdrant_edge_database.rs | 11 +++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md index 57497a85..2ca524d4 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md @@ -2,4 +2,5 @@ - Improved all assistants, so running tasks can continue when you leave the assistant and return later. - Improved the assistant overview so it shows which assistants are still running or have a result ready. - Improved the activity indicators for running chats and assistants so they use a consistent blue highlight. -- Improved the chat experience by automatically focusing the message composer again when it becomes available. Thanks, Dominic Neuburg (`donework`), for the contribution. \ No newline at end of file +- Improved the chat experience by automatically focusing the message composer again when it becomes available. Thanks, Dominic Neuburg (`donework`), for the contribution. +- Upgraded Rust to v1.96.1 \ No newline at end of file diff --git a/metadata.txt b/metadata.txt index 6259022c..a2fce27d 100644 --- a/metadata.txt +++ b/metadata.txt @@ -3,7 +3,7 @@ 242 9.0.118 (commit c8cbca4ed1) 9.0.17 (commit f2c8152eed) -1.96.0 (commit ac68faa20) +1.96.1 (commit 31fca3adb) 8.15.0 2.11.2 64e91ff4ffd, release diff --git a/runtime/src/qdrant_edge_database.rs b/runtime/src/qdrant_edge_database.rs index 0c495cb4..26ae04fe 100644 --- a/runtime/src/qdrant_edge_database.rs +++ b/runtime/src/qdrant_edge_database.rs @@ -402,12 +402,11 @@ fn remove_obsolete_qdrant_sidecar_files(app_handle: &tauri::A cfg_if::cfg_if! { if #[cfg(any(target_os = "windows", target_os = "macos"))]{ - if let Ok(current_exe) = std::env::current_exe() && let Some(exe_dir) = current_exe.parent() { - if exe_dir.to_string_lossy().contains("MindWork AI Studio") { - paths.push(exe_dir.join("target").join("databases").join("qdrant")); - paths.push(exe_dir.join("qdrant.exe")); - paths.push(exe_dir.join("qdrant")); - } + if let Ok(current_exe) = std::env::current_exe() && let Some(exe_dir) = current_exe.parent() + && exe_dir.to_string_lossy().contains("MindWork AI Studio") { + paths.push(exe_dir.join("target").join("databases").join("qdrant")); + paths.push(exe_dir.join("qdrant.exe")); + paths.push(exe_dir.join("qdrant")); } } } From dd72fd5f8bb513aa61b148ae859d74502fb6bfdf Mon Sep 17 00:00:00 2001 From: Peer Hogeterp Date: Sat, 4 Jul 2026 14:23:20 +0200 Subject: [PATCH 3/9] Improved source links in chats (#827) Co-authored-by: Thorsten Sommer --- .../Tools/SourceExtensions.cs | 90 ++++++++++++++++--- .../wwwroot/changelog/v26.6.3.md | 1 + 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/app/MindWork AI Studio/Tools/SourceExtensions.cs b/app/MindWork AI Studio/Tools/SourceExtensions.cs index c7ecf314..660f5d90 100644 --- a/app/MindWork AI Studio/Tools/SourceExtensions.cs +++ b/app/MindWork AI Studio/Tools/SourceExtensions.cs @@ -1,12 +1,83 @@ using System.Text; +using System.Text.RegularExpressions; using AIStudio.Tools.PluginSystem; namespace AIStudio.Tools; -public static class SourceExtensions +public static partial class SourceExtensions { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SourceExtensions).Namespace, nameof(SourceExtensions)); + + private static void AppendMarkdownLink(StringBuilder sb, string title, string url) + { + sb.Append('['); + sb.Append(EscapeMarkdownLinkText(title)); + sb.Append("](<"); + sb.Append(NormalizeLinkDestination(url)); + sb.Append(">)"); + } + + private static string EscapeMarkdownLinkText(string text) + { + return text + .Replace(@"\", @"\\") + .Replace("[", @"\[") + .Replace("]", @"\]") + .Replace("\r", " ") + .Replace("\n", " "); + } + + private static string NormalizeLinkDestination(string url) + { + var normalized = url.Trim().Replace("\r", string.Empty).Replace("\n", string.Empty); + normalized = TryUnwrapMarkdownLink(normalized); + + if (Uri.TryCreate(normalized, UriKind.Absolute, out var absoluteUri)) + return absoluteUri.GetComponents(UriComponents.AbsoluteUri, UriFormat.UriEscaped); + + var sb = new StringBuilder(normalized.Length); + foreach (var c in normalized) + { + if (IsSafeUrlCharacter(c)) + { + sb.Append(c); + continue; + } + + sb.Append(Uri.EscapeDataString(c.ToString())); + } + + return sb.ToString(); + } + + private static string TryUnwrapMarkdownLink(string value) + { + var match = MarkdownLinkWithOptionalSuffix().Match(value); + if (!match.Success) + return value; + + var label = match.Groups["label"].Value; + var url = match.Groups["url"].Value; + var suffix = match.Groups["suffix"].Value; + if (string.IsNullOrEmpty(suffix)) + return url; + + if (Uri.TryCreate(label, UriKind.Absolute, out var labelUri) && + Uri.TryCreate(url, UriKind.Absolute, out var urlUri) && + Uri.Compare(labelUri, urlUri, UriComponents.AbsoluteUri, UriFormat.SafeUnescaped, StringComparison.OrdinalIgnoreCase) == 0) + return url + suffix; + + return value; + } + + private static bool IsSafeUrlCharacter(char c) + { + if (char.IsAsciiLetterOrDigit(c)) + return true; + + return c is '-' or '.' or '_' or '~' or ':' or '/' or '?' or '#' or '[' or ']' or '@' or '!' or '$' or '&' or '\'' or '(' or ')' or '*' or '+' or ',' or ';' or '='; + } /// /// Converts a list of sources to a markdown-formatted string. @@ -36,11 +107,8 @@ public static class SourceExtensions } sb.Append($"- [{++sourceNum}] "); - sb.Append('['); - sb.Append(source.Title); - sb.Append("]("); - sb.Append(source.URL); - sb.AppendLine(")"); + AppendMarkdownLink(sb, source.Title, source.URL); + sb.AppendLine(); break; } } @@ -55,11 +123,8 @@ public static class SourceExtensions foreach (var source in ragSources) { sb.Append($"- [{++sourceNum}] "); - sb.Append('['); - sb.Append(source.Title); - sb.Append("]("); - sb.Append(source.URL); - sb.AppendLine(")"); + AppendMarkdownLink(sb, source.Title, source.URL); + sb.AppendLine(); } return sb.ToString(); @@ -76,4 +141,7 @@ public static class SourceExtensions if (sources.All(s => s.URL != addedSource.URL && s.Title != addedSource.Title)) sources.Add((Source)addedSource); } + + [GeneratedRegex(@"^\[(? public partial class ProviderDialog : MSGComponentBase, ISecretId { + private enum ReasoningOverrideMode + { + AUTOMATIC, + NO_REASONING, + CAN_BE_ENABLED, + ON_BY_DEFAULT, + ALWAYS_ON + } + [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!; @@ -83,6 +93,9 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId [Parameter] public string AdditionalJsonApiParameters { get; set; } = string.Empty; + + [Parameter] + public ProviderCapabilityOverrides? DataCapabilityOverrides { get; set; } [Inject] private RustService RustService { get; init; } = null!; @@ -91,6 +104,22 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId private ILogger Logger { get; init; } = null!; private static readonly Dictionary SPELLCHECK_ATTRIBUTES = new(); + private static readonly IReadOnlyList SWITCH_CAPABILITY_OVERRIDES = + [ + Capability.AUDIO_INPUT, + Capability.MULTIPLE_IMAGE_INPUT, + Capability.SPEECH_INPUT, + Capability.VIDEO_INPUT + ]; + + private static readonly IReadOnlyList REASONING_OVERRIDE_MODES = + [ + ReasoningOverrideMode.AUTOMATIC, + ReasoningOverrideMode.NO_REASONING, + ReasoningOverrideMode.CAN_BE_ENABLED, + ReasoningOverrideMode.ON_BY_DEFAULT, + ReasoningOverrideMode.ALWAYS_ON + ]; /// /// The list of used instance names. We need this to check for uniqueness. @@ -106,6 +135,7 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId private string dataLoadingModelsIssue = string.Empty; private bool usesLegacySystemModelFallback; private bool showExpertSettings; + private ProviderCapabilityOverrides capabilityOverrides = new(); // We get the form reference from Blazor code to validate it manually: private MudForm form = null!; @@ -160,6 +190,7 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId Host = this.DataHost, HFInferenceProvider = this.HFInferenceProviderId, AdditionalJsonApiParameters = this.AdditionalJsonApiParameters, + CapabilityOverrides = this.capabilityOverrides.HasOverrides ? this.capabilityOverrides : null, }; } @@ -178,7 +209,8 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId this.UsedInstanceNames = this.SettingsManager.ConfigurationData.Providers.Select(x => x.InstanceName.ToLowerInvariant()).ToList(); #pragma warning restore MWAIS0001 - this.showExpertSettings = !string.IsNullOrWhiteSpace(this.AdditionalJsonApiParameters); + this.capabilityOverrides = this.DataCapabilityOverrides ?? new(); + this.showExpertSettings = !string.IsNullOrWhiteSpace(this.AdditionalJsonApiParameters) || this.capabilityOverrides.HasOverrides; // When editing, we need to load the data: if(this.IsEditing) @@ -300,10 +332,18 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId this.DataHost = selectedHost; this.DataModel = default; this.dataManuallyModel = string.Empty; + this.capabilityOverrides = new(); this.availableModels.Clear(); this.dataLoadingModelsIssue = string.Empty; this.usesLegacySystemModelFallback = false; } + + private Task OnModelChanged(Model selectedModel) + { + this.DataModel = selectedModel; + this.capabilityOverrides = new(); + return Task.CompletedTask; + } private async Task ReloadModels() { @@ -369,6 +409,156 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId private void ToggleExpertSettings() => this.showExpertSettings = !this.showExpertSettings; + private void SetCapabilityOverride(Capability capability, bool value) + { + this.capabilityOverrides = this.capabilityOverrides.SetOverride(capability, value); + } + + private Task OnCapabilitySwitchChanged(Capability capability, bool value) + { + this.SetCapabilityOverride(capability, value); + return Task.CompletedTask; + } + + private void ResetCapabilityOverride(Capability capability) => + this.capabilityOverrides = this.capabilityOverrides.SetOverride(capability, null); + + private ReasoningOverrideMode GetReasoningOverrideMode() + { + var alwaysReasoning = this.capabilityOverrides.GetOverride(Capability.ALWAYS_REASONING); + var optionalReasoning = this.capabilityOverrides.GetOverride(Capability.OPTIONAL_REASONING); + var reasoningByDefault = this.capabilityOverrides.GetOverride(Capability.REASONING_BY_DEFAULT); + if (alwaysReasoning is null && optionalReasoning is null && reasoningByDefault is null) + return ReasoningOverrideMode.AUTOMATIC; + + var capabilities = this.GetCurrentModelCapabilities(); + if (capabilities.Contains(Capability.ALWAYS_REASONING)) + return ReasoningOverrideMode.ALWAYS_ON; + + if (capabilities.Contains(Capability.REASONING_BY_DEFAULT)) + return ReasoningOverrideMode.ON_BY_DEFAULT; + + if (capabilities.Contains(Capability.OPTIONAL_REASONING)) + return ReasoningOverrideMode.CAN_BE_ENABLED; + + return ReasoningOverrideMode.NO_REASONING; + } + + private ReasoningOverrideMode GetAutomaticReasoningOverrideMode() + { + var capabilities = this.GetAutomaticModelCapabilities(); + if (capabilities.Contains(Capability.ALWAYS_REASONING)) + return ReasoningOverrideMode.ALWAYS_ON; + + if (capabilities.Contains(Capability.REASONING_BY_DEFAULT)) + return ReasoningOverrideMode.ON_BY_DEFAULT; + + if (capabilities.Contains(Capability.OPTIONAL_REASONING)) + return ReasoningOverrideMode.CAN_BE_ENABLED; + + return ReasoningOverrideMode.NO_REASONING; + } + + private void SetReasoningOverrideMode(ReasoningOverrideMode mode) + { + this.capabilityOverrides = mode switch + { + ReasoningOverrideMode.AUTOMATIC => this.capabilityOverrides + .SetOverride(Capability.ALWAYS_REASONING, null) + .SetOverride(Capability.OPTIONAL_REASONING, null) + .SetOverride(Capability.REASONING_BY_DEFAULT, null), + + ReasoningOverrideMode.NO_REASONING => this.capabilityOverrides + .SetOverride(Capability.ALWAYS_REASONING, false) + .SetOverride(Capability.OPTIONAL_REASONING, false) + .SetOverride(Capability.REASONING_BY_DEFAULT, false), + + ReasoningOverrideMode.CAN_BE_ENABLED => this.capabilityOverrides + .SetOverride(Capability.ALWAYS_REASONING, false) + .SetOverride(Capability.OPTIONAL_REASONING, true) + .SetOverride(Capability.REASONING_BY_DEFAULT, false), + + ReasoningOverrideMode.ON_BY_DEFAULT => this.capabilityOverrides + .SetOverride(Capability.ALWAYS_REASONING, false) + .SetOverride(Capability.OPTIONAL_REASONING, true) + .SetOverride(Capability.REASONING_BY_DEFAULT, true), + + ReasoningOverrideMode.ALWAYS_ON => this.capabilityOverrides + .SetOverride(Capability.ALWAYS_REASONING, true) + .SetOverride(Capability.OPTIONAL_REASONING, false) + .SetOverride(Capability.REASONING_BY_DEFAULT, false), + + _ => this.capabilityOverrides + }; + } + + private string GetReasoningOverrideModeLabel(ReasoningOverrideMode mode) => mode switch + { + ReasoningOverrideMode.AUTOMATIC => T("Automatic"), + ReasoningOverrideMode.NO_REASONING => T("No reasoning (thinking)"), + ReasoningOverrideMode.CAN_BE_ENABLED => T("Can be enabled"), + ReasoningOverrideMode.ON_BY_DEFAULT => T("On by default"), + ReasoningOverrideMode.ALWAYS_ON => T("Always on"), + _ => mode.ToString() + }; + + private string GetReasoningOverrideModeDescription(ReasoningOverrideMode mode) => mode switch + { + ReasoningOverrideMode.AUTOMATIC => string.Format(T("Use detected model behavior: {0}."), this.GetReasoningOverrideModeLabel(this.GetAutomaticReasoningOverrideMode())), + ReasoningOverrideMode.NO_REASONING => T("No reasoning (thinking) capability."), + ReasoningOverrideMode.CAN_BE_ENABLED => T("Reasoning (thinking) is available, but off unless additional API parameters enable it."), + ReasoningOverrideMode.ON_BY_DEFAULT => T("Reasoning (thinking) is available and on unless additional API parameters disable it."), + ReasoningOverrideMode.ALWAYS_ON => T("The model always uses reasoning (thinking); it cannot be disabled."), + _ => string.Empty + }; + + private bool HasCapabilityOverride(Capability capability) => this.capabilityOverrides.GetOverride(capability) is not null; + + private bool IsCapabilityEnabled(Capability capability) + { + var capabilities = this.GetCurrentModelCapabilities(); + return capabilities.Contains(capability); + } + + private string GetCapabilityEffectiveLabel(Capability capability) + { + var isEnabled = this.IsCapabilityEnabled(capability); + if (this.HasCapabilityOverride(capability)) + return isEnabled ? T("Enabled") : T("Disabled"); + + return isEnabled ? T("Enabled (Auto)") : T("Disabled (Auto)"); + } + + private List GetCurrentModelCapabilities() + { + var currentProviderSettings = this.CreateProviderSettings(); + return currentProviderSettings.GetModelCapabilities(); + } + + private List GetAutomaticModelCapabilities() => this.DataLLMProvider.GetModelCapabilities(this.DataModel); + + private string GetCurrentModelApiLabel() + { + var capabilities = this.GetCurrentModelCapabilities(); + if (capabilities.Contains(Capability.RESPONSES_API)) + return "Responses API"; + + if (capabilities.Contains(Capability.CHAT_COMPLETION_API)) + return "Chat Completions API"; + + return "Unknown"; + } + + private string GetCapabilityOverrideLabel(Capability capability) => capability switch + { + Capability.AUDIO_INPUT => T("Audio input"), + Capability.MULTIPLE_IMAGE_INPUT => T("Multiple image input"), + Capability.SPEECH_INPUT => T("Speech input"), + Capability.VIDEO_INPUT => T("Video input"), + Capability.ALWAYS_REASONING => T("Always reasoning"), + _ => capability.ToString() + }; + private void OnInputChangeExpertSettings() { this.AdditionalJsonApiParameters = NormalizeAdditionalJsonApiParameters(this.AdditionalJsonApiParameters) @@ -536,7 +726,7 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId } private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty; - + private static string GetPlaceholderExpertSettings => """ "temperature": 0.5, diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 546b4793..156bb05d 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -74,6 +74,18 @@ CONFIG["LLM_PROVIDERS"] = {} -- -- Please do not add the enclosing curly braces {} here. Also, no trailing comma is allowed. -- ["AdditionalJsonApiParameters"] = "", -- +-- -- Optional: expert capability overrides. +-- -- Allowed keys are exactly: +-- -- AUDIO_INPUT, MULTIPLE_IMAGE_INPUT, SPEECH_INPUT, VIDEO_INPUT, +-- -- OPTIONAL_REASONING, ALWAYS_REASONING, REASONING_BY_DEFAULT +-- -- Allowed values are booleans only. +-- -- For default-on reasoning (rhinking), set OPTIONAL_REASONING and REASONING_BY_DEFAULT to true. +-- -- ALWAYS_REASONING means the model cannot disable reasoning (thinking). +-- -- Missing keys keep the automatic capability detection result. +-- -- ["CapabilityOverrides"] = { +-- -- ["VIDEO_INPUT"] = false, +-- -- }, +-- -- -- Optional: Hugging Face inference provider. Only relevant for UsedLLMProvider = HUGGINGFACE. -- -- Allowed values are: CEREBRAS, NEBIUS_AI_STUDIO, SAMBANOVA, NOVITA, HYPERBOLIC, TOGETHER_AI, FIREWORKS, HF_INFERENCE_API -- -- ["HFInferenceProvider"] = "NOVITA", diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index a97971b1..f89511b7 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -4380,9 +4380,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINGGUIDELINEDIALOG::T384594633"] = "De -- Prompting Guideline UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINGGUIDELINEDIALOG::T4250996615"] = "Prompting-Leitfaden" --- Please be aware: This section is for experts only. You are responsible for verifying the correctness of the additional parameters you provide to the API call. By default, AI Studio uses the OpenAI-compatible chat completions API, when that it is supported by the underlying service and model. -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1017509792"] = "Bitte beachten Sie: Dieser Bereich ist nur für Expertinnen und Experten. Sie sind dafür verantwortlich, die Korrektheit der zusätzlichen Parameter zu überprüfen, die Sie beim API‑Aufruf angeben. Standardmäßig verwendet AI Studio die OpenAI‑kompatible Chat Completions-API, sofern diese vom zugrunde liegenden Dienst und Modell unterstützt wird." - -- Hugging Face Inference Provider UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1085481431"] = "Hugging Face Inferenz-Anbieter" @@ -4401,30 +4398,57 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1356621346"] = "Konto erste -- Load models UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T15352225"] = "Modelle laden" +-- Automatic +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1634363268"] = "Automatisch" + +-- Disabled (Auto) +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1671157437"] = "Deaktiviert (automatisch)" + -- Add the parameters in proper JSON formatting, e.g., "temperature": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1689135032"] = "Fügen Sie die Parameter in korrekter JSON-Formatierung hinzu, z. B. \"temperature\": 0.5. Entfernen Sie abschließende Kommas. Die üblichen äußeren geschweiften Klammern {} dürfen dabei jedoch nicht verwendet werden." -- Hostname UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1727440780"] = "Hostname" +-- Always on +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1761671861"] = "Immer an" + +-- Reset +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T180921696"] = "Zurücksetzen" + -- Update UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1847791252"] = "Aktualisieren" -- Failed to load the API key from the operating system. The message was: {0}. You might ignore this message and provide the API key again. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1870831108"] = "Der API-Schlüssel konnte nicht vom Betriebssystem geladen werden. Die Meldung war: {0}. Sie können diese Meldung ignorieren und den API-Schlüssel erneut eingeben." +-- Speech input +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1874348907"] = "Spracheingabe" + -- Please enter a model name. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1936099896"] = "Bitte geben Sie einen Modellnamen ein." +-- Enabled (Auto) +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2001330464"] = "Aktiviert (automatisch)" + +-- The current model uses the {0}. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2029870721"] = "Das aktuelle Modell nutzt die {0}." + -- Additional API parameters must form a JSON object. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2051143391"] = "Zusätzliche API-Parameter müssen ein JSON-Objekt bilden." +-- Use detected model behavior: {0}. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2141072961"] = "Erkanntes Modellverhalten verwenden: {0}" + -- Model UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2189814010"] = "Modell" -- (Optional) API Key UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2331453405"] = "(Optional) API-Schlüssel" +-- Enabled +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2626085950"] = "Aktiviert" + -- Add UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2646845972"] = "Hinzufügen" @@ -4437,12 +4461,42 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2810182573"] = "Keine Model -- Instance Name UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2842060373"] = "Instanzname" +-- On by default +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2843289040"] = "Standardmäßig aktiviert" + +-- No reasoning (thinking) capability. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T301695429"] = "Keine Fähigkeit für Schlussfolgerungen (Denken)." + +-- Please be careful: wrong expert settings can break model usage, disable supported features, or make unsupported features appear available. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3079061205"] = "Achtung: Fehlerhafte Experteneinstellungen können die Modellnutzung beeinträchtigen, unterstützte Funktionen deaktivieren oder nicht unterstützte Funktionen als verfügbar erscheinen lassen." + +-- Reasoning (thinking) is available and on unless additional API parameters disable it. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T310420667"] = "Schlussfolgerungen (Denken) sind verfügbar und aktiviert, sofern es nicht durch zusätzliche API-Parameter deaktiviert wird." + +-- Disabled +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3217987877"] = "Deaktiviert" + +-- The model always uses reasoning (thinking); it cannot be disabled. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3294757333"] = "Das Modell verwendet immer Schlussfolgerungen (Denken); es kann nicht deaktiviert werden." + +-- Can be enabled +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3299454847"] = "Kann aktiviert werden" + -- Show Expert Settings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3361153305"] = "Experten-Einstellungen anzeigen" +-- Audio input +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3404621481"] = "Audioeingabe" + -- Invalid JSON: Add the parameters in proper JSON formatting, e.g., \"temperature\": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3502745319"] = "Ungültiges JSON: Fügen Sie die Parameter in korrektem JSON-Format hinzu, z. B. \"temperature\": 0.5. Entfernen Sie abschließende Kommas. Die üblichen umgebenden geschweiften Klammern {} dürfen jedoch nicht verwendet werden." +-- Reasoning (thinking) behavior +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3546126752"] = "Verhalten bezüglich Schlussfolgerungen (Denken)" + +-- Reasoning (thinking) is available, but off unless additional API parameters enable it. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3548835672"] = "Schlussfolgerungen (Denken) sind verfügbar, aber ausgeschaltet, sofern sie nicht durch zusätzliche API-Parameter aktiviert werden." + -- Show available models UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3763891899"] = "Verfügbare Modelle anzeigen" @@ -4452,18 +4506,36 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3783329915"] = "Dieser Host -- Duplicate key '{0}' found. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3804472591"] = "Doppelter Schlüssel '{0}' gefunden." +-- Override Model Capabilities +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3904244586"] = "Modellfähigkeiten überschreiben" + -- Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4116737656"] = "Derzeit können wir die Modelle für den ausgewählten Anbieter und/oder Host nicht abfragen. Bitte geben Sie daher den Modellnamen manuell ein." -- Model selection UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T416738168"] = "Modellauswahl" +-- Stored default model capabilities may not reflect its full range. Override them here if needed. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4217532151"] = "Die gespeicherten Standardfähigkeiten des Modells entsprechen möglicherweise nicht dessen vollständigem Funktionsumfang. Überschreiben Sie sie hier bei Bedarf." + +-- Video input +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4289835208"] = "Videoeingabe" + -- We are currently unable to communicate with the provider to load models. Please try again later. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T504465522"] = "Wir können derzeit nicht mit dem Anbieter kommunizieren, um Modelle zu laden. Bitte versuchen Sie es später erneut." +-- Always reasoning +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T641757736"] = "Immer schlussfolgernd (Denken)" + -- Host UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T808120719"] = "Host" +-- Multiple image input +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T858529900"] = "Bildeingabe" + +-- No reasoning (thinking) +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T87434533"] = "Keine Schlussfolgerungen (Denken)" + -- Provider UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T900237532"] = "Anbieter" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 04327411..7946fe7b 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -2436,23 +2436,23 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3654011106"] = "Open P -- You can switch between your profiles here UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "You can switch between your profiles here" --- Uses reasoning -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T163305901"] = "Uses reasoning" - --- Uses reasoning from provider settings -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1646870133"] = "Uses reasoning from provider settings" - -- Audio input possible UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1742581112"] = "Audio input possible" +-- Uses reasoning (thinking) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T2196970948"] = "Uses reasoning (thinking)" + -- Image input possible UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T2685487365"] = "Image input possible" -- Speech input possible UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T3005724142"] = "Speech input possible" --- Uses reasoning by default -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T3202628203"] = "Uses reasoning by default" +-- Uses reasoning (thinking) by default +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T3891860124"] = "Uses reasoning (thinking) by default" + +-- Uses reasoning (thinking) configured by settings +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T4279448758"] = "Uses reasoning (thinking) configured by settings" -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T900237532"] = "Provider" @@ -4380,9 +4380,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINGGUIDELINEDIALOG::T384594633"] = "Th -- Prompting Guideline UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINGGUIDELINEDIALOG::T4250996615"] = "Prompting Guideline" --- Please be aware: This section is for experts only. You are responsible for verifying the correctness of the additional parameters you provide to the API call. By default, AI Studio uses the OpenAI-compatible chat completions API, when that it is supported by the underlying service and model. -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1017509792"] = "Please be aware: This section is for experts only. You are responsible for verifying the correctness of the additional parameters you provide to the API call. By default, AI Studio uses the OpenAI-compatible chat completions API, when that it is supported by the underlying service and model." - -- Hugging Face Inference Provider UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1085481431"] = "Hugging Face Inference Provider" @@ -4401,30 +4398,57 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1356621346"] = "Create acco -- Load models UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T15352225"] = "Load models" +-- Automatic +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1634363268"] = "Automatic" + +-- Disabled (Auto) +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1671157437"] = "Disabled (Auto)" + -- Add the parameters in proper JSON formatting, e.g., "temperature": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1689135032"] = "Add the parameters in proper JSON formatting, e.g., \"temperature\": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though." -- Hostname UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1727440780"] = "Hostname" +-- Always on +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1761671861"] = "Always on" + +-- Reset +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T180921696"] = "Reset" + -- Update UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1847791252"] = "Update" -- Failed to load the API key from the operating system. The message was: {0}. You might ignore this message and provide the API key again. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1870831108"] = "Failed to load the API key from the operating system. The message was: {0}. You might ignore this message and provide the API key again." +-- Speech input +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1874348907"] = "Speech input" + -- Please enter a model name. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1936099896"] = "Please enter a model name." +-- Enabled (Auto) +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2001330464"] = "Enabled (Auto)" + +-- The current model uses the {0}. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2029870721"] = "The current model uses the {0}." + -- Additional API parameters must form a JSON object. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2051143391"] = "Additional API parameters must form a JSON object." +-- Use detected model behavior: {0}. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2141072961"] = "Use detected model behavior: {0}." + -- Model UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2189814010"] = "Model" -- (Optional) API Key UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2331453405"] = "(Optional) API Key" +-- Enabled +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2626085950"] = "Enabled" + -- Add UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2646845972"] = "Add" @@ -4437,12 +4461,42 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2810182573"] = "No models l -- Instance Name UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2842060373"] = "Instance Name" +-- On by default +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2843289040"] = "On by default" + +-- No reasoning (thinking) capability. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T301695429"] = "No reasoning (thinking) capability." + +-- Please be careful: wrong expert settings can break model usage, disable supported features, or make unsupported features appear available. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3079061205"] = "Please be careful: wrong expert settings can break model usage, disable supported features, or make unsupported features appear available." + +-- Reasoning (thinking) is available and on unless additional API parameters disable it. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T310420667"] = "Reasoning (thinking) is available and on unless additional API parameters disable it." + +-- Disabled +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3217987877"] = "Disabled" + +-- The model always uses reasoning (thinking); it cannot be disabled. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3294757333"] = "The model always uses reasoning (thinking); it cannot be disabled." + +-- Can be enabled +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3299454847"] = "Can be enabled" + -- Show Expert Settings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3361153305"] = "Show Expert Settings" +-- Audio input +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3404621481"] = "Audio input" + -- Invalid JSON: Add the parameters in proper JSON formatting, e.g., \"temperature\": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3502745319"] = "Invalid JSON: Add the parameters in proper JSON formatting, e.g., \\\"temperature\\\": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though." +-- Reasoning (thinking) behavior +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3546126752"] = "Reasoning (thinking) behavior" + +-- Reasoning (thinking) is available, but off unless additional API parameters enable it. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3548835672"] = "Reasoning (thinking) is available, but off unless additional API parameters enable it." + -- Show available models UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3763891899"] = "Show available models" @@ -4452,18 +4506,36 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3783329915"] = "This host u -- Duplicate key '{0}' found. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3804472591"] = "Duplicate key '{0}' found." +-- Override Model Capabilities +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3904244586"] = "Override Model Capabilities" + -- Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4116737656"] = "Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually." -- Model selection UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T416738168"] = "Model selection" +-- Stored default model capabilities may not reflect its full range. Override them here if needed. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4217532151"] = "Stored default model capabilities may not reflect its full range. Override them here if needed." + +-- Video input +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4289835208"] = "Video input" + -- We are currently unable to communicate with the provider to load models. Please try again later. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T504465522"] = "We are currently unable to communicate with the provider to load models. Please try again later." +-- Always reasoning +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T641757736"] = "Always reasoning" + -- Host UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T808120719"] = "Host" +-- Multiple image input +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T858529900"] = "Multiple image input" + +-- No reasoning (thinking) +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T87434533"] = "No reasoning (thinking)" + -- Provider UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T900237532"] = "Provider" diff --git a/app/MindWork AI Studio/Settings/Provider.cs b/app/MindWork AI Studio/Settings/Provider.cs index 08e01996..4662b1b1 100644 --- a/app/MindWork AI Studio/Settings/Provider.cs +++ b/app/MindWork AI Studio/Settings/Provider.cs @@ -33,7 +33,8 @@ public sealed record Provider( string Hostname = "http://localhost:1234", Host Host = Host.NONE, HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE, - string AdditionalJsonApiParameters = "") : ConfigurationBaseObject, ISecretId + string AdditionalJsonApiParameters = "", + ProviderCapabilityOverrides? CapabilityOverrides = null) : ConfigurationBaseObject, ISecretId { private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); @@ -152,6 +153,8 @@ public sealed record Provider( additionalJsonApiParameters = string.Empty; } + var capabilityOverrides = ProviderCapabilityOverrides.TryParseFromLuaTable(idx, table, configPluginId, LOGGER); + provider = new Provider { Num = 0, // will be set later by the PluginConfigurationObject @@ -166,6 +169,7 @@ public sealed record Provider( Host = host, HFInferenceProvider = hfInferenceProvider, AdditionalJsonApiParameters = additionalJsonApiParameters, + CapabilityOverrides = capabilityOverrides, }; // Handle encrypted API key if present: @@ -241,6 +245,8 @@ public sealed record Provider( """; } + var capabilityOverridesLine = this.CapabilityOverrides?.ExportAsLuaTable(" ") ?? string.Empty; + return $$""" CONFIG["LLM_PROVIDERS"][#CONFIG["LLM_PROVIDERS"]+1] = { ["Id"] = "{{Guid.NewGuid().ToString()}}", @@ -252,6 +258,7 @@ public sealed record Provider( {{hfInferenceProviderLine}} {{apiKeyLine}} ["AdditionalJsonApiParameters"] = "{{LuaTools.EscapeLuaString(this.AdditionalJsonApiParameters)}}", + {{capabilityOverridesLine}} ["Model"] = { ["Id"] = "{{LuaTools.EscapeLuaString(this.Model.Id)}}", ["DisplayName"] = "{{LuaTools.EscapeLuaString(this.Model.DisplayName ?? this.Model.Id)}}", diff --git a/app/MindWork AI Studio/Settings/ProviderCapabilityOverrides.cs b/app/MindWork AI Studio/Settings/ProviderCapabilityOverrides.cs new file mode 100644 index 00000000..6741000b --- /dev/null +++ b/app/MindWork AI Studio/Settings/ProviderCapabilityOverrides.cs @@ -0,0 +1,207 @@ +using System.Text; +using System.Text.Json.Serialization; + +using AIStudio.Provider; + +using Lua; + +using LuaTable = Lua.LuaTable; + +namespace AIStudio.Settings; + +/// +/// Optional expert capability overrides for a configured LLM provider. +/// Missing values keep the automatic capability detection result. +/// +public sealed record ProviderCapabilityOverrides +{ + private static readonly IReadOnlyList SUPPORTED_CAPABILITIES = + [ + Capability.AUDIO_INPUT, + Capability.MULTIPLE_IMAGE_INPUT, + Capability.SPEECH_INPUT, + Capability.VIDEO_INPUT, + Capability.OPTIONAL_REASONING, + Capability.ALWAYS_REASONING, + Capability.REASONING_BY_DEFAULT + ]; + + [JsonPropertyName("AUDIO_INPUT")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AudioInput { get; init; } + + [JsonPropertyName("MULTIPLE_IMAGE_INPUT")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? MultipleImageInput { get; init; } + + [JsonPropertyName("SPEECH_INPUT")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? SpeechInput { get; init; } + + [JsonPropertyName("VIDEO_INPUT")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? VideoInput { get; init; } + + [JsonPropertyName("OPTIONAL_REASONING")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? OptionalReasoning { get; init; } + + [JsonPropertyName("ALWAYS_REASONING")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AlwaysReasoning { get; init; } + + [JsonPropertyName("REASONING_BY_DEFAULT")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ReasoningByDefault { get; init; } + + [JsonIgnore] + public bool HasOverrides => + this.AudioInput is not null || + this.MultipleImageInput is not null || + this.SpeechInput is not null || + this.VideoInput is not null || + this.OptionalReasoning is not null || + this.AlwaysReasoning is not null || + this.ReasoningByDefault is not null; + + public bool? GetOverride(Capability capability) => capability switch + { + Capability.AUDIO_INPUT => this.AudioInput, + Capability.MULTIPLE_IMAGE_INPUT => this.MultipleImageInput, + Capability.SPEECH_INPUT => this.SpeechInput, + Capability.VIDEO_INPUT => this.VideoInput, + Capability.OPTIONAL_REASONING => this.OptionalReasoning, + Capability.ALWAYS_REASONING => this.AlwaysReasoning, + Capability.REASONING_BY_DEFAULT => this.ReasoningByDefault, + _ => null + }; + + public ProviderCapabilityOverrides SetOverride(Capability capability, bool? value) => capability switch + { + Capability.AUDIO_INPUT => this with { AudioInput = value }, + Capability.MULTIPLE_IMAGE_INPUT => this with { MultipleImageInput = value }, + Capability.SPEECH_INPUT => this with { SpeechInput = value }, + Capability.VIDEO_INPUT => this with { VideoInput = value }, + Capability.OPTIONAL_REASONING => this with { OptionalReasoning = value }, + Capability.ALWAYS_REASONING => this with { AlwaysReasoning = value }, + Capability.REASONING_BY_DEFAULT => this with { ReasoningByDefault = value }, + _ => this + }; + + public List ApplyTo(IEnumerable automaticCapabilities) + { + var mergedCapabilities = automaticCapabilities.Distinct().ToList(); + foreach (var capability in SUPPORTED_CAPABILITIES) + { + var overrideValue = this.GetOverride(capability); + if (overrideValue == true && !mergedCapabilities.Contains(capability)) + mergedCapabilities.Add(capability); + else if (overrideValue == false) + mergedCapabilities.Remove(capability); + } + + this.NormalizeReasoningCapabilities(mergedCapabilities); + return mergedCapabilities; + } + + private void NormalizeReasoningCapabilities(List capabilities) + { + if (this.AlwaysReasoning == true || + this.AlwaysReasoning is not false && + this.OptionalReasoning is not true && + this.ReasoningByDefault is not true && + capabilities.Contains(Capability.ALWAYS_REASONING)) + { + capabilities.Remove(Capability.OPTIONAL_REASONING); + capabilities.Remove(Capability.REASONING_BY_DEFAULT); + return; + } + + if (this.AlwaysReasoning == false || + this.OptionalReasoning == true || + this.ReasoningByDefault == true) + capabilities.Remove(Capability.ALWAYS_REASONING); + + if (this.OptionalReasoning == false) + { + capabilities.Remove(Capability.REASONING_BY_DEFAULT); + return; + } + + if (this.ReasoningByDefault == true && !capabilities.Contains(Capability.OPTIONAL_REASONING)) + capabilities.Add(Capability.OPTIONAL_REASONING); + + if (!capabilities.Contains(Capability.OPTIONAL_REASONING)) + capabilities.Remove(Capability.REASONING_BY_DEFAULT); + } + + public string ExportAsLuaTable(string indentation) + { + if (!this.HasOverrides) + return string.Empty; + + var builder = new StringBuilder(); + builder.AppendLine($@"{indentation}[""CapabilityOverrides""] = {{"); + foreach (var capability in SUPPORTED_CAPABILITIES) + { + var overrideValue = this.GetOverride(capability); + if (overrideValue is null) + continue; + + builder.AppendLine($@"{indentation} [""{capability}""] = {overrideValue.Value.ToString().ToLowerInvariant()},"); + } + + builder.Append($@"{indentation}}},"); + return builder.ToString(); + } + + public static ProviderCapabilityOverrides? TryParseFromLuaTable(int idx, LuaTable providerTable, Guid configPluginId, ILogger logger) + { + if (!providerTable.TryGetValue("CapabilityOverrides", out var capabilityOverridesValue)) + return null; + + if (capabilityOverridesValue.Type is not LuaValueType.Table || !capabilityOverridesValue.TryRead(out var capabilityOverridesTable)) + { + logger.LogWarning("The configured provider {ProviderIndex} contains an invalid CapabilityOverrides table. Automatic capability detection will be used instead. (Plugin ID: {PluginId})", idx, configPluginId); + return null; + } + + var result = new ProviderCapabilityOverrides(); + var previousKey = LuaValue.Nil; + while (capabilityOverridesTable.TryGetNext(previousKey, out var pair)) + { + previousKey = pair.Key; + + if (!pair.Key.TryRead(out var keyText)) + { + logger.LogWarning("The configured provider {ProviderIndex} contains a CapabilityOverrides entry with a non-string key. The entry will be ignored. (Plugin ID: {PluginId})", idx, configPluginId); + continue; + } + + if (!TryParseSupportedCapability(keyText, out var capability)) + { + logger.LogWarning("The configured provider {ProviderIndex} contains an unsupported capability override '{CapabilityKey}'. The entry will be ignored. (Plugin ID: {PluginId})", idx, keyText, configPluginId); + continue; + } + + if (!pair.Value.TryRead(out var overrideValue)) + { + logger.LogWarning("The configured provider {ProviderIndex} contains a non-boolean capability override for '{CapabilityKey}'. Automatic capability detection will be used for that capability. (Plugin ID: {PluginId})", idx, keyText, configPluginId); + continue; + } + + result = result.SetOverride(capability, overrideValue); + } + + return result.HasOverrides ? result : null; + } + + private static bool TryParseSupportedCapability(string capabilityKey, out Capability capability) + { + capability = Capability.NONE; + if (!Enum.TryParse(capabilityKey, true, out capability)) + return false; + + return SUPPORTED_CAPABILITIES.Contains(capability); + } +} diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs index 2ac6b7e2..ec95ee9b 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs @@ -42,7 +42,7 @@ public static partial class ProviderExtensions var capabilities = provider.GetModelCapabilities(); if (capabilities.Contains(Capability.ALWAYS_REASONING)) return ReasoningIndicatorState.ALWAYS_ON; - + var reasoningConfigurationState = GetReasoningConfigurationState(provider); if (capabilities.Contains(Capability.REASONING_BY_DEFAULT)) { @@ -264,7 +264,7 @@ public static partial class ProviderExtensions text.Equals("on", StringComparison.OrdinalIgnoreCase) || text.Equals("summarized", StringComparison.OrdinalIgnoreCase) => ReasoningConfigurationState.EXPLICITLY_ENABLED, - + true => ReasoningConfigurationState.EXPLICITLY_ENABLED, _ => ReasoningConfigurationState.NOT_CONFIGURED, }; @@ -369,7 +369,7 @@ public static partial class ProviderExtensions string text when text.Equals("enabled", StringComparison.OrdinalIgnoreCase) || text.Equals("adaptive", StringComparison.OrdinalIgnoreCase) => ReasoningConfigurationState.EXPLICITLY_ENABLED, - + string text when IsDisabledText(text) => ReasoningConfigurationState.EXPLICITLY_DISABLED, _ => GetLevelState(value), }; diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.cs index 8ce0ab2a..ca0bf8f6 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.cs @@ -9,7 +9,11 @@ public static partial class ProviderExtensions /// /// The configured provider. /// The capabilities of the configured model. - public static List GetModelCapabilities(this Provider provider) => provider.UsedLLMProvider.GetModelCapabilities(provider.Model); + public static List GetModelCapabilities(this Provider provider) + { + var automaticCapabilities = provider.UsedLLMProvider.GetModelCapabilities(provider.Model); + return provider.CapabilityOverrides?.ApplyTo(automaticCapabilities) ?? automaticCapabilities; + } /// /// Get the capabilities of a model for a specific provider. diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md index c02b6991..a9371b96 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md @@ -1,4 +1,5 @@ # v26.6.3, build 243 (2026-06-xx xx:xx UTC) +- Added expert capability overrides for providers, so advanced users and configuration plugins can manually adjust selected model capabilities, including reasoning (thinking) behavior, when automatic detection needs adjustment. - Improved the provider selection by showing small capability icons for supported audio, image, speech, and reasoning features of the selected model. - Improved source links in chat answers when file or document names contained spaces, umlauts, or other special characters. Source entries now open much more reliably for documents with names such as PDFs from shared portals or internal knowledge bases. - Improved all assistants, so running tasks can continue when you leave the assistant and return later. From d7c2dceb7b5d334d25cb73372eafbc49f8991d57 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 5 Jul 2026 12:59:03 +0200 Subject: [PATCH 7/9] Fixed configuration plugins so they can configure assistant plugin audit settings (#832) --- .../Plugins/configuration/plugin.lua | 27 +++++++++++++++++++ .../Tools/PluginSystem/PluginConfiguration.cs | 7 +++++ .../wwwroot/changelog/v26.6.3.md | 1 + 3 files changed, 35 insertions(+) diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 156bb05d..b8690ff4 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -386,6 +386,33 @@ CONFIG["SETTINGS"] = {} -- "00000000-0000-0000-0000-000000000001", -- } +-- Configure assistant plugin security audits. +-- +-- Configure whether assistant plugins must be audited before users can activate them. +-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.RequireAuditBeforeActivation"] = true +-- +-- Configure a dedicated provider for assistant plugin audits. +-- It must be one of the provider IDs defined in CONFIG["LLM_PROVIDERS"]. +-- Without a selected audit provider, AI Studio uses the app-wide default provider. +-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.PreselectedAgentProvider"] = "00000000-0000-0000-0000-000000000000" +-- +-- Configure the minimum audit level assistant plugins must meet. +-- Allowed values are: UNKNOWN, DANGEROUS, CAUTION, SAFE +-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.MinimumLevel"] = "CAUTION" +-- +-- Configure whether activation is blocked when the audit result is below the minimum level. +-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.BlockActivationBelowMinimum"] = true +-- +-- Configure whether new or changed assistant plugins are audited automatically in the background. +-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.AutomaticallyAuditAssistants"] = false +-- +-- Configure whether users can change assistant plugin audit settings locally. +-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.RequireAuditBeforeActivation.AllowUserOverride"] = false +-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.PreselectedAgentProvider.AllowUserOverride"] = false +-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.MinimumLevel.AllowUserOverride"] = false +-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.BlockActivationBelowMinimum.AllowUserOverride"] = false +-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.AutomaticallyAuditAssistants.AllowUserOverride"] = false + -- Example chat templates for this configuration: CONFIG["CHAT_TEMPLATES"] = {} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 92634f7b..2b492d6f 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -204,6 +204,13 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Config: data source security settings ManagedConfiguration.TryProcessConfiguration(x => x.DataSourceSecurity, x => x.TrustedProviderIds, this.Id, settingsTable, dryRun); + + // Config: assistant plugin audit settings + ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.RequireAuditBeforeActivation, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.PreselectedAgentProvider, Guid.Empty, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.MinimumLevel, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.BlockActivationBelowMinimum, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.AutomaticallyAuditAssistants, this.Id, settingsTable, dryRun); // Handle configured LLM providers: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun); diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md index a9371b96..7a923be1 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md @@ -7,4 +7,5 @@ - Improved the activity indicators for running chats and assistants so they use a consistent blue highlight. - Improved the chat experience by automatically focusing the message composer again when it becomes available. Thanks, Dominic Neuburg (`donework`), for the contribution. - Improved the chat message formatting so line breaks in responses are preserved more closely, making structured text easier to read. Thanks, Dominic Neuburg (`donework`), for the contribution. +- Fixed configuration plugins so they can configure assistant plugin audit settings, including required audit levels, audit provider selection, and automatic background audits. - Upgraded Rust to v1.96.1 \ No newline at end of file From 972ed73fe8b4c8dc67f65f412dda44f9cb4ad811 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 5 Jul 2026 14:46:31 +0200 Subject: [PATCH 8/9] Added configuration plugin options for default chat data sources (#833) --- .../Components/ChatComponent.razor.cs | 35 +++++++++++ .../Components/DataSourceSelection.razor | 10 +-- .../Components/DataSourceSelection.razor.cs | 63 +++++++++++++++---- ...ettingsPanelAgentDataSourceSelection.razor | 5 +- ...PanelAgentRetrievalContextValidation.razor | 9 +-- .../Dialogs/Settings/SettingsDialogChat.razor | 2 +- .../Plugins/configuration/plugin.lua | 46 ++++++++++++++ .../Settings/DataModel/Data.cs | 4 +- .../DataModel/DataAgentDataSourceSelection.cs | 15 ++++- .../DataAgentRetrievalContextValidation.cs | 19 ++++-- .../Settings/DataModel/DataChat.cs | 41 +++++++++++- .../Settings/ManagedConfiguration.Parsing.cs | 8 ++- .../Settings/ManagedConfiguration.cs | 13 ++-- .../Tools/PluginSystem/PluginConfiguration.cs | 15 +++++ .../PluginSystem/PluginFactory.Loading.cs | 35 +++++++++++ .../Tools/Services/DataSourceService.cs | 9 +-- .../wwwroot/changelog/v26.6.3.md | 2 + .../2026-07-chat-data-source-options.md | 26 ++++++++ 18 files changed, 311 insertions(+), 46 deletions(-) create mode 100644 documentation/compatibility-shims/2026-07-chat-data-source-options.md diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index acfd2fce..06b6fb92 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -59,6 +59,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private DataSourceSelection? dataSourceSelectionComponent; private DataSourceOptions earlyDataSourceOptions = new(); + private DataSourceOptions lastAppliedStandardDataSourceOptions = new(); private Profile currentProfile = Profile.NO_PROFILE; private ChatTemplate currentChatTemplate = ChatTemplate.NO_CHAT_TEMPLATE; private bool hasUnsavedChanges; @@ -118,6 +119,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if (!this.ComposerState.HasUserDraft && !this.ComposerState.HasComposerContent) this.ComposerState.ApplyTemplate(this.currentChatTemplate); + this.lastAppliedStandardDataSourceOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy(); + var deferredInput = MessageBus.INSTANCE.CheckDeferredMessages(Event.SEND_TO_CHAT_INPUT).FirstOrDefault(); if (!string.IsNullOrWhiteSpace(deferredInput)) this.ComposerState.SetUserInput(deferredInput); @@ -458,12 +461,42 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private void ApplyStandardDataSourceOptions() { var chatDefaultOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy(); + this.lastAppliedStandardDataSourceOptions = chatDefaultOptions.CreateCopy(); this.earlyDataSourceOptions = chatDefaultOptions; if(this.ChatThread is not null) this.ChatThread.DataSourceOptions = chatDefaultOptions; this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(chatDefaultOptions); } + + private async Task ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange() + { + var updatedStandardOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy(); + var previousStandardOptions = this.lastAppliedStandardDataSourceOptions; + this.lastAppliedStandardDataSourceOptions = updatedStandardOptions.CreateCopy(); + + if (this.ChatThread is null) + { + this.earlyDataSourceOptions = updatedStandardOptions; + this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(updatedStandardOptions); + return; + } + + if (!DataSourceOptionsAreEqual(this.ChatThread.DataSourceOptions, previousStandardOptions)) + return; + + await this.SetCurrentDataSourceOptions(updatedStandardOptions); + this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(updatedStandardOptions, this.ChatThread.AISelectedDataSources); + await this.ChatThreadChanged.InvokeAsync(this.ChatThread); + } + + private static bool DataSourceOptionsAreEqual(DataSourceOptions left, DataSourceOptions right) + { + return left.DisableDataSources == right.DisableDataSources + && left.AutomaticDataSourceSelection == right.AutomaticDataSourceSelection + && left.AutomaticValidation == right.AutomaticValidation + && left.PreselectedDataSourceIds.ToHashSet(StringComparer.Ordinal).SetEquals(right.PreselectedDataSourceIds); + } private string ExtractThreadName(string firstUserInput) { @@ -547,6 +580,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if (!this.ComposerState.HasUserDraft && previousChatTemplate != this.currentChatTemplate) this.ComposerState.ApplyTemplate(this.currentChatTemplate); + + await this.ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange(); } private IReadOnlyList GetAgentSelectedDataSources() diff --git a/app/MindWork AI Studio/Components/DataSourceSelection.razor b/app/MindWork AI Studio/Components/DataSourceSelection.razor index 29c0d709..c5f1be6c 100644 --- a/app/MindWork AI Studio/Components/DataSourceSelection.razor +++ b/app/MindWork AI Studio/Components/DataSourceSelection.razor @@ -160,13 +160,13 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE) } - + @if (this.areDataSourcesEnabled) { - - - - + + + + @foreach (var source in this.availableDataSources) { diff --git a/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs b/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs index 0727092f..7f11972a 100644 --- a/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs +++ b/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs @@ -52,6 +52,7 @@ public partial class DataSourceSelection : MSGComponentBase private bool aiBasedSourceSelection; private bool aiBasedValidation; private bool areDataSourcesEnabled; + private uint loadAndApplyFiltersGeneration; #region Overrides of ComponentBase @@ -75,15 +76,7 @@ public partial class DataSourceSelection : MSGComponentBase // Right before the preselection would be used to kick off the // RAG process, we will filter the data sources as well. // - var preselectedSources = new List(this.DataSourceOptions.PreselectedDataSourceIds.Count); - foreach (var preselectedDataSourceId in this.DataSourceOptions.PreselectedDataSourceIds) - { - var dataSource = this.SettingsManager.ConfigurationData.DataSources.FirstOrDefault(ds => ds.Id == preselectedDataSourceId); - if (dataSource is not null) - preselectedSources.Add(dataSource); - } - - this.selectedDataSources = preselectedSources; + this.selectedDataSources = this.GetDataSourcesFromConfiguredIds(); await base.OnInitializedAsync(); } @@ -94,6 +87,7 @@ public partial class DataSourceSelection : MSGComponentBase this.aiBasedSourceSelection = this.DataSourceOptions.AutomaticDataSourceSelection; this.aiBasedValidation = this.DataSourceOptions.AutomaticValidation; this.areDataSourcesEnabled = !this.DataSourceOptions.DisableDataSources; + this.selectedDataSources = this.GetDataSourcesFromConfiguredIds(); } switch (this.SelectionMode) @@ -119,7 +113,7 @@ public partial class DataSourceSelection : MSGComponentBase // In configuration mode, we have to load all data sources: // case DataSourceSelectionMode.CONFIGURATION_MODE: - this.availableDataSources = this.SettingsManager.ConfigurationData.DataSources; + this.availableDataSources = this.GetConfiguredDataSourcesSnapshot(); break; } @@ -156,7 +150,7 @@ public partial class DataSourceSelection : MSGComponentBase this.aiBasedSourceSelection = this.DataSourceOptions.AutomaticDataSourceSelection; this.aiBasedValidation = this.DataSourceOptions.AutomaticValidation; this.areDataSourcesEnabled = !this.DataSourceOptions.DisableDataSources; - this.selectedDataSources = this.SettingsManager.ConfigurationData.DataSources.Where(ds => this.DataSourceOptions.PreselectedDataSourceIds.Contains(ds.Id)).ToList(); + this.selectedDataSources = this.GetDataSourcesFromConfiguredIds(); this.waitingForDataSources = false; // @@ -176,20 +170,38 @@ public partial class DataSourceSelection : MSGComponentBase this.showDataSourceSelection = false; this.StateHasChanged(); } + + private IReadOnlyList GetConfiguredDataSourcesSnapshot() => this.SettingsManager.ConfigurationData.DataSources.ToList(); + + private IReadOnlyCollection GetDataSourcesFromConfiguredIds() + { + var preselectedDataSourceIds = this.DataSourceOptions.PreselectedDataSourceIds.ToHashSet(StringComparer.Ordinal); + return this.GetConfiguredDataSourcesSnapshot().Where(ds => preselectedDataSourceIds.Contains(ds.Id)).ToList(); + } private async Task LoadAndApplyFilters() { if(this.DataSourceOptions.DisableDataSources) + { + this.loadAndApplyFiltersGeneration++; return; + } if(this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE) + { + this.loadAndApplyFiltersGeneration++; return; + } + var generation = ++this.loadAndApplyFiltersGeneration; this.waitingForDataSources = true; this.StateHasChanged(); // Load the data sources: var sources = await this.DataSourceService.GetDataSources(this.LLMProvider, this.selectedDataSources); + if (generation != this.loadAndApplyFiltersGeneration) + return; + this.availableDataSources = sources.AllowedDataSources; this.selectedDataSources = sources.SelectedDataSources; this.waitingForDataSources = false; @@ -230,9 +242,38 @@ public partial class DataSourceSelection : MSGComponentBase await this.OptionsChanged(); } + private bool IsPreselectedDataSourcesDisabledLocked() + { + return this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE + && ManagedConfiguration.TryGet(x => x.Chat, x => x.PreselectedDataSourcesDisabled, out var meta) + && meta.IsLocked; + } + + private bool IsPreselectedDataSourcesAutomaticSelectionLocked() + { + return this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE + && ManagedConfiguration.TryGet(x => x.Chat, x => x.PreselectedDataSourcesAutomaticSelection, out var meta) + && meta.IsLocked; + } + + private bool IsPreselectedDataSourcesAutomaticValidationLocked() + { + return this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE + && ManagedConfiguration.TryGet(x => x.Chat, x => x.PreselectedDataSourcesAutomaticValidation, out var meta) + && meta.IsLocked; + } + + private bool IsPreselectedDataSourceIdsLocked() + { + return this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE + && ManagedConfiguration.TryGet(x => x.Chat, x => x.PreselectedDataSourceIds, out var meta) + && meta.IsLocked; + } + private async Task OptionsChanged() { this.internalChange = true; + this.loadAndApplyFiltersGeneration++; await this.DataSourceOptionsChanged.InvokeAsync(this.DataSourceOptions); diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelAgentDataSourceSelection.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelAgentDataSourceSelection.razor index e4b258cb..6e951c24 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelAgentDataSourceSelection.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelAgentDataSourceSelection.razor @@ -1,3 +1,4 @@ +@using AIStudio.Settings @inherits SettingsPanelBase @@ -5,7 +6,7 @@ @T("Use Case: this agent is used to select the appropriate data sources for the current prompt.") - - + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelAgentRetrievalContextValidation.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelAgentRetrievalContextValidation.razor index 061c3585..e6bcb880 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelAgentRetrievalContextValidation.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelAgentRetrievalContextValidation.razor @@ -1,16 +1,17 @@ +@using AIStudio.Settings @inherits SettingsPanelBase @T("Use Case: this agent is used to validate any retrieval context of any retrieval process. Perhaps there are many of these retrieval contexts and you want to validate them all. Therefore, you might want to use a cheap and fast LLM for this job. When using a local or self-hosted LLM, look for a small (e.g. 3B) and fast model.") - + @if (this.SettingsManager.ConfigurationData.AgentRetrievalContextValidation.EnableRetrievalContextValidation) { - - - + + + } \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChat.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChat.razor index 348f7a53..f80fa857 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChat.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChat.razor @@ -25,7 +25,7 @@ @if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager)) { - + } diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index b8690ff4..90cc0e1d 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -270,12 +270,38 @@ CONFIG["SETTINGS"] = {} -- Please note: using an empty string ("") or "00000000-0000-0000-0000-000000000000" means chats will use no chat template. -- CONFIG["SETTINGS"]["DataChat.PreselectedChatTemplate"] = "00000000-0000-0000-0000-000000000000" -- +-- +-- Configure default data source options for new chats. +-- +-- Controls whether data sources are off by default: +-- CONFIG["SETTINGS"]["DataChat.PreselectedDataSourcesDisabled"] = false + +-- Controls whether AI Studio asks an agent to choose data sources: +-- CONFIG["SETTINGS"]["DataChat.PreselectedDataSourcesAutomaticSelection"] = true + +-- Controls whether retrieved data is validated by an agent: +-- CONFIG["SETTINGS"]["DataChat.PreselectedDataSourcesAutomaticValidation"] = true + +-- Must contain IDs from CONFIG["DATA_SOURCES"] or user-configured data sources. +-- CONFIG["SETTINGS"]["DataChat.PreselectedDataSourceIds"] = { +-- "00000000-0000-0000-0000-000000000000", +-- } +-- +-- Configure whether default chat data source options are applied when assistant results are sent to chat. +-- Allowed values are: NO_DATA_SOURCES, APPLY_STANDARD_CHAT_DATA_SOURCE_OPTIONS +-- CONFIG["SETTINGS"]["DataChat.SendToChatDataSourceBehavior"] = "APPLY_STANDARD_CHAT_DATA_SOURCE_OPTIONS" +-- -- Allow users to change any configured chat default locally. -- Allowed values are: true, false -- CONFIG["SETTINGS"]["DataChat.PreselectOptions.AllowUserOverride"] = true -- CONFIG["SETTINGS"]["DataChat.PreselectedProvider.AllowUserOverride"] = true -- CONFIG["SETTINGS"]["DataChat.PreselectedProfile.AllowUserOverride"] = true -- CONFIG["SETTINGS"]["DataChat.PreselectedChatTemplate.AllowUserOverride"] = true +-- CONFIG["SETTINGS"]["DataChat.PreselectedDataSourcesDisabled.AllowUserOverride"] = true +-- CONFIG["SETTINGS"]["DataChat.PreselectedDataSourcesAutomaticSelection.AllowUserOverride"] = true +-- CONFIG["SETTINGS"]["DataChat.PreselectedDataSourcesAutomaticValidation.AllowUserOverride"] = true +-- CONFIG["SETTINGS"]["DataChat.PreselectedDataSourceIds.AllowUserOverride"] = true +-- CONFIG["SETTINGS"]["DataChat.SendToChatDataSourceBehavior.AllowUserOverride"] = true -- Configure the transcription provider for voice-to-text functionality. -- It must be one of the transcription provider IDs defined in CONFIG["TRANSCRIPTION_PROVIDERS"]. @@ -386,6 +412,26 @@ CONFIG["SETTINGS"] = {} -- "00000000-0000-0000-0000-000000000001", -- } +-- Configure the data source selection agent. +-- This agent is used when chat data source options enable AI-based data source selection. +-- The provider must be one of the provider IDs defined in CONFIG["LLM_PROVIDERS"]. +-- CONFIG["SETTINGS"]["DataAgentDataSourceSelection.PreselectAgentOptions"] = true +-- CONFIG["SETTINGS"]["DataAgentDataSourceSelection.PreselectedAgentProvider"] = "00000000-0000-0000-0000-000000000000" +-- CONFIG["SETTINGS"]["DataAgentDataSourceSelection.PreselectAgentOptions.AllowUserOverride"] = true +-- CONFIG["SETTINGS"]["DataAgentDataSourceSelection.PreselectedAgentProvider.AllowUserOverride"] = true + +-- Configure the retrieval context validation agent. +-- This agent is used when retrieval context validation is enabled globally and chat data source options enable AI-based validation. +-- The provider must be one of the provider IDs defined in CONFIG["LLM_PROVIDERS"]. +-- CONFIG["SETTINGS"]["DataAgentRetrievalContextValidation.EnableRetrievalContextValidation"] = true +-- CONFIG["SETTINGS"]["DataAgentRetrievalContextValidation.PreselectAgentOptions"] = true +-- CONFIG["SETTINGS"]["DataAgentRetrievalContextValidation.PreselectedAgentProvider"] = "00000000-0000-0000-0000-000000000000" +-- CONFIG["SETTINGS"]["DataAgentRetrievalContextValidation.NumParallelValidations"] = 3 +-- CONFIG["SETTINGS"]["DataAgentRetrievalContextValidation.EnableRetrievalContextValidation.AllowUserOverride"] = true +-- CONFIG["SETTINGS"]["DataAgentRetrievalContextValidation.PreselectAgentOptions.AllowUserOverride"] = true +-- CONFIG["SETTINGS"]["DataAgentRetrievalContextValidation.PreselectedAgentProvider.AllowUserOverride"] = true +-- CONFIG["SETTINGS"]["DataAgentRetrievalContextValidation.NumParallelValidations.AllowUserOverride"] = true + -- Configure assistant plugin security audits. -- -- Configure whether assistant plugins must be audited before users can activate them. diff --git a/app/MindWork AI Studio/Settings/DataModel/Data.cs b/app/MindWork AI Studio/Settings/DataModel/Data.cs index 07f9f9e2..327301b7 100644 --- a/app/MindWork AI Studio/Settings/DataModel/Data.cs +++ b/app/MindWork AI Studio/Settings/DataModel/Data.cs @@ -125,9 +125,9 @@ public sealed class Data public DataTextContentCleaner TextContentCleaner { get; init; } = new(); - public DataAgentDataSourceSelection AgentDataSourceSelection { get; init; } = new(); + public DataAgentDataSourceSelection AgentDataSourceSelection { get; init; } = new(x => x.AgentDataSourceSelection); - public DataAgentRetrievalContextValidation AgentRetrievalContextValidation { get; init; } = new(); + public DataAgentRetrievalContextValidation AgentRetrievalContextValidation { get; init; } = new(x => x.AgentRetrievalContextValidation); public DataAssistantPluginAudit AssistantPluginAudit { get; init; } = new(x => x.AssistantPluginAudit); diff --git a/app/MindWork AI Studio/Settings/DataModel/DataAgentDataSourceSelection.cs b/app/MindWork AI Studio/Settings/DataModel/DataAgentDataSourceSelection.cs index 55473dcc..1e2ad3d2 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataAgentDataSourceSelection.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataAgentDataSourceSelection.cs @@ -1,14 +1,23 @@ +using System.Linq.Expressions; + namespace AIStudio.Settings.DataModel; -public sealed class DataAgentDataSourceSelection +public sealed class DataAgentDataSourceSelection(Expression>? configSelection = null) { + /// + /// The default constructor for the JSON deserializer. + /// + public DataAgentDataSourceSelection() : this(null) + { + } + /// /// Preselect any data source selection options? /// - public bool PreselectAgentOptions { get; set; } + public bool PreselectAgentOptions { get; set; } = ManagedConfiguration.Register(configSelection, n => n.PreselectAgentOptions, false); /// /// Preselect a data source selection provider? /// - public string PreselectedAgentProvider { get; set; } = string.Empty; + public string PreselectedAgentProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.PreselectedAgentProvider, string.Empty); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataAgentRetrievalContextValidation.cs b/app/MindWork AI Studio/Settings/DataModel/DataAgentRetrievalContextValidation.cs index a4ae0a8f..93ae50dd 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataAgentRetrievalContextValidation.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataAgentRetrievalContextValidation.cs @@ -1,24 +1,33 @@ +using System.Linq.Expressions; + namespace AIStudio.Settings.DataModel; -public sealed class DataAgentRetrievalContextValidation +public sealed class DataAgentRetrievalContextValidation(Expression>? configSelection = null) { + /// + /// The default constructor for the JSON deserializer. + /// + public DataAgentRetrievalContextValidation() : this(null) + { + } + /// /// Enable the retrieval context validation agent? /// - public bool EnableRetrievalContextValidation { get; set; } + public bool EnableRetrievalContextValidation { get; set; } = ManagedConfiguration.Register(configSelection, n => n.EnableRetrievalContextValidation, false); /// /// Preselect any retrieval context validation options? /// - public bool PreselectAgentOptions { get; set; } + public bool PreselectAgentOptions { get; set; } = ManagedConfiguration.Register(configSelection, n => n.PreselectAgentOptions, false); /// /// Preselect a retrieval context validation provider? /// - public string PreselectedAgentProvider { get; set; } = string.Empty; + public string PreselectedAgentProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.PreselectedAgentProvider, string.Empty); /// /// Configure how many parallel validations to run. /// - public int NumParallelValidations { get; set; } = 3; + public int NumParallelValidations { get; set; } = ManagedConfiguration.Register(configSelection, n => n.NumParallelValidations, 3); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataChat.cs b/app/MindWork AI Studio/Settings/DataModel/DataChat.cs index f2a7ea37..67b3b313 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataChat.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataChat.cs @@ -29,7 +29,7 @@ public sealed class DataChat(Expression>? configSelection = /// /// Defines the data source behavior when sending assistant results to a chat. /// - public SendToChatDataSourceBehavior SendToChatDataSourceBehavior { get; set; } = SendToChatDataSourceBehavior.NO_DATA_SOURCES; + public SendToChatDataSourceBehavior SendToChatDataSourceBehavior { get; set; } = ManagedConfiguration.Register(configSelection, n => n.SendToChatDataSourceBehavior, SendToChatDataSourceBehavior.NO_DATA_SOURCES); /// /// Preselect any chat options? @@ -51,10 +51,47 @@ public sealed class DataChat(Expression>? configSelection = /// public string PreselectedChatTemplate { get; set; } = ManagedConfiguration.Register(configSelection, n => n.PreselectedChatTemplate, string.Empty); + /// + /// Whether data sources are disabled by default for new chats. + /// + public bool PreselectedDataSourcesDisabled { get; set; } = ManagedConfiguration.Register(configSelection, n => n.PreselectedDataSourcesDisabled, true); + + /// + /// Whether data sources should be selected automatically by default for new chats. + /// + public bool PreselectedDataSourcesAutomaticSelection { get; set; } = ManagedConfiguration.Register(configSelection, n => n.PreselectedDataSourcesAutomaticSelection, false); + + /// + /// Whether retrieved data should be validated automatically by default for new chats. + /// + public bool PreselectedDataSourcesAutomaticValidation { get; set; } = ManagedConfiguration.Register(configSelection, n => n.PreselectedDataSourcesAutomaticValidation, false); + + /// + /// The data source IDs that should be preselected by default for new chats. + /// + public List PreselectedDataSourceIds { get; set; } = ManagedConfiguration.Register(configSelection, n => n.PreselectedDataSourceIds, []); + /// /// Should we preselect data sources options for a created chat? /// - public DataSourceOptions PreselectedDataSourceOptions { get; set; } = new(); + // Compatibility shim: legacy settings used this nested object. See documentation/compatibility-shims/2026-07-chat-data-source-options.md; remove after 2027-01-05. + public DataSourceOptions PreselectedDataSourceOptions + { + get => new() + { + DisableDataSources = this.PreselectedDataSourcesDisabled, + AutomaticDataSourceSelection = this.PreselectedDataSourcesAutomaticSelection, + AutomaticValidation = this.PreselectedDataSourcesAutomaticValidation, + PreselectedDataSourceIds = [..this.PreselectedDataSourceIds], + }; + set + { + this.PreselectedDataSourcesDisabled = value.DisableDataSources; + this.PreselectedDataSourcesAutomaticSelection = value.AutomaticDataSourceSelection; + this.PreselectedDataSourcesAutomaticValidation = value.AutomaticValidation; + this.PreselectedDataSourceIds = [..value.PreselectedDataSourceIds]; + } + } /// /// Should we show the latest message after loading? When false, we show the first (aka oldest) message. diff --git a/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs b/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs index e44fc8dc..2aea5f96 100644 --- a/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs +++ b/app/MindWork AI Studio/Settings/ManagedConfiguration.Parsing.cs @@ -435,7 +435,9 @@ public static partial class ManagedConfiguration if(dryRun) return successful; - return HandleParsedValue(configPluginId, dryRun, successful, configMeta, configuredValue); + var settingName = SettingName(propertyExpression); + var managedMode = ReadManagedConfigurationMode(propertyExpression, settings); + return HandleParsedScalarValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName); } /// @@ -1026,6 +1028,10 @@ public static partial class ManagedConfiguration .Cast() .OrderBy(key => key.ToString(), StringComparer.Ordinal) .Select(key => $"{key}:{dictionary[key]}")), + System.Collections.IEnumerable enumerable => string.Join(";", enumerable + .Cast() + .Select(item => item.ToString() ?? string.Empty) + .Order(StringComparer.Ordinal)), IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() ?? string.Empty, diff --git a/app/MindWork AI Studio/Settings/ManagedConfiguration.cs b/app/MindWork AI Studio/Settings/ManagedConfiguration.cs index 876c8408..620c3b20 100644 --- a/app/MindWork AI Studio/Settings/ManagedConfiguration.cs +++ b/app/MindWork AI Studio/Settings/ManagedConfiguration.cs @@ -358,17 +358,18 @@ public static partial class ManagedConfiguration if (!TryGet(configSelection, propertyExpression, out var configMeta)) return false; + if (configMeta.ManagedMode is ManagedConfigurationMode.EDITABLE_DEFAULT) + return CleanupEditableDefaultState(configMeta, SettingName(propertyExpression), availablePlugins.ToList()); + if (configMeta.LockedByConfigPluginId == Guid.Empty || !configMeta.IsLocked) return false; var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.LockedByConfigPluginId); - if (plugin is null) - { - configMeta.ResetLockedConfiguration(); - return true; - } + if (plugin is not null) + return false; - return false; + configMeta.ResetLockedConfiguration(); + return true; } public static bool IsConfigurationLeftOver( diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 2b492d6f..c5478cf1 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -205,6 +205,16 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Config: data source security settings ManagedConfiguration.TryProcessConfiguration(x => x.DataSourceSecurity, x => x.TrustedProviderIds, this.Id, settingsTable, dryRun); + // Config: data source selection agent settings + ManagedConfiguration.TryProcessConfiguration(x => x.AgentDataSourceSelection, x => x.PreselectAgentOptions, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.AgentDataSourceSelection, x => x.PreselectedAgentProvider, Guid.Empty, this.Id, settingsTable, dryRun); + + // Config: retrieval context validation agent settings + ManagedConfiguration.TryProcessConfiguration(x => x.AgentRetrievalContextValidation, x => x.EnableRetrievalContextValidation, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.AgentRetrievalContextValidation, x => x.PreselectAgentOptions, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.AgentRetrievalContextValidation, x => x.PreselectedAgentProvider, Guid.Empty, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.AgentRetrievalContextValidation, x => x.NumParallelValidations, this.Id, settingsTable, dryRun); + // Config: assistant plugin audit settings ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.RequireAuditBeforeActivation, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.AssistantPluginAudit, x => x.PreselectedAgentProvider, Guid.Empty, this.Id, settingsTable, dryRun); @@ -250,6 +260,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedProfile, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedChatTemplate, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedDataSourcesDisabled, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedDataSourcesAutomaticSelection, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedDataSourcesAutomaticValidation, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedDataSourceIds, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.SendToChatDataSourceBehavior, this.Id, settingsTable, dryRun); // Config: transcription provider? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UseTranscriptionProvider, Guid.Empty, this.Id, settingsTable, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index 3f74c556..42396f9f 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -214,6 +214,21 @@ public static partial class PluginFactory if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedChatTemplate, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; + + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourcesDisabled, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourcesAutomaticSelection, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourcesAutomaticValidation, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.PreselectedDataSourceIds, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.Chat, x => x.SendToChatDataSourceBehavior, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; // Check for the update interval: if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.UpdateInterval, AVAILABLE_PLUGINS)) @@ -300,6 +315,26 @@ public static partial class PluginFactory if(ManagedConfiguration.IsConfigurationLeftOver(x => x.DataSourceSecurity, x => x.TrustedProviderIds, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; + // Check data source selection agent settings: + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentDataSourceSelection, x => x.PreselectAgentOptions, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentDataSourceSelection, x => x.PreselectedAgentProvider, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + + // Check retrieval context validation agent settings: + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.EnableRetrievalContextValidation, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.PreselectAgentOptions, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.PreselectedAgentProvider, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AgentRetrievalContextValidation, x => x.NumParallelValidations, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + // Check if audit is required before it can be activated if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.RequireAuditBeforeActivation, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; diff --git a/app/MindWork AI Studio/Tools/Services/DataSourceService.cs b/app/MindWork AI Studio/Tools/Services/DataSourceService.cs index dbd8954a..d4fcd838 100644 --- a/app/MindWork AI Studio/Tools/Services/DataSourceService.cs +++ b/app/MindWork AI Studio/Tools/Services/DataSourceService.cs @@ -70,9 +70,10 @@ public sealed class DataSourceService private async Task GetDataSources(bool usingTrustedProvider, IReadOnlyCollection? previousSelectedDataSources = null) { - var allDataSources = this.settingsManager.ConfigurationData.DataSources; + var allDataSources = this.settingsManager.ConfigurationData.DataSources.ToList(); + var previousSelectedDataSourceIds = previousSelectedDataSources?.Select(source => source.Id).ToHashSet(StringComparer.Ordinal) ?? []; var filteredDataSources = new List(allDataSources.Count); - var filteredSelectedDataSources = new List(previousSelectedDataSources?.Count ?? 0); + var filteredSelectedDataSources = new List(previousSelectedDataSourceIds.Count); var tasks = new List>(allDataSources.Count); // Start all checks in parallel: @@ -86,7 +87,7 @@ public sealed class DataSourceService if (source is not null) { filteredDataSources.Add(source); - if (previousSelectedDataSources is not null && previousSelectedDataSources.Contains(source)) + if (previousSelectedDataSourceIds.Contains(source.Id)) filteredSelectedDataSources.Add(source); } } @@ -205,4 +206,4 @@ public sealed class DataSourceService return null; } } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md index 7a923be1..e49cdc1d 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md @@ -1,5 +1,6 @@ # v26.6.3, build 243 (2026-06-xx xx:xx UTC) - Added expert capability overrides for providers, so advanced users and configuration plugins can manually adjust selected model capabilities, including reasoning (thinking) behavior, when automatic detection needs adjustment. +- Added configuration plugin options for default chat data source behavior and the related data source selection and validation agents. - Improved the provider selection by showing small capability icons for supported audio, image, speech, and reasoning features of the selected model. - Improved source links in chat answers when file or document names contained spaces, umlauts, or other special characters. Source entries now open much more reliably for documents with names such as PDFs from shared portals or internal knowledge bases. - Improved all assistants, so running tasks can continue when you leave the assistant and return later. @@ -8,4 +9,5 @@ - Improved the chat experience by automatically focusing the message composer again when it becomes available. Thanks, Dominic Neuburg (`donework`), for the contribution. - Improved the chat message formatting so line breaks in responses are preserved more closely, making structured text easier to read. Thanks, Dominic Neuburg (`donework`), for the contribution. - Fixed configuration plugins so they can configure assistant plugin audit settings, including required audit levels, audit provider selection, and automatic background audits. +- Fixed default data source selection so sources that require local or trusted providers are selected again after switching from a cloud provider to a suitable provider. - Upgraded Rust to v1.96.1 \ No newline at end of file diff --git a/documentation/compatibility-shims/2026-07-chat-data-source-options.md b/documentation/compatibility-shims/2026-07-chat-data-source-options.md new file mode 100644 index 00000000..22491e9f --- /dev/null +++ b/documentation/compatibility-shims/2026-07-chat-data-source-options.md @@ -0,0 +1,26 @@ +# Chat Data Source Options + +- Status: Active +- Introduced: 2026-07-05 +- Remove after: 2027-01-05 +- Code references: + - `app/MindWork AI Studio/Settings/DataModel/DataChat.cs` + +## User Impact + +Older settings files store default chat data source options in the nested `DataChat.PreselectedDataSourceOptions` object. + +Without this shim, those defaults would be lost after upgrading to a version that exposes the individual data source default fields to configuration plugins. + +## Compatibility Behavior + +`DataChat.PreselectedDataSourceOptions` remains available as a compatibility property. Reading it maps the new individual fields into a `DataSourceOptions` object, and setting it copies values from the legacy nested object into the new fields. + +This lets older settings files load without a settings version migration and keeps existing UI bindings working while configuration plugins manage the individual fields. + +## Removal Checklist + +- Confirm supported settings files are expected to contain `PreselectedDataSourcesDisabled`, `PreselectedDataSourcesAutomaticSelection`, `PreselectedDataSourcesAutomaticValidation`, and `PreselectedDataSourceIds`. +- Remove `DataChat.PreselectedDataSourceOptions`. +- Update any remaining callers to use the individual fields or a dedicated helper. +- Update this document's status to `Removed`. From 13cc2f837abc8631c30e94378d0b40a16138657a Mon Sep 17 00:00:00 2001 From: Peer Hogeterp Date: Sun, 5 Jul 2026 15:20:29 +0200 Subject: [PATCH 9/9] Added support for organization-approved assistant plugins (#821) --- .../Commands/AssistantPluginHashCommand.cs | 47 +++++++ app/Build/Program.cs | 3 +- .../Assistants/I18N/allTexts.lua | 69 ++++++++++ .../Components/AssistantBlock.razor | 15 ++- .../Components/AssistantBlock.razor.cs | 5 + .../AssistantPluginSecurityCard.razor | 106 +++++++++++++--- .../AssistantPluginSecurityCard.razor.cs | 11 ++ .../Dialogs/AssistantPluginAuditDialog.razor | 24 +++- .../AssistantPluginAuditDialog.razor.cs | 5 +- app/MindWork AI Studio/Pages/Assistants.razor | 4 +- .../Pages/Assistants.razor.cs | 51 ++++++++ .../Plugins/assistants/README.md | 28 +++++ .../Plugins/assistants/plugin.lua | 2 + .../Plugins/configuration/plugin.lua | 17 +++ .../plugin.lua | 69 ++++++++++ .../plugin.lua | 69 ++++++++++ .../DataModel/DataAssistantPluginAudit.cs | 5 + .../DataAssistantPluginEnterpriseApproval.cs | 13 ++ .../Settings/ProviderExtensions.OpenSource.cs | 2 +- .../AssistantSessionService.cs | 2 +- app/MindWork AI Studio/Tools/CommonTools.cs | 17 ++- .../AssistantPluginLaunchBehavior.cs | 7 ++ .../PluginAssistantSecurityResolver.cs | 67 +++++++++- .../PluginAssistantSecurityState.cs | 6 + .../PluginAssistantSecurityStatusSource.cs | 8 ++ .../Assistants/PluginAssistants.cs | 86 ++++++++----- .../Tools/PluginSystem/PluginConfiguration.cs | 118 ++++++++++++++++++ .../PluginSystem/PluginFactory.Loading.cs | 4 + .../PluginSystem/PluginFactory.Starting.cs | 28 +++++ .../Tools/Services/DataSourceService.cs | 2 +- .../Tools/WorkspaceBehaviour.cs | 40 ++++++ .../wwwroot/changelog/v26.6.3.md | 2 + app/SharedTools/AssistantPluginHash.cs | 40 ++++++ documentation/Enterprise IT.md | 42 +++++++ .../2026-07-chat-data-source-options.md | 2 +- 35 files changed, 954 insertions(+), 62 deletions(-) create mode 100644 app/Build/Commands/AssistantPluginHashCommand.cs create mode 100644 app/MindWork AI Studio/Settings/DataModel/DataAssistantPluginEnterpriseApproval.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/Assistants/AssistantPluginLaunchBehavior.cs create mode 100644 app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityStatusSource.cs create mode 100644 app/SharedTools/AssistantPluginHash.cs diff --git a/app/Build/Commands/AssistantPluginHashCommand.cs b/app/Build/Commands/AssistantPluginHashCommand.cs new file mode 100644 index 00000000..8d7552e4 --- /dev/null +++ b/app/Build/Commands/AssistantPluginHashCommand.cs @@ -0,0 +1,47 @@ +using SharedTools; + +namespace Build.Commands; + +public sealed class AssistantPluginHashCommand +{ + [Command("assistant-plugin-hash", Description = "Compute the canonical assistant-plugin hash for a plugin directory")] + public void ComputeAssistantPluginHash( + [Argument(Description = "Path to the assistant plugin directory")] string pluginDir, + [Option("lua-snippet", Description = "Also print a Lua snippet for CONFIG[\"SETTINGS\"]")] bool luaSnippet = false) + { + if (!Environment.IsWorkingDirectoryValid()) + return; + + var resolvedPath = Path.GetFullPath(pluginDir, Directory.GetCurrentDirectory()); + if (!Directory.Exists(resolvedPath)) + { + Console.WriteLine($"- Error: The plugin directory '{resolvedPath}' does not exist."); + return; + } + + var pluginHash = AssistantPluginHash.Compute(resolvedPath); + if (string.IsNullOrWhiteSpace(pluginHash)) + { + Console.WriteLine($"- Error: No Lua files were found in '{resolvedPath}'."); + return; + } + + Console.WriteLine(pluginHash); + + if (!luaSnippet) + return; + + var displayName = Path.GetFileName(resolvedPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var approvedAtUtc = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); + Console.WriteLine(); + Console.WriteLine("""CONFIG["SETTINGS"]["DataAssistantPluginAudit.EnterpriseApprovedPlugins"] = {"""); + Console.WriteLine(" {"); + Console.WriteLine($""" ["PluginHash"] = "{pluginHash}","""); + Console.WriteLine($""" ["DisplayName"] = "{displayName}","""); + Console.WriteLine(""" ["Comment"] = "","""); + Console.WriteLine(""" ["ApprovedBy"] = "","""); + Console.WriteLine($""" ["ApprovedAtUtc"] = "{approvedAtUtc}","""); + Console.WriteLine(" }"); + Console.WriteLine("}"); + } +} diff --git a/app/Build/Program.cs b/app/Build/Program.cs index e7744b36..f56078de 100644 --- a/app/Build/Program.cs +++ b/app/Build/Program.cs @@ -6,4 +6,5 @@ app.AddCommands(); app.AddCommands(); app.AddCommands(); app.AddCommands(); -app.Run(); \ No newline at end of file +app.AddCommands(); +app.Run(); diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index db8484ce..9dc77466 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -2005,6 +2005,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T661906146"] = "The resul -- Show or hide the detailed security information. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1045105126"] = "Show or hide the detailed security information." +-- This plugin is approved by your organization. A manual security audit is not required. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1213338416"] = "This plugin is approved by your organization. A manual security audit is not required." + -- Assistant Audit UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1506922856"] = "Assistant Audit" @@ -2020,6 +2023,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1805629238" -- Assistant Security UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1841954939"] = "Assistant Security" +-- Company approved +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2036497459"] = "Company approved" + +-- Approved name +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2282386733"] = "Approved name" + -- Required minimum UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2354026284"] = "Required minimum" @@ -2029,6 +2038,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2757790517" -- Technical Details UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2769062110"] = "Technical Details" +-- Approval comment +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2906887599"] = "Approval comment" + -- No audit yet UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3138877447"] = "No audit yet" @@ -2044,21 +2056,39 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3448155331" -- No stored audit details are available yet. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3647137899"] = "No stored audit details are available yet." +-- Enterprise approval is active +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3816183955"] = "Enterprise approval is active" + -- Current hash UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3896860082"] = "Current hash" +-- No user audit required +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3916957031"] = "No user audit required" + -- Audited at UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4103354206"] = "Audited at" +-- Approved hash +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4170340306"] = "Approved hash" + -- No security findings were stored for this assistant plugin. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4256679240"] = "No security findings were stored for this assistant plugin." +-- Status source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4289123040"] = "Status source" + -- Audit hash UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T53507304"] = "Audit hash" -- {0} Finding(s) UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T631393016"] = "{0} Finding(s)" +-- Approved by +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T894543751"] = "Approved by" + +-- Approved at +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T978873131"] = "Approved at" + -- Click the paperclip to attach files, or click the number to see your attached files. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1358313858"] = "Click the paperclip to attach files, or click the number to see your attached files." @@ -3457,6 +3487,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T3652671056"] = -- Unavailable UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T3662391977"] = "Unavailable" +-- This assistant plugin is approved by your organization. A manual security audit is not required. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T3680374624"] = "This assistant plugin is approved by your organization. A manual security audit is not required." + -- Plugin Structure UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T371537943"] = "Plugin Structure" @@ -7627,6 +7660,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::DATAMODEL::ASSISTANT -- Button UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::DATAMODEL::ASSISTANTCOMPONENTTYPEEXTENSIONS::T864557713"] = "Button" +-- The ASSISTANT table contains an invalid LaunchBehavior value. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T109828905"] = "The ASSISTANT table contains an invalid LaunchBehavior value." + +-- The ASSISTANT table contains an unsupported LaunchBehavior value. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T1194373781"] = "The ASSISTANT table contains an unsupported LaunchBehavior value." + -- Failed to parse the UI render tree from the ASSISTANT lua table. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T1318499252"] = "Failed to parse the UI render tree from the ASSISTANT lua table." @@ -7642,12 +7681,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T2 -- The ASSISTANT lua table does not exist or is not a valid table. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T3017816936"] = "The ASSISTANT lua table does not exist or is not a valid table." +-- The ASSISTANT table contains an empty WorkspaceName for LaunchBehavior 'OPEN_WORKSPACE_CHAT_BY_NAME'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T3233001282"] = "The ASSISTANT table contains an empty WorkspaceName for LaunchBehavior 'OPEN_WORKSPACE_CHAT_BY_NAME'." + -- The provided ASSISTANT lua table does not contain a valid system prompt. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T3402798667"] = "The provided ASSISTANT lua table does not contain a valid system prompt." -- The ASSISTANT table does not contain a valid system prompt. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T3723171842"] = "The ASSISTANT table does not contain a valid system prompt." +-- The ASSISTANT table contains the LaunchBehavior 'OPEN_WORKSPACE_CHAT_BY_NAME' but no valid WorkspaceName. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T4215554842"] = "The ASSISTANT table contains the LaunchBehavior 'OPEN_WORKSPACE_CHAT_BY_NAME' but no valid WorkspaceName." + -- ASSISTANT.BuildPrompt exists but is not a Lua function or has invalid syntax. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T683382975"] = "ASSISTANT.BuildPrompt exists but is not a Lua function or has invalid syntax." @@ -7690,6 +7735,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECUR -- The current audit result is '{0}', which is below your required minimum level '{1}'. Audit enforcement is currently disabled, so this assistant plugin can still be enabled or used. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T2774333862"] = "The current audit result is '{0}', which is below your required minimum level '{1}'. Audit enforcement is currently disabled, so this assistant plugin can still be enabled or used." +-- The current plugin hash matches an enterprise-managed approval. No manual security audit is required for activation or usage. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T2824524534"] = "The current plugin hash matches an enterprise-managed approval. No manual security audit is required for activation or usage." + -- Not Audited UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T2828154864"] = "Not Audited" @@ -7699,12 +7747,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECUR -- Open Security Check UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T290241209"] = "Open Security Check" +-- User Audit +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T3293963409"] = "User Audit" + -- Restricted UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T3325062668"] = "Restricted" -- Unknown UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T3424652889"] = "Unknown" +-- Approved by your organization +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T3508214481"] = "Approved by your organization" + -- Unlocked UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T3606159420"] = "Unlocked" @@ -7720,9 +7774,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECUR -- No security audit exists yet. Your current security settings do not require an audit before this assistant plugin may be used. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T3899951594"] = "No security audit exists yet. Your current security settings do not require an audit before this assistant plugin may be used." +-- No Approval +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T515592229"] = "No Approval" + +-- This assistant was approved by your organization. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T538196816"] = "This assistant was approved by your organization." + +-- Safe +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T760494712"] = "Safe" + +-- Open Security Details +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T803119455"] = "Open Security Details" + -- Start Security Check UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T811648299"] = "Start Security Check" +-- This assistant was approved by your organization as '{0}'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T834246718"] = "This assistant was approved by your organization as '{0}'." + -- This assistant currently has no stored audit. UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T921972844"] = "This assistant currently has no stored audit." diff --git a/app/MindWork AI Studio/Components/AssistantBlock.razor b/app/MindWork AI Studio/Components/AssistantBlock.razor index b46711c5..efb7eee4 100644 --- a/app/MindWork AI Studio/Components/AssistantBlock.razor +++ b/app/MindWork AI Studio/Components/AssistantBlock.razor @@ -34,9 +34,18 @@ - - @this.ButtonText - + @if (this.HasStartAction) + { + + @this.ButtonText + + } + else + { + + @this.ButtonText + + } @if (this.HasSettingsPanel) { diff --git a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs index 735b2974..985cf659 100644 --- a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs +++ b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs @@ -31,6 +31,9 @@ public partial class AssistantBlock : MSGComponentBase where TSetting [Parameter] public string Link { get; set; } = string.Empty; + [Parameter] + public EventCallback OnClick { get; set; } + [Parameter] public bool Disabled { get; set; } @@ -80,6 +83,8 @@ public partial class AssistantBlock : MSGComponentBase where TSetting private bool HasSettingsPanel => typeof(TSettings) != typeof(NoSettingsPanel); + private bool HasStartAction => this.OnClick.HasDelegate; + /// /// Gets the newest assistant session snapshot represented by this block. /// diff --git a/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor b/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor index 01012365..e3a77871 100644 --- a/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor +++ b/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor @@ -33,6 +33,12 @@ @state.AuditLabel + @if (!string.IsNullOrWhiteSpace(state.SourceLabel)) + { + + @state.SourceLabel + + } @if (!string.IsNullOrWhiteSpace(state.AvailabilityLabel)) { @@ -53,18 +59,28 @@ - - - @T("Confidence"): - - - @this.GetConfidenceLabel() - - + @if (state.IsEnterpriseApproved) + { + + + @T("Enterprise approval is active") + + } + else + { + + + @T("Confidence"): + + + @this.GetConfidenceLabel() + + + } @@ -104,12 +120,63 @@ @this.Plugin.Id + + + @T("Status source") + + @state.SourceLabel + @T("Current hash") @GetShortHash(state.CurrentHash) + @if (state.EnterpriseApproval is not null) + { + + + @T("Approved hash") + + @GetShortHash(state.EnterpriseApproval.PluginHash) + + @if (!string.IsNullOrWhiteSpace(state.EnterpriseApproval.DisplayName)) + { + + + @T("Approved name") + + @state.EnterpriseApproval.DisplayName + + } + @if (!string.IsNullOrWhiteSpace(state.EnterpriseApproval.ApprovedBy)) + { + + + @T("Approved by") + + @state.EnterpriseApproval.ApprovedBy + + } + @if (state.EnterpriseApproval.ApprovedAtUtc is not null) + { + + + @T("Approved at") + + @this.FormatFileTimestamp(state.EnterpriseApproval.ApprovedAtUtc.Value.ToLocalTime().DateTime) + + } + @if (!string.IsNullOrWhiteSpace(state.EnterpriseApproval.Comment)) + { + + + @T("Approval comment") + + @state.EnterpriseApproval.Comment + + } + } @if (state.Audit is not null) { @@ -156,9 +223,18 @@ @if (state.Audit is null) { - - @T("No stored audit details are available yet.") - + @if (state.IsEnterpriseApproved) + { + + @T("This plugin is approved by your organization. A manual security audit is not required.") + + } + else + { + + @T("No stored audit details are available yet.") + + } } else if (state.Audit.Findings.Count == 0) { diff --git a/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor.cs b/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor.cs index 412ca3e8..d1d56291 100644 --- a/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor.cs +++ b/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor.cs @@ -103,12 +103,23 @@ public partial class AssistantPluginSecurityCard : MSGComponentBase private string GetFindingSummary() { + if (this.SecurityState.IsEnterpriseApproved) + return this.T("No user audit required"); + var count = this.SecurityState.Audit?.Findings.Count ?? 0; return string.Format(this.T("{0} Finding(s)"), count); } private string GetAuditTimestampLabel() { + if (this.SecurityState.IsEnterpriseApproved) + { + var approvedAt = this.SecurityState.EnterpriseApproval?.ApprovedAtUtc; + return approvedAt is null + ? this.T("Company approved") + : this.FormatFileTimestamp(approvedAt.Value.ToLocalTime().DateTime); + } + var auditedAt = this.SecurityState.Audit?.AuditedAtUtc; return auditedAt is null ? this.T("No audit yet") diff --git a/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor b/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor index 637f3329..64867e09 100644 --- a/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor +++ b/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor @@ -12,9 +12,18 @@ else { - - @T("This security check uses a sample prompt preview. Empty or placeholder values in the preview are expected.") - + @if (this.securityState.IsEnterpriseApproved) + { + + @T("This assistant plugin is approved by your organization. A manual security audit is not required.") + + } + else + { + + @T("This security check uses a sample prompt preview. Empty or placeholder values in the preview are expected.") + + } @this.plugin.Name @@ -298,9 +307,12 @@ @(this.audit is null ? T("Cancel") : T("Close")) - - @T("Start Security Check") - + @if (!this.securityState.IsEnterpriseApproved) + { + + @T("Start Security Check") + + } @if (this.CanEnablePlugin) { diff --git a/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor.cs b/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor.cs index 122b6e40..2d85e400 100644 --- a/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor.cs @@ -37,6 +37,7 @@ public partial class AssistantPluginAuditDialog : MSGComponentBase private IReadOnlyCollection> fileSystemTreeItems = []; private CultureInfo currentCultureInfo = CultureInfo.InvariantCulture; private bool isAuditing; + private PluginAssistantSecurityState securityState = new(); private AIStudio.Settings.Provider CurrentProvider => this.SettingsManager.GetPreselectedProvider(Tools.Components.AGENT_ASSISTANT_PLUGIN_AUDIT, null, true); @@ -50,7 +51,7 @@ public partial class AssistantPluginAuditDialog : MSGComponentBase private string MinimumLevelLabel => this.MinimumLevel.GetName(); - private bool CanRunAudit => this.plugin is not null && this.CurrentProvider != AIStudio.Settings.Provider.NONE && !this.isAuditing; + private bool CanRunAudit => this.plugin is not null && this.CurrentProvider != AIStudio.Settings.Provider.NONE && !this.isAuditing && !this.securityState.IsEnterpriseApproved; private bool IsAuditBelowMinimum => this.audit is not null && this.audit.Level < this.MinimumLevel; @@ -74,6 +75,7 @@ public partial class AssistantPluginAuditDialog : MSGComponentBase .FirstOrDefault(x => x.Id == this.PluginId); if (this.plugin is not null) { + this.securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, this.plugin); this.promptPreview = await this.plugin.BuildAuditPromptPreviewAsync(); this.promptFallbackPreview = this.plugin.BuildAuditPromptFallbackPreview(); this.plugin.CreateAuditComponentSummary(); @@ -96,6 +98,7 @@ public partial class AssistantPluginAuditDialog : MSGComponentBase try { this.audit = await this.AssistantPluginAuditService.RunAuditAsync(this.plugin); + this.securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, this.plugin); } finally { diff --git a/app/MindWork AI Studio/Pages/Assistants.razor b/app/MindWork AI Studio/Pages/Assistants.razor index feca92fc..306406d1 100644 --- a/app/MindWork AI Studio/Pages/Assistants.razor +++ b/app/MindWork AI Studio/Pages/Assistants.razor @@ -42,13 +42,15 @@ @foreach (var assistantPlugin in this.AssistantPlugins) { var securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, assistantPlugin); + var launchLink = assistantPlugin.StartsChatDirectly ? string.Empty : $"{Routes.ASSISTANT_DYNAMIC}?assistantId={assistantPlugin.Id}"; + Link="@launchLink" + OnClick="@(() => this.StartAssistantPluginAsync(assistantPlugin))"> diff --git a/app/MindWork AI Studio/Pages/Assistants.razor.cs b/app/MindWork AI Studio/Pages/Assistants.razor.cs index f7668a1d..1d67cecb 100644 --- a/app/MindWork AI Studio/Pages/Assistants.razor.cs +++ b/app/MindWork AI Studio/Pages/Assistants.razor.cs @@ -1,3 +1,4 @@ +using AIStudio.Chat; using AIStudio.Components; using AIStudio.Agents.AssistantAudit; using AIStudio.Tools.PluginSystem; @@ -12,6 +13,12 @@ public partial class Assistants : MSGComponentBase [Inject] private AssistantPluginAuditService AssistantPluginAuditService { get; init; } = null!; + + [Inject] + private NavigationManager NavigationManager { get; init; } = null!; + + [Inject] + private ILogger Logger { get; init; } = null!; protected override async Task OnInitializedAsync() { @@ -81,6 +88,50 @@ public partial class Assistants : MSGComponentBase audits.Add(audit); } + private async Task StartAssistantPluginAsync(PluginAssistants assistantPlugin) + { + var securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, assistantPlugin); + if (!securityState.CanStartAssistant) + return; + + if (!assistantPlugin.StartsChatDirectly) + { + this.NavigationManager.NavigateTo($"{Routes.ASSISTANT_DYNAMIC}?assistantId={assistantPlugin.Id}"); + return; + } + + var chatThread = await this.TryCreateDirectChatThreadAsync(assistantPlugin); + if (chatThread is null) + return; + + MessageBus.INSTANCE.DeferMessage(this, Event.SEND_TO_CHAT, chatThread); + this.NavigationManager.NavigateTo(Routes.CHAT); + } + + private async Task TryCreateDirectChatThreadAsync(PluginAssistants assistantPlugin) + { + var workspaceId = await WorkspaceBehaviour.ResolveOrCreateWorkspaceIdByNameAsync(assistantPlugin.LaunchWorkspaceName); + if (workspaceId == Guid.Empty) + { + this.Logger.LogWarning("Assistant plugin '{PluginName}' could not resolve or create workspace '{WorkspaceName}'.", assistantPlugin.Name, assistantPlugin.LaunchWorkspaceName); + return null; + } + + return new ChatThread + { + IncludeDateTime = true, + SelectedProvider = string.Empty, + SelectedProfile = string.Empty, + SelectedChatTemplate = string.Empty, + SystemPrompt = SystemPrompts.DEFAULT, + WorkspaceId = workspaceId, + ChatId = Guid.NewGuid(), + Name = assistantPlugin.AssistantTitle, + DataSourceOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy(), + Blocks = [], + }; + } + protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default { if (triggeredEvent is Event.PLUGINS_RELOADED) diff --git a/app/MindWork AI Studio/Plugins/assistants/README.md b/app/MindWork AI Studio/Plugins/assistants/README.md index 2ce0a9c7..dfef8c10 100644 --- a/app/MindWork AI Studio/Plugins/assistants/README.md +++ b/app/MindWork AI Studio/Plugins/assistants/README.md @@ -81,6 +81,9 @@ Each assistant plugin lives in its own directory under the assistants plugin roo ## Structure - `ASSISTANT` is the root table. It must contain `Title`, `Description`, `SystemPrompt`, `SubmitText`, `AllowProfiles`, and the nested `UI` definition. +- `ASSISTANT` may optionally define direct-launch metadata for assistant tiles: + - `LaunchBehavior = "OPEN_WORKSPACE_CHAT_BY_NAME"` + - `WorkspaceName = ""` - `UI.Type` is always `"FORM"` and `UI.Children` is a list of component tables. - Each component table declares `Type`, an optional `Children` array, and a `Props` table that feeds the component’s parameters. @@ -92,6 +95,8 @@ ASSISTANT = { ["SystemPrompt"] = "", ["SubmitText"] = "", ["AllowProfiles"] = true, + ["LaunchBehavior"] = "OPEN_WORKSPACE_CHAT_BY_NAME", + ["WorkspaceName"] = "", ["UI"] = { ["Type"] = "FORM", ["Children"] = { @@ -101,6 +106,29 @@ ASSISTANT = { } ``` +## Direct Launch to Workspace Chat +Assistant plugins can optionally skip the normal assistant page and open a chat directly from the tile. + +```lua +ASSISTANT = { + ["Title"] = "Open Chat", + ["Description"] = "Open a new chat in the XXX workspace.", + ["SystemPrompt"] = "", + ["SubmitText"] = "Start", + ["AllowProfiles"] = true, + ["LaunchBehavior"] = "OPEN_WORKSPACE_CHAT_BY_NAME", + ["WorkspaceName"] = "XXX", + ["UI"] = { + ["Type"] = "FORM", + ["Children"] = {} + } +} +``` + +- `WorkspaceName` is resolved case-insensitively after trimming. +- If the workspace does not exist yet, AI Studio creates it automatically. +- The opened chat uses the normal default chat settings of AI Studio. + #### Supported types (matching the Blazor UI components): diff --git a/app/MindWork AI Studio/Plugins/assistants/plugin.lua b/app/MindWork AI Studio/Plugins/assistants/plugin.lua index 36d22016..e3610bc2 100644 --- a/app/MindWork AI Studio/Plugins/assistants/plugin.lua +++ b/app/MindWork AI Studio/Plugins/assistants/plugin.lua @@ -62,6 +62,8 @@ ASSISTANT = { ["SystemPrompt"] = "", -- required ["SubmitText"] = "