Skip to content

Commit 7e399cb

Browse files
committed
Add JSON console output rendering
1 parent 44dced5 commit 7e399cb

File tree

9 files changed

+1201
-70
lines changed

9 files changed

+1201
-70
lines changed

.netconfig

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,13 @@
151151
sha = 77e83f238196d2723640abef0c7b6f43994f9747
152152
etag = fcb9759a96966df40dcd24906fd328ddec05953b7e747a6bb8d0d1e4c3865274
153153
weak
154+
[file "src/AI/Extensions/System/Throw.cs"]
155+
url = https://github.com/devlooped/catbag/blob/main/System/Throw.cs
156+
sha = 3012d56be7554c483e5c5d277144c063969cada9
157+
etag = 43c81c6c6dcdf5baee40a9e3edc5e871e473e6c954c901b82bb87a3a48888ea0
158+
weak
159+
[file "src/Weaving/Extensions/System/Throw.cs"]
160+
url = https://github.com/devlooped/catbag/blob/main/System/Throw.cs
161+
sha = 3012d56be7554c483e5c5d277144c063969cada9
162+
etag = 43c81c6c6dcdf5baee40a9e3edc5e871e473e6c954c901b82bb87a3a48888ea0
163+
weak

src/AI.Tests/AI.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
<ItemGroup>
99
<PackageReference Include="coverlet.collector" Version="6.0.4" />
10-
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.5.0-preview.1.25265.7" />
10+
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.6.0-preview.1.25310.2" />
1111
<PackageReference Include="xunit" Version="2.9.3" />
1212
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" PrivateAssets="all" />
1313
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />

src/AI/AI.csproj

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
<PropertyGroup>
44
<TargetFramework>net8.0</TargetFramework>
5+
<LangVersion>Preview</LangVersion>
56
<PackageId>Devlooped.Extensions.AI</PackageId>
67
<Description>Extensions for Microsoft.Extensions.AI</Description>
7-
<PackOnBuild>true</PackOnBuild>
88
</PropertyGroup>
99

1010
<ItemGroup>
11+
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.6" />
1112
<PackageReference Include="NuGetizer" Version="1.2.4" PrivateAssets="all" />
12-
<PackageReference Include="Microsoft.Extensions.AI" Version="9.5.0" />
13+
<PackageReference Include="Microsoft.Extensions.AI" Version="9.6.0" />
14+
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.6.0-preview.1.25310.2" />
15+
<PackageReference Include="OpenAI" Version="2.2.0-beta.4" />
1316
<PackageReference Include="Spectre.Console" Version="0.50.0" />
1417
<PackageReference Include="Spectre.Console.Json" Version="0.50.0" />
1518
</ItemGroup>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Devlooped.Extensions.AI;
2+
3+
static class ConsoleExtensions
4+
{
5+
public static bool IsConsoleInteractive =>
6+
!Console.IsInputRedirected &&
7+
!Console.IsOutputRedirected &&
8+
!Console.IsErrorRedirected &&
9+
Environment.UserInteractive;
10+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System.Runtime.CompilerServices;
2+
using Microsoft.Extensions.AI;
3+
using Spectre.Console;
4+
using Spectre.Console.Json;
5+
6+
namespace Devlooped.Extensions.AI;
7+
8+
/// <summary>
9+
/// Chat client that logs messages and responses to the console in JSON format using Spectre.Console.
10+
/// </summary>
11+
/// <param name="innerClient"></param>
12+
public class JsonConsoleLoggingChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient)
13+
{
14+
/// <summary>
15+
/// Whether to include additional properties in the JSON output.
16+
/// </summary>
17+
public bool IncludeAdditionalProperties { get; set; } = true;
18+
19+
/// <summary>
20+
/// Optional maximum length to render for string values. Replaces remaining characters with "...".
21+
/// </summary>
22+
public int? MaxLength { get; set; }
23+
24+
public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
25+
{
26+
AnsiConsole.Write(new Panel(new JsonText(new
27+
{
28+
messages = messages.Where(x => x.Role != ChatRole.System).ToArray(),
29+
options
30+
}.ToJsonString(MaxLength, IncludeAdditionalProperties))));
31+
32+
var response = await InnerClient.GetResponseAsync(messages, options, cancellationToken);
33+
34+
AnsiConsole.Write(new Panel(new JsonText(response.ToJsonString(MaxLength, IncludeAdditionalProperties))));
35+
return response;
36+
}
37+
38+
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
39+
{
40+
AnsiConsole.Write(new Panel(new JsonText(new
41+
{
42+
messages = messages.Where(x => x.Role != ChatRole.System).ToArray(),
43+
options
44+
}.ToJsonString(MaxLength, IncludeAdditionalProperties))));
45+
46+
List<ChatResponseUpdate> updates = [];
47+
48+
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken))
49+
{
50+
updates.Add(update);
51+
yield return update;
52+
}
53+
54+
AnsiConsole.Write(new Panel(new JsonText(updates.ToJsonString(MaxLength, IncludeAdditionalProperties))));
55+
}
56+
}
57+
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using System.ClientModel.Primitives;
2+
using System.ComponentModel;
3+
using Devlooped.Extensions.AI;
4+
using Spectre.Console;
5+
using Spectre.Console.Json;
6+
7+
namespace Microsoft.Extensions.AI;
8+
9+
/// <summary>
10+
/// Adds console logging capabilities to the chat client and pipeline transport.
11+
/// </summary>
12+
[EditorBrowsable(EditorBrowsableState.Never)]
13+
public static class JsonConsoleLoggingExtensions
14+
{
15+
/// <summary>
16+
/// Sets a <see cref="ClientPipelineOptions.Transport"/> that renders HTTP messages to the
17+
/// console using Spectre.Console rich JSON formatting, but only if the console is interactive.
18+
/// </summary>
19+
/// <typeparam name="TOptions">The options type to configure for HTTP logging.</typeparam>
20+
/// <param name="options">The options instance to configure.</param>
21+
/// <remarks>
22+
/// NOTE: this is the lowst-level logging after all chat pipeline processing has been done.
23+
/// <para>
24+
/// If the options already provide a transport, it will be wrapped with the console
25+
/// logging transport to minimize the impact on existing configurations.
26+
/// </para>
27+
/// </remarks>
28+
public static TOptions UseJsonConsoleLogging<TOptions>(this TOptions options)
29+
where TOptions : ClientPipelineOptions
30+
=> UseJsonConsoleLogging(options, ConsoleExtensions.IsConsoleInteractive);
31+
32+
/// <summary>
33+
/// Sets a <see cref="ClientPipelineOptions.Transport"/> that renders HTTP messages to the
34+
/// console using Spectre.Console rich JSON formatting.
35+
/// </summary>
36+
/// <typeparam name="TOptions">The options type to configure for HTTP logging.</typeparam>
37+
/// <param name="options">The options instance to configure.</param>
38+
/// <param name="askConfirmation">Whether to confirm logging before enabling it.</param>
39+
/// <remarks>
40+
/// NOTE: this is the lowst-level logging after all chat pipeline processing has been done.
41+
/// <para>
42+
/// If the options already provide a transport, it will be wrapped with the console
43+
/// logging transport to minimize the impact on existing configurations.
44+
/// </para>
45+
/// </remarks>
46+
public static TOptions UseJsonConsoleLogging<TOptions>(this TOptions options, bool askConfirmation)
47+
where TOptions : ClientPipelineOptions
48+
{
49+
if (askConfirmation && !AnsiConsole.Confirm("Do you want to enable rich JSON console logging for HTTP pipeline messages?"))
50+
return options;
51+
52+
options.Transport = new ConsoleLoggingPipelineTransport(options.Transport ?? HttpClientPipelineTransport.Shared);
53+
54+
return options;
55+
}
56+
57+
/// <summary>
58+
/// Renders chat messages and responses to the console using Spectre.Console rich JSON formatting.
59+
/// </summary>
60+
/// <param name="builder">The builder in use.</param>
61+
/// <remarks>
62+
/// Confirmation will be asked if the console is interactive, otherwise, it will be
63+
/// enabled unconditionally.
64+
/// </remarks>
65+
public static ChatClientBuilder UseJsonConsoleLogging(this ChatClientBuilder builder)
66+
=> UseJsonConsoleLogging(builder, ConsoleExtensions.IsConsoleInteractive);
67+
68+
/// <summary>
69+
/// Renders chat messages and responses to the console using Spectre.Console rich JSON formatting.
70+
/// </summary>
71+
/// <param name="builder">The builder in use.</param>
72+
/// <param name="askConfirmation">If true, prompts the user for confirmation before enabling console logging.</param>
73+
/// <param name="maxLength">Optional maximum length to render for string values. Replaces remaining characters with "...".</param>
74+
public static ChatClientBuilder UseJsonConsoleLogging(this ChatClientBuilder builder, bool askConfirmation = false, Action<JsonConsoleLoggingChatClient>? configure = null)
75+
{
76+
if (askConfirmation && !AnsiConsole.Confirm("Do you want to enable console logging for chat messages?"))
77+
return builder;
78+
79+
return builder.Use(inner =>
80+
{
81+
var client = new JsonConsoleLoggingChatClient(inner);
82+
configure?.Invoke(client);
83+
return client;
84+
});
85+
}
86+
87+
class ConsoleLoggingPipelineTransport(PipelineTransport inner) : PipelineTransport
88+
{
89+
public static PipelineTransport Default { get; } = new ConsoleLoggingPipelineTransport();
90+
91+
public ConsoleLoggingPipelineTransport() : this(HttpClientPipelineTransport.Shared) { }
92+
93+
protected override PipelineMessage CreateMessageCore() => inner.CreateMessage();
94+
protected override void ProcessCore(PipelineMessage message) => inner.Process(message);
95+
96+
protected override async ValueTask ProcessCoreAsync(PipelineMessage message)
97+
{
98+
message.BufferResponse = true;
99+
await inner.ProcessAsync(message);
100+
101+
if (message.Request.Content is not null)
102+
{
103+
using var memory = new MemoryStream();
104+
message.Request.Content.WriteTo(memory);
105+
memory.Position = 0;
106+
using var reader = new StreamReader(memory);
107+
var content = await reader.ReadToEndAsync();
108+
109+
AnsiConsole.Write(new Panel(new JsonText(content)));
110+
}
111+
112+
if (message.Response != null)
113+
{
114+
AnsiConsole.Write(new Panel(new JsonText(message.Response.Content.ToString())));
115+
}
116+
}
117+
}
118+
119+
}

0 commit comments

Comments
 (0)