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);