Size: 7.4 KB Modified: 3/07/2026 4:23 PM
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client;

namespace KaiChat.Mobile;

public sealed class KaiChatClient : IAsyncDisposable
{
    private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
    {
        PropertyNameCaseInsensitive = true
    };

    private HubConnection? _hub;
    private string _serverUrl = "";

    public event Action<string, string?>? ConnectionChanged;
    public event Action<string>? StreamReplayStarted;
    public event Action? StreamStarted;
    public event Action<string>? TokenReceived;
    public event Action<string>? ThinkingReceived;
    public event Action? ResponseTranslationStarted;
    public event Action<string>? ResponseTranslationDelta;
    public event Action<string>? ResponseTranslated;
    public event Action? StreamCompleted;
    public event Action? StreamCancelled;
    public event Action<string>? ErrorReceived;
    public event Action<string, ChatMessageDto>? UserMessageSaved;
    public event Action<Conversation>? ConversationCreated;
    public event Action<string, string>? ConversationRenamed;
    public event Action? ConversationsChanged;
    public event Action<string>? ToolCallReceived;

    public bool IsConnected => _hub?.State == HubConnectionState.Connected;
    public string ServerUrl => _serverUrl;

    public async Task ConfigureAsync(string serverUrl)
    {
        serverUrl = NormalizeServerUrl(serverUrl);
        if (_serverUrl == serverUrl && _hub != null) return;

        await DisposeHubAsync();
        _serverUrl = serverUrl;
        Preferences.Default.Set("kaichat.serverUrl", _serverUrl);
        BuildHub();
    }

    public async Task ConnectAsync()
    {
        if (_hub == null) BuildHub();
        if (_hub == null || _hub.State != HubConnectionState.Disconnected) return;

        RaiseOnMainThread(() => ConnectionChanged?.Invoke("Connecting", _serverUrl));
        try
        {
            await _hub.StartAsync();
            RaiseOnMainThread(() => ConnectionChanged?.Invoke("Connected", _serverUrl));
        }
        catch (Exception ex)
        {
            RaiseOnMainThread(() => ConnectionChanged?.Invoke("Offline", ex.Message));
            throw;
        }
    }

    public async Task<IReadOnlyList<ConversationSummary>> GetConversationsAsync()
    {
        using var http = CreateHttpClient();
        var conversations = await http.GetFromJsonAsync<List<ConversationSummary>>("/api/conversations", _jsonOptions);
        return conversations ?? new();
    }

    public async Task<Conversation?> GetConversationAsync(string id)
    {
        using var http = CreateHttpClient();
        return await http.GetFromJsonAsync<Conversation>($"/api/conversations/{Uri.EscapeDataString(id)}", _jsonOptions);
    }

    public Task JoinConversationAsync(string conversationId)
    {
        return _hub?.InvokeAsync("JoinConversation", conversationId) ?? Task.CompletedTask;
    }

    public Task SendMessageAsync(OutboxMessage message)
    {
        if (_hub == null) throw new InvalidOperationException("Hub is not configured.");

        return _hub.InvokeAsync(
            "SendMessage",
            message.ConversationId ?? "",
            message.Text,
            message.Title,
            message.MessageId,
            message.TruncateBeforeMessageId);
    }

    public Task StopStreamingAsync(string? conversationId)
    {
        return _hub?.InvokeAsync("StopStreaming", conversationId) ?? Task.CompletedTask;
    }

    private void BuildHub()
    {
        if (string.IsNullOrWhiteSpace(_serverUrl)) return;

        _hub = new HubConnectionBuilder()
            .WithUrl($"{_serverUrl}/chatHub")
            .WithAutomaticReconnect()
            .Build();

        _hub.On<string>("StreamReplayStarted", id => RaiseOnMainThread(() => StreamReplayStarted?.Invoke(id)));
        _hub.On("StreamStart", () => RaiseOnMainThread(() => StreamStarted?.Invoke()));
        _hub.On<string>("ReceiveToken", token => RaiseOnMainThread(() => TokenReceived?.Invoke(token)));
        _hub.On<string>("ReceiveThinking", text => RaiseOnMainThread(() => ThinkingReceived?.Invoke(text)));
        _hub.On<int>("ResponseTranslationStarted", _ => RaiseOnMainThread(() => ResponseTranslationStarted?.Invoke()));
        _hub.On<string>("ResponseTranslationDelta", token => RaiseOnMainThread(() => ResponseTranslationDelta?.Invoke(token)));
        _hub.On<string>("ResponseTranslated", text => RaiseOnMainThread(() => ResponseTranslated?.Invoke(text)));
        _hub.On("StreamComplete", () => RaiseOnMainThread(() => StreamCompleted?.Invoke()));
        _hub.On<string>("StreamComplete", _ => RaiseOnMainThread(() => StreamCompleted?.Invoke()));
        _hub.On("StreamCancelled", () => RaiseOnMainThread(() => StreamCancelled?.Invoke()));
        _hub.On<string>("Error", error => RaiseOnMainThread(() => ErrorReceived?.Invoke(error)));
        _hub.On<string, ChatMessageDto>("UserMessageSaved", (conversationId, message) => RaiseOnMainThread(() => UserMessageSaved?.Invoke(conversationId, message)));
        _hub.On<Conversation>("ConversationCreated", conv => RaiseOnMainThread(() => ConversationCreated?.Invoke(conv)));
        _hub.On<string, string>("ConversationRenamed", (id, title) => RaiseOnMainThread(() => ConversationRenamed?.Invoke(id, title)));
        _hub.On("ConversationsChanged", () => RaiseOnMainThread(() => ConversationsChanged?.Invoke()));
        _hub.On<string, string>("ToolCall", (_, status) => RaiseOnMainThread(() => ToolCallReceived?.Invoke(status)));

        _hub.Reconnecting += error =>
        {
            RaiseOnMainThread(() => ConnectionChanged?.Invoke("Reconnecting", error?.Message));
            return Task.CompletedTask;
        };

        _hub.Reconnected += _ =>
        {
            RaiseOnMainThread(() => ConnectionChanged?.Invoke("Connected", _serverUrl));
            return Task.CompletedTask;
        };

        _hub.Closed += error =>
        {
            RaiseOnMainThread(() => ConnectionChanged?.Invoke("Offline", error?.Message));
            return Task.CompletedTask;
        };
    }

    private static void RaiseOnMainThread(Action action)
    {
        if (MainThread.IsMainThread)
        {
            TryRaise(action);
            return;
        }

        MainThread.BeginInvokeOnMainThread(() => TryRaise(action));
    }

    private static void TryRaise(Action action)
    {
        try
        {
            action();
        }
        catch
        {
            // UI event handlers should not be able to take down the Android process.
        }
    }

    private HttpClient CreateHttpClient()
    {
        if (string.IsNullOrWhiteSpace(_serverUrl))
            throw new InvalidOperationException("KaiChat server URL is not set.");

        return new HttpClient { BaseAddress = new Uri(_serverUrl) };
    }

    private static string NormalizeServerUrl(string serverUrl)
    {
        serverUrl = serverUrl.Trim().TrimEnd('/');
        if (serverUrl.Length == 0) return "";
        if (!serverUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
            !serverUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
        {
            serverUrl = "http://" + serverUrl;
        }
        return serverUrl;
    }

    private async Task DisposeHubAsync()
    {
        if (_hub == null) return;
        await _hub.DisposeAsync();
        _hub = null;
    }

    public async ValueTask DisposeAsync()
    {
        await DisposeHubAsync();
    }
}
Offline