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.
214 lines
7.1 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|