diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs index f88d5bf7..b941beab 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs @@ -132,6 +132,9 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase if (chatTemplate == ChatTemplate.NO_CHAT_TEMPLATE || chatTemplate.IsEnterpriseConfiguration) return; + if (!await this.ConfirmExportOfLocalDataSources(chatTemplate)) + return; + await this.CopyChatTemplateLuaToClipboard(chatTemplate); } @@ -145,10 +148,14 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase if (chatTemplate.FileAttachments.Count == 0) { + // That way asks about the local data sources itself, so we must not ask twice: await this.ExportChatTemplateWithSharedAttachmentPaths(chatTemplate); return; } + if (!await this.ConfirmExportOfLocalDataSources(chatTemplate)) + return; + this.isPluginDirectoryDialogOpen = true; try { @@ -164,6 +171,35 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase } } + /// + /// Asks whether to export a template although it preselects data sources of this machine. + /// + /// + /// The export writes the preselected data source IDs unchanged, which is what makes a template + /// usable across an organization — but a local file or folder exists here and nowhere else, so + /// its ID points at nothing on the machine reading the plugin. Nothing breaks, the chat simply + /// starts without that source, and that is precisely why it has to be said beforehand: nobody + /// would notice it afterwards. Exporting anyway is a fair choice, because the rest of the + /// template is worth rolling out. + /// + /// The chat template about to be exported. + /// True when the export may go ahead. + private async Task ConfirmExportOfLocalDataSources(ChatTemplate chatTemplate) + { + var localDataSourceNames = ChatTemplate.GetPreselectedLocalDataSourceNames(chatTemplate, this.SettingsManager.ConfigurationData.DataSources); + if (localDataSourceNames.Count == 0) + return true; + + var dialogParameters = new DialogParameters + { + { x => x.Message, string.Format(T("This chat template preselects data sources which exist on this machine only: {0}. They cannot be rolled out, so a chat started with this template elsewhere begins without them. Do you want to export the template anyway?"), string.Join(", ", localDataSourceNames)) }, + }; + + var dialogReference = await this.DialogService.ShowAsync(T("Export Chat Template"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + return dialogResult is { Canceled: false }; + } + private async Task CopyChatTemplateLuaToClipboard(ChatTemplate chatTemplate) { if (!chatTemplate.TryExportAsConfigurationSection(out var luaCode, out var issue)) diff --git a/app/MindWork AI Studio/Settings/ChatTemplate.cs b/app/MindWork AI Studio/Settings/ChatTemplate.cs index afd114ef..ceacdc2f 100644 --- a/app/MindWork AI Studio/Settings/ChatTemplate.cs +++ b/app/MindWork AI Studio/Settings/ChatTemplate.cs @@ -145,6 +145,32 @@ public record ChatTemplate( return (templateOptions.CreateCopy(), launcherOptions is not null); } + /// + /// Names the preselected data sources which exist on this machine only. + /// + /// + /// Such a source is a sensible choice inside a chat and a dead end in an export: its ID travels + /// into the plugin unchanged, and on the machine which reads that plugin it points at nothing. + /// Only ERI sources describe something the whole organization can reach, which is why they are + /// also the only ones the app offers an export for.

+ /// IDs which match no configured source at all are left out. Those are covered by the note the + /// export writes above the data source IDs anyway, and the name to warn about is missing. + ///
+ /// The chat template about to be exported. + /// The data sources configured on this machine. + /// The names of the preselected local data sources, in the order they are configured in. + public static IReadOnlyList GetPreselectedLocalDataSourceNames(ChatTemplate chatTemplate, IEnumerable configuredDataSources) + { + if (chatTemplate.DataSourceOptions is not { PreselectedDataSourceIds.Count: > 0 } options) + return []; + + var preselectedIds = options.PreselectedDataSourceIds.ToHashSet(StringComparer.OrdinalIgnoreCase); + return configuredDataSources + .Where(source => source is IInternalDataSource && preselectedIds.Contains(source.Id)) + .Select(source => source.Name) + .ToList(); + } + public static bool TryParseChatTemplateTable(int idx, LuaTable table, Guid configPluginId, string pluginPath, out ConfigurationBaseObject template) { template = NO_CHAT_TEMPLATE; diff --git a/app/Tests/Settings/ChatTemplateConfigurationTests.cs b/app/Tests/Settings/ChatTemplateConfigurationTests.cs index c898435e..a4186a28 100644 --- a/app/Tests/Settings/ChatTemplateConfigurationTests.cs +++ b/app/Tests/Settings/ChatTemplateConfigurationTests.cs @@ -1,4 +1,5 @@ using AIStudio.Settings; +using AIStudio.Settings.DataModel; using Lua; using Lua.Standard; @@ -14,6 +15,8 @@ namespace AIStudio.Tests.Settings; /// template says nothing" and "this template says none" survives. Both end up as an empty /// selection in the chat on a fresh installation, so a mistake here stays invisible until somebody /// sets a default tool for their chats -- and then quietly hands out a tool the template ruled out. +/// The last tests cover what the export says out loud before it runs, for the same reason: a data +/// source which cannot be rolled out goes unnoticed on the machine reading the plugin. /// [TestFixture] public sealed class ChatTemplateConfigurationTests @@ -183,6 +186,55 @@ public sealed class ChatTemplateConfigurationTests }); } + [Test] + public void LocalDataSourcesOfATemplateAreNamedBeforeItIsExported() + { + var template = NewTemplate() with + { + DataSourceOptions = new() + { + DisableDataSources = false, + PreselectedDataSourceIds = ["11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"], + }, + }; + + var localNames = ChatTemplate.GetPreselectedLocalDataSourceNames(template, ConfiguredDataSources()); + + Assert.That(localNames, Is.EqualTo(new[] { "Meeting notes" }), "Only the local source can be named: its ID means nothing on the machine which reads the exported plugin, while the ERI source points at something the whole organization reaches."); + } + + [Test] + public void ATemplateWithoutLocalDataSourcesIsExportedWithoutAQuestion() + { + var eriOnly = NewTemplate() with + { + DataSourceOptions = new() { DisableDataSources = false, PreselectedDataSourceIds = ["22222222-2222-2222-2222-222222222222"] }, + }; + + var agentic = NewTemplate() with + { + DataSourceOptions = new() { DisableDataSources = false, AutomaticDataSourceSelection = true }, + }; + + Assert.Multiple(() => + { + Assert.That(ChatTemplate.GetPreselectedLocalDataSourceNames(eriOnly, ConfiguredDataSources()), Is.Empty); + Assert.That(ChatTemplate.GetPreselectedLocalDataSourceNames(agentic, ConfiguredDataSources()), Is.Empty, "An agent picks the sources per message, so this template names none to begin with."); + Assert.That(ChatTemplate.GetPreselectedLocalDataSourceNames(NewTemplate(), ConfiguredDataSources()), Is.Empty); + }); + } + + [Test] + public void AnIdWhichMatchesNoDataSourceIsNotReportedAsALocalOne() + { + var template = NewTemplate() with + { + DataSourceOptions = new() { DisableDataSources = false, PreselectedDataSourceIds = ["99999999-9999-9999-9999-999999999999"] }, + }; + + Assert.That(ChatTemplate.GetPreselectedLocalDataSourceNames(template, ConfiguredDataSources()), Is.Empty, "There is no name to warn about, and the note above the exported IDs already tells the admin to check them."); + } + /// /// A template with the parts every export needs, and nothing said about tools or data sources. /// @@ -198,6 +250,15 @@ public sealed class ChatTemplateConfigurationTests AllowProfileUsage = true, }; + /// + /// One data source of each kind: a local one, which cannot be rolled out, and an ERI one, which can. + /// + private static IReadOnlyList ConfiguredDataSources() => + [ + new DataSourceLocalFile { Id = "11111111-1111-1111-1111-111111111111", Name = "Meeting notes", Type = DataSourceType.LOCAL_FILE }, + new DataSourceERI_V1 { Id = "22222222-2222-2222-2222-222222222222", Name = "Intranet", Type = DataSourceType.ERI_V1 }, + ]; + private static async Task ExportAndReadBackAsync(ChatTemplate template) { Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue);