diff --git a/app/MindWork AI Studio/Tools/DataSourceReindexWarning.cs b/app/MindWork AI Studio/Tools/DataSourceReindexWarning.cs new file mode 100644 index 00000000..34c4a584 --- /dev/null +++ b/app/MindWork AI Studio/Tools/DataSourceReindexWarning.cs @@ -0,0 +1,147 @@ +using System.Text; + +using AIStudio.Dialogs; +using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.Services; + +namespace AIStudio.Tools; + +/// +/// Asks before an edit makes the prepared documents of data sources useless. +/// +/// +/// Kept here rather than in the dialogs which ask -- the embedding provider dialog and the two data +/// source dialogs -- so the sentence naming what a rebuild costs cannot drift apart between them. +/// That is the same reason DataSourceRepair sits next to it, and both name the same two costs. +/// +/// Nothing is asked when nothing is lost. A data source only reaches the question when the edit +/// really changes its embedding signature and when the index already holds something for it, so +/// renaming an embedding provider or editing a data source nobody has indexed yet stays silent. +/// +public static class DataSourceReindexWarning +{ + /// + /// How many data sources are named before the rest is only counted. + /// + private const int MAX_NAMED_DATA_SOURCES = 10; + + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(DataSourceReindexWarning).Namespace, nameof(DataSourceReindexWarning)); + + /// + /// Asks before an edited embedding provider is saved. + /// + /// The dialog service to ask with. + /// The settings, read for the data sources behind the provider. + /// The service which knows what the index holds. + /// The embedding provider as it is stored. + /// The embedding provider as it would be stored. + /// The cancellation token. + /// True when the edit may be saved. + public static async Task ConfirmEmbeddingProviderChangeAsync(IDialogService dialogService, SettingsManager settingsManager, DataSourceEmbeddingService embeddingService, + EmbeddingProvider before, EmbeddingProvider after, CancellationToken token = default) + { + // Nothing was stored under this id, so no data source can point at it: + if (before == EmbeddingProvider.NONE) + return true; + + var candidates = GetDataSourcesUsing(settingsManager, before.Id) + .Where(dataSource => EmbeddingChangeImpact.AffectsStoredIndex(dataSource, before, after)) + .Cast() + .ToList(); + + if (candidates.Count == 0) + return true; + + var affected = await embeddingService.GetDataSourcesWithStoredIndexAsync(candidates, token); + return await ConfirmAsync(dialogService, affected, !after.IsSelfHosted); + } + + /// + /// Asks before an edited data source is saved. + /// + /// The dialog service to ask with. + /// The settings, read for the embedding provider of the data source. + /// The service which knows what the index holds. + /// The data source as it is stored. + /// The data source as it would be stored. + /// The cancellation token. + /// True when the edit may be saved. + public static async Task ConfirmDataSourceChangeAsync(IDialogService dialogService, SettingsManager settingsManager, DataSourceEmbeddingService embeddingService, + IInternalDataSource before, IInternalDataSource after, CancellationToken token = default) + { + // Without a provider nothing is embedded at all, so nothing can be lost: + if (!DataSourceEmbeddingProviders.TryResolve(settingsManager, after, out var embeddingProvider)) + return true; + + if (!EmbeddingChangeImpact.AffectsStoredIndex(embeddingProvider, before, after)) + return true; + + var affected = await embeddingService.GetDataSourcesWithStoredIndexAsync([after], token); + return await ConfirmAsync(dialogService, affected, !embeddingProvider.IsSelfHosted); + } + + /// + /// The data sources which are indexed with a given embedding provider. + /// + /// The settings holding the data sources. + /// The id of the embedding provider. + /// The data sources pointing at that embedding provider. + private static IReadOnlyList GetDataSourcesUsing(SettingsManager settingsManager, string embeddingProviderId) => + settingsManager.ConfigurationData.DataSources + .OfType() + .Where(dataSource => embeddingProviderId.Equals(dataSource.EmbeddingId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + /// + /// Names data sources as a Markdown list, counting the rest when there are too many to name. + /// + /// The data sources to name. + /// The Markdown list. + private static string FormatDataSourceNames(IReadOnlyList dataSources) + { + var names = dataSources + .Select(dataSource => dataSource.Name) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var lines = names.Take(MAX_NAMED_DATA_SOURCES).Select(name => $"- {name}").ToList(); + if (names.Count > MAX_NAMED_DATA_SOURCES) + lines.Add($"- {string.Format(TB("and {0} more."), (names.Count - MAX_NAMED_DATA_SOURCES).CompactCount())}"); + + return string.Join(Environment.NewLine, lines); + } + + private static async Task ConfirmAsync(IDialogService dialogService, IReadOnlyList affected, bool usesCloudEmbedding) + { + if (affected.Count == 0) + return true; + + var body = new StringBuilder(); + + // Counted rather than put into a plural form: the I18N has no mechanism for one. + body.AppendLine(string.Format(TB("This change makes the prepared documents of the following data sources unusable ({0}):"), affected.Count.CompactCount())); + body.AppendLine(); + body.AppendLine(FormatDataSourceNames(affected)); + body.AppendLine(); + body.AppendLine(TB("Everything prepared for them is thrown away, and every one of their documents goes to your embedding provider once more. With a large data source, this takes a while.")); + + if (usesCloudEmbedding) + { + body.AppendLine(); + body.AppendLine(TB("Your embedding provider runs in the cloud, so preparing everything again costs money.")); + } + + body.AppendLine(); + body.AppendLine(TB("Do you want to apply this change anyway?")); + + var dialogParameters = new DialogParameters + { + { x => x.MarkdownBody, body.ToString() }, + }; + + var dialogReference = await dialogService.ShowAsync(TB("Documents Will Be Prepared Again"), dialogParameters, Dialogs.DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + return dialogResult is not null && !dialogResult.Canceled; + } +} \ No newline at end of file