mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-24 20:52:11 +00:00
Reduced memory usage and fixed several memory leaks (#933)
Some checks failed
Build and Release / Determine run mode (push) Has been cancelled
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Sync Flatpak repo (push) Has been cancelled
Build and Release / Collect Flatpak artifacts (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled
Some checks failed
Build and Release / Determine run mode (push) Has been cancelled
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Sync Flatpak repo (push) Has been cancelled
Build and Release / Collect Flatpak artifacts (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled
This commit is contained in:
parent
902a01a4d0
commit
7d9a4f5ab1
@ -270,7 +270,7 @@ public partial class AssistantAgenda : AssistantBaseCore<SettingsDialogAgenda>
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_AGENDA_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_AGENDA_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputContent = deferredContent;
|
this.inputContent = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -478,6 +478,12 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
|||||||
this.CancellationTokenSource?.Dispose();
|
this.CancellationTokenSource?.Dispose();
|
||||||
this.CancellationTokenSource = null;
|
this.CancellationTokenSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// The handlers above close over this assistant, and the content stays in the chat
|
||||||
|
// thread. The stream is over by now, so nothing has to listen to it anymore:
|
||||||
|
//
|
||||||
|
aiText.ResetStreamingHandlers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,15 +15,15 @@ public partial class AssistantBatchProcessing
|
|||||||
{
|
{
|
||||||
return IsTranscribableMedia(fileResult.FilePath)
|
return IsTranscribableMedia(fileResult.FilePath)
|
||||||
? this.LoadMediaTranscriptAsync(fileResult, token)
|
? this.LoadMediaTranscriptAsync(fileResult, token)
|
||||||
: this.LoadDocumentContentAsync(fileResult);
|
: this.LoadDocumentContentAsync(fileResult, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> LoadDocumentContentAsync(BatchProcessingFileResult fileResult)
|
private async Task<string?> LoadDocumentContentAsync(BatchProcessingFileResult fileResult, CancellationToken token)
|
||||||
{
|
{
|
||||||
FileExtractionResult extraction;
|
FileExtractionResult extraction;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
extraction = await this.RustService.ReadArbitraryFileData(fileResult.FilePath, int.MaxValue);
|
extraction = await this.RustService.ReadArbitraryFileData(fileResult.FilePath, int.MaxValue, token: token);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
@ -31,6 +31,16 @@ public partial class AssistantBatchProcessing
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// The user stopped the batch run while we were reading this file. That says nothing about
|
||||||
|
// the file, so it gets the same status as a cancelled AI request instead of a failure:
|
||||||
|
//
|
||||||
|
if (extraction.ErrorCode is FileExtractionErrorCode.CANCELLED)
|
||||||
|
{
|
||||||
|
this.FinishFileResult(fileResult, BatchProcessingFileStatus.CANCELED, T("The batch run was canceled."));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!extraction.HasUsableContent)
|
if (!extraction.HasUsableContent)
|
||||||
{
|
{
|
||||||
this.Logger.LogError("Reading the batch file '{FilePath}' failed: code={ErrorCode}, message='{ErrorMessage}'.", fileResult.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
|
this.Logger.LogError("Reading the batch file '{FilePath}' failed: code={ErrorCode}, message='{ErrorMessage}'.", fileResult.FilePath, extraction.ErrorCode, extraction.ErrorMessage);
|
||||||
|
|||||||
@ -143,7 +143,7 @@ public partial class AssistantCoding : AssistantBaseCore<SettingsDialogCoding>
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_CODING_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_CODING_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.questions = deferredContent;
|
this.questions = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -124,7 +124,7 @@ public partial class AssistantEMail : AssistantBaseCore<SettingsDialogWritingEMa
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_EMAIL_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_EMAIL_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputBulletPoints = deferredContent;
|
this.inputBulletPoints = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,7 @@ public partial class AssistantGrammarSpelling : AssistantBaseCore<SettingsDialog
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_GRAMMAR_SPELLING_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_GRAMMAR_SPELLING_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputText = deferredContent;
|
this.inputText = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -5698,6 +5698,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2129302565"] = "Load f
|
|||||||
-- Image View
|
-- Image View
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Image View"
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Image View"
|
||||||
|
|
||||||
|
-- Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2468296835"] = "Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document."
|
||||||
|
|
||||||
-- See how we load your file. Review the content before we process it further.
|
-- See how we load your file. Review the content before we process it further.
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "See how we load your file. Review the content before we process it further."
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "See how we load your file. Review the content before we process it further."
|
||||||
|
|
||||||
|
|||||||
@ -78,7 +78,7 @@ public partial class AssistantIconFinder : AssistantBaseCore<SettingsDialogIconF
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_ICON_FINDER_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_ICON_FINDER_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputContext = deferredContent;
|
this.inputContext = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -177,7 +177,7 @@ public partial class AssistantJobPostings : AssistantBaseCore<SettingsDialogJobP
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_JOB_POSTING_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_JOB_POSTING_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputJobDescription = deferredContent;
|
this.inputJobDescription = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -90,7 +90,7 @@ public partial class AssistantLegalCheck : AssistantBaseCore<SettingsDialogLegal
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_LEGAL_CHECK_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_LEGAL_CHECK_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputQuestions = deferredContent;
|
this.inputQuestions = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -139,7 +139,7 @@ public partial class AssistantMyTasks : AssistantBaseCore<SettingsDialogMyTasks>
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_MY_TASKS_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_MY_TASKS_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputText = deferredContent;
|
this.inputText = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -152,7 +152,7 @@ public partial class AssistantPromptOptimizer : AssistantBaseCore<SettingsDialog
|
|||||||
this.ResetGuidelineSummaryToDefault();
|
this.ResetGuidelineSummaryToDefault();
|
||||||
this.hasUpdatedDefaultRecommendations = false;
|
this.hasUpdatedDefaultRecommendations = false;
|
||||||
|
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_PROMPT_OPTIMIZER_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_PROMPT_OPTIMIZER_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputPrompt = deferredContent;
|
this.inputPrompt = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -77,7 +77,7 @@ public partial class AssistantRewriteImprove : AssistantBaseCore<SettingsDialogR
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_REWRITE_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_REWRITE_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputText = deferredContent;
|
this.inputText = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -256,7 +256,7 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_SLIDE_BUILDER_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_SLIDE_BUILDER_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputContent = deferredContent;
|
this.inputContent = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -131,7 +131,7 @@ public partial class AssistantSynonyms : AssistantBaseCore<SettingsDialogSynonym
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_SYNONYMS_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_SYNONYMS_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputContext = deferredContent;
|
this.inputContext = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -115,7 +115,7 @@ public partial class AssistantTextSummarizer : AssistantBaseCore<SettingsDialogT
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_TEXT_SUMMARIZER_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_TEXT_SUMMARIZER_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputText = deferredContent;
|
this.inputText = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -119,7 +119,7 @@ public partial class AssistantTranslation : AssistantBaseCore<SettingsDialogTran
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_TRANSLATION_ASSISTANT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_TRANSLATION_ASSISTANT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
this.inputText = deferredContent;
|
this.inputText = deferredContent;
|
||||||
|
|
||||||
|
|||||||
@ -138,6 +138,11 @@ public partial class VisualBriefingAssistant
|
|||||||
this.MediaTranscriptionService.ClearOwnerState(MediaImportOwner.ForVisualBriefing(id));
|
this.MediaTranscriptionService.ClearOwnerState(MediaImportOwner.ForVisualBriefing(id));
|
||||||
await this.Store.DeleteAsync(id);
|
await this.Store.DeleteAsync(id);
|
||||||
await this.Store.ForgetSelectionAsync(id);
|
await this.Store.ForgetSelectionAsync(id);
|
||||||
|
|
||||||
|
// The briefing is gone, so neither its build state nor its progress snapshot is of use:
|
||||||
|
this.BuildOrchestrator.ForgetBriefing(id);
|
||||||
|
this.BuildProgressService.Forget(id);
|
||||||
|
|
||||||
this.ClearSelectedProject();
|
this.ClearSelectedProject();
|
||||||
|
|
||||||
await this.ReloadListAsync();
|
await this.ReloadListAsync();
|
||||||
|
|||||||
@ -158,7 +158,7 @@ public partial class VisualBriefingAssistant : MSGComponentBase
|
|||||||
await this.ReloadListAsync();
|
await this.ReloadListAsync();
|
||||||
await this.ConsumePendingMediaOutcomesAsync();
|
await this.ConsumePendingMediaOutcomesAsync();
|
||||||
_ = this.MonitorSourceStatusAsync(this.sourceMonitorCancellation.Token);
|
_ = this.MonitorSourceStatusAsync(this.sourceMonitorCancellation.Token);
|
||||||
var deferredInstruction = this.MessageBus.CheckDeferredMessages<string>(Event.SEND_TO_VISUAL_BRIEFING_ASSISTANT).FirstOrDefault();
|
var deferredInstruction = this.MessageBus.TakeDeferredMessages<string>(Event.SEND_TO_VISUAL_BRIEFING_ASSISTANT).LastOrDefault();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(deferredInstruction))
|
if (!string.IsNullOrWhiteSpace(deferredInstruction))
|
||||||
{
|
{
|
||||||
|
|||||||
@ -63,6 +63,21 @@ internal sealed partial class VisualBriefingBuildOrchestrator
|
|||||||
public VisualBriefingOperationDiagnostics? GetDiagnostics(Guid briefingId) =>
|
public VisualBriefingOperationDiagnostics? GetDiagnostics(Guid briefingId) =>
|
||||||
this.liveDiagnostics.GetValueOrDefault(briefingId);
|
this.liveDiagnostics.GetValueOrDefault(briefingId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops what we kept for a briefing which does not exist anymore.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both dictionaries only ever grew: every briefing which was built once stayed in them for as
|
||||||
|
/// long as the app was running. The build lock is not disposed, because another build might
|
||||||
|
/// still wait on it.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="briefingId">The identifier of the deleted briefing.</param>
|
||||||
|
public void ForgetBriefing(Guid briefingId)
|
||||||
|
{
|
||||||
|
this.buildLocks.TryRemove(briefingId, out _);
|
||||||
|
this.liveDiagnostics.TryRemove(briefingId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds or resumes a visual briefing operation.
|
/// Builds or resumes a visual briefing operation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -33,4 +33,14 @@ public sealed class VisualBriefingBuildProgressService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public VisualBriefingBuildRecord? GetLatest(Guid briefingId) =>
|
public VisualBriefingBuildRecord? GetLatest(Guid briefingId) =>
|
||||||
this.latest.GetValueOrDefault(briefingId);
|
this.latest.GetValueOrDefault(briefingId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops the snapshot of a briefing which does not exist anymore.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A snapshot is a complete build record. Without this, every briefing which was ever built
|
||||||
|
/// kept one for as long as the app was running.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="briefingId">The identifier of the deleted briefing.</param>
|
||||||
|
public void Forget(Guid briefingId) => this.latest.TryRemove(briefingId, out _);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -398,6 +398,7 @@ public sealed partial class VisualBriefingStore
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
gate.Release();
|
gate.Release();
|
||||||
|
this.ForgetLock(briefingId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -167,6 +167,16 @@ public sealed partial class VisualBriefingStore(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private SemaphoreSlim GetLock(Guid briefingId) => this.briefingLocks.GetOrAdd(briefingId, _ => new(1, 1));
|
private SemaphoreSlim GetLock(Guid briefingId) => this.briefingLocks.GetOrAdd(briefingId, _ => new(1, 1));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops the lock of a briefing which does not exist anymore.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Otherwise, this dictionary keeps one entry per briefing the app ever touched. We do not
|
||||||
|
/// dispose the semaphore: another operation might still wait on it, and disposing it under
|
||||||
|
/// their feet would turn a deleted briefing into an exception somewhere else.
|
||||||
|
/// </remarks>
|
||||||
|
private void ForgetLock(Guid briefingId) => this.briefingLocks.TryRemove(briefingId, out _);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Defines <c>BriefingDirectory</c> for the visual briefing feature.
|
/// Defines <c>BriefingDirectory</c> for the visual briefing feature.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ namespace AIStudio.Chat;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The UI component for a chat content block, i.e., for any IContent.
|
/// The UI component for a chat content block, i.e., for any IContent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
public partial class ContentBlockComponent : MSGComponentBase
|
||||||
{
|
{
|
||||||
private const string CHAT_MATH_SYNC_FUNCTION = "chatMath.syncContainer";
|
private const string CHAT_MATH_SYNC_FUNCTION = "chatMath.syncContainer";
|
||||||
private const string CHAT_MATH_DISPOSE_FUNCTION = "chatMath.disposeContainer";
|
private const string CHAT_MATH_DISPOSE_FUNCTION = "chatMath.disposeContainer";
|
||||||
@ -601,16 +601,24 @@ public partial class ContentBlockComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
private async Task OpenAttachmentsDialog()
|
private async Task OpenAttachmentsDialog()
|
||||||
{
|
{
|
||||||
var result = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.Content.FileAttachments.ToHashSet());
|
var result = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.Content.FileAttachments.ToHashSet());
|
||||||
this.Content.FileAttachments = result.ToList();
|
this.Content.FileAttachments = [.. result];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
protected override async ValueTask DisposeResourcesAsync()
|
||||||
{
|
{
|
||||||
if (this.isDisposed)
|
if (this.isDisposed)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.isDisposed = true;
|
this.isDisposed = true;
|
||||||
|
|
||||||
|
//
|
||||||
|
// Our handlers close over this component, while the content belongs to the chat thread and
|
||||||
|
// outlives us. We only detach what is still ours, though: when this content is streaming
|
||||||
|
// again, another component has registered its own handlers in the meantime.
|
||||||
|
//
|
||||||
|
if (this.Content.StreamingDone == this.AfterStreaming)
|
||||||
|
this.Content.ResetStreamingHandlers();
|
||||||
|
|
||||||
await this.DisposeMathContainerIfNeededAsync();
|
await this.DisposeMathContainerIfNeededAsync();
|
||||||
this.Dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,11 +22,11 @@ public sealed class ContentImage : IContent, IImageSource
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Func<Task> StreamingDone { get; set; } = () => Task.CompletedTask;
|
public Func<Task> StreamingDone { get; set; } = IContent.NO_STREAMING_HANDLER;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Func<Task> StreamingEvent { get; set; } = () => Task.CompletedTask;
|
public Func<Task> StreamingEvent { get; set; } = IContent.NO_STREAMING_HANDLER;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public List<Source> Sources { get; set; } = [];
|
public List<Source> Sources { get; set; } = [];
|
||||||
|
|||||||
@ -37,11 +37,11 @@ public sealed class ContentText : IContent
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Func<Task> StreamingDone { get; set; } = () => Task.CompletedTask;
|
public Func<Task> StreamingDone { get; set; } = IContent.NO_STREAMING_HANDLER;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Func<Task> StreamingEvent { get; set; } = () => Task.CompletedTask;
|
public Func<Task> StreamingEvent { get; set; } = IContent.NO_STREAMING_HANDLER;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public List<Source> Sources { get; set; } = [];
|
public List<Source> Sources { get; set; } = [];
|
||||||
|
|||||||
@ -38,6 +38,11 @@ public interface IContent
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Func<Task> StreamingDone { get; set; }
|
public Func<Task> StreamingDone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a content does while nobody listens to its stream: nothing.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Func<Task> NO_STREAMING_HANDLER = () => Task.CompletedTask;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The provided sources, if any.
|
/// The provided sources, if any.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
20
app/MindWork AI Studio/Chat/IContentExtensions.cs
Normal file
20
app/MindWork AI Studio/Chat/IContentExtensions.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
namespace AIStudio.Chat;
|
||||||
|
|
||||||
|
public static class IContentExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Detaches whoever listens to the stream of this content.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The streaming handlers are closures over the component which registered them. A content
|
||||||
|
/// object belongs to the chat thread and therefore outlives every component which renders it,
|
||||||
|
/// so handlers left behind would keep those components alive for as long as the thread exists.
|
||||||
|
/// Whoever registers a handler calls this when it is no longer needed.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="content">The content whose streaming handlers you want to detach.</param>
|
||||||
|
public static void ResetStreamingHandlers(this IContent content)
|
||||||
|
{
|
||||||
|
content.StreamingEvent = IContent.NO_STREAMING_HANDLER;
|
||||||
|
content.StreamingDone = IContent.NO_STREAMING_HANDLER;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,7 +14,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
|||||||
|
|
||||||
namespace AIStudio.Components;
|
namespace AIStudio.Components;
|
||||||
|
|
||||||
public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
public partial class ChatComponent : MSGComponentBase
|
||||||
{
|
{
|
||||||
private readonly Guid draftMediaOwnerId = Guid.NewGuid();
|
private readonly Guid draftMediaOwnerId = Guid.NewGuid();
|
||||||
private const string CHAT_INPUT_ID = "chat-user-input";
|
private const string CHAT_INPUT_ID = "chat-user-input";
|
||||||
@ -131,7 +131,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
this.lastAppliedStandardDataSourceOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy();
|
this.lastAppliedStandardDataSourceOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy();
|
||||||
|
|
||||||
var deferredInput = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_CHAT_INPUT).FirstOrDefault();
|
var deferredInput = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_CHAT_INPUT).LastOrDefault();
|
||||||
if (!string.IsNullOrWhiteSpace(deferredInput))
|
if (!string.IsNullOrWhiteSpace(deferredInput))
|
||||||
this.ComposerState.SetUserInput(deferredInput);
|
this.ComposerState.SetUserInput(deferredInput);
|
||||||
|
|
||||||
@ -139,7 +139,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
// Check for deferred messages of the kind 'SEND_TO_CHAT',
|
// Check for deferred messages of the kind 'SEND_TO_CHAT',
|
||||||
// aka the user sends an assistant result to the chat:
|
// aka the user sends an assistant result to the chat:
|
||||||
//
|
//
|
||||||
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<ChatThread>(Event.SEND_TO_CHAT).FirstOrDefault();
|
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<ChatThread>(Event.SEND_TO_CHAT).LastOrDefault();
|
||||||
if (deferredContent is not null)
|
if (deferredContent is not null)
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
@ -234,7 +234,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
// component sends a message to the chat component to load
|
// component sends a message to the chat component to load
|
||||||
// the chat with the bias:
|
// the chat with the bias:
|
||||||
//
|
//
|
||||||
var deferredLoading = MessageBus.INSTANCE.CheckDeferredMessages<LoadChat>(Event.LOAD_CHAT).FirstOrDefault();
|
var deferredLoading = MessageBus.INSTANCE.TakeDeferredMessages<LoadChat>(Event.LOAD_CHAT).LastOrDefault();
|
||||||
if (deferredLoading != default)
|
if (deferredLoading != default)
|
||||||
{
|
{
|
||||||
this.loadChat = deferredLoading;
|
this.loadChat = deferredLoading;
|
||||||
@ -1288,9 +1288,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Implementation of IAsyncDisposable
|
#region Overrides of MSGComponentBase
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
protected override async ValueTask DisposeResourcesAsync()
|
||||||
{
|
{
|
||||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||||
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
|
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
|
||||||
@ -1300,7 +1300,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false);
|
await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false);
|
||||||
this.Dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Components;
|
|||||||
|
|
||||||
namespace AIStudio.Components;
|
namespace AIStudio.Components;
|
||||||
|
|
||||||
public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBusReceiver, ILang
|
public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDisposable, IMessageBusReceiver, ILang
|
||||||
{
|
{
|
||||||
[Inject]
|
[Inject]
|
||||||
protected SettingsManager SettingsManager { get; init; } = null!;
|
protected SettingsManager SettingsManager { get; init; } = null!;
|
||||||
@ -103,10 +103,20 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus
|
|||||||
this.MessageBus.ApplyFilters(this, filterComponents, eventsList.ToHashSet());
|
this.MessageBus.ApplyFilters(this, filterComponents, eventsList.ToHashSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases what this component has acquired. Override this instead of implementing
|
||||||
|
/// IDisposable again, so the deregistration from the message bus cannot be lost.
|
||||||
|
/// </summary>
|
||||||
protected virtual void DisposeResources()
|
protected virtual void DisposeResources()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases what this component has acquired and needs an await to release. Override this
|
||||||
|
/// instead of implementing IAsyncDisposable, see the remarks on DisposeAsync below.
|
||||||
|
/// </summary>
|
||||||
|
protected virtual ValueTask DisposeResourcesAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
#region Implementation of IDisposable
|
#region Implementation of IDisposable
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@ -116,4 +126,25 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Implementation of IAsyncDisposable
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases this component asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This base class implements both ways of disposing on purpose. Blazor calls only DisposeAsync
|
||||||
|
/// when a component offers both, so a derived component which implements IAsyncDisposable on
|
||||||
|
/// its own would silently skip everything Dispose does — above all the deregistration from the
|
||||||
|
/// message bus, which holds a strong reference to every receiver. Deriving components override
|
||||||
|
/// DisposeResources or DisposeResourcesAsync instead, and this stays the one place which knows
|
||||||
|
/// about both.
|
||||||
|
/// </remarks>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await this.DisposeResourcesAsync();
|
||||||
|
this.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
@ -15,7 +15,7 @@ using RetrievalInfo = AIStudio.Tools.ERIClient.DataModel.RetrievalInfo;
|
|||||||
|
|
||||||
namespace AIStudio.Dialogs;
|
namespace AIStudio.Dialogs;
|
||||||
|
|
||||||
public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, IAsyncDisposable, ISecretId
|
public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, ISecretId
|
||||||
{
|
{
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||||
@ -186,9 +186,9 @@ public partial class DataSourceERI_V1InfoDialog : MSGComponentBase, IAsyncDispos
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Implementation of IDisposable
|
#region Overrides of MSGComponentBase
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
protected override async ValueTask DisposeResourcesAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@ -10,7 +10,7 @@ using Timer = System.Timers.Timer;
|
|||||||
|
|
||||||
namespace AIStudio.Dialogs;
|
namespace AIStudio.Dialogs;
|
||||||
|
|
||||||
public partial class DataSourceLocalDirectoryInfoDialog : MSGComponentBase, IAsyncDisposable
|
public partial class DataSourceLocalDirectoryInfoDialog : MSGComponentBase
|
||||||
{
|
{
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private IMudDialogInstance MudDialog { get; set; } = null!;
|
private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||||
@ -89,9 +89,9 @@ public partial class DataSourceLocalDirectoryInfoDialog : MSGComponentBase, IAsy
|
|||||||
this.MudDialog.Close();
|
this.MudDialog.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Implementation of IDisposable
|
#region Overrides of MSGComponentBase
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
protected override async ValueTask DisposeResourcesAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
@if (this.Document is null)
|
@if (this.Document is null)
|
||||||
{
|
{
|
||||||
<ReadFileContent Text="@T("Load file")" @bind-FileContent="@this.FileContent" EnableDragDrop="true" Layer="@DropLayers.DIALOGS" CatchAllDocuments="true"/>
|
<ReadFileContent Text="@T("Load file")" FileContent="@this.FileContent" FileContentChanged="@this.ApplyLoadedFileContent" EnableDragDrop="true" Layer="@DropLayers.DIALOGS" CatchAllDocuments="true"/>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -51,6 +51,13 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
@if (this.previewCutOffCharacters > 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="my-2">
|
||||||
|
@string.Format(T("Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document."), this.previewCutOffCharacters)
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">
|
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">
|
||||||
@if (this.Document?.IsImage ?? false)
|
@if (this.Document?.IsImage ?? false)
|
||||||
{
|
{
|
||||||
@ -70,14 +77,14 @@
|
|||||||
Class="ma-2 pe-4"
|
Class="ma-2 pe-4"
|
||||||
HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")">
|
HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")">
|
||||||
<div style="max-height: 40vh; overflow-y: auto;">
|
<div style="max-height: 40vh; overflow-y: auto;">
|
||||||
<MudMarkdown Value="@this.FileContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE"/>
|
<MudMarkdown Value="@this.previewContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE"/>
|
||||||
</div>
|
</div>
|
||||||
</MudField>
|
</MudField>
|
||||||
</MudTabPanel>
|
</MudTabPanel>
|
||||||
<MudTabPanel Text="@T("Simple View")" Icon="@Icons.Material.Filled.Terminal">
|
<MudTabPanel Text="@T("Simple View")" Icon="@Icons.Material.Filled.Terminal">
|
||||||
<MudTextField
|
<MudTextField
|
||||||
T="string"
|
T="string"
|
||||||
@bind-Text="@this.FileContent"
|
Text="@this.previewContent"
|
||||||
AdornmentIcon="@Icons.Material.Filled.Article"
|
AdornmentIcon="@Icons.Material.Filled.Article"
|
||||||
Adornment="Adornment.Start"
|
Adornment="Adornment.Start"
|
||||||
Immediate="@true"
|
Immediate="@true"
|
||||||
|
|||||||
@ -21,11 +21,41 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public string FileContent { get; set; } = string.Empty;
|
public string FileContent { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many characters we show at most. Rendering a huge document costs us a large Markdown
|
||||||
|
/// syntax tree and an equally large render tree. This dialog answers the question of how we
|
||||||
|
/// read the file, though — the beginning of the document is enough for that, and the AI still
|
||||||
|
/// receives the entire content.
|
||||||
|
/// </summary>
|
||||||
|
private const int PREVIEW_CHARACTER_LIMIT = 200_000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set when reading the file failed, so the dialog shows the reason instead of empty content.
|
/// Set when reading the file failed, so the dialog shows the reason instead of empty content.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private string? loadFailureMessage;
|
private string? loadFailureMessage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What we show to the user: either the entire file content, or its beginning. We keep this in
|
||||||
|
/// its own field so that we cut the content only once, instead of on every render.
|
||||||
|
/// </summary>
|
||||||
|
private string previewContent = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many characters we cut off from the preview. Zero when we show the entire content.
|
||||||
|
/// </summary>
|
||||||
|
private int previewCutOffCharacters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends the extraction when this dialog is gone before the file was read completely.
|
||||||
|
/// </summary>
|
||||||
|
private readonly CancellationTokenSource extractionCancellation = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once this dialog was disposed. The extraction runs across awaits, so it may return
|
||||||
|
/// long after the user closed the dialog — it must not touch this component afterwards.
|
||||||
|
/// </summary>
|
||||||
|
private bool isDisposed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True while we extract the file content. Reading happens after the first render, so the
|
/// True while we extract the file content. Reading happens after the first render, so the
|
||||||
/// dialog can tell the user that it is working instead of showing an empty document.
|
/// dialog can tell the user that it is working instead of showing an empty document.
|
||||||
@ -54,6 +84,7 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
|||||||
this.Document.Exists &&
|
this.Document.Exists &&
|
||||||
string.IsNullOrWhiteSpace(this.FileContent);
|
string.IsNullOrWhiteSpace(this.FileContent);
|
||||||
|
|
||||||
|
this.UpdatePreview();
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,7 +97,10 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var extraction = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService);
|
var extraction = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.DialogService, this.extractionCancellation.Token);
|
||||||
|
if (this.isDisposed)
|
||||||
|
return;
|
||||||
|
|
||||||
this.FileContent = extraction.Content;
|
this.FileContent = extraction.Content;
|
||||||
|
|
||||||
//
|
//
|
||||||
@ -76,6 +110,10 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
|||||||
if (!extraction.HasUsableContent)
|
if (!extraction.HasUsableContent)
|
||||||
this.loadFailureMessage = extraction.ToUserMessage(this.Document.FileName);
|
this.loadFailureMessage = extraction.ToUserMessage(this.Document.FileName);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// The user closed this dialog while we were reading the file. Nothing left to do.
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.Document);
|
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.Document);
|
||||||
@ -84,14 +122,67 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
this.isLoadingContent = false;
|
if (!this.isDisposed)
|
||||||
this.StateHasChanged();
|
{
|
||||||
|
this.isLoadingContent = false;
|
||||||
|
this.UpdatePreview();
|
||||||
|
this.StateHasChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (firstRender)
|
else if (firstRender)
|
||||||
this.Logger.LogWarning("Document check dialog opened without a valid file path.");
|
this.Logger.LogWarning("Document check dialog opened without a valid file path.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when the user loads a file through this dialog. We don't use a two-way binding here,
|
||||||
|
/// since we have to refresh the preview whenever the content changes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileContent">The content of the file the user has loaded.</param>
|
||||||
|
private void ApplyLoadedFileContent(string fileContent)
|
||||||
|
{
|
||||||
|
this.FileContent = fileContent;
|
||||||
|
this.UpdatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines what part of the file content we show to the user.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdatePreview()
|
||||||
|
{
|
||||||
|
if (this.FileContent.Length <= PREVIEW_CHARACTER_LIMIT)
|
||||||
|
{
|
||||||
|
this.previewContent = this.FileContent;
|
||||||
|
this.previewCutOffCharacters = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// We cut at the last line break before our limit. Otherwise, we might tear apart a Markdown
|
||||||
|
// construct like a table row or a code fence in the middle of a line:
|
||||||
|
//
|
||||||
|
var cutIndex = this.FileContent.LastIndexOf('\n', PREVIEW_CHARACTER_LIMIT - 1) + 1;
|
||||||
|
if (cutIndex < 1)
|
||||||
|
cutIndex = PREVIEW_CHARACTER_LIMIT;
|
||||||
|
|
||||||
|
this.previewContent = this.FileContent[..cutIndex];
|
||||||
|
this.previewCutOffCharacters = this.FileContent.Length - cutIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends a running extraction. Without this, reading a large document would continue after the
|
||||||
|
/// user closed this dialog and would keep this component, the extracted content, and the
|
||||||
|
/// response stream alive until the runtime is done.
|
||||||
|
/// </summary>
|
||||||
|
protected override void DisposeResources()
|
||||||
|
{
|
||||||
|
this.isDisposed = true;
|
||||||
|
this.extractionCancellation.Cancel();
|
||||||
|
this.extractionCancellation.Dispose();
|
||||||
|
|
||||||
|
base.DisposeResources();
|
||||||
|
}
|
||||||
|
|
||||||
private CodeBlockTheme CodeColorPalette => this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default;
|
private CodeBlockTheme CodeColorPalette => this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default;
|
||||||
|
|
||||||
private MudMarkdownStyling MarkdownStyling => new()
|
private MudMarkdownStyling MarkdownStyling => new()
|
||||||
|
|||||||
@ -292,8 +292,10 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
|||||||
//
|
//
|
||||||
// Check if there is an enterprise configuration plugin to download:
|
// Check if there is an enterprise configuration plugin to download:
|
||||||
//
|
//
|
||||||
|
// Every deferred environment matters here: each one is a configuration
|
||||||
|
// to download, so this is the one place which uses all of them.
|
||||||
var enterpriseEnvironments = this.MessageBus
|
var enterpriseEnvironments = this.MessageBus
|
||||||
.CheckDeferredMessages<EnterpriseEnvironment>(Event.STARTUP_ENTERPRISE_ENVIRONMENT)
|
.TakeDeferredMessages<EnterpriseEnvironment>(Event.STARTUP_ENTERPRISE_ENVIRONMENT)
|
||||||
.Where(env => env != default)
|
.Where(env => env != default)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,15 @@
|
|||||||
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> <!-- Enable reflection for JSON serialization -->
|
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> <!-- Enable reflection for JSON serialization -->
|
||||||
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings> <!-- Suppress trim analysis warnings -->
|
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings> <!-- Suppress trim analysis warnings -->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The Web SDK defaults to server GC. AI Studio is a single-user desktop app, though: workstation
|
||||||
|
GC collects earlier and returns the memory to the OS sooner. This matters for devices with
|
||||||
|
little RAM, e.g., a Raspberry Pi. Please note that we override an SDK default here, so this is
|
||||||
|
not a redundant repetition of the default value.
|
||||||
|
-->
|
||||||
|
<ServerGarbageCollection>false</ServerGarbageCollection>
|
||||||
|
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
IL2026: Usage of methods marked as RequiresUnreferencedCode. None issue here, since we use partial trim mode, though.
|
IL2026: Usage of methods marked as RequiresUnreferencedCode. None issue here, since we use partial trim mode, though.
|
||||||
CS8974: Converting method group to non-delegate type; Did you intend to invoke the method? We have this issue with MudBlazor validation methods.
|
CS8974: Converting method group to non-delegate type; Did you intend to invoke the method? We have this issue with MudBlazor validation methods.
|
||||||
|
|||||||
@ -5700,6 +5700,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2129302565"] = "Datei
|
|||||||
-- Image View
|
-- Image View
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Bildansicht"
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Bildansicht"
|
||||||
|
|
||||||
|
-- Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2468296835"] = "Ihr Dokument ist groß, daher zeigen wir Ihnen hier nur den Anfang. Die verbleibenden {0:N0} Zeichen werden ausgeblendet. Keine Sorge: Die KI erhält trotzdem Ihr gesamtes Dokument."
|
||||||
|
|
||||||
-- See how we load your file. Review the content before we process it further.
|
-- See how we load your file. Review the content before we process it further.
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "So wird Ihre Datei geladen. Überprüfen Sie den Inhalt, bevor wir ihn weiterverarbeiten."
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "So wird Ihre Datei geladen. Überprüfen Sie den Inhalt, bevor wir ihn weiterverarbeiten."
|
||||||
|
|
||||||
|
|||||||
@ -5700,6 +5700,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2129302565"] = "Load f
|
|||||||
-- Image View
|
-- Image View
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Image View"
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Image View"
|
||||||
|
|
||||||
|
-- Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document.
|
||||||
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2468296835"] = "Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document."
|
||||||
|
|
||||||
-- See how we load your file. Review the content before we process it further.
|
-- See how we load your file. Review the content before we process it further.
|
||||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "See how we load your file. Review the content before we process it further."
|
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "See how we load your file. Review the content before we process it further."
|
||||||
|
|
||||||
|
|||||||
@ -203,6 +203,13 @@ internal sealed class Program
|
|||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents(options =>
|
.AddInteractiveServerComponents(options =>
|
||||||
{
|
{
|
||||||
|
//
|
||||||
|
// We keep disconnected circuits for a long time on purpose: when the machine goes to
|
||||||
|
// sleep, the WebView loses its connection. Without this retention period, the user would
|
||||||
|
// return to a lost app state after waking up the machine (cf. issue #849). Since AI Studio
|
||||||
|
// is a single-user desktop app, at most two circuits are retained, which bounds the memory
|
||||||
|
// this costs us.
|
||||||
|
//
|
||||||
options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromDays(30);
|
options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromDays(30);
|
||||||
options.DisconnectedCircuitMaxRetained = 2;
|
options.DisconnectedCircuitMaxRetained = 2;
|
||||||
})
|
})
|
||||||
|
|||||||
@ -16,7 +16,13 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
|
|||||||
|
|
||||||
public required CancellationToken CancellationToken { get; init; }
|
public required CancellationToken CancellationToken { get; init; }
|
||||||
|
|
||||||
public required ChatGenerationRequest ChatGenerationRequest { get; init; }
|
/// <summary>
|
||||||
|
/// What the job works on. This is the heavy part of a job: it holds the entire chat thread.
|
||||||
|
/// We release it once the job is done, so a finished job does not keep a chat alive for as
|
||||||
|
/// long as the app runs. Everything a finished job still has to answer lives in the
|
||||||
|
/// snapshot, which is small.
|
||||||
|
/// </summary>
|
||||||
|
public ChatGenerationRequest? ChatGenerationRequest { get; set; }
|
||||||
|
|
||||||
public required AIJobSnapshot Snapshot { get; set; }
|
public required AIJobSnapshot Snapshot { get; set; }
|
||||||
|
|
||||||
@ -70,7 +76,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
|
|||||||
if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId))
|
if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return this.jobs.TryGetValue(jobId, out var job) ? job.ChatGenerationRequest.ChatThread : null;
|
return this.jobs.TryGetValue(jobId, out var job) ? job.ChatGenerationRequest?.ChatThread : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AIJobSnapshot?> TryStartChatGenerationAsync(ChatGenerationRequest request)
|
public async Task<AIJobSnapshot?> TryStartChatGenerationAsync(ChatGenerationRequest request)
|
||||||
@ -185,6 +191,9 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
|
|||||||
private async Task RunChatGenerationAsync(AIJobState state)
|
private async Task RunChatGenerationAsync(AIJobState state)
|
||||||
{
|
{
|
||||||
var request = state.ChatGenerationRequest;
|
var request = state.ChatGenerationRequest;
|
||||||
|
if (request is null)
|
||||||
|
return;
|
||||||
|
|
||||||
var token = state.CancellationToken;
|
var token = state.CancellationToken;
|
||||||
|
|
||||||
try
|
try
|
||||||
@ -281,7 +290,11 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
|
|||||||
state.IsCompletionStarted = true;
|
state.IsCompletionStarted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var aiText = state.ChatGenerationRequest.AIText;
|
var request = state.ChatGenerationRequest;
|
||||||
|
if (request is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var aiText = request.AIText;
|
||||||
aiText.InitialRemoteWait = false;
|
aiText.InitialRemoteWait = false;
|
||||||
aiText.IsStreaming = false;
|
aiText.IsStreaming = false;
|
||||||
aiText.Text = aiText.Text.RemoveThinkTags().Trim();
|
aiText.Text = aiText.Text.RemoveThinkTags().Trim();
|
||||||
@ -298,31 +311,72 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activeChatJobsByChatId.TryRemove(state.ChatGenerationRequest.ChatThread.ChatId, out _);
|
this.activeChatJobsByChatId.TryRemove(request.ChatThread.ChatId, out _);
|
||||||
await CheckpointChatAsync(state, force: true);
|
await CheckpointChatAsync(state, force: true);
|
||||||
await this.NotifyChangedAsync(state);
|
await this.NotifyChangedAsync(state);
|
||||||
await messageBus.SendMessage(null, Event.AI_JOB_FINISHED, state.Snapshot);
|
await messageBus.SendMessage(null, Event.AI_JOB_FINISHED, state.Snapshot);
|
||||||
state.CancellationTokenSource.Dispose();
|
state.CancellationTokenSource.Dispose();
|
||||||
|
|
||||||
|
//
|
||||||
|
// The chat is stored and everyone was told about it, so nothing needs the request anymore.
|
||||||
|
// Releasing it here is what keeps a finished job from holding an entire chat thread — even
|
||||||
|
// one the user has deleted in the meantime. We do it under the lock, because that is where
|
||||||
|
// every other access to the state happens:
|
||||||
|
//
|
||||||
|
lock (state.SyncRoot)
|
||||||
|
{
|
||||||
|
state.ChatGenerationRequest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.PruneCompletedJobs(state.Snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops the finished jobs which nothing needs anymore.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What the app asks for is the outcome of the last generation of a chat, cf. TryGetChatSnapshot.
|
||||||
|
/// Everything older than that is a history no one reads, and it would grow for as long as the
|
||||||
|
/// app runs. Active jobs are never touched, and neither is the job we just finished.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="latest">The snapshot of the job which just finished.</param>
|
||||||
|
private void PruneCompletedJobs(AIJobSnapshot latest)
|
||||||
|
{
|
||||||
|
var supersededJobIds = this.jobs.Values
|
||||||
|
.Select(job => job.Snapshot)
|
||||||
|
.Where(snapshot => snapshot.Kind == latest.Kind)
|
||||||
|
.Where(snapshot => snapshot.SubjectId == latest.SubjectId)
|
||||||
|
.Where(snapshot => snapshot.JobId != latest.JobId)
|
||||||
|
.Where(snapshot => !snapshot.IsActive)
|
||||||
|
.Select(snapshot => snapshot.JobId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var jobId in supersededJobIds)
|
||||||
|
this.jobs.TryRemove(jobId, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RemoveEmptyAIResponse(AIJobState state)
|
private static void RemoveEmptyAIResponse(AIJobState state)
|
||||||
{
|
{
|
||||||
var aiText = state.ChatGenerationRequest.AIText;
|
var request = state.ChatGenerationRequest;
|
||||||
|
if (request is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var aiText = request.AIText;
|
||||||
if (!string.IsNullOrWhiteSpace(aiText.Text))
|
if (!string.IsNullOrWhiteSpace(aiText.Text))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var aiBlock = state.ChatGenerationRequest.ChatThread.Blocks
|
var aiBlock = request.ChatThread.Blocks
|
||||||
.LastOrDefault(block => ReferenceEquals(block.Content, aiText));
|
.LastOrDefault(block => ReferenceEquals(block.Content, aiText));
|
||||||
|
|
||||||
if (aiBlock is not null)
|
if (aiBlock is not null)
|
||||||
state.ChatGenerationRequest.ChatThread.Blocks.Remove(aiBlock);
|
request.ChatThread.Blocks.Remove(aiBlock);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TrySetWaitingForRemote(AIJobState state, CancellationToken token)
|
private static bool TrySetWaitingForRemote(AIJobState state, CancellationToken token)
|
||||||
{
|
{
|
||||||
lock (state.SyncRoot)
|
lock (state.SyncRoot)
|
||||||
{
|
{
|
||||||
if (state.IsCompletionStarted || token.IsCancellationRequested)
|
if (state.IsCompletionStarted || token.IsCancellationRequested || state.ChatGenerationRequest is null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
state.ChatGenerationRequest.AIText.InitialRemoteWait = true;
|
state.ChatGenerationRequest.AIText.InitialRemoteWait = true;
|
||||||
@ -334,7 +388,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
|
|||||||
{
|
{
|
||||||
lock (state.SyncRoot)
|
lock (state.SyncRoot)
|
||||||
{
|
{
|
||||||
if (state.IsCompletionStarted || token.IsCancellationRequested)
|
if (state.IsCompletionStarted || token.IsCancellationRequested || state.ChatGenerationRequest is null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var aiText = state.ChatGenerationRequest.AIText;
|
var aiText = state.ChatGenerationRequest.AIText;
|
||||||
@ -360,9 +414,13 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
|
|||||||
{
|
{
|
||||||
lock (state.SyncRoot)
|
lock (state.SyncRoot)
|
||||||
{
|
{
|
||||||
|
//
|
||||||
|
// A released request keeps its last known title: the job is done, so there is nothing
|
||||||
|
// left to read a newer one from.
|
||||||
|
//
|
||||||
state.Snapshot = state.Snapshot with
|
state.Snapshot = state.Snapshot with
|
||||||
{
|
{
|
||||||
Title = state.ChatGenerationRequest.ChatThread.Name,
|
Title = state.ChatGenerationRequest?.ChatThread.Name ?? state.Snapshot.Title,
|
||||||
UpdatedAt = DateTimeOffset.Now,
|
UpdatedAt = DateTimeOffset.Now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -376,8 +434,12 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
|
|||||||
if (!force && now - state.LastCheckpoint < CHECKPOINT_MIN_TIME)
|
if (!force && now - state.LastCheckpoint < CHECKPOINT_MIN_TIME)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var request = state.ChatGenerationRequest;
|
||||||
|
if (request is null)
|
||||||
|
return;
|
||||||
|
|
||||||
state.LastCheckpoint = now;
|
state.LastCheckpoint = now;
|
||||||
await WorkspaceBehaviour.StoreChatAsync(state.ChatGenerationRequest.ChatThread);
|
await WorkspaceBehaviour.StoreChatAsync(request.ChatThread);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ModelsMatch(Model modelA, Model modelB)
|
private static bool ModelsMatch(Model modelA, Model modelB)
|
||||||
|
|||||||
@ -83,4 +83,11 @@ public enum FileExtractionErrorCode
|
|||||||
/// The extraction finished without reporting a failure, but produced no content at all.
|
/// The extraction finished without reporting a failure, but produced no content at all.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
NO_CONTENT,
|
NO_CONTENT,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The caller no longer needs the content, e.g. because the user closed the dialog which
|
||||||
|
/// asked for it. This is not a failure: nobody has to be told about it, which is why there
|
||||||
|
/// is no user-facing message for this code.
|
||||||
|
/// </summary>
|
||||||
|
CANCELLED,
|
||||||
}
|
}
|
||||||
@ -93,22 +93,47 @@ public sealed class MessageBus
|
|||||||
|
|
||||||
public Task SendInfo(DataInfoMessage dataInfoMessage) => this.SendMessage(null, Event.SHOW_INFO, dataInfoMessage);
|
public Task SendInfo(DataInfoMessage dataInfoMessage) => this.SendMessage(null, Event.SHOW_INFO, dataInfoMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores a message until someone asks for it, cf. TakeDeferredMessages. This is how a
|
||||||
|
/// component hands data to a component which does not exist yet, e.g. an assistant which
|
||||||
|
/// sends its result to the chat before the user gets there.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sendingComponent">That's you, the sender.</param>
|
||||||
|
/// <param name="triggeredEvent">The event this message belongs to.</param>
|
||||||
|
/// <param name="data">The data to hand over.</param>
|
||||||
public void DeferMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data = default)
|
public void DeferMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data = default)
|
||||||
{
|
{
|
||||||
if (this.deferredMessages.TryGetValue(triggeredEvent, out var queue))
|
var queue = this.deferredMessages.GetOrAdd(triggeredEvent, _ => new());
|
||||||
queue.Enqueue(new Message(sendingComponent, triggeredEvent, data));
|
queue.Enqueue(new Message(sendingComponent, triggeredEvent, data));
|
||||||
else
|
|
||||||
{
|
|
||||||
this.deferredMessages[triggeredEvent] = new();
|
|
||||||
this.deferredMessages[triggeredEvent].Enqueue(new Message(sendingComponent, triggeredEvent, data));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<T?> CheckDeferredMessages<T>(Event triggeredEvent)
|
/// <summary>
|
||||||
|
/// Takes all deferred messages of an event out of the bus.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This empties the queue and returns what was in it. It used to be a lazy iterator, which
|
||||||
|
/// meant that a caller stopping after the first message left the rest of the queue behind:
|
||||||
|
/// those messages were never delivered, and the data they carry — a complete chat thread, for
|
||||||
|
/// instance — stayed alive for as long as the app ran. Returning a list makes that impossible.
|
||||||
|
/// Callers who expect a single message take the last one, since that is the most recent thing
|
||||||
|
/// the user asked for.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="triggeredEvent">The event whose messages you want.</param>
|
||||||
|
/// <returns>The deferred messages, oldest first. Empty when there are none.</returns>
|
||||||
|
public IReadOnlyList<T?> TakeDeferredMessages<T>(Event triggeredEvent)
|
||||||
{
|
{
|
||||||
if (this.deferredMessages.TryGetValue(triggeredEvent, out var queue))
|
//
|
||||||
while (queue.TryDequeue(out var message))
|
// Removing the queue along with its messages is what keeps the dictionary from growing:
|
||||||
yield return message.Data is T data ? data : default;
|
// otherwise, every event which ever deferred a message would keep an empty queue forever.
|
||||||
|
//
|
||||||
|
if (!this.deferredMessages.TryRemove(triggeredEvent, out var queue))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var messages = new List<T?>();
|
||||||
|
while (queue.TryDequeue(out var message))
|
||||||
|
messages.Add(message.Data is T data ? data : default);
|
||||||
|
|
||||||
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TResult?> SendMessageUseFirstResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data = default)
|
public async Task<TResult?> SendMessageUseFirstResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data = default)
|
||||||
|
|||||||
@ -6,7 +6,7 @@ namespace AIStudio.Tools.PluginSystem;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the base of any AI Studio plugin.
|
/// Represents the base of any AI Studio plugin.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract partial class PluginBase : IPluginMetadata
|
public abstract partial class PluginBase : IPluginMetadata, IDisposable
|
||||||
{
|
{
|
||||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginBase).Namespace, nameof(PluginBase));
|
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginBase).Namespace, nameof(PluginBase));
|
||||||
|
|
||||||
@ -546,4 +546,18 @@ public abstract partial class PluginBase : IPluginMetadata
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Implementation of IDisposable
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases the Lua runtime of this plugin.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Every plugin owns a Lua state, which is an entire scripting runtime. Dropping a plugin
|
||||||
|
/// without disposing it leaves that runtime behind: before this existed, each hot reload added
|
||||||
|
/// another set of them for as long as the app was running.
|
||||||
|
/// </remarks>
|
||||||
|
public void Dispose() => this.State.Dispose();
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,16 @@ public static partial class PluginFactory
|
|||||||
AutoReset = false,
|
AutoReset = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether hot reloading was set up already.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The timer and the watcher are static, while this method is called from a component. Calling
|
||||||
|
/// it twice would add a second handler to each of them, and every change in the plugins
|
||||||
|
/// directory would then trigger as many reloads as there were calls.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool IS_HOT_RELOADING_SET_UP;
|
||||||
|
|
||||||
public static void SetUpHotReloading()
|
public static void SetUpHotReloading()
|
||||||
{
|
{
|
||||||
if (!IsInitialized)
|
if (!IsInitialized)
|
||||||
@ -29,6 +39,14 @@ public static partial class PluginFactory
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IS_HOT_RELOADING_SET_UP)
|
||||||
|
{
|
||||||
|
LOG.LogInformation("Hot reloading is already set up. Skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IS_HOT_RELOADING_SET_UP = true;
|
||||||
|
|
||||||
LOG.LogInformation($"Start hot reloading plugins for path '{HOT_RELOAD_WATCHER.Path}'.");
|
LOG.LogInformation($"Start hot reloading plugins for path '{HOT_RELOAD_WATCHER.Path}'.");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@ -69,8 +69,13 @@ public static partial class PluginFactory
|
|||||||
AVAILABLE_PLUGINS.Remove(plugin);
|
AVAILABLE_PLUGINS.Remove(plugin);
|
||||||
|
|
||||||
if (RUNNING_PLUGINS.FirstOrDefault(runningPlugin => runningPlugin.Id == plugin.Id) is { } runningPluginToRemove)
|
if (RUNNING_PLUGINS.FirstOrDefault(runningPlugin => runningPlugin.Id == plugin.Id) is { } runningPluginToRemove)
|
||||||
|
{
|
||||||
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
||||||
|
|
||||||
|
// The plugin is unloaded, so its Lua runtime is of no use anymore:
|
||||||
|
runningPluginToRemove.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
LOG.LogInformation("Unloaded the plugin '{PluginName}' ({PluginId}). Reason: {Reason}.", plugin.Name, plugin.Id, reason);
|
LOG.LogInformation("Unloaded the plugin '{PluginName}' ({PluginId}). Reason: {Reason}.", plugin.Name, plugin.Id, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,15 @@ public static partial class PluginFactory
|
|||||||
{
|
{
|
||||||
LOG.LogInformation("Try to start or restart all plugins.");
|
LOG.LogInformation("Try to start or restart all plugins.");
|
||||||
var configObjects = new List<PluginConfigurationObject>();
|
var configObjects = new List<PluginConfigurationObject>();
|
||||||
|
|
||||||
|
//
|
||||||
|
// Dropping the plugins is not enough: each one owns a Lua runtime, which we have to release
|
||||||
|
// ourselves. Otherwise, every restart — above all every hot reload during development —
|
||||||
|
// leaves another set of runtimes behind:
|
||||||
|
//
|
||||||
|
foreach (var runningPlugin in RUNNING_PLUGINS)
|
||||||
|
runningPlugin.Dispose();
|
||||||
|
|
||||||
RUNNING_PLUGINS.Clear();
|
RUNNING_PLUGINS.Clear();
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@ -16,7 +16,19 @@ public sealed partial class RustService
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static readonly TimeSpan EXTRACTION_TIMEOUT = TimeSpan.FromMinutes(10);
|
private static readonly TimeSpan EXTRACTION_TIMEOUT = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
public async Task<FileExtractionResult> ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false)
|
/// <summary>
|
||||||
|
/// Reads the content of an arbitrary file through the Rust runtime.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path of the file to read.</param>
|
||||||
|
/// <param name="maxChunks">How many chunks of the content stream we read at most.</param>
|
||||||
|
/// <param name="extractImages">Whether we want the images of the file as well.</param>
|
||||||
|
/// <param name="token">
|
||||||
|
/// Cancels the extraction when the caller no longer needs the content. Reading a large document
|
||||||
|
/// takes a while, and without this, the runtime would keep streaming into a caller which is
|
||||||
|
/// already gone.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The result of reading the file.</returns>
|
||||||
|
public async Task<FileExtractionResult> ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
// The runtime filters prompt injections while it streams the file. Doing it there rather
|
// The runtime filters prompt injections while it streams the file. Doing it there rather
|
||||||
@ -28,8 +40,13 @@ public sealed partial class RustService
|
|||||||
var streamId = Guid.NewGuid().ToString();
|
var streamId = Guid.NewGuid().ToString();
|
||||||
var requestUri = $"/retrieval/fs/extract?path={Uri.EscapeDataString(path)}&stream_id={streamId}&extract_images={extractImages}";
|
var requestUri = $"/retrieval/fs/extract?path={Uri.EscapeDataString(path)}&stream_id={streamId}&extract_images={extractImages}";
|
||||||
|
|
||||||
|
//
|
||||||
|
// Both reasons to stop end the same read, so we combine them: our own timeout bounds the
|
||||||
|
// operation, and the caller's token ends it as soon as nobody needs the content anymore.
|
||||||
|
//
|
||||||
using var timeoutTokenSource = new CancellationTokenSource(EXTRACTION_TIMEOUT);
|
using var timeoutTokenSource = new CancellationTokenSource(EXTRACTION_TIMEOUT);
|
||||||
var cancellationToken = timeoutTokenSource.Token;
|
using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutTokenSource.Token, token);
|
||||||
|
var cancellationToken = cancellationTokenSource.Token;
|
||||||
|
|
||||||
var resultBuilder = new StringBuilder();
|
var resultBuilder = new StringBuilder();
|
||||||
var failedPages = new List<int>();
|
var failedPages = new List<int>();
|
||||||
@ -162,6 +179,16 @@ public sealed partial class RustService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The caller dropped out, e.g. because the user closed the dialog which asked for this
|
||||||
|
// file. That is not a failure, so we log it as information and leave it to the caller
|
||||||
|
// to stay silent about it.
|
||||||
|
//
|
||||||
|
this.logger?.LogInformation("Reading the file '{Path}' was cancelled by the caller.", path);
|
||||||
|
return FileExtractionResult.Failed(FileExtractionErrorCode.CANCELLED, "The caller cancelled reading the file.");
|
||||||
|
}
|
||||||
catch (OperationCanceledException) when (timeoutTokenSource.IsCancellationRequested)
|
catch (OperationCanceledException) when (timeoutTokenSource.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
this.logger?.LogError("Reading the file '{Path}' timed out after {Timeout}.", path, EXTRACTION_TIMEOUT);
|
this.logger?.LogError("Reading the file '{Path}' timed out after {Timeout}.", path, EXTRACTION_TIMEOUT);
|
||||||
|
|||||||
@ -22,8 +22,9 @@ public static class UserFile
|
|||||||
/// <param name="filePath">The full path to the file to be read. Must not be null or empty.</param>
|
/// <param name="filePath">The full path to the file to be read. Must not be null or empty.</param>
|
||||||
/// <param name="rustService">Rust service used to read file content.</param>
|
/// <param name="rustService">Rust service used to read file content.</param>
|
||||||
/// <param name="dialogService">Dialogservice used to display the Pandoc installation dialog if needed.</param>
|
/// <param name="dialogService">Dialogservice used to display the Pandoc installation dialog if needed.</param>
|
||||||
|
/// <param name="token">Cancels the extraction when the caller no longer needs the content.</param>
|
||||||
/// <returns>The result of reading the file.</returns>
|
/// <returns>The result of reading the file.</returns>
|
||||||
public static async Task<FileExtractionResult> LoadFileData(string filePath, RustService rustService, IDialogService dialogService)
|
public static async Task<FileExtractionResult> LoadFileData(string filePath, RustService rustService, IDialogService dialogService, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(filePath))
|
if (string.IsNullOrEmpty(filePath))
|
||||||
{
|
{
|
||||||
@ -61,7 +62,15 @@ public static class UserFile
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await rustService.ReadArbitraryFileData(filePath, int.MaxValue);
|
var result = await rustService.ReadArbitraryFileData(filePath, int.MaxValue, token: token);
|
||||||
|
|
||||||
|
//
|
||||||
|
// Nobody wants to read that their own cancellation failed. We hand the result back so the
|
||||||
|
// caller can tell the two apart, but we report nothing to the user:
|
||||||
|
//
|
||||||
|
if (result.ErrorCode is FileExtractionErrorCode.CANCELLED)
|
||||||
|
return result;
|
||||||
|
|
||||||
if (!result.HasUsableContent)
|
if (!result.HasUsableContent)
|
||||||
{
|
{
|
||||||
LOGGER.LogError("Reading the file '{FilePath}' failed: code={ErrorCode}, message='{ErrorMessage}'.", filePath, result.ErrorCode, result.ErrorMessage);
|
LOGGER.LogError("Reading the file '{FilePath}' failed: code={ErrorCode}, message='{ErrorMessage}'.", filePath, result.ErrorCode, result.ErrorMessage);
|
||||||
|
|||||||
@ -82,11 +82,23 @@ public static class WorkspaceBehaviour
|
|||||||
|
|
||||||
private static readonly string TEMPORARY_CHATS_ROOT_DIRECTORY = Path.Join(SettingsManager.DataDirectory, "tempChats");
|
private static readonly string TEMPORARY_CHATS_ROOT_DIRECTORY = Path.Join(SettingsManager.DataDirectory, "tempChats");
|
||||||
|
|
||||||
private static SemaphoreSlim GetChatSemaphore(Guid workspaceId, Guid chatId)
|
private static string ChatSemaphoreKey(Guid workspaceId, Guid chatId) => $"{workspaceId}_{chatId}";
|
||||||
{
|
|
||||||
var key = $"{workspaceId}_{chatId}";
|
private static SemaphoreSlim GetChatSemaphore(Guid workspaceId, Guid chatId) =>
|
||||||
return CHAT_STORAGE_SEMAPHORES.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
|
CHAT_STORAGE_SEMAPHORES.GetOrAdd(ChatSemaphoreKey(workspaceId, chatId), _ => new SemaphoreSlim(1, 1));
|
||||||
}
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops the storage semaphore of a chat which does not exist anymore.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deleting the chat is the one moment where we know that nobody will ask for this semaphore
|
||||||
|
/// again; without this, the dictionary would keep one entry per chat the app ever touched. We
|
||||||
|
/// do not dispose the semaphore, though: another operation might still be waiting on it, and
|
||||||
|
/// disposing it under their feet would turn a deleted chat into an exception somewhere else.
|
||||||
|
/// The garbage collector takes care of it once the last waiter is gone.
|
||||||
|
/// </remarks>
|
||||||
|
private static void ForgetChatSemaphore(Guid workspaceId, Guid chatId) =>
|
||||||
|
CHAT_STORAGE_SEMAPHORES.TryRemove(ChatSemaphoreKey(workspaceId, chatId), out _);
|
||||||
|
|
||||||
private static async Task<(bool Acquired, SemaphoreSlim Semaphore)> TryAcquireChatSemaphoreAsync(Guid workspaceId, Guid chatId, string callerName)
|
private static async Task<(bool Acquired, SemaphoreSlim Semaphore)> TryAcquireChatSemaphoreAsync(Guid workspaceId, Guid chatId, string callerName)
|
||||||
{
|
{
|
||||||
@ -1114,6 +1126,7 @@ public static class WorkspaceBehaviour
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
semaphore.Release();
|
semaphore.Release();
|
||||||
|
ForgetChatSemaphore(workspaceId, chatId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +1,7 @@
|
|||||||
# v26.8.2, build 255 (2026-08-xx xx:xx UTC)
|
# v26.8.2, build 255 (2026-08-xx xx:xx UTC)
|
||||||
- Added protection against prompt injection. Documents, web pages, and retrieved content can carry instructions written for the AI rather than for you, for example, text telling it to ignore its rules or to hand over its instructions. AI Studio now always removes such passages before the content reaches a model, while the rest of your document stays intact and usable. When something was removed, AI Studio tells you and can show you which passages it took out. You can turn off the detailed dialog in the app settings. For IT departments: the new setting `DataApp.ShowPromptInjectionAlert` lets you configure the detailed dialog for your organization. Many thanks to Sabrina `Sabrina-devops` for implementing this feature and to Simon `SimonBpunkt` for his work on the detection patterns and their translations.
|
- Added protection against prompt injection. Documents, web pages, and retrieved content can carry instructions written for the AI rather than for you, for example, text telling it to ignore its rules or to hand over its instructions. AI Studio now always removes such passages before the content reaches a model, while the rest of your document stays intact and usable. When something was removed, AI Studio tells you and can show you which passages it took out. You can turn off the detailed dialog in the app settings. For IT departments: the new setting `DataApp.ShowPromptInjectionAlert` lets you configure the detailed dialog for your organization. Many thanks to Sabrina `Sabrina-devops` for implementing this feature and to Simon `SimonBpunkt` for his work on the detection patterns and their translations.
|
||||||
|
- Improved how much memory AI Studio needs. Working with large documents used to grow the app to several gigabytes, and on macOS that memory was never handed back. AI Studio now stays at a fraction of that and returns memory to your system. This matters most on devices with little memory, such as a Raspberry Pi.
|
||||||
|
- Improved the preview for large documents. It now shows you the beginning of your document instead of loading all of it, so the dialog opens right away. Your complete document still goes to the AI.
|
||||||
|
- Fixed AI Studio reading a document to the end even after you closed its preview. Closing the dialog now stops that work immediately.
|
||||||
|
- Fixed AI Studio holding on to finished chats, presentation images, and plugin data. It releases them now, so memory no longer grows the longer you keep the app running.
|
||||||
|
- Fixed assistants handing an outdated result to the chat. When you sent several results without opening the chat in between, you now receive the one you sent last.
|
||||||
@ -747,8 +747,8 @@ async fn stream_data(file_path: &str, extract_images: bool, stream_id: &str) ->
|
|||||||
ExtractionRoute::Pdf => stream_pdf(file_path).await?,
|
ExtractionRoute::Pdf => stream_pdf(file_path).await?,
|
||||||
ExtractionRoute::Docx | ExtractionRoute::Odt => stream_document(file_path, extract_images, stream_id).await?,
|
ExtractionRoute::Docx | ExtractionRoute::Odt => stream_document(file_path, extract_images, stream_id).await?,
|
||||||
ExtractionRoute::PandocHtml => convert_with_pandoc(file_path, HTML, TO_MARKDOWN).await?,
|
ExtractionRoute::PandocHtml => convert_with_pandoc(file_path, HTML, TO_MARKDOWN).await?,
|
||||||
ExtractionRoute::PresentationPptx => stream_presentation(file_path, extract_images, PresentationFormat::Pptx).await?,
|
ExtractionRoute::PresentationPptx => stream_presentation(file_path, extract_images, PresentationFormat::Pptx, stream_id).await?,
|
||||||
ExtractionRoute::PresentationOdp => stream_presentation(file_path, extract_images, PresentationFormat::Odp).await?,
|
ExtractionRoute::PresentationOdp => stream_presentation(file_path, extract_images, PresentationFormat::Odp, stream_id).await?,
|
||||||
ExtractionRoute::Spreadsheet => stream_spreadsheet_as_csv(file_path).await?,
|
ExtractionRoute::Spreadsheet => stream_spreadsheet_as_csv(file_path).await?,
|
||||||
ExtractionRoute::Csv => stream_text_file(file_path, true, Some("csv".to_string())).await?,
|
ExtractionRoute::Csv => stream_text_file(file_path, true, Some("csv".to_string())).await?,
|
||||||
ExtractionRoute::Text => stream_text_file(file_path, false, None).await?,
|
ExtractionRoute::Text => stream_text_file(file_path, false, None).await?,
|
||||||
@ -1359,8 +1359,9 @@ async fn stream_document(file_path: &str, extract_images: bool, stream_id: &str)
|
|||||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_presentation(file_path: &str, extract_images: bool, format: PresentationFormat) -> Result<ChunkStream> {
|
async fn stream_presentation(file_path: &str, extract_images: bool, format: PresentationFormat, stream_id: &str) -> Result<ChunkStream> {
|
||||||
let path = Path::new(file_path).to_owned();
|
let path = Path::new(file_path).to_owned();
|
||||||
|
let stream_id = stream_id.to_owned();
|
||||||
|
|
||||||
let parser_config = ParserConfig::builder()
|
let parser_config = ParserConfig::builder()
|
||||||
.extract_images(extract_images)
|
.extract_images(extract_images)
|
||||||
@ -1443,6 +1444,12 @@ async fn stream_presentation(file_path: &str, extract_images: bool, format: Pres
|
|||||||
|
|
||||||
if let Some(images) = slide.load_images_manually() {
|
if let Some(images) = slide.load_images_manually() {
|
||||||
for image in images.iter() {
|
for image in images.iter() {
|
||||||
|
//
|
||||||
|
// The image ID carries the stream it belongs to, exactly like the document
|
||||||
|
// route does above. The app removes the segments of a finished extraction by
|
||||||
|
// that prefix, so an ID without it would stay in memory forever:
|
||||||
|
//
|
||||||
|
let image_id = format!("{stream_id}-{}-{}", slide.slide_number, image.img_ref.id);
|
||||||
let base64_data = &image.base64_content;
|
let base64_data = &image.base64_content;
|
||||||
let total_length = base64_data.len();
|
let total_length = base64_data.len();
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
@ -1454,7 +1461,7 @@ async fn stream_presentation(file_path: &str, extract_images: bool, format: Pres
|
|||||||
let is_end = end == total_length;
|
let is_end = end == total_length;
|
||||||
|
|
||||||
let base64_image = Base64Image::new(
|
let base64_image = Base64Image::new(
|
||||||
image.img_ref.id.clone(),
|
image_id.clone(),
|
||||||
segment_content.to_string(),
|
segment_content.to_string(),
|
||||||
segment_index,
|
segment_index,
|
||||||
is_end,
|
is_end,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user