Files
Luna/Luna.Clients.Cli/ChatClientService.cs
T
darman 1d7ae158a4 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.
2026-04-04 04:12:41 +02:00

214 lines
7.1 KiB
C#

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<ChatStreamUpdate>? 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<ChatStreamUpdate>("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<ChatStreamUpdate>();
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;
}
}
}