Improved how AI Studio saves its files (#1201)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-14 21:16:40 +01:00
1 parent b7255694cd
commit dc97db256e
24 files changed
+626 -258

No files matched your search

@@ -129,32 +129,20 @@ public partial class AssistantBatchProcessing
private async Task WriteCsvFileAsync(string targetFilePath, string content)
{
// Write to a sibling file first, then rename. This way, an aborted
// write can never destroy the results of the previous files:
var tempFilePath = targetFilePath + ".tmp";
try
{
// We write the CSV file with a byte order mark, so that spreadsheet
// applications recognize the UTF-8 encoding of, e.g., umlauts:
await File.WriteAllTextAsync(tempFilePath, content, new UTF8Encoding(true), CancellationToken.None);
File.Move(tempFilePath, targetFilePath, true);
// AtomicFile writes to a sibling file first, then renames it. This way, an aborted
// write can never destroy the results of the previous files. It also removes the
// sibling file when the write fails, so that it neither looks like a result to the
// user nor piles up over several runs. We write the CSV file with a byte order
// mark, so that spreadsheet applications recognize the UTF-8 encoding of, e.g.,
// umlauts:
await AtomicFile.WriteTextAsync(targetFilePath, content, new UTF8Encoding(true), CancellationToken.None, logger: this.Logger);
}
catch (Exception e)
{
this.Logger.LogError(e, "Was not able to write the batch output file '{TargetFilePath}'.", targetFilePath);
// Remove our leftover: a failing rename keeps the temporary file in
// the output folder, where it looks like a result to the user and
// piles up over several runs.
try
{
File.Delete(tempFilePath);
}
catch (Exception deleteError)
{
this.Logger.LogWarning(deleteError, "Was not able to remove the temporary file '{TempFilePath}'.", tempFilePath);
}
// A failing write repeats for every document. We report it once per
// run: without any message, the UI would show a successful run
// while the files on disk stay behind.
@@ -81,58 +81,32 @@ public sealed partial class BeyondFactsLabStore(ILogger<BeyondFactsLabStore> log
/// <summary>
/// Writes a text file so that readers see either the old or the new content, never a part of it.
/// </summary>
/// <remarks>
/// The content reaches the disk before it replaces the file: a preparation is the work of hours, and a power
/// failure must not take it away.
/// </remarks>
/// <param name="targetPath">The file to write.</param>
/// <param name="content">The new content.</param>
/// <param name="token">The cancellation token.</param>
private static Task WriteTextAtomicAsync(string targetPath, string content, CancellationToken token) => WriteAtomicAsync(targetPath, temporaryPath => File.WriteAllTextAsync(temporaryPath, content, new UTF8Encoding(false), token), token);
private static Task WriteTextAtomicAsync(string targetPath, string content, CancellationToken token)
{
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
return AtomicFile.WriteTextAsync(targetPath, content, new UTF8Encoding(false), token, flushToDisk: true);
}
/// <summary>
/// Writes a binary file so that readers see either the old or the new content, never a part of it.
/// </summary>
/// <remarks>
/// Like WriteTextAtomicAsync, the content reaches the disk before it replaces the file.
/// </remarks>
/// <param name="targetPath">The file to write.</param>
/// <param name="content">The new content.</param>
/// <param name="token">The cancellation token.</param>
private static Task WriteBytesAtomicAsync(string targetPath, ReadOnlyMemory<byte> content, CancellationToken token) => WriteAtomicAsync(targetPath, temporaryPath => File.WriteAllBytesAsync(temporaryPath, content, token), token);
/// <summary>
/// Writes a file beside its target first and moves it there once it is complete.
/// </summary>
/// <param name="targetPath">The file to write.</param>
/// <param name="writeTemporaryFile">Writes the new content into the file of the path given.</param>
/// <param name="token">The cancellation token.</param>
private static async Task WriteAtomicAsync(string targetPath, Func<string, Task> writeTemporaryFile, CancellationToken token)
private static Task WriteBytesAtomicAsync(string targetPath, ReadOnlyMemory<byte> content, CancellationToken token)
{
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
var temporaryPath = $"{targetPath}.tmp-{Guid.NewGuid():N}";
try
{
await writeTemporaryFile(temporaryPath);
await using (var stream = new FileStream(temporaryPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None, 4_096, true))
await stream.FlushAsync(token);
File.Move(temporaryPath, targetPath, overwrite: true);
}
finally
{
TryDeleteFile(temporaryPath);
}
}
/// <summary>
/// Deletes a file when it exists, without failing.
/// </summary>
/// <param name="path">The file to delete.</param>
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// Cleaning up a temporary file is best effort; the next start tries again.
}
return AtomicFile.WriteAsync(targetPath, (stream, writeToken) => stream.WriteAsync(content, writeToken).AsTask(), token, flushToDisk: true);
}
/// <summary>
@@ -197,8 +171,7 @@ public sealed partial class BeyondFactsLabStore(ILogger<BeyondFactsLabStore> log
return;
Directory.CreateDirectory(this.RootDirectory);
foreach (var temporaryPath in Directory.EnumerateFiles(this.RootDirectory, "*.tmp-*", SearchOption.AllDirectories))
TryDeleteFile(temporaryPath);
AtomicFile.DeleteTemporaryFiles(this.RootDirectory, logger);
await this.LoadSelectionAsync(token);
this.initialized = true;
@@ -103,17 +103,16 @@ public static class BflPromptOverrides
continue;
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
var temporary = $"{target}.{Guid.NewGuid():N}.tmp";
await File.WriteAllTextAsync(temporary, await BflPromptLibrary.ReadBuiltInAsync(path, token), new UTF8Encoding(false), token);
var prompt = await BflPromptLibrary.ReadBuiltInAsync(path, token);
try
{
// Moving fails when the file appeared meanwhile, so the person's file is never overwritten:
File.Move(temporary, target, false);
// Writing fails when the file appeared meanwhile, so the person's file is never overwritten:
await AtomicFile.WriteTextAsync(target, prompt, new UTF8Encoding(false), token, overwrite: false, flushToDisk: true);
copied++;
}
catch (IOException) when (File.Exists(target))
{
File.Delete(temporary);
// The person's file stays, and AtomicFile removed its temporary file already.
}
}
@@ -18769,6 +18769,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T3060804484"] = "Content Contribut
-- Financial Support
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T3061261435"] = "Financial Support"
-- Thank you, Dominic, for supporting us not only financially but also with code: the Model Comparison assistant, a smoother chat, the audio quality setting for transcriptions, personal API keys for providers of an organization, sharing feature usage with self-hosted servers, and a better Confluence search.
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T3091322681"] = "Thank you, Dominic, for supporting us not only financially but also with code: the Model Comparison assistant, a smoother chat, the audio quality setting for transcriptions, personal API keys for providers of an organization, sharing feature usage with self-hosted servers, and a better Confluence search."
-- Thank you, Prodman, for bringing LiteLLM to AI Studio as a new provider.
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T3226413110"] = "Thank you, Prodman, for bringing LiteLLM to AI Studio as a new provider."
@@ -18781,9 +18784,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T3264998714"] = "German Aerospace
-- The first 10 supporters who make a monthly contribution:
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T3364384944"] = "The first 10 supporters who make a monthly contribution:"
-- Thank you, Dominic, for supporting us not only financially but also with code: a smoother chat, the audio quality setting for transcriptions, personal API keys for providers of an organization, sharing feature usage with self-hosted servers, and a better Confluence search.
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T348735370"] = "Thank you, Dominic, for supporting us not only financially but also with code: a smoother chat, the audio quality setting for transcriptions, personal API keys for providers of an organization, sharing feature usage with self-hosted servers, and a better Confluence search."
-- Thanks, Eric, for bringing llmman to AI Studio as a new option for self-hosted models.
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T3520213035"] = "Thanks, Eric, for bringing llmman to AI Studio as a new option for self-hosted models."
@@ -1,5 +1,4 @@
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using AIStudio.Settings;
@@ -79,44 +78,19 @@ public sealed partial class ModelComparisonStore(ILogger<ModelComparisonStore> l
/// <summary>
/// Writes a file of the store so that readers see either the old or the new content, never a part of it.
/// </summary>
/// <remarks>
/// The content reaches the disk before it replaces the file: the results of a series took weeks of work, and a
/// power failure must not take them away.
/// </remarks>
/// <typeparam name="T">The type of the file.</typeparam>
/// <param name="targetPath">The file to write.</param>
/// <param name="value">The new content.</param>
/// <param name="token">The cancellation token.</param>
/// <param name="options">The JSON options, those of the assistant by default; a chat takes those of the workspaces.</param>
private static async Task WriteJsonAtomicAsync<T>(string targetPath, T value, CancellationToken token, JsonSerializerOptions? options = null)
private static Task WriteJsonAtomicAsync<T>(string targetPath, T value, CancellationToken token, JsonSerializerOptions? options = null)
{
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
var temporaryPath = $"{targetPath}.tmp-{Guid.NewGuid():N}";
try
{
await File.WriteAllTextAsync(temporaryPath, JsonSerializer.Serialize(value, options ?? JSON_OPTIONS), new UTF8Encoding(false), token);
await using (var stream = new FileStream(temporaryPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None, 4_096, true))
await stream.FlushAsync(token);
File.Move(temporaryPath, targetPath, overwrite: true);
}
finally
{
TryDeleteFile(temporaryPath);
}
}
/// <summary>
/// Deletes a file when it exists, without failing.
/// </summary>
/// <param name="path">The file to delete.</param>
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// Cleaning up a temporary file is best effort; the next start tries again.
}
return AtomicFile.WriteJsonAsync(targetPath, value, options ?? JSON_OPTIONS, token, flushToDisk: true);
}
/// <summary>
@@ -150,8 +124,7 @@ public sealed partial class ModelComparisonStore(ILogger<ModelComparisonStore> l
return;
Directory.CreateDirectory(this.RootDirectory);
foreach (var temporaryPath in Directory.EnumerateFiles(this.RootDirectory, "*.tmp-*", SearchOption.AllDirectories))
TryDeleteFile(temporaryPath);
AtomicFile.DeleteTemporaryFiles(this.RootDirectory, logger);
this.initialized = true;
}
@@ -192,27 +192,9 @@
<MudPaper Outlined="true" Class="pa-4 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">@T("Briefing settings")</MudText>
@*
The confidence belongs to the provider chosen right next to it, so both share one row.
It uses the icon trigger, like the chat does, so this row ends the same way the profile
row below it does: a field followed by one compact icon button.
Do not add a margin to that button to "correct" its height: a dense outlined select with
a label carries margin-top 8px and margin-bottom 4px of its own, so centring the boxes
already lands within a few pixels of the visible frame, and any added margin makes it
worse. Baseline alignment does not work here either, because the wrapper below takes
its baseline from its last line box, which sits under the input.
*@
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Wrap="Wrap.NoWrap">
@* ProviderSelection marks its select as flex-grow-0, and that utility is declared
!important, so StretchItems cannot widen it. The width has to come from here. *@
<div class="flex-grow-1">
<ProviderSelection @bind-ProviderSettings="@this.editor.Provider" ValidateProvider="@this.ValidateProvider" ExplicitMinimumConfidence="@this.MinimumProviderConfidence" Disabled="@this.IsCurrentBusy"/>
</div>
@if (this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence)
{
<ConfidenceInfo LLMProvider="@this.editor.Provider.UsedLLMProvider"/>
}
</MudStack>
@* The provider row ends in a field followed by one compact icon button, the same way the
profile row below it does. *@
<ProviderSelectionWithConfidence @bind-ProviderSettings="@this.editor.Provider" ValidateProvider="@this.ValidateProvider" ExplicitMinimumConfidence="@this.MinimumProviderConfidence" Disabled="@this.IsCurrentBusy"/>
<ProfileFormSelection @bind-Profile="@this.editor.Profile" Disabled="@this.IsCurrentBusy"/>
<EnumSelection T="CommonLanguages" NameFunc="@(language => language.Name)" @bind-Value="@this.editor.TargetLanguage" Icon="@AppIcons.LANGUAGE" Label="@T("Target language")" AllowOther="true" @bind-OtherInput="@this.editor.CustomTargetLanguage" OtherValue="CommonLanguages.OTHER" LabelOther="@T("Custom target language")" ValidateOther="@this.ValidateCustomTargetLanguage" SelectionUpdated="@(_ => this.ScheduleFormValidation())" Disabled="@this.IsCurrentBusy"/>
@@ -98,8 +98,7 @@ public sealed partial class VisualBriefingStore
return;
Directory.CreateDirectory(this.RootDirectory);
foreach (var temporaryPath in Directory.EnumerateFiles(this.RootDirectory, "*.tmp-*", SearchOption.AllDirectories))
TryDeleteFile(temporaryPath);
AtomicFile.DeleteTemporaryFiles(this.RootDirectory, logger);
await this.LoadSelectionAsync(token);
foreach (var directory in Directory.EnumerateDirectories(this.RootDirectory))
@@ -111,26 +111,22 @@ public sealed partial class VisualBriefingStore(
}
/// <summary>
/// Defines <c>WriteTextAtomicAsync</c> for the visual briefing feature.
/// Writes a text file of a briefing so that readers see either the old or the new content, never a part of it.
/// </summary>
private static async Task WriteTextAtomicAsync(string targetPath,
/// <remarks>
/// The content reaches the disk before it replaces the file, so that a power failure takes no briefing away.
/// </remarks>
/// <param name="targetPath">The file to write.</param>
/// <param name="content">The new content.</param>
/// <param name="overwrite">Whether an existing file is replaced; otherwise, an existing file fails the write with an IOException.</param>
/// <param name="token">The cancellation token.</param>
private static Task WriteTextAtomicAsync(string targetPath,
string content,
bool overwrite,
CancellationToken token)
{
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
var temporaryPath = $"{targetPath}.tmp-{Guid.NewGuid():N}";
try
{
await File.WriteAllTextAsync(temporaryPath, content, new UTF8Encoding(false), token);
await using (var stream = new FileStream(temporaryPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None, 4_096, true))
await stream.FlushAsync(token);
File.Move(temporaryPath, targetPath, overwrite);
}
finally
{
TryDeleteFile(temporaryPath);
}
return AtomicFile.WriteTextAsync(targetPath, content, new UTF8Encoding(false), token, overwrite, flushToDisk: true);
}
/// <summary>