mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-06-27 15:56:28 +00:00
Enhanced settings manager with versioned backups and migrations
This commit is contained in:
parent
6d48252db3
commit
9da669ec39
@ -17,6 +17,7 @@ namespace AIStudio.Settings;
|
|||||||
public sealed class SettingsManager
|
public sealed class SettingsManager
|
||||||
{
|
{
|
||||||
private const string SETTINGS_FILENAME = "settings.json";
|
private const string SETTINGS_FILENAME = "settings.json";
|
||||||
|
private const Version CURRENT_SETTINGS_VERSION = Version.V6;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
private static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
||||||
{
|
{
|
||||||
@ -26,6 +27,7 @@ public sealed class SettingsManager
|
|||||||
|
|
||||||
private readonly ILogger<SettingsManager> logger;
|
private readonly ILogger<SettingsManager> logger;
|
||||||
private readonly RustService rustService;
|
private readonly RustService rustService;
|
||||||
|
private bool settingsWriteBlocked;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The settings manager.
|
/// The settings manager.
|
||||||
@ -87,6 +89,7 @@ public sealed class SettingsManager
|
|||||||
/// <returns>A (migrated) settings snapshot, or null if it could not be read.</returns>
|
/// <returns>A (migrated) settings snapshot, or null if it could not be read.</returns>
|
||||||
public async Task<Data?> TryReadSettingsSnapshot()
|
public async Task<Data?> TryReadSettingsSnapshot()
|
||||||
{
|
{
|
||||||
|
this.settingsWriteBlocked = false;
|
||||||
if(!this.IsSetUp)
|
if(!this.IsSetUp)
|
||||||
{
|
{
|
||||||
this.logger.LogWarning("Cannot load settings, because the configuration is not set up yet.");
|
this.logger.LogWarning("Cannot load settings, because the configuration is not set up yet.");
|
||||||
@ -100,38 +103,133 @@ public sealed class SettingsManager
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We read the `"Version": "V3"` line to determine the version of the settings file:
|
var settingsVersion = await this.TryReadSettingsVersion(settingsPath);
|
||||||
await foreach (var line in File.ReadLinesAsync(settingsPath))
|
|
||||||
{
|
|
||||||
if (!line.Contains("""
|
|
||||||
"Version":
|
|
||||||
"""))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// Extract the version from the line:
|
|
||||||
var settingsVersionText = line.Split('"')[3];
|
|
||||||
|
|
||||||
// Parse the version:
|
|
||||||
Enum.TryParse(settingsVersionText, out Version settingsVersion);
|
|
||||||
if(settingsVersion is Version.UNKNOWN)
|
if(settingsVersion is Version.UNKNOWN)
|
||||||
{
|
{
|
||||||
this.logger.LogError("Unknown version of the settings file found.");
|
this.logger.LogError("Unknown version of the settings file found. Settings writes are blocked to avoid overwriting newer or unreadable settings.");
|
||||||
return new();
|
this.settingsWriteBlocked = true;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var settingsData = SettingsMigrations.Migrate(this.logger, settingsVersion, await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS);
|
if(settingsVersion > CURRENT_SETTINGS_VERSION)
|
||||||
|
{
|
||||||
|
this.logger.LogError($"The settings file uses the newer version '{settingsVersion}'. Settings writes are blocked to avoid overwriting newer settings.");
|
||||||
|
this.settingsWriteBlocked = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Data? settingsData;
|
||||||
|
if(settingsVersion < CURRENT_SETTINGS_VERSION)
|
||||||
|
{
|
||||||
|
settingsData = await this.TryReadCurrentVersionBackupSnapshot();
|
||||||
|
if(settingsData is not null)
|
||||||
|
{
|
||||||
|
this.PrepareLoadedSettings(settingsData);
|
||||||
|
await this.StoreSettingsSnapshot(settingsData, settingsPath);
|
||||||
|
await this.StoreCurrentVersionBackup(settingsData);
|
||||||
|
this.logger.LogInformation($"Restored settings from the '{GetBackupSettingsFilename(CURRENT_SETTINGS_VERSION)}' backup file.");
|
||||||
|
return settingsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.LogInformation("No valid current-version settings backup was found. Migrating the settings file.");
|
||||||
|
settingsData = SettingsMigrations.Migrate(this.logger, settingsVersion, await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS);
|
||||||
|
this.PrepareLoadedSettings(settingsData);
|
||||||
|
await this.StoreSettingsSnapshot(settingsData, settingsPath);
|
||||||
|
await this.StoreCurrentVersionBackup(settingsData);
|
||||||
|
return settingsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsData = await this.TryDeserializeCurrentSettings(settingsPath, "settings file");
|
||||||
|
if(settingsData is null)
|
||||||
|
{
|
||||||
|
this.settingsWriteBlocked = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.PrepareLoadedSettings(settingsData);
|
||||||
|
await this.StoreCurrentVersionBackup(settingsData);
|
||||||
|
return settingsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Version> TryReadSettingsVersion(string settingsPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var settingsStream = File.OpenRead(settingsPath);
|
||||||
|
using var settingsDocument = await JsonDocument.ParseAsync(settingsStream);
|
||||||
|
if(!settingsDocument.RootElement.TryGetProperty("Version", out var versionElement))
|
||||||
|
{
|
||||||
|
this.logger.LogError($"Failed to read the version of the settings file '{settingsPath}'.");
|
||||||
|
return Version.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(versionElement.ValueKind is JsonValueKind.String && versionElement.GetString() is { } versionText && Enum.TryParse(versionText, out Version stringVersion))
|
||||||
|
return stringVersion;
|
||||||
|
|
||||||
|
if(versionElement.ValueKind is JsonValueKind.Number && versionElement.TryGetInt32(out var numericVersion) && Enum.IsDefined(typeof(Version), numericVersion))
|
||||||
|
return (Version)numericVersion;
|
||||||
|
}
|
||||||
|
catch(Exception e)
|
||||||
|
{
|
||||||
|
this.logger.LogError(e, $"Failed to read the version of the settings file '{settingsPath}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Version.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Data?> TryReadCurrentVersionBackupSnapshot()
|
||||||
|
{
|
||||||
|
var backupSettingsPath = GetBackupSettingsPath(CURRENT_SETTINGS_VERSION);
|
||||||
|
if(!File.Exists(backupSettingsPath))
|
||||||
|
{
|
||||||
|
this.logger.LogInformation($"The settings backup file '{backupSettingsPath}' does not exist.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var backupVersion = await this.TryReadSettingsVersion(backupSettingsPath);
|
||||||
|
if(backupVersion != CURRENT_SETTINGS_VERSION)
|
||||||
|
{
|
||||||
|
this.logger.LogWarning($"The settings backup file '{backupSettingsPath}' uses version '{backupVersion}' instead of '{CURRENT_SETTINGS_VERSION}'.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.TryDeserializeCurrentSettings(backupSettingsPath, "settings backup file");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Data?> TryDeserializeCurrentSettings(string settingsPath, string sourceDescription)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settingsData = JsonSerializer.Deserialize<Data>(await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS);
|
||||||
|
if(settingsData is null)
|
||||||
|
{
|
||||||
|
this.logger.LogError($"Failed to parse the {sourceDescription} '{settingsPath}'.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(settingsData.Version != CURRENT_SETTINGS_VERSION)
|
||||||
|
{
|
||||||
|
this.logger.LogError($"The {sourceDescription} '{settingsPath}' uses version '{settingsData.Version}' instead of '{CURRENT_SETTINGS_VERSION}'.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settingsData;
|
||||||
|
}
|
||||||
|
catch(Exception e)
|
||||||
|
{
|
||||||
|
this.logger.LogError(e, $"Failed to parse the {sourceDescription} '{settingsPath}'.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PrepareLoadedSettings(Data settingsData)
|
||||||
|
{
|
||||||
//
|
//
|
||||||
// We filter the enabled preview features based on the preview visibility.
|
// We filter the enabled preview features based on the preview visibility.
|
||||||
// This is necessary when the app starts up: some preview features may have
|
// This is necessary when the app starts up: some preview features may have
|
||||||
// been disabled or released from the last time the app was started.
|
// been disabled or released from the last time the app was started.
|
||||||
//
|
//
|
||||||
settingsData.App.EnabledPreviewFeatures = settingsData.App.PreviewVisibility.FilterPreviewFeatures(settingsData.App.EnabledPreviewFeatures);
|
settingsData.App.EnabledPreviewFeatures = settingsData.App.PreviewVisibility.FilterPreviewFeatures(settingsData.App.EnabledPreviewFeatures);
|
||||||
return settingsData;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.LogError("Failed to read the version of the settings file.");
|
|
||||||
return new();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -145,19 +243,48 @@ public sealed class SettingsManager
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(this.settingsWriteBlocked)
|
||||||
|
{
|
||||||
|
this.logger.LogWarning("Cannot store settings, because the loaded settings file uses an unknown or unreadable version.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME);
|
var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME);
|
||||||
|
await this.StoreSettingsSnapshot(this.ConfigurationData, settingsPath);
|
||||||
|
await this.StoreCurrentVersionBackup(this.ConfigurationData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetBackupSettingsFilename(Version version) => $"settings.{version.ToString().ToLowerInvariant()}.json";
|
||||||
|
|
||||||
|
private static string GetBackupSettingsPath(Version version) => Path.Combine(ConfigDirectory!, GetBackupSettingsFilename(version));
|
||||||
|
|
||||||
|
private async Task StoreCurrentVersionBackup(Data settingsData)
|
||||||
|
{
|
||||||
|
if(settingsData.Version != CURRENT_SETTINGS_VERSION)
|
||||||
|
{
|
||||||
|
this.logger.LogWarning($"Skipping settings backup because the settings version '{settingsData.Version}' is not the current version '{CURRENT_SETTINGS_VERSION}'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var backupSettingsPath = GetBackupSettingsPath(CURRENT_SETTINGS_VERSION);
|
||||||
|
await this.StoreSettingsSnapshot(settingsData, backupSettingsPath);
|
||||||
|
this.logger.LogInformation($"Stored the settings backup file '{backupSettingsPath}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StoreSettingsSnapshot(Data settingsData, string settingsPath)
|
||||||
|
{
|
||||||
if(!Directory.Exists(ConfigDirectory))
|
if(!Directory.Exists(ConfigDirectory))
|
||||||
{
|
{
|
||||||
this.logger.LogInformation("Creating the configuration directory.");
|
this.logger.LogInformation("Creating the configuration directory.");
|
||||||
Directory.CreateDirectory(ConfigDirectory!);
|
Directory.CreateDirectory(ConfigDirectory!);
|
||||||
}
|
}
|
||||||
|
|
||||||
var settingsJson = JsonSerializer.Serialize(this.ConfigurationData, JSON_OPTIONS);
|
var settingsJson = JsonSerializer.Serialize(settingsData, JSON_OPTIONS);
|
||||||
var tempFile = Path.GetTempFileName();
|
var tempFile = Path.GetTempFileName();
|
||||||
await File.WriteAllTextAsync(tempFile, settingsJson);
|
await File.WriteAllTextAsync(tempFile, settingsJson);
|
||||||
|
|
||||||
File.Move(tempFile, settingsPath, true);
|
File.Move(tempFile, settingsPath, true);
|
||||||
this.logger.LogInformation("Stored the settings to the file system.");
|
this.logger.LogInformation($"Stored the settings to '{settingsPath}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void InjectSpellchecking(Dictionary<string, object?> attributes) => attributes["spellcheck"] = this.ConfigurationData.App.EnableSpellchecking ? "true" : "false";
|
public void InjectSpellchecking(Dictionary<string, object?> attributes) => attributes["spellcheck"] = this.ConfigurationData.App.EnableSpellchecking ? "true" : "false";
|
||||||
|
|||||||
@ -7,4 +7,5 @@
|
|||||||
- Changed provider confidence settings to appear in their own settings panel, because they apply to LLM, embedding, and transcription providers.
|
- Changed provider confidence settings to appear in their own settings panel, because they apply to LLM, embedding, and transcription providers.
|
||||||
- Fixed chat provider, profile, and template selections not updating live after configuration plugins were changed.
|
- Fixed chat provider, profile, and template selections not updating live after configuration plugins were changed.
|
||||||
- Fixed organization-managed chat templates not showing the correct icon in the chat template selection menu.
|
- Fixed organization-managed chat templates not showing the correct icon in the chat template selection menu.
|
||||||
|
- Fixed personal settings sometimes being lost after a settings-format upgrade when an older app version was started again. AI Studio now keeps versioned settings backups and restores them automatically when needed.
|
||||||
- Fixed self-hosted provider API keys sometimes being stored under a localized name. AI Studio now uses a stable key name, keeps correct entries working, and automatically migrates known localized entries for LLM, transcription, and embedding providers. Organizations using configuration plugins do not need to change their plugins; affected users who still see an invalid API key warning should open the provider, transcription, or embedding settings and update the API key once. Thanks, Tim & Eric, for the detailed bug report and testing help.
|
- Fixed self-hosted provider API keys sometimes being stored under a localized name. AI Studio now uses a stable key name, keeps correct entries working, and automatically migrates known localized entries for LLM, transcription, and embedding providers. Organizations using configuration plugins do not need to change their plugins; affected users who still see an invalid API key warning should open the provider, transcription, or embedding settings and update the API key once. Thanks, Tim & Eric, for the detailed bug report and testing help.
|
||||||
Loading…
Reference in New Issue
Block a user