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