Added a Send to menu to every chat message and kept restrictions when sending content on (#1124)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-28 13:28:30 +01:00
1 parent d06688ca08
commit bf54b8cd0d
73 files changed
+1923 -390

No files matched your search

@@ -0,0 +1,82 @@
using System.Reflection;
using AIStudio.Assistants;
using AIStudio.Assistants.VisualBriefing;
using AIStudio.Tools;
using AIStudio.Tools.AssistantLaunch;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Tests.Assistants;
/// <summary>
/// Checks that every assistant which content may be sent to takes it when it opens.
/// </summary>
/// <remarks>
/// The text waits on the message bus until its destination opens, and the bus hands it only to a receiver which
/// asks for its type: a receiver which asks for another type, or none at all, gets nothing, and the text is lost
/// without a word. That is why each destination is looked up by its route and checked for its part of the
/// receiving. The Visual Briefing assistant is no assistant of the common kind and takes the text itself. The
/// components are written out in full, since a short name would find a namespace of this assembly first.
/// </remarks>
[TestFixture]
public sealed class SendToReceiverTests
{
private static readonly Assembly APP = typeof(AssistantLaunchService).Assembly;
private static IEnumerable<AIStudio.Tools.Components> Destinations() => Enum.GetValues<AIStudio.Tools.Components>()
.Where(component => component.AllowSendTo())
.Where(component => component is not AIStudio.Tools.Components.CHAT);
[TestCaseSource(nameof(Destinations))]
public void ADestinationHasAnEventAndAPage(AIStudio.Tools.Components destination)
{
var data = destination.GetData();
Assert.Multiple(() =>
{
Assert.That(data.Event, Is.Not.EqualTo(Event.NONE), "Without an event of its own, the text has nothing to travel with.");
Assert.That(PagesOf(data.Route), Has.Count.EqualTo(1), "The text is taken by the page which opens under the route.");
});
}
[TestCaseSource(nameof(Destinations))]
public void ADestinationTakesTheTextSentToIt(AIStudio.Tools.Components destination)
{
var page = PagesOf(destination.GetData().Route).Single();
if (page == typeof(VisualBriefingAssistant))
Assert.Pass("The Visual Briefing assistant takes the text itself, since it is no assistant of the common kind.");
var applyMethod = page.GetMethod("ApplyTextLaunchPayload", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.Multiple(() =>
{
Assert.That(IsAssistant(page), Is.True, $"{page.Name} is expected to be an assistant.");
Assert.That(applyMethod, Is.Not.Null);
Assert.That(applyMethod!.DeclaringType, Is.Not.EqualTo(applyMethod.GetBaseDefinition().DeclaringType), $"{page.Name} allows sending to it, so it has to put the text sent to it into a field.");
});
}
[Test]
public void TextForTheChatGoesIntoTheFieldForTheNextMessage()
{
Assert.Multiple(() =>
{
Assert.That(AssistantLaunchService.TextEventFor(AIStudio.Tools.Components.CHAT), Is.EqualTo(Event.SEND_TO_CHAT_INPUT));
Assert.That(AssistantLaunchService.TextEventFor(AIStudio.Tools.Components.SLIDE_BUILDER_ASSISTANT), Is.EqualTo(Event.SEND_TO_SLIDE_BUILDER_ASSISTANT));
});
}
private static List<Type> PagesOf(string route) => APP.GetTypes()
.Where(type => type.GetCustomAttributes<RouteAttribute>().Any(attribute => string.Equals(attribute.Template, route, StringComparison.Ordinal)))
.ToList();
private static bool IsAssistant(Type type)
{
for (var current = type.BaseType; current is not null; current = current.BaseType)
if (current.IsGenericType && current.GetGenericTypeDefinition() == typeof(AssistantBase<>))
return true;
return false;
}
}
@@ -0,0 +1,154 @@
using System.Text.Json.Nodes;
using AIStudio.Assistants.VisualBriefing;
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Assistants.VisualBriefing;
/// <summary>
/// Checks that a briefing holds to the restrictions of a text sent to it.
/// </summary>
/// <remarks>
/// The Visual Briefing assistant keeps its briefings on disk and builds them in several stages, each with a
/// thread of its own. The restrictions of a text sent to it therefore live in the manifest of the briefing,
/// where every stage of every build finds them, also after a restart. The editor replaces the settings of a
/// briefing with every save, so the restrictions must survive that.<br/><br/>
/// The checks of a provider reach the settings through Program.SERVICE_PROVIDER, as in the app.
/// </remarks>
[TestFixture]
public sealed class VisualBriefingRestrictionsTests
{
private const string MAILBOX_ID = "5c9e2a7b-3f1d-4e8a-b6c4-2d7f9a1e3b5c";
private const string CLOUD_PROVIDER_ID = "11111111-1111-1111-1111-111111111111";
private const string SELF_HOSTED_PROVIDER_ID = "22222222-2222-2222-2222-222222222222";
private static readonly ChatRestrictions FROM_MAILBOX = new ChatRestrictions(ConfidenceLevel.HIGH, DataSourceSecurity.SELF_HOSTED, new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, MAILBOX_ID), []).WithWebAddressesWrittenByModelIn("The minutes are on https://example.org/minutes.");
private string dataDirectory = string.Empty;
private SettingsManager settingsManager = null!;
private ServiceProvider serviceProvider = null!;
private IServiceProvider previousServiceProvider = null!;
[SetUp]
public void CreateSettingsAndStorage()
{
this.dataDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-visual-briefing-restrictions-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.dataDirectory);
this.settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
this.previousServiceProvider = Program.SERVICE_PROVIDER;
this.serviceProvider = new ServiceCollection().AddSingleton(this.settingsManager).BuildServiceProvider();
Program.SERVICE_PROVIDER = this.serviceProvider;
}
[TearDown]
public void RestoreApplicationState()
{
Program.SERVICE_PROVIDER = this.previousServiceProvider;
this.serviceProvider.Dispose();
if (Directory.Exists(this.dataDirectory))
Directory.Delete(this.dataDirectory, true);
}
[Test]
public async Task ABriefingKeepsTheRestrictionsOfItsTextWhenTheEditorSavesIt()
{
var store = this.CreateStore();
var created = await store.CreateAsync("Briefing", string.Empty, new VisualBriefingLocalSettings(), FROM_MAILBOX);
await store.SaveProjectAsync(created.BriefingId, "Renamed briefing", "Author", new VisualBriefingLocalSettings { Instruction = "Summarize the minutes." }, []);
var loaded = await this.CreateStore().LoadAsync(created.BriefingId);
Assert.That(loaded, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(loaded!.Settings.Instruction, Is.EqualTo("Summarize the minutes."), "The save did not happen.");
AssertSame(loaded.Restrictions, FROM_MAILBOX);
});
}
[Test]
public async Task ABriefingNothingWasSentToDemandsNothing()
{
var store = this.CreateStore();
var created = await store.CreateAsync("Briefing", string.Empty, new VisualBriefingLocalSettings(), ChatRestrictions.NONE);
var loaded = await this.CreateStore().LoadAsync(created.BriefingId);
Assert.That(loaded, Is.Not.Null);
AssertSame(loaded!.Restrictions, ChatRestrictions.NONE);
}
[Test]
public async Task ABriefingWrittenBeforeTheRestrictionsExistedStillLoadsAndDemandsNothing()
{
var created = await this.CreateStore().CreateAsync("Briefing", string.Empty, new VisualBriefingLocalSettings(), ChatRestrictions.NONE);
var manifestPath = Directory.GetFiles(this.dataDirectory, "manifest.json", SearchOption.AllDirectories).Single();
var manifest = JsonNode.Parse(await File.ReadAllTextAsync(manifestPath))!.AsObject();
manifest.Remove("restrictions");
manifest["manifestVersion"] = 1;
await File.WriteAllTextAsync(manifestPath, manifest.ToJsonString());
var loaded = await this.CreateStore().LoadAsync(created.BriefingId);
Assert.That(loaded, Is.Not.Null, "A briefing of the first manifest version no longer loads.");
AssertSame(loaded!.Restrictions, ChatRestrictions.NONE);
}
[Test]
public async Task ASavedBriefingTellsAnOlderVersionThatItNeedsANewerOne()
{
var store = this.CreateStore();
var created = await store.CreateAsync("Briefing", string.Empty, new VisualBriefingLocalSettings(), FROM_MAILBOX);
var manifestPath = Directory.GetFiles(this.dataDirectory, "manifest.json", SearchOption.AllDirectories).Single();
var manifest = JsonNode.Parse(await File.ReadAllTextAsync(manifestPath))!.AsObject();
manifest["manifestVersion"] = 1;
await File.WriteAllTextAsync(manifestPath, manifest.ToJsonString());
await store.SaveProjectAsync(created.BriefingId, "Briefing", string.Empty, new VisualBriefingLocalSettings(), []);
var saved = JsonNode.Parse(await File.ReadAllTextAsync(manifestPath))!.AsObject();
Assert.That(saved["manifestVersion"]!.GetValue<int>(), Is.EqualTo(VisualBriefingVersions.MANIFEST), "An older AI Studio would fail on the restrictions instead of saying it needs a newer version.");
}
[Test]
public void TheBuildRefusesAProviderWhichMayNotSeeTheTextSentToTheBriefing()
{
var cloudProvider = this.AddProvider(CLOUD_PROVIDER_ID, LLMProviders.OPEN_AI, isSelfHosted: false);
var selfHostedProvider = this.AddProvider(SELF_HOSTED_PROVIDER_ID, LLMProviders.SELF_HOSTED, isSelfHosted: true);
var restricted = new VisualBriefingManifest { Restrictions = ChatRestrictions.NONE with { DataSecurity = DataSourceSecurity.SELF_HOSTED } };
var exception = Assert.Throws<VisualBriefingBuildException>(() => VisualBriefingBuildOrchestrator.ValidateProviderMaySeeContent(restricted, cloudProvider));
Assert.Multiple(() =>
{
Assert.That(exception!.Code, Is.EqualTo(VisualBriefingFailureCode.PROVIDER_NOT_ALLOWED));
Assert.DoesNotThrow(() => VisualBriefingBuildOrchestrator.ValidateProviderMaySeeContent(restricted, selfHostedProvider));
Assert.DoesNotThrow(() => VisualBriefingBuildOrchestrator.ValidateProviderMaySeeContent(new VisualBriefingManifest(), cloudProvider), "A briefing nothing was sent to may use any provider.");
});
}
private VisualBriefingStore CreateStore() => new(null!, NullLogger<VisualBriefingStore>.Instance, new VisualBriefingStorageOptions { DataDirectory = this.dataDirectory });
private static void AssertSame(ChatRestrictions actual, ChatRestrictions expected) => Assert.Multiple(() =>
{
Assert.That(actual.ProviderConfidence, Is.EqualTo(expected.ProviderConfidence));
Assert.That(actual.DataSecurity, Is.EqualTo(expected.DataSecurity));
Assert.That(actual.OutboundData, Is.EqualTo(expected.OutboundData));
Assert.That(actual.WebAddressesWrittenByModels, Is.EquivalentTo(expected.WebAddressesWrittenByModels));
});
private AIStudio.Settings.Provider AddProvider(string id, LLMProviders llmProvider, bool isSelfHosted)
{
var providers = this.settingsManager.ConfigurationData.Providers;
providers.Add(new((uint)providers.Count + 1, id, $"Instance {providers.Count + 1}", llmProvider, new("test-model", null), isSelfHosted));
return this.settingsManager.GetProviderById(id);
}
}