Reduced memory usage and fixed several memory leaks (#933)
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:
Thorsten Sommer authored and GitHub committed 2026-08-23 21:15:37 +02:00
1 parent 902a01a4d0
commit 7d9a4f5ab1
51 files changed
+531 -85

No files matched your search

@@ -16,7 +16,13 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
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; }
@@ -70,7 +76,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId))
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)
@@ -185,6 +191,9 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
private async Task RunChatGenerationAsync(AIJobState state)
{
var request = state.ChatGenerationRequest;
if (request is null)
return;
var token = state.CancellationToken;
try
@@ -281,7 +290,11 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
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.IsStreaming = false;
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 this.NotifyChangedAsync(state);
await messageBus.SendMessage(null, Event.AI_JOB_FINISHED, state.Snapshot);
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)
{
var aiText = state.ChatGenerationRequest.AIText;
var request = state.ChatGenerationRequest;
if (request is null)
return;
var aiText = request.AIText;
if (!string.IsNullOrWhiteSpace(aiText.Text))
return;
var aiBlock = state.ChatGenerationRequest.ChatThread.Blocks
var aiBlock = request.ChatThread.Blocks
.LastOrDefault(block => ReferenceEquals(block.Content, aiText));
if (aiBlock is not null)
state.ChatGenerationRequest.ChatThread.Blocks.Remove(aiBlock);
request.ChatThread.Blocks.Remove(aiBlock);
}
private static bool TrySetWaitingForRemote(AIJobState state, CancellationToken token)
{
lock (state.SyncRoot)
{
if (state.IsCompletionStarted || token.IsCancellationRequested)
if (state.IsCompletionStarted || token.IsCancellationRequested || state.ChatGenerationRequest is null)
return false;
state.ChatGenerationRequest.AIText.InitialRemoteWait = true;
@@ -334,7 +388,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
{
lock (state.SyncRoot)
{
if (state.IsCompletionStarted || token.IsCancellationRequested)
if (state.IsCompletionStarted || token.IsCancellationRequested || state.ChatGenerationRequest is null)
return false;
var aiText = state.ChatGenerationRequest.AIText;
@@ -360,9 +414,13 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
{
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
{
Title = state.ChatGenerationRequest.ChatThread.Name,
Title = state.ChatGenerationRequest?.ChatThread.Name ?? state.Snapshot.Title,
UpdatedAt = DateTimeOffset.Now,
};
}
@@ -376,8 +434,12 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
if (!force && now - state.LastCheckpoint < CHECKPOINT_MIN_TIME)
return;
var request = state.ChatGenerationRequest;
if (request is null)
return;
state.LastCheckpoint = now;
await WorkspaceBehaviour.StoreChatAsync(state.ChatGenerationRequest.ChatThread);
await WorkspaceBehaviour.StoreChatAsync(request.ChatThread);
}
private static bool ModelsMatch(Model modelA, Model modelB)