From 1d7ae158a46b83f0aeee4435b05ae73eb88defad Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 4 Apr 2026 04:12:41 +0200 Subject: [PATCH] Add CLI client application with SignalR connectivity Implement standalone CLI client that connects to Luna.Core via SignalR for interactive chat sessions. Includes ChatClientService for managing the hub connection and streaming responses, with a hosted service architecture using Microsoft.Extensions.Hosting. --- Luna.Clients.Cli/ChatClientService.cs | 213 +++++++++++++++++++++++ Luna.Clients.Cli/Luna.Clients.Cli.csproj | 18 ++ Luna.Clients.Cli/Program.cs | 9 + 3 files changed, 240 insertions(+) create mode 100644 Luna.Clients.Cli/ChatClientService.cs create mode 100644 Luna.Clients.Cli/Luna.Clients.Cli.csproj create mode 100644 Luna.Clients.Cli/Program.cs diff --git a/Luna.Clients.Cli/ChatClientService.cs b/Luna.Clients.Cli/ChatClientService.cs new file mode 100644 index 0000000..6bb9ebe --- /dev/null +++ b/Luna.Clients.Cli/ChatClientService.cs @@ -0,0 +1,213 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Channels; +using Luna.Shared; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Luna.Cli; + +public class ChatClientService(IHostApplicationLifetime lifetime) : IHostedService +{ + private HubConnection? connection; + private readonly Guid conversationId = Guid.NewGuid(); + private Channel? responseChannel; + + public async Task StartAsync(CancellationToken cancellationToken) + { + var serverUrl = Environment.GetEnvironmentVariable("LUNA_SERVER_URL") ?? "http://localhost:5000"; + var hubUrl = $"{serverUrl.TrimEnd('/')}/chat"; + + connection = new HubConnectionBuilder() + .WithUrl(hubUrl) + .WithAutomaticReconnect() + .AddJsonProtocol(options => + { + options.PayloadSerializerOptions.Converters.Add( + new System.Text.Json.Serialization.JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy.CamelCase)); + }) + .Build(); + + connection.On("OnMessageStreamReceived", update => + { + responseChannel?.Writer.TryWrite(update); + + if (update.IsComplete) + responseChannel?.Writer.TryComplete(); + }); + + connection.Closed += error => + { + Console.WriteLine(error != null + ? $"[connection closed: {error.Message}]" + : "[connection closed]"); + responseChannel?.Writer.TryComplete(error); + return Task.CompletedTask; + }; + + connection.Reconnecting += error => + { + Console.WriteLine($"[reconnecting... {error?.Message}]"); + responseChannel?.Writer.TryComplete(error); + return Task.CompletedTask; + }; + + connection.Reconnected += connectionId => + { + Console.WriteLine($"[reconnected: {connectionId}]"); + return Task.CompletedTask; + }; + + try + { + await connection.StartAsync(cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine($"[failed to connect to {hubUrl}: {ex.Message}]"); + lifetime.StopApplication(); + return; + } + + // Print startup banner + Console.WriteLine("=== Luna CLI ==="); + Console.WriteLine($"Connected to {hubUrl}"); + Console.WriteLine("Type messages and press Enter to send."); + Console.WriteLine("Commands: /exit (quit), /clear (clear screen)"); + Console.WriteLine(); + + // Start input loop in background + _ = Task.Run(() => InputLoopAsync(lifetime.ApplicationStopping), lifetime.ApplicationStopping); + } + + private async Task InputLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("you> "); + Console.ResetColor(); + + var input = await Task.Run(() => Console.ReadLine(), cancellationToken); + + if (input is null || string.Equals(input.Trim(), "/exit", StringComparison.OrdinalIgnoreCase)) + { + lifetime.StopApplication(); + break; + } + + if (string.Equals(input.Trim(), "/clear", StringComparison.OrdinalIgnoreCase)) + { + Console.Clear(); + continue; + } + + if (string.IsNullOrWhiteSpace(input)) + { + continue; + } + + var message = new ChannelMessage( + MessageId: Guid.NewGuid().ToString(), + ConversationId: conversationId.ToString(), + SenderId: "cli-user", + SenderName: "User", + Content: input.Trim(), + Timestamp: DateTimeOffset.UtcNow + ); + + await StreamResponseAsync(message, cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Console.WriteLine($"[error] {ex.Message}"); + } + } + } + + private async Task StreamResponseAsync(ChannelMessage message, CancellationToken cancellationToken) + { + if (connection is null) return; + + responseChannel = Channel.CreateUnbounded(); + + var assistantLabelPrinted = false; + var thinkingLabelPrinted = false; + + try + { + await connection.InvokeAsync("OnMessageReceived", message, cancellationToken); + + await foreach (var update in responseChannel.Reader.ReadAllAsync(cancellationToken)) + { + if (update.IsComplete) + { + if (assistantLabelPrinted || thinkingLabelPrinted) + Console.WriteLine(); + break; + } + + switch (update.Type) + { + case ChatStreamUpdateType.Thinking: + if (!thinkingLabelPrinted) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write("thinking> "); + Console.ResetColor(); + thinkingLabelPrinted = true; + } + if (update.Content != null) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write(update.Content); + Console.ResetColor(); + } + break; + + case ChatStreamUpdateType.Text: + if (!assistantLabelPrinted) + { + Console.ForegroundColor = ConsoleColor.Magenta; + Console.Write("assistant> "); + Console.ResetColor(); + assistantLabelPrinted = true; + } + Console.Write(update.Content); + break; + case ChatStreamUpdateType.Usage: + Console.WriteLine($"\n[{update.Type.ToString().ToLower()}: {update.Content}]"); + break; + } + } + + // Ensure newline if stream ended without IsComplete flag + if (assistantLabelPrinted || thinkingLabelPrinted) + Console.WriteLine(); + } + catch (OperationCanceledException) + { + Console.WriteLine(); + } + catch (Exception ex) + { + Console.WriteLine($"\n[stream error] {ex.Message}"); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (connection != null) + { + await connection.DisposeAsync(); + connection = null; + } + } +} diff --git a/Luna.Clients.Cli/Luna.Clients.Cli.csproj b/Luna.Clients.Cli/Luna.Clients.Cli.csproj new file mode 100644 index 0000000..ee0b870 --- /dev/null +++ b/Luna.Clients.Cli/Luna.Clients.Cli.csproj @@ -0,0 +1,18 @@ + + + Exe + net10.0 + enable + enable + Luna.Cli + + + + + + + + + + + diff --git a/Luna.Clients.Cli/Program.cs b/Luna.Clients.Cli/Program.cs new file mode 100644 index 0000000..350fb38 --- /dev/null +++ b/Luna.Clients.Cli/Program.cs @@ -0,0 +1,9 @@ +using Luna.Cli; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); +builder.Logging.ClearProviders(); +builder.Services.AddHostedService(); +await builder.Build().RunAsync();