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