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