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.
This commit is contained in:
2026-04-04 04:12:41 +02:00
parent f21d55d87d
commit 1d7ae158a4
3 changed files with 240 additions and 0 deletions
+213
View File
@@ -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<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;
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Luna.Cli</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.4" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.Json" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Luna.Shared\Luna.Shared.csproj" />
</ItemGroup>
</Project>
+9
View File
@@ -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<ChatClientService>();
await builder.Build().RunAsync();