Files
Luna/Luna.Clients.Cli/ChatClientService.cs
darman ec78c0cdf5 Add user identity system with channel pairing and web /link command
Introduce Luna.Identity project with user identity service, pairing code
generation, and channel linking. Channels (Telegram, CLI) now verify link
status before routing messages and prompt users to pair via the web
interface. WebChannel handles /link <channel-type> <code> commands to
complete the pairing flow.

Fix inverted expiration check in IsPairingCodeExpired that caused codes
to be treated as expired immediately after creation.
2026-04-04 16:09:10 +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("OnMessageReceivedAsync", 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;
}
}
}