← Back to Files
KaiChatClient.cs
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();
}
}