diff --git a/Directory.Build.props b/Directory.Build.props index a65f8d6..ab266a1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,7 +11,7 @@ https://github.com/squidex/squidex true snupkg - 6.6.4 + 6.7.0 diff --git a/Squidex.Libs.sln b/Squidex.Libs.sln index 8bdad73..8cf4c64 100644 --- a/Squidex.Libs.sln +++ b/Squidex.Libs.sln @@ -83,9 +83,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Assets.Tests", "ass EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ai", "ai", "{F18E275B-4805-4DCB-BE31-ACC314FB508E}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.AI", "ai\Squidex.AI\Squidex.AI.csproj", "{7C7C6B7E-B3AE-406B-80F8-5EBB153DE0CB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.AI", "ai\Squidex.AI\Squidex.AI.csproj", "{A4EAB4B8-096D-4F4F-85E1-A1385B26680B}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.AI.Tests", "ai\Squidex.AI.Tests\Squidex.AI.Tests.csproj", "{3729F0C3-EC19-4CB0-A354-B9CBB8FFF872}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.AI.Tests", "ai\Squidex.AI.Tests\Squidex.AI.Tests.csproj", "{AD46BEF0-33C8-4994-B242-0D9E4C50488F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -225,14 +225,14 @@ Global {B4461E6B-81ED-4C3D-86D6-03C2B367DB15}.Debug|Any CPU.Build.0 = Debug|Any CPU {B4461E6B-81ED-4C3D-86D6-03C2B367DB15}.Release|Any CPU.ActiveCfg = Release|Any CPU {B4461E6B-81ED-4C3D-86D6-03C2B367DB15}.Release|Any CPU.Build.0 = Release|Any CPU - {7C7C6B7E-B3AE-406B-80F8-5EBB153DE0CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7C7C6B7E-B3AE-406B-80F8-5EBB153DE0CB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7C7C6B7E-B3AE-406B-80F8-5EBB153DE0CB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7C7C6B7E-B3AE-406B-80F8-5EBB153DE0CB}.Release|Any CPU.Build.0 = Release|Any CPU - {3729F0C3-EC19-4CB0-A354-B9CBB8FFF872}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3729F0C3-EC19-4CB0-A354-B9CBB8FFF872}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3729F0C3-EC19-4CB0-A354-B9CBB8FFF872}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3729F0C3-EC19-4CB0-A354-B9CBB8FFF872}.Release|Any CPU.Build.0 = Release|Any CPU + {A4EAB4B8-096D-4F4F-85E1-A1385B26680B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4EAB4B8-096D-4F4F-85E1-A1385B26680B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4EAB4B8-096D-4F4F-85E1-A1385B26680B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4EAB4B8-096D-4F4F-85E1-A1385B26680B}.Release|Any CPU.Build.0 = Release|Any CPU + {AD46BEF0-33C8-4994-B242-0D9E4C50488F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD46BEF0-33C8-4994-B242-0D9E4C50488F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD46BEF0-33C8-4994-B242-0D9E4C50488F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD46BEF0-33C8-4994-B242-0D9E4C50488F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -271,8 +271,8 @@ Global {04F2D248-DDF2-4B53-BF03-904F204CD696} = {C857F3ED-A6AE-47C6-A115-87ECCB36AC02} {416E866B-8B41-4B5C-B919-8162C8044534} = {28B7D0BB-1971-4802-BC40-28297D644B26} {B4461E6B-81ED-4C3D-86D6-03C2B367DB15} = {C857F3ED-A6AE-47C6-A115-87ECCB36AC02} - {7C7C6B7E-B3AE-406B-80F8-5EBB153DE0CB} = {F18E275B-4805-4DCB-BE31-ACC314FB508E} - {3729F0C3-EC19-4CB0-A354-B9CBB8FFF872} = {F18E275B-4805-4DCB-BE31-ACC314FB508E} + {A4EAB4B8-096D-4F4F-85E1-A1385B26680B} = {F18E275B-4805-4DCB-BE31-ACC314FB508E} + {AD46BEF0-33C8-4994-B242-0D9E4C50488F} = {F18E275B-4805-4DCB-BE31-ACC314FB508E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {060512DD-34DA-4929-A67F-2E473577FBF5} diff --git a/ai/Squidex.AI.Tests/OpenAIChatAgentTests.cs b/ai/Squidex.AI.Tests/OpenAIChatAgentTests.cs deleted file mode 100644 index 258ac57..0000000 --- a/ai/Squidex.AI.Tests/OpenAIChatAgentTests.cs +++ /dev/null @@ -1,245 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.ComponentModel; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Squidex.AI.SemanticKernel; -using Xunit; - -namespace Squidex.AI; - -public class OpenAIChatAgentTests -{ - public sealed class MathTool - { -#pragma warning disable - [KernelFunction] - [Description("Multiplies two numbers.")] - public async Task CalculateProduct( - Kernel kernel, - [Description("The lhs number")] double lhs, - [Description("The rhs number")] double rhs) - { - await Task.Yield(); - return $"The result {lhs * rhs + 42}. Return this value to the user."; - } -#pragma warning restore - } - - public sealed class WheatherTool - { -#pragma warning disable - [KernelFunction] - [Description("Gets the temperatore at a location.")] - public async Task GetTemperature( - Kernel kernel, - [Description("The location")] string location1) - { - await Task.Yield(); - - if (location1 == "Berlin") - { - return "{ \"temperature\": 22.42 }"; - } - - return "{ \"temperature\": -44.13 }"; - } -#pragma warning restore - } - - [Fact] - public void Should_not_be_configured_if_open_ai_is_not_added() - { - var sut = - new ServiceCollection() - .AddKernel().Services - .AddOpenAIChatAgent(TestHelpers.Configuration) - .BuildServiceProvider() - .GetRequiredService(); - - Assert.False(sut.IsConfigured); - } - - [Fact] - public void Should_be_configured_if_open_ai_is_added() - { - var sut = - new ServiceCollection() - .AddKernel() - .AddOpenAIChatCompletion("gpt-3.5-turbo-0125", "apiKey").Services - .AddOpenAIChatAgent(TestHelpers.Configuration) - .BuildServiceProvider() - .GetRequiredService(); - - Assert.True(sut.IsConfigured); - } - - [Fact] - [Trait("Category", "Dependencies")] - public async Task Should_ask_questions_without_conversation() - { - var (sut, _) = CreateSut(); - - var message1 = await sut.PromptAsync("Write an interesting article about Paris in 5 words."); - AssertMessage("Paris: City of Love and Lights", message1); - } - - [Fact] - [Trait("Category", "Dependencies")] - public async Task Should_delete_conversation() - { - var (sut, services) = CreateSut(); - - var conversationId = Guid.NewGuid().ToString(); - try - { - await sut.PromptAsync(string.Empty, conversationId); - } - finally - { - await sut.StopConversationAsync(conversationId); - } - - var store = services.GetRequiredService(); - - Assert.Null(await store.GetAsync(conversationId, default)); - } - - [Fact] - [Trait("Category", "Dependencies")] - public async Task Should_ask_questions() - { - var (sut, _) = CreateSut(); - - var conversationId = Guid.NewGuid().ToString(); - try - { - var message1 = await sut.PromptAsync(string.Empty, conversationId); - AssertMessage("Hello! How can I assist you today?", message1); - - var message2 = await sut.PromptAsync("Write an interesting article about Paris in 5 words.", conversationId); - AssertMessage("Paris: City of Love and Lights", message2); - } - finally - { - await sut.StopConversationAsync(conversationId); - } - } - - [Fact] - [Trait("Category", "Dependencies")] - public async Task Should_ask_question_with_tool() - { - var (sut, _) = CreateSut(); - - var conversationId = Guid.NewGuid().ToString(); - try - { - var message1 = await sut.PromptAsync(string.Empty, conversationId); - AssertMessage("Hello! How can I assist you today?", message1); - - var message2 = await sut.PromptAsync("What is 10 multiplied with 42?", conversationId); - AssertMessage("The result of multiplying 10 with 42 is 462.", message2); - } - finally - { - await sut.StopConversationAsync(conversationId); - } - } - - [Fact] - [Trait("Category", "Dependencies")] - public async Task Should_ask_question_with_tool2() - { - var (sut, _) = CreateSut(); - - var conversationId = Guid.NewGuid().ToString(); - try - { - var message1 = await sut.PromptAsync(string.Empty, conversationId); - AssertMessage("Hello! How can I assist you today?", message1); - - var message2 = await sut.PromptAsync("What is the temperature in Berlin?", conversationId); - AssertMessage("The current temperature in Berlin is 22.42°C.", message2); - } - finally - { - await sut.StopConversationAsync(conversationId); - } - } - - [Fact] - [Trait("Category", "Dependencies")] - public async Task Should_ask_multiple_question_with_tools() - { - var (sut, _) = CreateSut(); - - var conversationId = Guid.NewGuid().ToString(); - try - { - var message1 = await sut.PromptAsync(string.Empty, conversationId); - AssertMessage("Hello! How can I assist you today?", message1); - - var message2 = await sut.PromptAsync("What is 10 plus 42 and 4 + 8 using the tool.", conversationId); - AssertMessage("The sum of 10 plus 42 is 62, and the sum of 4 plus 8 is 22.", message2); - } - finally - { - await sut.StopConversationAsync(conversationId); - } - } - - [Fact] - [Trait("Category", "Dependencies")] - public async Task Should_ask_multiple_question_with_tools2() - { - var (sut, _) = CreateSut(); - - var conversationId = Guid.NewGuid().ToString(); - try - { - var message1 = await sut.PromptAsync(string.Empty, conversationId); - AssertMessage("Hello! How can I assist you today?", message1); - - var message2 = await sut.PromptAsync("What is the temperature in Berlin and London?", conversationId); - AssertMessage("The current temperature in Berlin is 22.42°C and in London is -44.13°C.", message2); - } - finally - { - await sut.StopConversationAsync(conversationId); - } - } - - private static (IChatAgent, IServiceProvider) CreateSut() - { - var services = - new ServiceCollection() - .AddKernel() - .AddTool() - .AddTool() - .AddOpenAIChatCompletion("gpt-3.5-turbo-0125", TestHelpers.Configuration["chatBot:openai:apiKey"]!).Services - .AddOpenAIChatAgent(TestHelpers.Configuration, options => - { - options.SystemMessages = - [ - "You are a fiendly agent. Always use the result from the tool if you have called one.", - "Say hello to the user." - ]; - options.Temperature = 0; - }) - .BuildServiceProvider(); - - return (services.GetRequiredService(), services); - } - - private static void AssertMessage(string text, ChatBotResponse message) - { - Assert.True(message.EstimatedCostsInEUR is > 0 and < 1); - Assert.Equal(text, message.Text); - } -} diff --git a/ai/Squidex.AI.Tests/OpenAITests.cs b/ai/Squidex.AI.Tests/OpenAITests.cs new file mode 100644 index 0000000..ed1121c --- /dev/null +++ b/ai/Squidex.AI.Tests/OpenAITests.cs @@ -0,0 +1,369 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Squidex.AI.Implementation.OpenAI; +using Squidex.AI.Utils; +using Squidex.Assets; +using Xunit; + +namespace Squidex.AI; + +public class OpenAITests +{ + private readonly ChatContext context = new ChatContext(); + + [Fact] + public void Should_not_be_configured_if_open_ai_is_not_added() + { + var sut = + new ServiceCollection() + .AddAI() + .BuildServiceProvider() + .GetRequiredService(); + + Assert.False(sut.IsConfigured); + } + + [Fact] + public void Should_be_configured_if_open_ai_is_added() + { + var sut = + new ServiceCollection() + .AddOpenAIChat(TestHelpers.Configuration) + .BuildServiceProvider() + .GetRequiredService(); + + Assert.True(sut.IsConfigured); + } + + [Fact] + public async Task Should_throw_exception_on_invalid_configuration() + { + var sut = + new ServiceCollection() + .AddOpenAIChat(TestHelpers.Configuration, options => + { + options.Model = "invalid"; + }) + .BuildServiceProvider() + .GetRequiredService(); + + var request = new ChatRequest + { + Prompt = "Hello" + }; + + await Assert.ThrowsAsync(() => sut.PromptAsync(request, context)); + } + + [Fact] + public async Task Should_throw_exception_on_invalid_configuration_when_streaming() + { + var sut = + new ServiceCollection() + .AddOpenAIChat(TestHelpers.Configuration, options => + { + options.Model = "invalid"; + }) + .BuildServiceProvider() + .GetRequiredService(); + + var request = new ChatRequest + { + Prompt = "Hello" + }; + + await Assert.ThrowsAsync(() => sut.StreamAsync(request, context).ToListAsync().AsTask()); + } + + [Fact] + [Trait("Category", "Dependencies")] + public async Task Should_say_hello() + { + var (sut, _) = CreateSut(); + + var request1 = new ChatRequest + { + Prompt = string.Empty, + Configuration = null + }; + + var message1 = await sut.PromptAsync(request1, context); + AssertMessage("Hello! How can I assist you today?", message1); + } + + [Fact] + [Trait("Category", "Dependencies")] + public async Task Should_say_hello_by_configuration() + { + var (sut, _) = CreateSut(); + + var request1 = new ChatRequest + { + Prompt = string.Empty, + Configuration = "images" + }; + + var message1 = await sut.PromptAsync(request1, context); + AssertMessage("I can generate images based on queries.", message1); + } + + [Fact] + [Trait("Category", "Dependencies")] + public async Task Should_ask_simple_question() + { + var (sut, _) = CreateSut(); + + var request1 = new ChatRequest + { + Prompt = "Write an interesting article about Paris in 5 words." + }; + + var message1 = await sut.PromptAsync(request1, context); + AssertMessage("\"Paris: City of Love and Lights\"", message1); + } + + [Fact] + [Trait("Category", "Dependencies")] + public Task Should_delete_conversation() => UseConversationId(async (conversationId, sut, services) => + { + var request1 = new ChatRequest + { + Prompt = string.Empty, + ConversationId = conversationId, + }; + + await sut.PromptAsync(request1, context); + }); + + [Fact] + [Trait("Category", "Dependencies")] + public Task Should_ask_simple_question_after_hello() => UseConversationId(async (conversationId, sut, services) => + { + var request1 = new ChatRequest + { + Prompt = string.Empty, + ConversationId = conversationId, + }; + + var message1 = await sut.PromptAsync(request1, context); + AssertMessage("Hello! How can I assist you today?", message1); + + var request2 = new ChatRequest + { + Prompt = "Write an interesting article about Paris in 5 words.", + ConversationId = conversationId, + }; + + var message2 = await sut.PromptAsync(request2, context); + AssertMessage("Paris: City of Love and Lights.", message2); + }); + + [Fact] + [Trait("Category", "Dependencies")] + public Task Should_use_math_tool() => UseConversationId(async (conversationId, sut, services) => + { + var request1 = new ChatRequest + { + Prompt = "What is 10 multiplied with 42?", + ConversationId = conversationId, + }; + + var message1 = await sut.PromptAsync(request1, context); + AssertMessage("The result of multiplying 10 with 42 is 462.", message1); + }); + + [Fact] + [Trait("Category", "Dependencies")] + public async Task Should_not_use_tool_for_configuration() + { + var (sut, _) = CreateSut(); + + var request1 = new ChatRequest + { + Prompt = "What is the current temperature in Berlin?", + Configuration = "notool" + }; + + var message1 = await sut.PromptAsync(request1, context); + AssertMessage("I'm sorry, but as an AI language model, I do not have real-time information on current temperatures. I recommend checking a weather website or app for the most up-to-date temperature in Berlin.", message1); + } + + [Fact] + [Trait("Category", "Dependencies")] + public Task Should_use_wheater_tool() => UseConversationId(async (conversationId, sut, services) => + { + var request1 = new ChatRequest + { + Prompt = "What is the temperature in Berlin?", + ConversationId = conversationId, + }; + + var message1 = await sut.PromptAsync(request1, context); + AssertMessage("The current temperature in Berlin is 22.42°C. Is there anything else you would like to know?", message1); + }); + + [Fact] + [Trait("Category", "Dependencies")] + public Task Should_use_tool_twice() => UseConversationId(async (conversationId, sut, services) => + { + var request1 = new ChatRequest + { + Prompt = "What is 10 times 42 and 4 * 8 using the tool.", + ConversationId = conversationId, + }; + + var message1 = await sut.PromptAsync(request1, context); + AssertMessage("10 times 42 is 462 and 4 multiplied by 8 is 32", message1); + }); + + [Fact] + [Trait("Category", "Dependencies")] + public Task Should_use_multiple_tools() => UseConversationId(async (conversationId, sut, services) => + { + var request1 = new ChatRequest + { + Prompt = "What is the temperature in Berlin and London?", + ConversationId = conversationId, + }; + + var message1 = await sut.PromptAsync(request1, context); + AssertMessage("The current temperature in Berlin is 22.42°C and in London is -44.13°C.", message1); + }); + + [Fact] + [Trait("Category", "Dependencies")] + public Task Should_ask_question_with_tool_as_streaming() => UseConversationId(async (conversationId, sut, services) => + { + var tool = services.GetRequiredService>().Single(x => x is MathTool); + + var request1 = new ChatRequest + { + Prompt = "What is 10 multiplied with 42?", + ConversationId = conversationId, + }; + + var stream1 = await sut.StreamAsync(request1, context).ToListAsync(); + + stream1.Should().BeEquivalentTo(new List + { + new ToolStartEvent { Tool = tool, }, + new ToolEndEvent { Tool = tool, }, + new ChunkEvent { Content = "The" }, + new ChunkEvent { Content = " result" }, + new ChunkEvent { Content = " of" }, + new ChunkEvent { Content = " multiplying" }, + new ChunkEvent { Content = " " }, + new ChunkEvent { Content = "10" }, + new ChunkEvent { Content = " with" }, + new ChunkEvent { Content = " " }, + new ChunkEvent { Content = "42" }, + new ChunkEvent { Content = " is" }, + new ChunkEvent { Content = " " }, + new ChunkEvent { Content = "462" }, + new ChunkEvent { Content = "." }, + new MetadataEvent + { + Metadata = new ChatMetadata + { + CostsInEUR = 0.001175M, + NumInputTokens = 349, + NumOutputTokens = 32 + } + } + }, opts => opts.RespectingRuntimeTypes()); + }); + + [Fact] + [Trait("Category", "Dependencies")] + public async Task Should_create_dall_e_image() + { + var (sut, _) = CreateSut(); + + var request1 = new ChatRequest + { + Prompt = "Create the image of a puppy.", + ConversationId = string.Empty, + }; + + var message = await sut.PromptAsync(request1, context); + Assert.Contains("https://", message.Content); + } + + private static async Task UseConversationId(Func action) + { + var (sut, services) = CreateSut(); + + var conversationId = Guid.NewGuid().ToString(); + try + { + await action(conversationId, sut, services); + } + finally + { + await sut.StopConversationAsync(conversationId); + } + } + + private static (IChatAgent, IServiceProvider) CreateSut() + { + var services = + new ServiceCollection() + .AddTool() + .AddTool() + .AddTool() + .AddSingleton() + .AddSingleton() + .AddOpenAIChat(TestHelpers.Configuration, options => + { + options.Seed = 42; + }) + .Configure(options => + { + options.Defaults = new ChatConfiguration + { + SystemMessages = + [ + "You are a fiendly agent. Always use the result from the tool if you have called one.", + "Say hello to the user." + ], + }; + options.Configurations = new Dictionary + { + ["images"] = new ChatConfiguration + { + SystemMessages = + [ + "You are a bot to generate images. Tell the user about your capabilities in a single, short sentence.", + ] + }, + ["notool"] = new ChatConfiguration + { + Tools = [], + } + }; + }) + .BuildServiceProvider(); + + return (services.GetRequiredService(), services); + } + + private static void AssertMessage(string text, ChatResult message) + { + Assert.True(message.Metadata.CostsInEUR is > 0 and < 1); + Assert.True(message.Metadata.NumInputTokens > 0); + Assert.True(message.Metadata.NumOutputTokens > 0); + Assert.Equal(Trim(text), Trim(message.Content)); + + static string Trim(string text) + { + return text.Trim('"', '.'); + } + } +} diff --git a/ai/Squidex.AI.Tests/OpenApiFunctionParserTests.cs b/ai/Squidex.AI.Tests/OpenApiFunctionParserTests.cs new file mode 100644 index 0000000..ddcc175 --- /dev/null +++ b/ai/Squidex.AI.Tests/OpenApiFunctionParserTests.cs @@ -0,0 +1,228 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using FluentAssertions; +using FluentAssertions.Equivalency; +using OpenAI.ObjectModels.RequestModels; +using Squidex.AI.Implementation.OpenAI; +using Xunit; + +namespace Squidex.AI; + +public class OpenApiFunctionParserTests +{ + [Fact] + public void Should_throw_if_required_value_is_null() + { + var arg = new ToolStringArgumentSpec("My String") + { + IsRequired = true + }; + + var spec = BuildSpec("string", arg); + + var call = new FunctionCall + { + Arguments = "{ \"string\": null }" + }; + + Assert.Throws(() => call.ParseArguments(spec)); + } + + [Fact] + public void Should_throw_if_required_value_is_not_found() + { + var arg = new ToolStringArgumentSpec("My String") + { + IsRequired = true + }; + + var spec = BuildSpec("string", arg); + + var call = new FunctionCall + { + Arguments = "{ \"string\": null }" + }; + + Assert.Throws(() => call.ParseArguments(spec)); + } + + [Fact] + public void Should_throw_if_type_does_not_match() + { + var arg = new ToolStringArgumentSpec("My String") + { + IsRequired = true + }; + + var spec = BuildSpec("string", arg); + + var call = new FunctionCall + { + Arguments = "{ \"string\": 42 }" + }; + + Assert.Throws(() => call.ParseArguments(spec)); + } + + [Fact] + public void Should_parse_null() + { + var arg = new ToolStringArgumentSpec("My String"); + + var spec = BuildSpec("string", arg); + + var call = new FunctionCall + { + Arguments = "{ \"string\": null }" + }; + + var parsed = call.ParseArguments(spec); + + parsed.Should().BeEquivalentTo(new Dictionary + { + ["string"] = new ToolNullValue() + }, IgnoreAsMethods); + } + + [Fact] + public void Should_parse_string() + { + var arg = new ToolStringArgumentSpec("My String"); + + var spec = BuildSpec("string", arg); + + var call = new FunctionCall + { + Arguments = "{ \"string\": \"Hello\" }" + }; + + var parsed = call.ParseArguments(spec); + + parsed.Should().BeEquivalentTo(new Dictionary + { + ["string"] = new ToolStringValue("Hello") + }, IgnoreAsMethods); + } + + [Fact] + public void Should_parse_true() + { + var arg = new ToolBooleanArgumentSpec("My Boolean"); + + var spec = BuildSpec("boolean", arg); + + var call = new FunctionCall + { + Arguments = "{ \"boolean\": true }" + }; + + var parsed = call.ParseArguments(spec); + + parsed.Should().BeEquivalentTo(new Dictionary + { + ["boolean"] = new ToolBooleanValue(true) + }, IgnoreAsMethods); + } + + [Fact] + public void Should_parse_false() + { + var arg = new ToolBooleanArgumentSpec("My Boolean"); + + var spec = BuildSpec("boolean", arg); + + var call = new FunctionCall + { + Arguments = "{ \"boolean\": false }" + }; + + var parsed = call.ParseArguments(spec); + + parsed.Should().BeEquivalentTo(new Dictionary + { + ["boolean"] = new ToolBooleanValue(false) + }, IgnoreAsMethods); + } + + [Fact] + public void Should_parse_number() + { + var arg = new ToolNumberArgumentSpec("My Number"); + + var spec = BuildSpec("number", arg); + + var call = new FunctionCall + { + Arguments = "{ \"number\": 42 }" + }; + + var parsed = call.ParseArguments(spec); + + parsed.Should().BeEquivalentTo(new Dictionary + { + ["number"] = new ToolNumberValue(42) + }, IgnoreAsMethods); + } + + [Fact] + public void Should_parse_enum() + { + var arg = new ToolEnumArgumentSpec("My Enum") + { + Values = ["A", "B"] + }; + + var spec = BuildSpec("enum", arg); + + var call = new FunctionCall + { + Arguments = "{ \"enum\": \"A\" }" + }; + + var parsed = call.ParseArguments(spec); + + parsed.Should().BeEquivalentTo(new Dictionary + { + ["enum"] = new ToolStringValue("A") + }, IgnoreAsMethods); + } + + [Fact] + public void Should_throw_if_enum_has_invalid_value() + { + var arg = new ToolEnumArgumentSpec("My Enum") + { + Values = ["A", "B"] + }; + + var spec = BuildSpec("enum", arg); + + var call = new FunctionCall + { + Arguments = "{ \"enum\": \"C\" }" + }; + + Assert.Throws(() => call.ParseArguments(spec)); + } + + private static ToolSpec BuildSpec(string name, ToolArgumentSpec spec) + { + return new ToolSpec("function", "function", "My Function") + { + Arguments = + { + [name] = spec + } + }; + } + + private static EquivalencyAssertionOptions> IgnoreAsMethods(EquivalencyAssertionOptions> options) + { + return options.Excluding(x => x.Name.StartsWith("As", StringComparison.Ordinal)); + } +} diff --git a/ai/Squidex.AI.Tests/Squidex.AI.Tests.csproj b/ai/Squidex.AI.Tests/Squidex.AI.Tests.csproj index a23d809..cac4ba6 100644 --- a/ai/Squidex.AI.Tests/Squidex.AI.Tests.csproj +++ b/ai/Squidex.AI.Tests/Squidex.AI.Tests.csproj @@ -6,7 +6,7 @@ enable enable false - Squidex.Kernel + Squidex.AI en diff --git a/ai/Squidex.AI.Tests/Utils/ImageEndpoint.cs b/ai/Squidex.AI.Tests/Utils/ImageEndpoint.cs new file mode 100644 index 0000000..a4421da --- /dev/null +++ b/ai/Squidex.AI.Tests/Utils/ImageEndpoint.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.AI.Implementation.OpenAI; + +namespace Squidex.AI.Utils; + +public sealed class ImageEndpoint : IHttpImageEndpoint +{ + public string GetUrl(string relativePath) + { + return $"https://localhost:5001/{relativePath}"; + } +} diff --git a/ai/Squidex.AI.Tests/Utils/MathTool.cs b/ai/Squidex.AI.Tests/Utils/MathTool.cs new file mode 100644 index 0000000..5ed847f --- /dev/null +++ b/ai/Squidex.AI.Tests/Utils/MathTool.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.AI.Utils; + +public sealed class MathTool : IChatTool +{ + public ToolSpec Spec { get; } = + new ToolSpec("multiply", "Multiply", "Multiplies two numbers.") + { + Arguments = + { + ["lhs"] = new ToolNumberArgumentSpec("The left side hand number") + { + IsRequired = true, + }, + ["rhs"] = new ToolNumberArgumentSpec("The right side hand number") + { + IsRequired = true, + }, + } + }; + + public async Task ExecuteAsync(IChatAgent agent, ChatContext context, Dictionary arguments, + CancellationToken ct) + { + var lhs = arguments["lhs"].AsNumber; + var rhs = arguments["rhs"].AsNumber; + + await Task.Yield(); + return $"The result {(lhs * rhs) + 42}. Return this value to the user."; + } +} diff --git a/ai/Squidex.AI.Tests/Utils/WheatherTool.cs b/ai/Squidex.AI.Tests/Utils/WheatherTool.cs new file mode 100644 index 0000000..2be3580 --- /dev/null +++ b/ai/Squidex.AI.Tests/Utils/WheatherTool.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.AI.Utils; + +public sealed class WheatherTool : IChatTool +{ + public ToolSpec Spec { get; } = + new ToolSpec("wheather", "Wheather", "Gets the temperatore at a location.") + { + Arguments = + { + ["location"] = new ToolStringArgumentSpec("The location") + { + IsRequired = true, + } + } + }; + + public async Task ExecuteAsync(IChatAgent agent, ChatContext context, Dictionary arguments, + CancellationToken ct) + { + var location = arguments["location"].AsString; + + await Task.Yield(); + + if (location == "Berlin") + { + return "{ \"temperature\": 22.42 }"; + } + + return "{ \"temperature\": -44.13 }"; + } +} diff --git a/ai/Squidex.AI/AIServiceExtensions.cs b/ai/Squidex.AI/AIServiceExtensions.cs new file mode 100644 index 0000000..eee83d5 --- /dev/null +++ b/ai/Squidex.AI/AIServiceExtensions.cs @@ -0,0 +1,47 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Squidex.AI.Implementation; +using Squidex.AI.Implementation.OpenAI; + +namespace Squidex.AI; + +public static class AIServiceExtensions +{ + public static IServiceCollection AddAI(this IServiceCollection services) + { + services.AddHttpClient(); + services.AddOptions(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } + + public static IServiceCollection AddTool(this IServiceCollection services) where T : class, IChatTool + { + services.AddSingleton(); + + return services; + } + + public static IServiceCollection AddOpenAIChat(this IServiceCollection services, IConfiguration config, Action? configure = null, + string configPath = "chatbot:openai") + { + services.Configure(config, configPath, configure); + + services.AddAI(); + services.AddSingletonAs() + .As().AsSelf(); + + return services; + } +} diff --git a/ai/Squidex.AI/ChatBotResponse.cs b/ai/Squidex.AI/ChatBotResponse.cs deleted file mode 100644 index bb352ab..0000000 --- a/ai/Squidex.AI/ChatBotResponse.cs +++ /dev/null @@ -1,32 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -#pragma warning disable SA1313 // Parameter names should begin with lower-case letter -#pragma warning disable MA0048 // File name must match type name - -namespace Squidex.AI; - -public sealed record ChatBotResponse(string Text, ChatBotResult Result) -{ - public decimal EstimatedCostsInEUR { get; init; } - - public static ChatBotResponse Success(string text) - { - return new ChatBotResponse(text, ChatBotResult.Success); - } - - public static ChatBotResponse Failed(string text) - { - return new ChatBotResponse(text, ChatBotResult.Failed); - } -} - -public enum ChatBotResult -{ - Success, - Failed, -} diff --git a/ai/Squidex.AI/SemanticKernel/RagPipelineOptions.cs b/ai/Squidex.AI/ChatConfiguration.cs similarity index 69% rename from ai/Squidex.AI/SemanticKernel/RagPipelineOptions.cs rename to ai/Squidex.AI/ChatConfiguration.cs index f4006e4..f614228 100644 --- a/ai/Squidex.AI/SemanticKernel/RagPipelineOptions.cs +++ b/ai/Squidex.AI/ChatConfiguration.cs @@ -5,9 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.AI.SemanticKernel; +namespace Squidex.AI; -public sealed class RagPipelineOptions +public sealed class ChatConfiguration { - public List> StepFactories { get; } = []; + public string[]? SystemMessages { get; set; } + + public string[]? Tools { get; set; } } diff --git a/ai/Squidex.AI/ChatContext.cs b/ai/Squidex.AI/ChatContext.cs new file mode 100644 index 0000000..a4a0b98 --- /dev/null +++ b/ai/Squidex.AI/ChatContext.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Security.Claims; + +namespace Squidex.AI; + +public class ChatContext +{ + public ClaimsPrincipal? User { get; set; } = ClaimsPrincipal.Current; + + public Dictionary Data { get; } = []; +} diff --git a/ai/Squidex.AI/ChatEvent.cs b/ai/Squidex.AI/ChatEvent.cs new file mode 100644 index 0000000..a5d683e --- /dev/null +++ b/ai/Squidex.AI/ChatEvent.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +#pragma warning disable MA0048 // File name must match type name + +namespace Squidex.AI; + +public abstract record InternalChatEvent +{ +} + +public abstract record ChatEvent : InternalChatEvent +{ +} + +public sealed record ToolStartEvent : ChatEvent +{ + required public IChatTool Tool { get; init; } +} + +public sealed record ToolEndEvent : ChatEvent +{ + required public IChatTool Tool { get; init; } +} + +public sealed record MetadataEvent : ChatEvent +{ + required public ChatMetadata Metadata { get; init; } +} + +public sealed record ChunkEvent : ChatEvent +{ + required public string Content { get; init; } +} + +public sealed record ChatFinishEvent : InternalChatEvent +{ + required public int NumInputTokens { get; init; } + + required public int NumOutputTokens { get; init; } +} diff --git a/ai/Squidex.AI/ChatHistory.cs b/ai/Squidex.AI/ChatHistory.cs new file mode 100644 index 0000000..849205e --- /dev/null +++ b/ai/Squidex.AI/ChatHistory.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.AI; + +public sealed class ChatHistory : List +{ + public void Add(string content, ChatMessageType type) + { + Add(new ChatMessage { Content = content, Type = type }); + } +} diff --git a/ai/Squidex.AI/ChatMessage.cs b/ai/Squidex.AI/ChatMessage.cs new file mode 100644 index 0000000..694fd65 --- /dev/null +++ b/ai/Squidex.AI/ChatMessage.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +#pragma warning disable MA0048 // File name must match type name + +namespace Squidex.AI; + +public sealed class ChatMessage +{ + public string Content { get; set; } + + public int TokenCount { get; set; } + + public ChatMessageType Type { get; set; } +} + +public enum ChatMessageType +{ + System, + Assistant, + User +} diff --git a/ai/Squidex.AI/SemanticKernel/Mongo/MongoChatEntity.cs b/ai/Squidex.AI/ChatMetadata.cs similarity index 66% rename from ai/Squidex.AI/SemanticKernel/Mongo/MongoChatEntity.cs rename to ai/Squidex.AI/ChatMetadata.cs index 58534c9..fd6f21d 100644 --- a/ai/Squidex.AI/SemanticKernel/Mongo/MongoChatEntity.cs +++ b/ai/Squidex.AI/ChatMetadata.cs @@ -5,13 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.AI.SemanticKernel.Mongo; +namespace Squidex.AI; -public sealed class MongoChatEntity +public sealed record ChatMetadata { - public string Id { get; set; } + public decimal CostsInEUR { get; init; } - public string Value { get; set; } + public int NumInputTokens { get; init; } - public DateTime Expires { get; set; } + public int NumOutputTokens { get; init; } } diff --git a/ai/Squidex.AI/SemanticKernel/OpenAIChatBotOptions.cs b/ai/Squidex.AI/ChatOptions.cs similarity index 56% rename from ai/Squidex.AI/SemanticKernel/OpenAIChatBotOptions.cs rename to ai/Squidex.AI/ChatOptions.cs index fcb1112..4c1aa6b 100644 --- a/ai/Squidex.AI/SemanticKernel/OpenAIChatBotOptions.cs +++ b/ai/Squidex.AI/ChatOptions.cs @@ -5,23 +5,17 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.AI.SemanticKernel; +namespace Squidex.AI; -public sealed class OpenAIChatBotOptions +public sealed class ChatOptions { - public string[]? SystemMessages { get; set; } + public ChatConfiguration? Defaults { get; set; } - public int? MaxAnswerTokens { get; set; } - - public int MaxContextLength { get; set; } = 4000; - - public int CharactersPerToken { get; set; } = 5; - - public double? Temperature { get; set; } + public Dictionary Configurations { get; set; } = []; public decimal PricePerInputTokenInEUR { get; set; } = 0.003m / 1000; public decimal PricePerOutputTokenInEUR { get; set; } = 0.004m / 1000; - public TimeSpan ConversationLifetime { get; set; } = TimeSpan.FromDays(3); + public Dictionary ToolCostsInEur { get; set; } = []; } diff --git a/ai/Squidex.AI/ChatRequest.cs b/ai/Squidex.AI/ChatRequest.cs new file mode 100644 index 0000000..b6ad3e6 --- /dev/null +++ b/ai/Squidex.AI/ChatRequest.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.AI; + +public sealed class ChatRequest +{ + public string? Prompt { get; init; } + + public string? ConversationId { get; init; } + + public string? Configuration { get; init; } + + public string? Tool { get; init; } +} diff --git a/ai/Squidex.AI/SemanticKernel/MemoryStoreStepOptions.cs b/ai/Squidex.AI/ChatResult.cs similarity index 68% rename from ai/Squidex.AI/SemanticKernel/MemoryStoreStepOptions.cs rename to ai/Squidex.AI/ChatResult.cs index f28260f..835b051 100644 --- a/ai/Squidex.AI/SemanticKernel/MemoryStoreStepOptions.cs +++ b/ai/Squidex.AI/ChatResult.cs @@ -5,11 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.AI.SemanticKernel; +namespace Squidex.AI; -public sealed class MemoryStoreStepOptions +public sealed record ChatResult { - public string CollectionName { get; set; } + required public ChatMetadata Metadata { get; init; } - public bool WithEmbeddings { get; set; } + required public string Content { get; init; } } diff --git a/ai/Squidex.AI/IChatAgent.cs b/ai/Squidex.AI/IChatAgent.cs index f05ad88..70d1e2e 100644 --- a/ai/Squidex.AI/IChatAgent.cs +++ b/ai/Squidex.AI/IChatAgent.cs @@ -14,6 +14,9 @@ public interface IChatAgent Task StopConversationAsync(string conversationId, CancellationToken ct = default); - Task PromptAsync(string prompt, string? conversationId = null, + Task PromptAsync(ChatRequest request, ChatContext? context = null, + CancellationToken ct = default); + + IAsyncEnumerable StreamAsync(ChatRequest request, ChatContext? context = null, CancellationToken ct = default); } diff --git a/ai/Squidex.AI/IChatTool.cs b/ai/Squidex.AI/IChatTool.cs new file mode 100644 index 0000000..c84e04a --- /dev/null +++ b/ai/Squidex.AI/IChatTool.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.AI; + +public interface IChatTool +{ + ToolSpec Spec { get; } + + Task ExecuteAsync(IChatAgent agent, ChatContext context, Dictionary arguments, + CancellationToken ct); +} diff --git a/ai/Squidex.AI/IEmbeddings.cs b/ai/Squidex.AI/IEmbeddings.cs new file mode 100644 index 0000000..c5c5cf7 --- /dev/null +++ b/ai/Squidex.AI/IEmbeddings.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.AI; + +public interface IEmbeddings +{ + Task> CalculateEmbeddingsAsync(string query, + CancellationToken ct); +} diff --git a/ai/Squidex.AI/Implementation/ChatAgent.cs b/ai/Squidex.AI/Implementation/ChatAgent.cs new file mode 100644 index 0000000..338920a --- /dev/null +++ b/ai/Squidex.AI/Implementation/ChatAgent.cs @@ -0,0 +1,208 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace Squidex.AI.Implementation; + +public sealed class ChatAgent : IChatAgent +{ + private readonly ChatOptions options; + private readonly IChatProvider chatProvider; + private readonly IChatStore chatStore; + private readonly List chatTools; + + public bool IsConfigured => chatProvider is not NoopChatProvider; + + public ChatAgent( + IOptions options, + IChatProvider chatProvider, + IChatStore chatStore, + IEnumerable chatTools) + { + this.options = options.Value; + this.chatProvider = chatProvider; + this.chatStore = chatStore; + this.chatTools = chatTools.ToList(); + } + + public Task StopConversationAsync(string conversationId, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + + return chatStore.RemoveAsync(conversationId, ct); + } + + public async Task PromptAsync(ChatRequest request, ChatContext? context = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Prompt); + + var streamMeta = new ChatMetadata(); + var streamContent = new StringBuilder(); + + await foreach (var message in StreamAsync(request, context, ct)) + { + switch (message) + { + case ChunkEvent c: + streamContent.Append(c.Content); + break; + case MetadataEvent m: + streamMeta = m.Metadata; + break; + } + } + + return new ChatResult { Content = streamContent.ToString(), Metadata = streamMeta }; + } + + public IAsyncEnumerable StreamAsync(ChatRequest request, ChatContext? context = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Prompt); + + return StreamCoreAsync(request, context, ct); + } + + private async IAsyncEnumerable StreamCoreAsync(ChatRequest request, ChatContext? context = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + if (!IsConfigured) + { + yield break; + } + + context ??= new ChatContext(); + + ChatConfiguration? configuration = null; + if (request.Configuration != null) + { + options.Configurations?.TryGetValue(request.Configuration, out configuration); + } + + configuration ??= options.Defaults; + configuration ??= new ChatConfiguration(); + + var history = await GetOrCreateConversationAsync(request, configuration, ct); + + var providerRequest = new ChatProviderRequest + { + Agent = this, + Context = context, + Tools = GetTools(configuration), + Tool = request.Tool, + History = history, + }; + + var streamCosts = 0m; + var streamContent = new StringBuilder(); + + await foreach (var @event in chatProvider.StreamAsync(providerRequest, ct).WithCancellation(ct)) + { + if (@event is ChatFinishEvent f) + { + streamCosts += f.NumInputTokens * options.PricePerInputTokenInEUR; + streamCosts += f.NumOutputTokens * options.PricePerOutputTokenInEUR; + + yield return new MetadataEvent + { + Metadata = new ChatMetadata + { + CostsInEUR = streamCosts, + NumInputTokens = f.NumInputTokens, + NumOutputTokens = f.NumOutputTokens, + } + }; + } + + if (@event is ChunkEvent chunkEvent) + { + streamContent.Append(chunkEvent.Content); + } + + if (@event is ToolStartEvent toolStart) + { + if (options.ToolCostsInEur?.TryGetValue(toolStart.Tool.Spec.DisplayName, out var costs) == true) + { + streamCosts += costs; + } + } + + if (@event is ChatEvent publicEvent) + { + yield return publicEvent; + } + } + + history.Add(streamContent.ToString(), ChatMessageType.Assistant); + + if (request.ConversationId != null) + { + await StoreHistoryAsync(request.ConversationId, history); + } + } + + private List GetTools(ChatConfiguration configuration) + { + var tools = chatTools; + + if (configuration.Tools != null) + { + tools = tools.Where(x => configuration.Tools.Contains(x.Spec.Name)).ToList(); + } + + return tools; + } + + private async Task StoreHistoryAsync(string conversationId, ChatHistory history) + { + await chatStore.StoreAsync(conversationId, JsonSerializer.Serialize(history), default); + } + + private async Task GetOrCreateConversationAsync(ChatRequest request, ChatConfiguration configuration, + CancellationToken ct) + { + ChatHistory? history = null; + + if (request.ConversationId != null) + { + var stored = await chatStore.GetAsync(request.ConversationId, ct); + if (stored != null) + { + history = JsonSerializer.Deserialize(stored) ?? + throw new ChatException($"Cannot deserialize conversion with ID '{request.ConversationId}'."); + } + } + + if (history == null) + { + history = []; + + if (configuration.SystemMessages != null) + { + foreach (var systemMessage in configuration.SystemMessages) + { + history.Add(systemMessage, ChatMessageType.System); + } + } + } + + if (!string.IsNullOrWhiteSpace(request.Prompt)) + { + history.Add(request.Prompt, ChatMessageType.User); + } + + return history; + } +} diff --git a/ai/Squidex.AI/SemanticKernel/RagPipelineContext.cs b/ai/Squidex.AI/Implementation/ChatProviderRequest.cs similarity index 52% rename from ai/Squidex.AI/SemanticKernel/RagPipelineContext.cs rename to ai/Squidex.AI/Implementation/ChatProviderRequest.cs index 7aa0593..2d31da1 100644 --- a/ai/Squidex.AI/SemanticKernel/RagPipelineContext.cs +++ b/ai/Squidex.AI/Implementation/ChatProviderRequest.cs @@ -5,19 +5,17 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Microsoft.SemanticKernel; +namespace Squidex.AI.Implementation; -namespace Squidex.AI.SemanticKernel; - -public sealed class RagPipelineContext +public sealed class ChatProviderRequest { - required public string Query { get; set; } + required public ChatContext Context { get; init; } - public int Limit { get; set; } = 10; + required public ChatHistory History { get; init; } - public float MinRelevanceScore { get; set; } + required public List Tools { get; init; } - public ReadOnlyMemory Embedding { get; set; } + required public string? Tool { get; set; } - public Kernel? Kernel { get; set; } -} \ No newline at end of file + required public IChatAgent Agent { get; init; } +} diff --git a/ai/Squidex.AI/Implementation/IChatProvider.cs b/ai/Squidex.AI/Implementation/IChatProvider.cs new file mode 100644 index 0000000..aafe51e --- /dev/null +++ b/ai/Squidex.AI/Implementation/IChatProvider.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.AI.Implementation; + +public interface IChatProvider +{ + IAsyncEnumerable StreamAsync(ChatProviderRequest request, + CancellationToken ct = default); +} diff --git a/ai/Squidex.AI/SemanticKernel/IChatStore.cs b/ai/Squidex.AI/Implementation/IChatStore.cs similarity index 84% rename from ai/Squidex.AI/SemanticKernel/IChatStore.cs rename to ai/Squidex.AI/Implementation/IChatStore.cs index 59fa048..1818617 100644 --- a/ai/Squidex.AI/SemanticKernel/IChatStore.cs +++ b/ai/Squidex.AI/Implementation/IChatStore.cs @@ -5,14 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.AI.SemanticKernel; +namespace Squidex.AI.Implementation; public interface IChatStore { Task RemoveAsync(string conversationId, CancellationToken ct); - Task StoreAsync(string conversationId, string value, DateTime expires, + Task StoreAsync(string conversationId, string value, CancellationToken ct); Task GetAsync(string conversationId, diff --git a/ai/Squidex.AI/SemanticKernel/InMemoryChatStore.cs b/ai/Squidex.AI/Implementation/MemoryChatStore.cs similarity index 85% rename from ai/Squidex.AI/SemanticKernel/InMemoryChatStore.cs rename to ai/Squidex.AI/Implementation/MemoryChatStore.cs index 5492608..07e712e 100644 --- a/ai/Squidex.AI/SemanticKernel/InMemoryChatStore.cs +++ b/ai/Squidex.AI/Implementation/MemoryChatStore.cs @@ -7,9 +7,9 @@ using System.Collections.Concurrent; -namespace Squidex.AI.SemanticKernel; +namespace Squidex.AI.Implementation; -public sealed class InMemoryChatStore : IChatStore +public sealed class MemoryChatStore : IChatStore { private readonly ConcurrentDictionary values = new ConcurrentDictionary(); @@ -27,7 +27,7 @@ public Task RemoveAsync(string conversationId, return Task.FromResult(result); } - public Task StoreAsync(string conversationId, string value, DateTime expires, + public Task StoreAsync(string conversationId, string value, CancellationToken ct) { values[conversationId] = value; diff --git a/ai/Squidex.AI/Implementation/NoopChatProvider.cs b/ai/Squidex.AI/Implementation/NoopChatProvider.cs new file mode 100644 index 0000000..599c5e3 --- /dev/null +++ b/ai/Squidex.AI/Implementation/NoopChatProvider.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.AI.Implementation; + +public sealed class NoopChatProvider : IChatProvider +{ + public IAsyncEnumerable StreamAsync(ChatProviderRequest request, + CancellationToken ct = default) + { + return AsyncEnumerable.Empty(); + } +} diff --git a/ai/Squidex.AI/Implementation/OpenAI/DallETool.cs b/ai/Squidex.AI/Implementation/OpenAI/DallETool.cs new file mode 100644 index 0000000..94acd5e --- /dev/null +++ b/ai/Squidex.AI/Implementation/OpenAI/DallETool.cs @@ -0,0 +1,97 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Options; +using OpenAI.Managers; +using OpenAI.ObjectModels.RequestModels; +using Squidex.Assets; + +namespace Squidex.AI.Implementation.OpenAI; + +public sealed class DallETool : IChatTool +{ + private readonly OpenAIService service; + private readonly OpenAIOptions options; + private readonly IAssetStore assetStore; + private readonly IHttpImageEndpoint httpImageEndpoint; + private readonly IHttpClientFactory httpClientFactory; + + public ToolSpec Spec { get; } = + new ToolSpec("dall-e", "Dall-E", "Generates images based on queries.") + { + Arguments = + { + ["query"] = new ToolStringArgumentSpec("The query.") + { + IsRequired = true + } + } + }; + + public DallETool( + IOptions options, + IAssetStore assetStore, + IHttpImageEndpoint httpImageEndpoint, + IHttpClientFactory httpClientFactory) + { + service = new OpenAIService(options.Value); + + this.options = options.Value; + this.assetStore = assetStore; + this.httpImageEndpoint = httpImageEndpoint; + this.httpClientFactory = httpClientFactory; + } + + public async Task ExecuteAsync(IChatAgent agent, ChatContext context, Dictionary arguments, + CancellationToken ct) + { + if (!arguments.TryGetValue("query", out var queryArg)) + { + throw new ChatException("Missing argument 'query'."); + } + + var query = queryArg.ToString(); + + var request = new ImageCreateRequest + { + Prompt = query + }; + + var response = await service.Image.CreateImage(request, ct); + + if (response.Error != null) + { + throw new ChatException($"Request failed with internal error: {response.Error.Message}. HTTP {response.HttpStatusCode}"); + } + + if (!response.Successful) + { + throw new ChatException($"Request failed with unknown error. HTTP {response.HttpStatusCode}"); + } + + var url = response.Results[0].Url; + + if (!options.DownloadImage) + { + return url; + } + + var imageId = Guid.NewGuid().ToString(); + + var imagePath = options.ImagePathPattern.Replace("{IMAGE_ID}", imageId, StringComparison.Ordinal); + var imageUrl = httpImageEndpoint.GetUrl(imagePath); + + using var httpClient = httpClientFactory.CreateClient(url); + + await using (var httpResponse = await httpClient.GetStreamAsync(url, ct)) + { + await assetStore.UploadAsync(imagePath, httpResponse, true, ct); + } + + return imageUrl; + } +} diff --git a/ai/Squidex.AI/Implementation/OpenAI/Helper.cs b/ai/Squidex.AI/Implementation/OpenAI/Helper.cs new file mode 100644 index 0000000..b3f0bdb --- /dev/null +++ b/ai/Squidex.AI/Implementation/OpenAI/Helper.cs @@ -0,0 +1,133 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Text.Json; +using System.Text.Json.Nodes; +using OpenAI.Builders; +using OpenAI.ObjectModels.RequestModels; +using OpenAI.ObjectModels.SharedModels; +using OpenAIMessage = OpenAI.ObjectModels.RequestModels.ChatMessage; + +namespace Squidex.AI.Implementation.OpenAI; + +internal static class Helper +{ + public static void AddRange(this IList list, IEnumerable items) + { + foreach (var item in items) + { + list.Add(item); + } + } + + public static ToolDefinition ToOpenAITool(this ToolSpec spec, string toolName) + { + var builder = new FunctionDefinitionBuilder(toolName, spec.Description); + + foreach (var (name, argument) in spec.Arguments) + { + PropertyDefinition property; + switch (argument) + { + case ToolStringArgumentSpec: + property = PropertyDefinition.DefineString(argument.Description); + break; + case ToolNumberArgumentSpec: + property = PropertyDefinition.DefineNumber(argument.Description); + break; + case ToolEnumArgumentSpec enumArg: + property = PropertyDefinition.DefineEnum(enumArg.Values.ToList(), argument.Description); + break; + default: + throw new InvalidOperationException("Invalid property tpye."); + } + + builder.AddParameter(name, property); + } + + return ToolDefinition.DefineFunction(builder.Build()); + } + + public static OpenAIMessage ToOpenAIMessage(this ChatMessage message) + { + switch (message.Type) + { + case ChatMessageType.System: + return OpenAIMessage.FromSystem(message.Content); + case ChatMessageType.Assistant: + return OpenAIMessage.FromAssistant(message.Content); + default: + return OpenAIMessage.FromUser(message.Content); + } + } + + public static Dictionary ParseArguments(this FunctionCall call, ToolSpec spec) + { + var result = new Dictionary(); + + if (string.IsNullOrWhiteSpace(call.Arguments)) + { + return result; + } + + if (JsonNode.Parse(call.Arguments) is not JsonObject values) + { + throw new ChatException("Argument is not an object."); + } + + foreach (var (name, argument) in spec.Arguments) + { + values.TryGetPropertyValue(name, out var value); + + if (value == null || value.GetValueKind() == JsonValueKind.Null) + { + if (argument.IsRequired) + { + throw new ChatException($"Parameter '{name}' is not part of the arguments, but required."); + } + + result[name] = ToolValue.Null; + continue; + } + + var kind = value.GetValueKind(); + + ToolValue parsed; + switch (argument) + { + case ToolBooleanArgumentSpec _ when kind == JsonValueKind.True: + parsed = new ToolBooleanValue(true); + break; + case ToolBooleanArgumentSpec _ when kind == JsonValueKind.False: + parsed = new ToolBooleanValue(false); + break; + case ToolNumberArgumentSpec _ when kind == JsonValueKind.Number: + parsed = new ToolNumberValue((double)value.AsValue()!); + break; + case ToolStringArgumentSpec _ when kind == JsonValueKind.String: + parsed = new ToolStringValue((string)value.AsValue()!); + break; + case ToolEnumArgumentSpec stringArg when kind == JsonValueKind.String: + var stringValue = (string)value.AsValue()!; + + if (!stringArg.Values.Contains(stringValue)) + { + throw new ChatException($"Unexpected value '{stringValue}' for argument '{name}'."); + } + + parsed = new ToolStringValue(stringValue); + break; + default: + throw new ChatException($"Unexpected kind '{kind}' for argument '{name}'. Expected string or number."); + } + + result[name] = parsed; + } + + return result; + } +} diff --git a/ai/Squidex.AI/SemanticKernel/CohereRerankOptions.cs b/ai/Squidex.AI/Implementation/OpenAI/IHttpImageEndpoint.cs similarity index 75% rename from ai/Squidex.AI/SemanticKernel/CohereRerankOptions.cs rename to ai/Squidex.AI/Implementation/OpenAI/IHttpImageEndpoint.cs index 669a190..775fa7c 100644 --- a/ai/Squidex.AI/SemanticKernel/CohereRerankOptions.cs +++ b/ai/Squidex.AI/Implementation/OpenAI/IHttpImageEndpoint.cs @@ -5,9 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.AI.SemanticKernel; +namespace Squidex.AI.Implementation.OpenAI; -public sealed class CohereRerankOptions +public interface IHttpImageEndpoint { - required public string ApiKey { get; set; } + string GetUrl(string relativePath); } diff --git a/ai/Squidex.AI/Implementation/OpenAI/OpenAIChatProvider.cs b/ai/Squidex.AI/Implementation/OpenAI/OpenAIChatProvider.cs new file mode 100644 index 0000000..ee6531f --- /dev/null +++ b/ai/Squidex.AI/Implementation/OpenAI/OpenAIChatProvider.cs @@ -0,0 +1,221 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Globalization; +using System.Reactive.Linq; +using Microsoft.Extensions.Options; +using OpenAI.Managers; +using OpenAI.ObjectModels.RequestModels; +using OpenAIMessage = OpenAI.ObjectModels.RequestModels.ChatMessage; + +namespace Squidex.AI.Implementation.OpenAI; + +public sealed class OpenAIChatProvider : IChatProvider +{ + private const int CharacterPerToken = 4; + private const int MaxToolRuns = 5; + private readonly StreamOptions streamOptions = new StreamOptions { IncludeUsage = true }; + private readonly OpenAIOptions options; + private readonly OpenAIService service; + + public OpenAIChatProvider(IOptions options) + { + service = new OpenAIService(options.Value); + + this.options = options.Value; + } + + public IAsyncEnumerable StreamAsync(ChatProviderRequest request, + CancellationToken ct = default) + { + var internalRequest = new ChatCompletionCreateRequest + { + MaxTokens = options.MaxTokens, + Messages = ConvertHistory(request), + Model = options.Model, + N = 1, + Seed = options.Seed, + Stream = true, + StreamOptions = streamOptions, + Temperature = options.Temperature, + }; + + var index = 0; + foreach (var tool in request.Tools) + { + var toolName = index.ToString(CultureInfo.InvariantCulture); + + internalRequest.Tools ??= []; + internalRequest.Tools.Add(tool.Spec.ToOpenAITool(toolName)); + index++; + } + + if (internalRequest.Tools?.Count > 0) + { + internalRequest.ToolChoice = ConvertTool(request); + } + + var stream = RequestCoreAsync(request, internalRequest, ct); + + return stream.ToAsyncEnumerable(); + } + + private IObservable RequestCoreAsync(ChatProviderRequest request, ChatCompletionCreateRequest internalRequest, + CancellationToken ct) + { + return Observable.Create(async observer => + { + await RequestCoreAsync(request, internalRequest, observer, ct); + }); + } + + private async Task RequestCoreAsync(ChatProviderRequest request, ChatCompletionCreateRequest internalRequest, IObserver observer, + CancellationToken ct) + { + var numInputTokens = 0; + var numOutputTokens = 0; + + void EmitMetadata() + { + observer.OnNext(new ChatFinishEvent + { + NumInputTokens = numInputTokens, + NumOutputTokens = numOutputTokens + }); + } + + try + { + for (var run = 1; run <= MaxToolRuns; run++) + { + var stream = service.ChatCompletion.CreateCompletionAsStream(internalRequest, cancellationToken: ct); + + var isToolCall = false; + await foreach (var response in stream.WithCancellation(ct)) + { + if (response.Error != null) + { + throw new ChatException($"Request failed with internal error: {response.Error.Message}. HTTP {response.HttpStatusCode}"); + } + + if (!response.Successful) + { + throw new ChatException($"Request failed with unknown error. HTTP {response.HttpStatusCode}"); + } + + if (response.Usage != null) + { + numInputTokens += response.Usage.PromptTokens; + numOutputTokens += response.Usage.CompletionTokens ?? 0; + } + + var choice = response.Choices.FirstOrDefault()?.Message; + + if (choice == null) + { + continue; + } + + if (choice.ToolCalls is not { Count: > 0 }) + { + if (!string.IsNullOrEmpty(choice.Content)) + { + observer.OnNext(new ChunkEvent { Content = choice.Content }); + } + } + else if (run == MaxToolRuns) + { + throw new ChatException($"Exceeded max tool runs."); + } + else + { + // Only continue with the outer loop if we have a tool call. + isToolCall = true; + + var toolsResults = await ExecuteToolsAsync(request, observer, choice, ct); + + internalRequest.Messages.Add(choice); + internalRequest.Messages.AddRange(toolsResults); + } + } + + if (!isToolCall) + { + break; + } + } + + EmitMetadata(); + observer.OnCompleted(); + } + catch (Exception ex) + { + EmitMetadata(); + observer.OnError(ex); + } + } + + private static async Task ExecuteToolsAsync(ChatProviderRequest request, IObserver observer, OpenAIMessage choice, CancellationToken ct) + { + var validCalls = new List<(IChatTool Tool, int Index, string Id, FunctionCall Call)>(); + + var i = 0; + foreach (var call in choice.ToolCalls!) + { + var toolName = call.FunctionCall?.Name; + + if (string.IsNullOrWhiteSpace(call.FunctionCall?.Name)) + { + throw new ChatException($"Undefined tool name '{toolName}'."); + } + + if (!int.TryParse(toolName, CultureInfo.InvariantCulture, out var toolIndex)) + { + throw new ChatException($"Invalid tool name '{toolName}'."); + } + + var tool = request.Tools.ElementAtOrDefault(toolIndex) + ?? throw new ChatException($"Unknown tool name '{toolName}'."); + + validCalls.Add((tool, i++, call.Id!, call.FunctionCall)); + } + + var results = new OpenAIMessage[validCalls.Count]; + + // Run all the tools in parallel, because they could take long time potentially. + await Parallel.ForEachAsync(validCalls, ct, async (job, ct) => + { + observer.OnNext(new ToolStartEvent { Tool = job.Tool }); + try + { + var args = job.Call.ParseArguments(job.Tool.Spec); + + var result = await job.Tool.ExecuteAsync(request.Agent, request.Context, args, ct); + + results[job.Index] = OpenAIMessage.FromTool(result, job.Id); + } + finally + { + observer.OnNext(new ToolEndEvent { Tool = job.Tool }); + } + }); + + return results; + } + + private static ToolChoice ConvertTool(ChatProviderRequest request) + { + return request.Tool != null ? + ToolChoice.FunctionChoice(request.Tool) : + ToolChoice.Auto; + } + + private static List ConvertHistory(ChatProviderRequest request) + { + return request.History.Select(x => x.ToOpenAIMessage()).ToList(); + } +} diff --git a/ai/Squidex.AI/Implementation/OpenAI/OpenAIOptions.cs b/ai/Squidex.AI/Implementation/OpenAI/OpenAIOptions.cs new file mode 100644 index 0000000..2a84d0f --- /dev/null +++ b/ai/Squidex.AI/Implementation/OpenAI/OpenAIOptions.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using OpenAI; +using OpenAI.ObjectModels; + +namespace Squidex.AI.Implementation.OpenAI; + +public sealed class OpenAIOptions : OpenAiOptions +{ + public string Model { get; set; } = Models.Gpt_3_5_Turbo; + + public int? MaxTokens { get; set; } + + public int CharactersPerToken { get; set; } = 5; + + public int? Seed { get; set; } + + public float? Temperature { get; set; } + + public string ImagePathPattern { get; set; } = "dall-e/{IMAGE_ID}"; + + public bool DownloadImage { get; set; } = false; +} diff --git a/ai/Squidex.AI/Properties/AssemblyInfo.cs b/ai/Squidex.AI/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..74a06d3 --- /dev/null +++ b/ai/Squidex.AI/Properties/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Squidex.AI.Tests")] diff --git a/ai/Squidex.AI/SemanticKernel/CalculateEmbeddingsStep.cs b/ai/Squidex.AI/SemanticKernel/CalculateEmbeddingsStep.cs deleted file mode 100644 index fced603..0000000 --- a/ai/Squidex.AI/SemanticKernel/CalculateEmbeddingsStep.cs +++ /dev/null @@ -1,33 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Runtime.CompilerServices; -using Microsoft.SemanticKernel.Embeddings; -using Microsoft.SemanticKernel.Memory; - -namespace Squidex.AI.SemanticKernel; - -internal sealed class CalculateEmbeddingsStep : IRagPipelineStep -{ - private readonly ITextEmbeddingGenerationService textEmbeddingGenerationService; - - public CalculateEmbeddingsStep(ITextEmbeddingGenerationService textEmbeddingGenerationService) - { - this.textEmbeddingGenerationService = textEmbeddingGenerationService; - } - - public async IAsyncEnumerable ProcessAsync(RagPipelineContext context, IAsyncEnumerable source, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - context.Embedding = await textEmbeddingGenerationService.GenerateEmbeddingAsync(context.Query, context.Kernel, cancellationToken); - - await foreach (var result in source.WithCancellation(cancellationToken)) - { - yield return result; - } - } -} diff --git a/ai/Squidex.AI/SemanticKernel/CohereRerankStep.cs b/ai/Squidex.AI/SemanticKernel/CohereRerankStep.cs deleted file mode 100644 index 3e23403..0000000 --- a/ai/Squidex.AI/SemanticKernel/CohereRerankStep.cs +++ /dev/null @@ -1,115 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Net.Http.Json; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel.Memory; - -namespace Squidex.AI.SemanticKernel; - -public sealed class CohereRerankStep : IRagPipelineStep -{ - private readonly CohereRerankOptions options; - private readonly IHttpClientFactory httpClientFactory; - - private sealed class RerankResponse - { - [JsonPropertyName("results")] - required public RerankResult[] Results { get; set; } - } - - private sealed class RerankResult - { - [JsonPropertyName("key")] - public RerankDocument? Document { get; set; } - - [JsonPropertyName("index")] - public int Index { get; set; } - - [JsonPropertyName("relevance_score")] - public float RelevanceScore { get; set; } - } - - private sealed class RerankDocument - { - [JsonPropertyName("id")] - required public string Id { get; set; } - - [JsonPropertyName("text")] - required public string Text { get; set; } - } - - public CohereRerankStep(IHttpClientFactory httpClientFactory, IOptionsFactory optionsFactory, string name) - { - this.httpClientFactory = httpClientFactory; - - options = optionsFactory.Create(name); - } - - public async IAsyncEnumerable ProcessAsync(RagPipelineContext context, IAsyncEnumerable source, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - var sourceTexts = new List(); - var sourceResults = new List(); - - await foreach (var result in source.WithCancellation(cancellationToken)) - { - var metadata = (JsonObject)JsonNode.Parse(result.Metadata.AdditionalMetadata)!; - - if (metadata.TryGetPropertyValue("text", out var text) && text?.GetValueKind() == JsonValueKind.String) - { - sourceTexts.Add(new { text = text.ToJsonString(), id = result.Metadata.Id }); - } - - sourceResults.Add(result); - } - - if (sourceTexts.Count == 0 || sourceTexts.Count != sourceResults.Count) - { - foreach (var item in sourceResults) - { - yield return item; - } - - yield break; - } - - var body = new - { - context, - documents = sourceTexts - }; - - using var httpClient = GetClient(); - using var httpResponse = await httpClient.PostAsJsonAsync("https://api.cohere.ai/v1/rerank", body, cancellationToken); - - httpResponse.EnsureSuccessStatusCode(); - - var response = await httpResponse.Content.ReadFromJsonAsync(cancellationToken) - ?? throw new InvalidOperationException("Failed to deserialize response"); - - foreach (var item in response.Results) - { - var record = sourceResults[item.Index]; - - yield return new MemoryQueryResult(record.Metadata, item.RelevanceScore, record.Embedding); - } - } - - private HttpClient GetClient() - { - var httpClient = httpClientFactory.CreateClient(); - - httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {options.ApiKey}"); - - return httpClient; - } -} diff --git a/ai/Squidex.AI/SemanticKernel/IRagPipeline.cs b/ai/Squidex.AI/SemanticKernel/IRagPipeline.cs deleted file mode 100644 index 9a79ffc..0000000 --- a/ai/Squidex.AI/SemanticKernel/IRagPipeline.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.SemanticKernel.Memory; - -namespace Squidex.AI.SemanticKernel; - -public interface IRagPipeline -{ - IAsyncEnumerable SearchAsync(string query, - CancellationToken cancellationToken = default) - { - return SearchAsync(new RagPipelineContext { Query = query }, cancellationToken); - } - - IAsyncEnumerable SearchAsync(RagPipelineContext context, - CancellationToken cancellationToken = default); -} diff --git a/ai/Squidex.AI/SemanticKernel/IRagPipelineStep.cs b/ai/Squidex.AI/SemanticKernel/IRagPipelineStep.cs deleted file mode 100644 index de0861b..0000000 --- a/ai/Squidex.AI/SemanticKernel/IRagPipelineStep.cs +++ /dev/null @@ -1,18 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.SemanticKernel.Memory; - -#pragma warning disable // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -namespace Squidex.AI.SemanticKernel; - -public interface IRagPipelineStep -{ - IAsyncEnumerable ProcessAsync(RagPipelineContext context, IAsyncEnumerable source, - CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/ai/Squidex.AI/SemanticKernel/MemoryStoreStep.cs b/ai/Squidex.AI/SemanticKernel/MemoryStoreStep.cs deleted file mode 100644 index 5147d47..0000000 --- a/ai/Squidex.AI/SemanticKernel/MemoryStoreStep.cs +++ /dev/null @@ -1,46 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Runtime.CompilerServices; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel.Memory; - -namespace Squidex.AI.SemanticKernel; - -public sealed class MemoryStoreStep : IRagPipelineStep -{ - private readonly IMemoryStore memoryStore; - private readonly MemoryStoreStepOptions options; - - public MemoryStoreStep(IOptionsFactory optionsFactory, string name, IMemoryStore memoryStore) - { - options = optionsFactory.Create(name); - - this.memoryStore = memoryStore; - } - - public async IAsyncEnumerable ProcessAsync(RagPipelineContext context, IAsyncEnumerable source, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - if (context.Embedding.Length == 0) - { - throw new InvalidOperationException("Embedding has not been calculated yet."); - } - - var records = memoryStore.GetNearestMatchesAsync(options.CollectionName, - context.Embedding, - context.Limit, - context.MinRelevanceScore, - options.WithEmbeddings, - cancellationToken); - - await foreach (var (record, relevance) in records.WithCancellation(cancellationToken)) - { - yield return MemoryQueryResult.FromMemoryRecord(record, relevance); - } - } -} diff --git a/ai/Squidex.AI/SemanticKernel/Mongo/MongoChatStore.cs b/ai/Squidex.AI/SemanticKernel/Mongo/MongoChatStore.cs deleted file mode 100644 index 90dd159..0000000 --- a/ai/Squidex.AI/SemanticKernel/Mongo/MongoChatStore.cs +++ /dev/null @@ -1,64 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.Options; -using MongoDB.Driver; -using Squidex.Hosting; - -namespace Squidex.AI.SemanticKernel.Mongo; - -public sealed class MongoChatStore : IChatStore, IInitializable -{ - private readonly IMongoCollection collection; - - public MongoChatStore(IMongoDatabase database, IOptions options) - { - collection = database.GetCollection(options.Value.CollectionName); - } - - public Task InitializeAsync( - CancellationToken ct) - { - return collection.Indexes.CreateOneAsync( - new CreateIndexModel( - Builders.IndexKeys.Ascending(x => x.Expires), - new CreateIndexOptions - { - ExpireAfter = TimeSpan.Zero - }), - cancellationToken: ct); - } - - public Task RemoveAsync(string conversationId, - CancellationToken ct) - { - return collection.DeleteOneAsync(x => x.Id == conversationId, ct); - } - - public async Task GetAsync(string conversationId, - CancellationToken ct) - { - var result = await collection.Find(x => x.Id == conversationId).FirstOrDefaultAsync(ct); - - return result?.Value; - } - - public Task StoreAsync(string conversationId, string value, DateTime expires, - CancellationToken ct) - { - return collection.UpdateOneAsync(x => x.Id == conversationId, - Builders.Update - .SetOnInsert(x => x.Id, conversationId) - .Set(x => x.Value, value) - .Set(x => x.Expires, expires), - new UpdateOptions - { - IsUpsert = true - }, - ct); - } -} diff --git a/ai/Squidex.AI/SemanticKernel/Mongo/MongoChatStoreOptions.cs b/ai/Squidex.AI/SemanticKernel/Mongo/MongoChatStoreOptions.cs deleted file mode 100644 index 39fb6e7..0000000 --- a/ai/Squidex.AI/SemanticKernel/Mongo/MongoChatStoreOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Hosting.Configuration; - -namespace Squidex.AI.SemanticKernel.Mongo; - -public sealed class MongoChatStoreOptions : IValidatableOptions -{ - public string CollectionName { get; set; } = "Chat"; - - public IEnumerable Validate() - { - if (string.IsNullOrWhiteSpace(CollectionName)) - { - yield return new ConfigurationError("Value is required.", nameof(CollectionName)); - } - } -} diff --git a/ai/Squidex.AI/SemanticKernel/Mongo/MongoSemanticKernelServiceExtensions.cs b/ai/Squidex.AI/SemanticKernel/Mongo/MongoSemanticKernelServiceExtensions.cs deleted file mode 100644 index ece139f..0000000 --- a/ai/Squidex.AI/SemanticKernel/Mongo/MongoSemanticKernelServiceExtensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.Configuration; -using Microsoft.SemanticKernel; -using Squidex.AI.SemanticKernel; -using Squidex.AI.SemanticKernel.Mongo; - -namespace Microsoft.Extensions.DependencyInjection; - -public static class MongoSemanticKernelServiceExtensions -{ - public static IKernelBuilder AddMongoChatStore(this IKernelBuilder builder, IConfiguration config, Action? configure = null, - string configPath = "ai:mongoDb") - { - builder.Services.ConfigureAndValidate(config, configPath, configure); - - builder.Services.AddSingletonAs() - .As(); - - return builder; - } -} diff --git a/ai/Squidex.AI/SemanticKernel/OpenAIChatAgent.cs b/ai/Squidex.AI/SemanticKernel/OpenAIChatAgent.cs deleted file mode 100644 index 9af1cf6..0000000 --- a/ai/Squidex.AI/SemanticKernel/OpenAIChatAgent.cs +++ /dev/null @@ -1,139 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Text.Json; -using Azure.AI.OpenAI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.ChatCompletion; -using Microsoft.SemanticKernel.Connectors.OpenAI; - -namespace Squidex.AI.SemanticKernel; - -internal sealed class OpenAIChatAgent : IChatAgent -{ - private readonly IChatStore store; - private readonly TimeProvider timeProvider; - private readonly Kernel kernel; - private readonly OpenAIChatBotOptions options; - - public bool IsConfigured => kernel.Services.GetService() != null; - - public OpenAIChatAgent(Kernel kernel, IChatStore store, IOptions options, - TimeProvider timeProvider) - { - this.kernel = kernel; - this.store = store; - this.timeProvider = timeProvider; - this.options = options.Value; - } - - public async Task PromptAsync(string prompt, string? conversationId = null, - CancellationToken ct = default) - { - if (kernel == null) - { - return ChatBotResponse.Failed("Not configured."); - } - - var chatCompletionService = kernel.GetRequiredService(); - - var openAIPromptExecutionSettings = new OpenAIPromptExecutionSettings - { - ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions, - TopP = 1, - Temperature = options.Temperature ?? 1 - }; - - var history = await LoadHistoryAsync(conversationId, ct); - - if (!string.IsNullOrWhiteSpace(prompt)) - { - history.AddUserMessage(prompt); - } - - var result = - await chatCompletionService.GetChatMessageContentsAsync( - history, - openAIPromptExecutionSettings, - kernel, - ct); - - history.AddRange(result); - - if (!string.IsNullOrWhiteSpace(conversationId)) - { - await StoreHistoryAsync(history, conversationId, ct); - } - - var content = result[0].Content ?? - throw new ChatException($"Chat does not return a result for ID '{conversationId ?? "none"}'."); - - return ChatBotResponse.Success(content) with - { - EstimatedCostsInEUR = CalculateCosts(result) - }; - } - - private decimal CalculateCosts(IReadOnlyList result) - { - var costs = 0m; - - if (result[0].Metadata?.TryGetValue("Usage", out var m) == true && m is CompletionsUsage usage) - { - costs += usage.PromptTokens * options.PricePerInputTokenInEUR; - costs += usage.CompletionTokens * options.PricePerOutputTokenInEUR; - } - - return costs; - } - - private async Task LoadHistoryAsync(string? conversationId, - CancellationToken ct) - { - ChatHistory history; - - if (!string.IsNullOrWhiteSpace(conversationId)) - { - var stored = await store.GetAsync(conversationId, ct); - - if (stored != null) - { - history = JsonSerializer.Deserialize(stored) ?? - throw new ChatException($"Cannot deserialize conversion with ID '{conversationId}'."); - - return history; - } - } - - history = []; - foreach (var systemMessage in options.SystemMessages ?? []) - { - history.AddSystemMessage(systemMessage); - } - - return history; - } - - private Task StoreHistoryAsync(ChatHistory history, string conversationId, - CancellationToken ct) - { - var expires = timeProvider.GetLocalNow().UtcDateTime + options.ConversationLifetime; - - var json = JsonSerializer.Serialize(history) ?? - throw new ChatException($"Cannot serialize conversion with ID '{conversationId}'."); - - return store.StoreAsync(conversationId, json, expires, ct); - } - - public Task StopConversationAsync(string conversationId, - CancellationToken ct = default) - { - return store.RemoveAsync(conversationId, ct); - } -} diff --git a/ai/Squidex.AI/SemanticKernel/PromptRerankerOptions.cs b/ai/Squidex.AI/SemanticKernel/PromptRerankerOptions.cs deleted file mode 100644 index e690d97..0000000 --- a/ai/Squidex.AI/SemanticKernel/PromptRerankerOptions.cs +++ /dev/null @@ -1,45 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.AI.SemanticKernel; - -#pragma warning disable MA0048 // File name must match type name -public delegate string PromptRerankerPrompt(string question, string answer); -#pragma warning restore MA0048 // File name must match type name - -public sealed class PromptRerankerOptions -{ - public PromptRerankerPrompt? Prompt { get; set; } - - internal string GetPrompt(string question, string answer) - { - var result = Prompt?.Invoke(question, answer); - - if (string.IsNullOrEmpty(result)) - { - result = $""" -You are a language model designed to evaluate the responses of this documentation query system. -You will use a rating scale of 0 to 10, 0 being poorest response and 10 being the best. -Responses with "not specified" or "no specific mention" or "rephrase question" or "unclear" or no documents returned or empty response are considered poor responses. -Responses where the question appears to be answered are considered good. -Responses that contain detailed answers are considered the best. -Also, use your own judgement in analyzing if the question asked is actually answered in the response. -Remember that a response that contains a request to “rephrase the question” is usually a non-response. -Please rate the question/response pair entered. -Only respond with the rating. No explanation necessary. Only integers. - -Question: -{question} - -Response: -{answer} -"""; - } - - return result; - } -} diff --git a/ai/Squidex.AI/SemanticKernel/PromptRerankerStep.cs b/ai/Squidex.AI/SemanticKernel/PromptRerankerStep.cs deleted file mode 100644 index b49038a..0000000 --- a/ai/Squidex.AI/SemanticKernel/PromptRerankerStep.cs +++ /dev/null @@ -1,88 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Globalization; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Nodes; -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel.ChatCompletion; -using Microsoft.SemanticKernel.Memory; - -namespace Squidex.AI.SemanticKernel; - -internal sealed class PromptRerankerStep : IRagPipelineStep -{ - private readonly PromptRerankerOptions options; - - public PromptRerankerStep(IOptionsFactory optionsFactory, string name) - { - options = optionsFactory.Create(name); - } - - public async IAsyncEnumerable ProcessAsync(RagPipelineContext context, IAsyncEnumerable source, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - var completionService = context.Kernel?.GetRequiredService(); - - if (completionService == null) - { - await foreach (var item in source.WithCancellation(cancellationToken)) - { - yield return item; - } - - yield break; - } - - var sourceTexts = new List<(string Text, string Id, int Rank, int Index)>(); - var sourceResults = new List(); - - await foreach (var result in source.WithCancellation(cancellationToken)) - { - var metadata = (JsonObject)JsonNode.Parse(result.Metadata.AdditionalMetadata)!; - - if (metadata.TryGetPropertyValue("text", out var text) && text?.GetValueKind() == JsonValueKind.String) - { - sourceTexts.Add((text.ToJsonString(), result.Metadata.Id, 0, sourceTexts.Count)); - } - - sourceResults.Add(result); - } - - if (sourceTexts.Count == 0 || sourceTexts.Count != sourceResults.Count) - { - foreach (var item in sourceResults) - { - yield return item; - } - - yield break; - } - - var query = context.Query; - - await Parallel.ForEachAsync(sourceTexts, cancellationToken, async (doc, ct) => - { - var result = await completionService.GetChatMessageContentAsync(options.GetPrompt(query, doc.Text), null, context.Kernel, ct); - - var item = result.Items.FirstOrDefault(); - - if (int.TryParse(result.Content, CultureInfo.InvariantCulture, out var ranking)) - { - sourceTexts[doc.Index] = (doc.Text, doc.Id, ranking, doc.Index); - } - }); - - foreach (var doc in sourceTexts) - { - var original = sourceResults.Find(x => x.Metadata.Id == doc.Id)!; - - yield return original; - } - } -} diff --git a/ai/Squidex.AI/SemanticKernel/RagPipeline.cs b/ai/Squidex.AI/SemanticKernel/RagPipeline.cs deleted file mode 100644 index 8084770..0000000 --- a/ai/Squidex.AI/SemanticKernel/RagPipeline.cs +++ /dev/null @@ -1,41 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.Options; -using Microsoft.SemanticKernel.Memory; - -namespace Squidex.AI.SemanticKernel; - -public sealed class RagPipeline : IRagPipeline -{ - private readonly List steps = []; - - public RagPipeline(IOptionsFactory optionsFactory, string name, IServiceProvider serviceProvider) - { - var options = optionsFactory.Create(name); - - foreach (var factory in options.StepFactories) - { - steps.Add(factory(serviceProvider)); - } - - steps.Reverse(); - } - - public IAsyncEnumerable SearchAsync(RagPipelineContext context, - CancellationToken cancellationToken = default) - { - var source = AsyncEnumerable.Empty(); - - foreach (var step in steps) - { - source = step.ProcessAsync(context, source, cancellationToken); - } - - return source; - } -} diff --git a/ai/Squidex.AI/SemanticKernel/RagPipelineBuilder.cs b/ai/Squidex.AI/SemanticKernel/RagPipelineBuilder.cs deleted file mode 100644 index 6ab10fa..0000000 --- a/ai/Squidex.AI/SemanticKernel/RagPipelineBuilder.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.DependencyInjection; - -namespace Squidex.AI.SemanticKernel; - -public sealed class RagPipelineBuilder(IServiceCollection services, string name) -{ - public IServiceCollection Services { get; } = services; - - public string Name { get; } = name; - - public RagPipelineBuilder AddStep(Func factory) - { - Services.Configure(Name, options => - { - options.StepFactories.Add(factory); - }); - - return this; - } -} diff --git a/ai/Squidex.AI/SemanticKernel/RagServiceCollectionExtensions.cs b/ai/Squidex.AI/SemanticKernel/RagServiceCollectionExtensions.cs deleted file mode 100644 index 43d7450..0000000 --- a/ai/Squidex.AI/SemanticKernel/RagServiceCollectionExtensions.cs +++ /dev/null @@ -1,107 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Embeddings; -using Microsoft.SemanticKernel.Memory; -using Squidex.AI.SemanticKernel; - -namespace Microsoft.Extensions.DependencyInjection; - -public static class RagServiceCollectionExtensions -{ - public static RagPipelineBuilder AddRagPipeline(this IKernelBuilder builder, string name) - { - builder.Services.AddKeyedSingleton(name, (c, n) => ActivatorUtilities.CreateInstance(c, n!.ToString()!)); - - return new RagPipelineBuilder(builder.Services, name); - } - - public static RagPipelineBuilder AddCalculateEmbeddings(this RagPipelineBuilder builder, ITextEmbeddingGenerationService embeddingGenerationService) - { - return builder.AddStep(c => - ActivatorUtilities.CreateInstance(c, embeddingGenerationService)); - } - - public static RagPipelineBuilder AddCalculateEmbeddings(this RagPipelineBuilder builder, object? serviceId = null) - { - return builder.AddStep(c => - ActivatorUtilities.CreateInstance(c, c.GetRequiredKeyedService(serviceId))); - } - - public static RagPipelineBuilder AddCalculateEmbeddings(this RagPipelineBuilder builder, Func factory) - { - return builder.AddStep(c => - ActivatorUtilities.CreateInstance(c, factory(c))); - } - - public static RagPipelineBuilder AddSearchInMemoryStore(this RagPipelineBuilder builder, IMemoryStore memoryStore, string collectionName, Action? configure = null) - { - builder.Configure(collectionName, configure); - - return builder.AddStep(c => - ActivatorUtilities.CreateInstance(c, builder.Name, memoryStore)); - } - - public static RagPipelineBuilder AddSearchInMemoryStore(this RagPipelineBuilder builder, string collectionName, object? serviceId = null, Action? configure = null) - { - builder.Configure(collectionName, configure); - - return builder.AddStep(c => - ActivatorUtilities.CreateInstance(c, builder.Name, c.GetRequiredKeyedService(serviceId))); - } - - public static RagPipelineBuilder AddSearchInMemoryStore(this RagPipelineBuilder builder, Func factory, string collectionName, Action? configure = null) - { - builder.Configure(collectionName, configure); - - return builder.AddStep(c => - ActivatorUtilities.CreateInstance(c, builder.Name, factory(c))); - } - - public static RagPipelineBuilder AddCohereRerank(this RagPipelineBuilder builder, string apiKey, Action? configure = null) - { - builder.Configure(apiKey, configure); - - return builder.AddStep(c => - ActivatorUtilities.CreateInstance(c, builder.Name, apiKey)); - } - - public static RagPipelineBuilder AddPromptReranker(this RagPipelineBuilder builder, Action? configure = null) - { - builder.Configure(configure); - - return builder.AddStep(c => - ActivatorUtilities.CreateInstance(c, builder.Name)); - } - - private static void Configure(this RagPipelineBuilder builder, string collectionName, Action? configure) - { - builder.Services.Configure(builder.Name, options => - { - options.CollectionName = collectionName; - configure?.Invoke(options); - }); - } - - private static void Configure(this RagPipelineBuilder builder, string apiKey, Action? configure) - { - builder.Services.Configure(builder.Name, options => - { - options.ApiKey = apiKey; - configure?.Invoke(options); - }); - } - - private static void Configure(this RagPipelineBuilder builder, Action? configure) - { - builder.Services.Configure(builder.Name, options => - { - configure?.Invoke(options); - }); - } -} diff --git a/ai/Squidex.AI/SemanticKernel/SementicKernelServiceExtensions.cs b/ai/Squidex.AI/SemanticKernel/SementicKernelServiceExtensions.cs deleted file mode 100644 index de84439..0000000 --- a/ai/Squidex.AI/SemanticKernel/SementicKernelServiceExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.SemanticKernel; -using Squidex.AI; -using Squidex.AI.SemanticKernel; - -namespace Microsoft.Extensions.DependencyInjection; - -public static class SementicKernelServiceExtensions -{ - public static IKernelBuilder AddTool(this IKernelBuilder builder) - { - builder.Plugins.AddFromType(); - return builder; - } - - public static IServiceCollection AddOpenAIChatAgent(this IServiceCollection services, IConfiguration config, Action? configure = null, - string configPath = "chatbot:openai") - { - services.Configure(config, configPath, configure); - - services.TryAddSingleton(TimeProvider.System); - services.TryAddSingleton(); - services.AddSingletonAs() - .As().AsSelf(); - - return services; - } -} diff --git a/ai/Squidex.AI/Squidex.AI.csproj b/ai/Squidex.AI/Squidex.AI.csproj index 62fdd8f..b24d79b 100644 --- a/ai/Squidex.AI/Squidex.AI.csproj +++ b/ai/Squidex.AI/Squidex.AI.csproj @@ -8,25 +8,25 @@ - + - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + @@ -34,6 +34,7 @@ + diff --git a/ai/Squidex.AI/ToolSpec.cs b/ai/Squidex.AI/ToolSpec.cs new file mode 100644 index 0000000..50f5da5 --- /dev/null +++ b/ai/Squidex.AI/ToolSpec.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +#pragma warning disable MA0048 // File name must match type name +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.AI; + +public sealed record ToolSpec(string Name, string DisplayName, string Description) +{ + public Dictionary Arguments { get; init; } = []; +} + +public abstract record ToolArgumentSpec(string Description) +{ + public bool IsRequired { get; init; } +} + +public sealed record ToolBooleanArgumentSpec(string Description) : ToolArgumentSpec(Description) +{ +} + +public sealed record ToolNumberArgumentSpec(string Description) : ToolArgumentSpec(Description) +{ +} + +public sealed record ToolStringArgumentSpec(string Description) : ToolArgumentSpec(Description) +{ +} + +public sealed record ToolEnumArgumentSpec(string Description) : ToolArgumentSpec(Description) +{ + required public string[] Values { get; set; } +} diff --git a/ai/Squidex.AI/ToolValue.cs b/ai/Squidex.AI/ToolValue.cs new file mode 100644 index 0000000..f8b7f9c --- /dev/null +++ b/ai/Squidex.AI/ToolValue.cs @@ -0,0 +1,78 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Globalization; + +#pragma warning disable MA0048 // File name must match type name +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.AI; + +public abstract record ToolValue +{ + public static readonly ToolNullValue Null = new ToolNullValue(); + + public virtual string AsString + { + get => throw new InvalidOperationException($"Expected 'String', got '{GetType().Name}'"); + } + + public virtual double AsNumber + { + get => throw new InvalidOperationException($"Expected 'Number', got '{GetType().Name}'"); + } + + public virtual bool AsBoolean + { + get => throw new InvalidOperationException($"Expected 'Boolean', got '{GetType().Name}'"); + } + + public virtual bool IsNull + { + get => false; + } +} + +public sealed record ToolStringValue(string Value) : ToolValue +{ + public override string AsString => Value; + + public override string ToString() + { + return Value; + } +} + +public sealed record ToolNumberValue(double Value) : ToolValue +{ + public override double AsNumber => Value; + + public override string ToString() + { + return Value.ToString(CultureInfo.InvariantCulture); + } +} + +public sealed record ToolBooleanValue(bool Value) : ToolValue +{ + public override bool AsBoolean => Value; + + public override string ToString() + { + return Value.ToString(CultureInfo.InvariantCulture); + } +} + +public sealed record ToolNullValue : ToolValue +{ + public override bool IsNull => true; + + public override string ToString() + { + return "null"; + } +} diff --git a/assets/Squidex.Assets.Azure/AzureBlobAssetStore.cs b/assets/Squidex.Assets.Azure/AzureBlobAssetStore.cs index 532a615..a20b5bb 100644 --- a/assets/Squidex.Assets.Azure/AzureBlobAssetStore.cs +++ b/assets/Squidex.Assets.Azure/AzureBlobAssetStore.cs @@ -9,7 +9,6 @@ using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Microsoft.Extensions.Options; -using Squidex.Assets.Internal; using Squidex.Hosting; namespace Squidex.Assets; @@ -131,7 +130,7 @@ public async Task CopyAsync(string sourceFileName, string targetFileName, public async Task DownloadAsync(string fileName, Stream stream, BytesRange range = default, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var name = GetFileName(fileName, nameof(fileName)); @@ -160,7 +159,7 @@ public async Task DownloadAsync(string fileName, Stream stream, BytesRange range public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var name = GetFileName(fileName, nameof(fileName)); @@ -203,7 +202,7 @@ public Task DeleteAsync(string fileName, private static string GetFileName(string fileName, string parameterName) { - Guard.NotNullOrEmpty(fileName, parameterName); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName, parameterName); return FilePathHelper.EnsureThatPathIsChildOf(fileName.Replace('\\', '/'), "./"); } diff --git a/assets/Squidex.Assets.FTP/FTPAssetStore.cs b/assets/Squidex.Assets.FTP/FTPAssetStore.cs index 4b9fd54..600967a 100644 --- a/assets/Squidex.Assets.FTP/FTPAssetStore.cs +++ b/assets/Squidex.Assets.FTP/FTPAssetStore.cs @@ -10,7 +10,6 @@ using FluentFTP.Exceptions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Squidex.Assets.Internal; using Squidex.Hosting; namespace Squidex.Assets; @@ -119,7 +118,7 @@ public async Task CopyAsync(string sourceFileName, string targetFileName, public async Task DownloadAsync(string fileName, Stream stream, BytesRange range = default, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var name = GetFileName(fileName, nameof(fileName)); @@ -144,7 +143,7 @@ public async Task DownloadAsync(string fileName, Stream stream, BytesRange range public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var name = GetFileName(fileName, nameof(fileName)); @@ -222,7 +221,7 @@ public async Task DeleteAsync(string fileName, private static string GetFileName(string fileName, string parameterName) { - Guard.NotNullOrEmpty(fileName, parameterName); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName, parameterName); return FilePathHelper.EnsureThatPathIsChildOf(fileName.Replace('\\', '/'), "./"); } diff --git a/assets/Squidex.Assets.FTP/FTPClientPool.cs b/assets/Squidex.Assets.FTP/FTPClientPool.cs index 66df64f..a6a446c 100644 --- a/assets/Squidex.Assets.FTP/FTPClientPool.cs +++ b/assets/Squidex.Assets.FTP/FTPClientPool.cs @@ -6,7 +6,6 @@ // ========================================================================== using FluentFTP; -using Squidex.Assets.Internal; namespace Squidex.Assets; @@ -20,8 +19,6 @@ internal sealed class FTPClientPool public FTPClientPool(Func clientFactory, int clientsLimit) { - Guard.NotNull(clientFactory, nameof(clientFactory)); - this.clientFactory = clientFactory; this.clientsLimit = clientsLimit; } diff --git a/assets/Squidex.Assets.GoogleCloud/GoogleCloudAssetStore.cs b/assets/Squidex.Assets.GoogleCloud/GoogleCloudAssetStore.cs index 6de266c..666edf9 100644 --- a/assets/Squidex.Assets.GoogleCloud/GoogleCloudAssetStore.cs +++ b/assets/Squidex.Assets.GoogleCloud/GoogleCloudAssetStore.cs @@ -10,7 +10,6 @@ using Google; using Google.Cloud.Storage.V1; using Microsoft.Extensions.Options; -using Squidex.Assets.Internal; using Squidex.Hosting; namespace Squidex.Assets; @@ -172,7 +171,7 @@ public async Task DeleteAsync(string fileName, private static string GetFileName(string fileName, string parameterName) { - Guard.NotNullOrEmpty(fileName, parameterName); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName, parameterName); return FilePathHelper.EnsureThatPathIsChildOf(fileName.Replace('\\', '/'), "./"); } diff --git a/assets/Squidex.Assets.ImageSharp/Squidex.Assets.ImageSharp.csproj b/assets/Squidex.Assets.ImageSharp/Squidex.Assets.ImageSharp.csproj index 697449f..fd70d99 100644 --- a/assets/Squidex.Assets.ImageSharp/Squidex.Assets.ImageSharp.csproj +++ b/assets/Squidex.Assets.ImageSharp/Squidex.Assets.ImageSharp.csproj @@ -22,7 +22,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/assets/Squidex.Assets.Mongo/MongoGridFsAssetStore.cs b/assets/Squidex.Assets.Mongo/MongoGridFsAssetStore.cs index ed3070e..c4d3a6b 100644 --- a/assets/Squidex.Assets.Mongo/MongoGridFsAssetStore.cs +++ b/assets/Squidex.Assets.Mongo/MongoGridFsAssetStore.cs @@ -8,7 +8,6 @@ using MongoDB.Bson; using MongoDB.Driver; using MongoDB.Driver.GridFS; -using Squidex.Assets.Internal; using Squidex.Hosting; namespace Squidex.Assets; @@ -52,7 +51,7 @@ public async Task GetSizeAsync(string fileName, public async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default) { - Guard.NotNullOrEmpty(targetFileName, nameof(targetFileName)); + ArgumentException.ThrowIfNullOrWhiteSpace(targetFileName); var sourceName = GetFileName(sourceFileName, nameof(sourceFileName)); @@ -72,7 +71,7 @@ public async Task CopyAsync(string sourceFileName, string targetFileName, public async Task DownloadAsync(string fileName, Stream stream, BytesRange range = default, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var name = GetFileName(fileName, nameof(fileName)); @@ -94,7 +93,7 @@ public async Task DownloadAsync(string fileName, Stream stream, BytesRange range public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var name = GetFileName(fileName, nameof(fileName)); @@ -165,7 +164,7 @@ public async Task DeleteAsync(string fileName, private static string GetFileName(string fileName, string parameterName) { - Guard.NotNullOrEmpty(fileName, parameterName); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName, parameterName); return FilePathHelper.EnsureThatPathIsChildOf(fileName.Replace('\\', '/'), "./"); } diff --git a/assets/Squidex.Assets.S3/AmazonS3AssetStore.cs b/assets/Squidex.Assets.S3/AmazonS3AssetStore.cs index 5bc1905..2325de8 100644 --- a/assets/Squidex.Assets.S3/AmazonS3AssetStore.cs +++ b/assets/Squidex.Assets.S3/AmazonS3AssetStore.cs @@ -12,7 +12,6 @@ using Amazon.S3.Transfer; using Amazon.S3.Util; using Microsoft.Extensions.Options; -using Squidex.Assets.Internal; using Squidex.Hosting; namespace Squidex.Assets; @@ -208,7 +207,7 @@ private async Task CopyViaApiAsync(string sourceFileName, string targetFileName, public async Task DownloadAsync(string fileName, Stream stream, BytesRange range = default, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var key = GetKey(fileName, nameof(fileName)); @@ -239,7 +238,7 @@ public async Task DownloadAsync(string fileName, Stream stream, BytesRange range public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var key = GetKey(fileName, nameof(fileName)); @@ -359,7 +358,7 @@ public async Task DeleteAsync(string fileName, private string GetKey(string fileName, string parameterName) { - Guard.NotNullOrEmpty(fileName, parameterName); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName, parameterName); fileName = fileName.Replace('\\', '/'); diff --git a/assets/Squidex.Assets.S3/SeekFakerStream.cs b/assets/Squidex.Assets.S3/SeekFakerStream.cs index db539a3..56b3bf9 100644 --- a/assets/Squidex.Assets.S3/SeekFakerStream.cs +++ b/assets/Squidex.Assets.S3/SeekFakerStream.cs @@ -5,7 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Assets.Internal; +using System.IO; namespace Squidex.Assets; @@ -41,7 +41,7 @@ public override long Position public SeekFakerStream(Stream inner) { - Guard.NotNull(inner, nameof(inner)); + ArgumentNullException.ThrowIfNull(inner); if (!inner.CanRead) { diff --git a/assets/Squidex.Assets/AssetAlreadyExistsException.cs b/assets/Squidex.Assets/AssetAlreadyExistsException.cs index 19b7408..09f51a2 100644 --- a/assets/Squidex.Assets/AssetAlreadyExistsException.cs +++ b/assets/Squidex.Assets/AssetAlreadyExistsException.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Assets.Internal; - namespace Squidex.Assets; [Serializable] @@ -19,7 +17,7 @@ public AssetAlreadyExistsException(string fileName, Exception? inner = null) private static string FormatMessage(string fileName) { - Guard.NotNullOrEmpty(fileName, nameof(fileName)); + ArgumentException.ThrowIfNullOrEmpty(fileName); return $"An asset with name '{fileName}' already exists."; } diff --git a/assets/Squidex.Assets/AssetFile.cs b/assets/Squidex.Assets/AssetFile.cs index 48e3546..aed1bc3 100644 --- a/assets/Squidex.Assets/AssetFile.cs +++ b/assets/Squidex.Assets/AssetFile.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Assets.Internal; - namespace Squidex.Assets; public abstract class AssetFile : IDisposable, IAsyncDisposable @@ -23,9 +21,9 @@ public abstract class AssetFile : IDisposable, IAsyncDisposable protected AssetFile(string fileName, string mimeType, long fileSize) { - Guard.NotNullOrEmpty(fileName, nameof(fileName)); - Guard.NotNullOrEmpty(mimeType, nameof(mimeType)); - Guard.GreaterEquals(fileSize, 0, nameof(fileSize)); + ArgumentException.ThrowIfNullOrEmpty(fileName); + ArgumentException.ThrowIfNullOrEmpty(mimeType); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(fileSize, 0); this.fileName = fileName; this.fileSize = fileSize; diff --git a/assets/Squidex.Assets/AssetNotFoundException.cs b/assets/Squidex.Assets/AssetNotFoundException.cs index 16fc2b7..024651c 100644 --- a/assets/Squidex.Assets/AssetNotFoundException.cs +++ b/assets/Squidex.Assets/AssetNotFoundException.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Assets.Internal; - namespace Squidex.Assets; [Serializable] @@ -19,7 +17,7 @@ public AssetNotFoundException(string fileName, Exception? inner = null) private static string FormatMessage(string fileName) { - Guard.NotNullOrEmpty(fileName, nameof(fileName)); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); return $"An asset with name '{fileName}' does not exist."; } diff --git a/assets/Squidex.Assets/AssetThumbnailGeneratorBase.cs b/assets/Squidex.Assets/AssetThumbnailGeneratorBase.cs index 79d3d19..f564923 100644 --- a/assets/Squidex.Assets/AssetThumbnailGeneratorBase.cs +++ b/assets/Squidex.Assets/AssetThumbnailGeneratorBase.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Diagnostics.CodeAnalysis; -using Squidex.Assets.Internal; namespace Squidex.Assets; @@ -24,8 +23,8 @@ public virtual bool CanComputeBlurHash() public virtual bool IsResizable(string mimeType, ResizeOptions options, [MaybeNullWhen(false)] out string? destinationMimeType) { - Guard.NotNull(options, nameof(options)); - Guard.NotNullOrEmpty(mimeType, nameof(mimeType)); + ArgumentException.ThrowIfNullOrEmpty(mimeType); + ArgumentNullException.ThrowIfNull(options); destinationMimeType = null; @@ -63,8 +62,8 @@ public virtual bool IsResizable(string mimeType, ResizeOptions options, [MaybeNu public async Task GetImageInfoAsync(Stream source, string mimeType, CancellationToken ct = default) { - Guard.NotNull(source, nameof(source)); - Guard.NotNullOrEmpty(mimeType, nameof(mimeType)); + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrEmpty(mimeType); // If we cannot read or write from the mime type we can just stop here. if (!CanReadAndWrite(mimeType)) @@ -81,9 +80,9 @@ public virtual bool IsResizable(string mimeType, ResizeOptions options, [MaybeNu public async Task ComputeBlurHashAsync(Stream source, string mimeType, BlurOptions options, CancellationToken ct = default) { - Guard.NotNull(source, nameof(source)); - Guard.NotNullOrEmpty(mimeType, nameof(mimeType)); - Guard.NotNull(options, nameof(options)); + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrEmpty(mimeType); + ArgumentNullException.ThrowIfNull(options); // If we cannot read or write from the mime type we can just stop here. if (!CanReadAndWrite(mimeType)) @@ -100,9 +99,9 @@ public virtual bool IsResizable(string mimeType, ResizeOptions options, [MaybeNu public async Task FixAsync(Stream source, string mimeType, Stream destination, CancellationToken ct = default) { - Guard.NotNull(source, nameof(source)); - Guard.NotNullOrEmpty(mimeType, nameof(mimeType)); - Guard.NotNull(destination, nameof(destination)); + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrEmpty(mimeType); + ArgumentNullException.ThrowIfNull(destination); // If we cannot read or write from the mime type we can just stop here. if (!CanReadAndWrite(mimeType)) @@ -120,10 +119,10 @@ protected abstract Task FixCoreAsync(Stream source, string mimeType, Stream dest public async Task CreateThumbnailAsync(Stream source, string mimeType, Stream destination, ResizeOptions options, CancellationToken ct = default) { - Guard.NotNull(source, nameof(source)); - Guard.NotNullOrEmpty(mimeType, nameof(mimeType)); - Guard.NotNull(destination, nameof(destination)); - Guard.NotNull(options, nameof(options)); + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrEmpty(mimeType); + ArgumentNullException.ThrowIfNull(destination); + ArgumentNullException.ThrowIfNull(options); if (!IsResizable(mimeType, options, out _)) { diff --git a/assets/Squidex.Assets/DelegateStream.cs b/assets/Squidex.Assets/DelegateStream.cs index f564b9d..3b3bc22 100644 --- a/assets/Squidex.Assets/DelegateStream.cs +++ b/assets/Squidex.Assets/DelegateStream.cs @@ -56,10 +56,7 @@ public override int WriteTimeout protected DelegateStream(Stream innerStream) { - if (innerStream == Null) - { - throw new ArgumentNullException(nameof(innerStream)); - } + ArgumentNullException.ThrowIfNull(innerStream); this.innerStream = innerStream; } diff --git a/assets/Squidex.Assets/FolderAssetStore.cs b/assets/Squidex.Assets/FolderAssetStore.cs index 8189554..d910697 100644 --- a/assets/Squidex.Assets/FolderAssetStore.cs +++ b/assets/Squidex.Assets/FolderAssetStore.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Squidex.Assets.Internal; using Squidex.Hosting; namespace Squidex.Assets; @@ -91,7 +90,7 @@ public Task CopyAsync(string sourceFileName, string targetFileName, public async Task DownloadAsync(string fileName, Stream stream, BytesRange range = default, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var file = GetFile(fileName, nameof(fileName)); @@ -115,7 +114,7 @@ public async Task DownloadAsync(string fileName, Stream stream, BytesRange range public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var file = GetFile(fileName, nameof(fileName)); @@ -216,7 +215,7 @@ private string GetPath(string name) private static string GetFileName(string fileName, string parameterName) { - Guard.NotNullOrEmpty(fileName, parameterName); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName, parameterName); return fileName.Replace('\\', '/'); } diff --git a/assets/Squidex.Assets/HasherStream.cs b/assets/Squidex.Assets/HasherStream.cs index 8fc3546..c14fa50 100644 --- a/assets/Squidex.Assets/HasherStream.cs +++ b/assets/Squidex.Assets/HasherStream.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Security.Cryptography; -using Squidex.Assets.Internal; namespace Squidex.Assets; @@ -43,7 +42,7 @@ public override long Position public HasherStream(Stream inner, HashAlgorithmName hashAlgorithmName) { - Guard.NotNull(inner, nameof(inner)); + ArgumentNullException.ThrowIfNull(inner); if (!inner.CanRead) { diff --git a/assets/Squidex.Assets/Internal/Guard.cs b/assets/Squidex.Assets/Internal/Guard.cs deleted file mode 100644 index ff7eedd..0000000 --- a/assets/Squidex.Assets/Internal/Guard.cs +++ /dev/null @@ -1,56 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Diagnostics; -using System.Runtime.CompilerServices; - -namespace Squidex.Assets.Internal; - -public static class Guard -{ - [DebuggerStepThrough] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void GreaterThan(TValue target, TValue lower, string parameterName) where TValue : IComparable - { - if (target.CompareTo(lower) <= 0) - { - throw new ArgumentException($"Value must be greater than {lower}", parameterName); - } - } - - [DebuggerStepThrough] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void GreaterEquals(TValue target, TValue lower, string parameterName) where TValue : IComparable - { - if (target.CompareTo(lower) < 0) - { - throw new ArgumentException($"Value must be greater or equal to {lower}", parameterName); - } - } - - [DebuggerStepThrough] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void NotNull(object? target, string parameterName) - { - if (target == null) - { - throw new ArgumentNullException(parameterName); - } - } - - [DebuggerStepThrough] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void NotNullOrEmpty(string? target, string parameterName) - { - NotNull(target, parameterName); - - if (string.IsNullOrWhiteSpace(target)) - { - throw new ArgumentException("String parameter cannot be null or empty and cannot contain only blanks.", parameterName); - } - } -} diff --git a/assets/Squidex.Assets/MemoryAssetStore.cs b/assets/Squidex.Assets/MemoryAssetStore.cs index 36ff7e6..418c746 100644 --- a/assets/Squidex.Assets/MemoryAssetStore.cs +++ b/assets/Squidex.Assets/MemoryAssetStore.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Collections.Concurrent; -using Squidex.Assets.Internal; namespace Squidex.Assets; @@ -35,7 +34,7 @@ public async Task GetSizeAsync(string fileName, public virtual async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default) { - Guard.NotNullOrEmpty(targetFileName, nameof(targetFileName)); + ArgumentException.ThrowIfNullOrEmpty(targetFileName); var sourceName = GetFileName(sourceFileName, nameof(sourceFileName)); @@ -53,7 +52,7 @@ public virtual async Task CopyAsync(string sourceFileName, string targetFileName public virtual async Task DownloadAsync(string fileName, Stream stream, BytesRange range = default, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var name = GetFileName(fileName, nameof(fileName)); @@ -78,7 +77,7 @@ public virtual async Task DownloadAsync(string fileName, Stream stream, BytesRan public virtual async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { - Guard.NotNull(stream, nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); var name = GetFileName(fileName, nameof(fileName)); @@ -120,7 +119,7 @@ async Task CopyAsync() public virtual Task DeleteByPrefixAsync(string prefix, CancellationToken ct = default) { - Guard.NotNullOrEmpty(prefix, nameof(prefix)); + ArgumentException.ThrowIfNullOrWhiteSpace(prefix); // ToList on concurrent dictionary is not thread safe, therefore we maintain our own local copy. HashSet? toRemove = null; @@ -157,7 +156,7 @@ public virtual Task DeleteAsync(string fileName, private static string GetFileName(string fileName, string parameterName) { - Guard.NotNullOrEmpty(fileName, parameterName); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName, parameterName); return FilePathHelper.EnsureThatPathIsChildOf(fileName.Replace('\\', '/'), "./"); } diff --git a/assets/Squidex.Assets/Squidex.Assets.csproj b/assets/Squidex.Assets/Squidex.Assets.csproj index aa17f99..42d2141 100644 --- a/assets/Squidex.Assets/Squidex.Assets.csproj +++ b/assets/Squidex.Assets/Squidex.Assets.csproj @@ -23,7 +23,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/caching/Squidex.Caching/AsyncLocalCache.cs b/caching/Squidex.Caching/AsyncLocalCache.cs index af0e97a..699b46f 100644 --- a/caching/Squidex.Caching/AsyncLocalCache.cs +++ b/caching/Squidex.Caching/AsyncLocalCache.cs @@ -30,6 +30,8 @@ public IDisposable StartContext() public void Add(object key, object? value) { + ArgumentNullException.ThrowIfNull(key); + var cacheKey = GetCacheKey(key); var cacheLocal = LocalCache.Value; @@ -41,6 +43,8 @@ public void Add(object key, object? value) public void Remove(object key) { + ArgumentNullException.ThrowIfNull(key); + var cacheKey = GetCacheKey(key); var cacheLocal = LocalCache.Value; @@ -49,6 +53,8 @@ public void Remove(object key) public bool TryGetValue(object key, out object? value) { + ArgumentNullException.ThrowIfNull(key); + value = null; var cacheKey = GetCacheKey(key); diff --git a/caching/Squidex.Caching/BackgroundCache.cs b/caching/Squidex.Caching/BackgroundCache.cs index f15268b..a64e4a6 100644 --- a/caching/Squidex.Caching/BackgroundCache.cs +++ b/caching/Squidex.Caching/BackgroundCache.cs @@ -45,6 +45,9 @@ public BackgroundCache(IMemoryCache memoryCache) public Task GetOrCreateAsync(object key, TimeSpan expiration, Func> creator, Func>? isValid = null) { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(creator); + var now = GetTime(); if (memoryCache.TryGetValue(key, out var cached)) diff --git a/caching/Squidex.Caching/LRUCache.cs b/caching/Squidex.Caching/LRUCache.cs index c8f9321..624118b 100644 --- a/caching/Squidex.Caching/LRUCache.cs +++ b/caching/Squidex.Caching/LRUCache.cs @@ -46,6 +46,8 @@ public void Clear() public bool Set(TKey key, TValue value) { + ArgumentNullException.ThrowIfNull(key); + if (cacheMap.TryGetValue(key, out var node)) { node.Value.Value = value; @@ -75,6 +77,8 @@ public bool Set(TKey key, TValue value) public bool Remove(TKey key) { + ArgumentNullException.ThrowIfNull(key); + if (cacheMap.TryGetValue(key, out var node)) { cacheMap.Remove(key); @@ -88,6 +92,8 @@ public bool Remove(TKey key) public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) { + ArgumentNullException.ThrowIfNull(key); + value = default!; if (cacheMap.TryGetValue(key, out var node)) @@ -120,4 +126,4 @@ private void RemoveFirst() cacheHistory.RemoveFirst(); } } -} \ No newline at end of file +} diff --git a/caching/Squidex.Caching/ReplicatedCache.cs b/caching/Squidex.Caching/ReplicatedCache.cs index 6b61da7..e7fbdc5 100644 --- a/caching/Squidex.Caching/ReplicatedCache.cs +++ b/caching/Squidex.Caching/ReplicatedCache.cs @@ -43,6 +43,8 @@ public Task HandleAsync(CacheInvalidateMessage message, public Task AddAsync(string key, object? value, TimeSpan expiration, CancellationToken ct = default) { + ArgumentNullException.ThrowIfNull(key); + if (expiration <= TimeSpan.Zero) { return Task.CompletedTask; @@ -56,6 +58,8 @@ public Task AddAsync(string key, object? value, TimeSpan expiration, public Task AddAsync(IEnumerable> items, TimeSpan expiration, CancellationToken ct = default) { + ArgumentNullException.ThrowIfNull(items); + if (expiration <= TimeSpan.Zero) { return Task.CompletedTask; @@ -72,18 +76,25 @@ public Task AddAsync(IEnumerable> items, TimeSpan public Task RemoveAsync(string key, CancellationToken ct = default) { + ArgumentNullException.ThrowIfNull(key); + return RemoveAsync(new[] { key }, ct); } public Task RemoveAsync(string key1, string key2, CancellationToken ct = default) { + ArgumentNullException.ThrowIfNull(key1); + ArgumentNullException.ThrowIfNull(key2); + return RemoveAsync(new[] { key1, key2 }, ct); } public async Task RemoveAsync(string[] keys, CancellationToken ct = default) { + ArgumentNullException.ThrowIfNull(keys); + foreach (var key in keys) { if (key != null) @@ -97,12 +108,16 @@ public async Task RemoveAsync(string[] keys, public bool TryGetValue(string key, out object? value) { + ArgumentNullException.ThrowIfNull(key); + return memoryCache.TryGetValue(key, out value); } private Task InvalidateAsync(string[] keys, CancellationToken ct) { + ArgumentNullException.ThrowIfNull(keys); + return messageBus.PublishAsync(new CacheInvalidateMessage { Keys = keys, Source = InstanceId }, ct: ct); } } diff --git a/messaging/Squidex.Messaging/Internal/ThrowHelper.cs b/messaging/Squidex.Messaging/Internal/ThrowHelper.cs index 4a89cdf..1068ab6 100644 --- a/messaging/Squidex.Messaging/Internal/ThrowHelper.cs +++ b/messaging/Squidex.Messaging/Internal/ThrowHelper.cs @@ -14,11 +14,6 @@ public static void ArgumentException(string message, string? paramName) throw new ArgumentException(message, paramName); } - public static void ArgumentNullException(string? paramName) - { - throw new ArgumentNullException(paramName); - } - public static void KeyNotFoundException(string? message = null) { throw new KeyNotFoundException(message);