Warn before exporting a chat template with local data sources

This commit is contained in:
Thorsten Sommer 2026-09-22 15:49:18 +02:00
parent 52c0f3aa89
commit 9bd0ff24cf
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
3 changed files with 123 additions and 0 deletions

View File

@ -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
}
}
/// <summary>
/// Asks whether to export a template although it preselects data sources of this machine.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="chatTemplate">The chat template about to be exported.</param>
/// <returns>True when the export may go ahead.</returns>
private async Task<bool> ConfirmExportOfLocalDataSources(ChatTemplate chatTemplate)
{
var localDataSourceNames = ChatTemplate.GetPreselectedLocalDataSourceNames(chatTemplate, this.SettingsManager.ConfigurationData.DataSources);
if (localDataSourceNames.Count == 0)
return true;
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{ 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<ConfirmDialog>(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))

View File

@ -145,6 +145,32 @@ public record ChatTemplate(
return (templateOptions.CreateCopy(), launcherOptions is not null);
}
/// <summary>
/// Names the preselected data sources which exist on this machine only.
/// </summary>
/// <remarks>
/// 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.<br/><br/>
/// 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.
/// </remarks>
/// <param name="chatTemplate">The chat template about to be exported.</param>
/// <param name="configuredDataSources">The data sources configured on this machine.</param>
/// <returns>The names of the preselected local data sources, in the order they are configured in.</returns>
public static IReadOnlyList<string> GetPreselectedLocalDataSourceNames(ChatTemplate chatTemplate, IEnumerable<IDataSource> 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;

View File

@ -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.
/// </remarks>
[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.");
}
/// <summary>
/// A template with the parts every export needs, and nothing said about tools or data sources.
/// </summary>
@ -198,6 +250,15 @@ public sealed class ChatTemplateConfigurationTests
AllowProfileUsage = true,
};
/// <summary>
/// One data source of each kind: a local one, which cannot be rolled out, and an ERI one, which can.
/// </summary>
private static IReadOnlyList<IDataSource> 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<ChatTemplate> ExportAndReadBackAsync(ChatTemplate template)
{
Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue);