Files
Luna/Luna.Clients.Cli/ChatClientService.cs
T
2026-04-04 05:01:51 +02:00

212 lines
7.1 KiB
C#

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;
}
}
}