diff --git a/src/AI.Tests/AI.Tests.csproj b/src/AI.Tests/AI.Tests.csproj index b8b4560..f63736c 100644 --- a/src/AI.Tests/AI.Tests.csproj +++ b/src/AI.Tests/AI.Tests.csproj @@ -1,23 +1,26 @@  - net9.0 + net8.0 OPENAI001;$(NoWarn) - - - + - - - - - + + + + + + + + + + diff --git a/src/AI/OpenAI/OpenAIResponseClientExtensions.cs b/src/AI.Tests/OpenAIResponseClientExtensions.cs similarity index 90% rename from src/AI/OpenAI/OpenAIResponseClientExtensions.cs rename to src/AI.Tests/OpenAIResponseClientExtensions.cs index c4258a0..82088e2 100644 --- a/src/AI/OpenAI/OpenAIResponseClientExtensions.cs +++ b/src/AI.Tests/OpenAIResponseClientExtensions.cs @@ -18,10 +18,10 @@ public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient class ToolsReponseClient(OpenAIResponseClient inner, ResponseTool[] tools) : OpenAIResponseClient { - public override Task> CreateResponseAsync(IEnumerable inputItems, ResponseCreationOptions options = null, CancellationToken cancellationToken = default) + public override Task> CreateResponseAsync(IEnumerable inputItems, ResponseCreationOptions? options = null, CancellationToken cancellationToken = default) => inner.CreateResponseAsync(inputItems, AddTools(options), cancellationToken); - public override AsyncCollectionResult CreateResponseStreamingAsync(IEnumerable inputItems, ResponseCreationOptions options = null, CancellationToken cancellationToken = default) + public override AsyncCollectionResult CreateResponseStreamingAsync(IEnumerable inputItems, ResponseCreationOptions? options = null, CancellationToken cancellationToken = default) => inner.CreateResponseStreamingAsync(inputItems, AddTools(options), cancellationToken); ResponseCreationOptions AddTools(ResponseCreationOptions options) diff --git a/src/AI.Tests/RetrievalTests.cs b/src/AI.Tests/RetrievalTests.cs index 41ebc17..128b1fe 100644 --- a/src/AI.Tests/RetrievalTests.cs +++ b/src/AI.Tests/RetrievalTests.cs @@ -12,7 +12,7 @@ namespace Devlooped.Extensions.AI; public class RetrievalTests(ITestOutputHelper output) { [SecretsTheory("OpenAI:Key")] - [InlineData("gpt-4.1-mini", "Qué es la rebeldía en el Código Procesal Civil y Comercial Nacional?")] + [InlineData("gpt-4.1-nano", "Qué es la rebeldía en el Código Procesal Civil y Comercial Nacional?")] [InlineData("gpt-4.1-nano", "What's the battery life in an iPhone 15?", true)] public async Task CanRetrieveContent(string model, string question, bool empty = false) { @@ -31,6 +31,11 @@ public async Task CanRetrieveContent(string model, string question, bool empty = ResponseTool.CreateFileSearchTool([store.VectorStoreId])) .AsBuilder() .UseLogging(output.AsLoggerFactory()) + .Use((messages, options, next, cancellationToken) => + { + + return next.Invoke(messages, options, cancellationToken); + }) .Build(); var response = await chat.GetResponseAsync( diff --git a/src/AI/AI.csproj b/src/AI/AI.csproj index e965342..5f9ffa0 100644 --- a/src/AI/AI.csproj +++ b/src/AI/AI.csproj @@ -1,13 +1,21 @@  - net9.0 + net8.0 + Devlooped.Extensions.AI + Extensions for Microsoft.Extensions.AI + true - - - + + + + - + + + + + \ No newline at end of file diff --git a/src/AI/Devlooped.Extensions.AI.props b/src/AI/Devlooped.Extensions.AI.props new file mode 100644 index 0000000..892ff6f --- /dev/null +++ b/src/AI/Devlooped.Extensions.AI.props @@ -0,0 +1,34 @@ + + + true + enable + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AI/JsonConsoleLoggingExtensions.cs b/src/AI/JsonConsoleLoggingExtensions.cs new file mode 100644 index 0000000..a062521 --- /dev/null +++ b/src/AI/JsonConsoleLoggingExtensions.cs @@ -0,0 +1,63 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Devlooped.Extensions.AI; +using Spectre.Console; +using Spectre.Console.Json; + +namespace Microsoft.Extensions.AI; + +/// +/// Adds console logging capabilities to the chat client. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class JsonConsoleLoggingExtensions +{ + /// + /// Renders chat messages and responses to the console using Spectre.Console rich JSON formatting. + /// + /// The builder in use. + /// If true, prompts the user for confirmation before enabling console logging. + public static ChatClientBuilder UseJsonConsoleLogging(this ChatClientBuilder builder, bool askConfirmation = false) + { + if (askConfirmation && !AnsiConsole.Confirm("Do you want to enable console logging for chat messages?")) + return builder; + + return builder.Use(inner => new ConsoleLoggingChatClient(inner)); + } + + class ConsoleLoggingChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient) + { + public override async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + AnsiConsole.Write(new Panel(new JsonText(new + { + messages = messages.Where(x => x.Role != ChatRole.System).ToArray(), + options + }.ToJsonString()))); + + var response = await InnerClient.GetResponseAsync(messages, options, cancellationToken); + + AnsiConsole.Write(new Panel(new JsonText(response.ToJsonString()))); + return response; + } + + public override async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + AnsiConsole.Write(new Panel(new JsonText(new + { + messages = messages.Where(x => x.Role != ChatRole.System).ToArray(), + options + }.ToJsonString()))); + + List updates = []; + + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + yield return update; + } + + AnsiConsole.Write(new Panel(new JsonText(updates.ToJsonString()))); + } + } +} diff --git a/src/AI/JsonExtensions.cs b/src/AI/JsonExtensions.cs new file mode 100644 index 0000000..7b94e3a --- /dev/null +++ b/src/AI/JsonExtensions.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Devlooped.Extensions.AI; + +static class JsonExtensions +{ + static readonly JsonSerializerOptions options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + + /// + /// Recursively truncates long strings in an object before serialization and optionally excludes additional properties. + /// + public static string ToJsonString(this object? value, int maxStringLength = 100, bool includeAdditionalProperties = true) + { + if (value is null) + return "{}"; + + var node = JsonSerializer.SerializeToNode(value, value.GetType(), options); + return FilterNode(node, maxStringLength, includeAdditionalProperties)?.ToJsonString() ?? "{}"; + } + + static JsonNode? FilterNode(JsonNode? node, int maxStringLength, bool includeAdditionalProperties) + { + if (node is JsonObject obj) + { + var filtered = new JsonObject(); + foreach (var prop in obj) + { + if (!includeAdditionalProperties && prop.Key == "AdditionalProperties") + continue; + if (FilterNode(prop.Value, maxStringLength, includeAdditionalProperties) is JsonNode value) + filtered[prop.Key] = value.DeepClone(); + } + return filtered; + } + if (node is JsonArray arr) + { + var filtered = new JsonArray(); + foreach (var item in arr) + { + if (FilterNode(item, maxStringLength, includeAdditionalProperties) is JsonNode value) + filtered.Add(value.DeepClone()); + } + + return filtered; + } + if (node is JsonValue val && val.TryGetValue(out string? str) && str is not null && str.Length > maxStringLength) + { + return str[..maxStringLength] + "..."; + } + return node; + } +} \ No newline at end of file diff --git a/src/Directory.props b/src/Directory.props index caa219a..3b6852d 100644 --- a/src/Directory.props +++ b/src/Directory.props @@ -7,6 +7,7 @@ 6eb457f9-16bc-49c5-81f2-33399b254e04 https://api.nuget.org/v3/index.json;https://pkg.kzu.app/index.json + https://github.com/devlooped/Extensions.AI \ No newline at end of file diff --git a/src/Directory.targets b/src/Directory.targets index 4de98b5..a790e87 100644 --- a/src/Directory.targets +++ b/src/Directory.targets @@ -1,3 +1,33 @@ - + + + + + + + + + + + + + + + + + + + + \ No newline at end of file