mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-15 02:08:27 +00:00
Improved how AI Studio saves its files (#1201)
This commit is contained in:
1 parent
b7255694cd
commit
dc97db256e
24 files changed
+626
-258
No files matched your search
+7
-19
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user