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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user