From be1c3d4b263fc01fee871d73d952266f2bc3949e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 19:27:41 +0000 Subject: [PATCH] Add local API server (OpenAI-compatible) on port 11435 - Add `Microsoft.AspNetCore.App` FrameworkReference to enable embedded Kestrel server. - Add `internetClientServer` and `privateNetworkClientServer` capabilities to `Package.appxmanifest`. - Implement `ApiService` hosting an embedded ASP.NET Core server. - Expose `GET /v1/models` and `POST /v1/chat/completions` (streaming). - Integrate `ApiService` startup and shutdown into `App.xaml.cs`. --- KaiROS.AI/App.xaml.cs | 18 ++- KaiROS.AI/KaiROS.AI.csproj | 4 + KaiROS.AI/Package.appxmanifest | 2 + KaiROS.AI/Services/ApiService.cs | 223 +++++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 KaiROS.AI/Services/ApiService.cs diff --git a/KaiROS.AI/App.xaml.cs b/KaiROS.AI/App.xaml.cs index dfbbbfe..150886f 100644 --- a/KaiROS.AI/App.xaml.cs +++ b/KaiROS.AI/App.xaml.cs @@ -29,6 +29,11 @@ protected override void OnStartup(System.Windows.StartupEventArgs e) ConfigureServices(services, configuration); _serviceProvider = services.BuildServiceProvider(); + // Start API Server + var apiService = _serviceProvider.GetRequiredService(); + // Fire and forget - runs in background + _ = apiService.StartAsync(); + // Create and show main window var mainWindow = _serviceProvider.GetRequiredService(); mainWindow.Show(); @@ -97,6 +102,7 @@ private void ConfigureServices(IServiceCollection services, IConfiguration confi services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); // ViewModels services.AddSingleton(); @@ -111,7 +117,17 @@ private void ConfigureServices(IServiceCollection services, IConfiguration confi protected override void OnExit(System.Windows.ExitEventArgs e) { - _serviceProvider?.Dispose(); + if (_serviceProvider != null) + { + var apiService = _serviceProvider.GetService(); + if (apiService != null) + { + // We can't await here in OnExit, but StopAsync should be fast enough + // or we could use OnSessionEnding/etc. + apiService.StopAsync().Wait(); + } + _serviceProvider.Dispose(); + } base.OnExit(e); } } diff --git a/KaiROS.AI/KaiROS.AI.csproj b/KaiROS.AI/KaiROS.AI.csproj index 9b7f2e3..205983e 100644 --- a/KaiROS.AI/KaiROS.AI.csproj +++ b/KaiROS.AI/KaiROS.AI.csproj @@ -51,6 +51,10 @@ + + + + PreserveNewest diff --git a/KaiROS.AI/Package.appxmanifest b/KaiROS.AI/Package.appxmanifest index a923c70..09b903a 100644 --- a/KaiROS.AI/Package.appxmanifest +++ b/KaiROS.AI/Package.appxmanifest @@ -43,5 +43,7 @@ + + diff --git a/KaiROS.AI/Services/ApiService.cs b/KaiROS.AI/Services/ApiService.cs new file mode 100644 index 0000000..befe083 --- /dev/null +++ b/KaiROS.AI/Services/ApiService.cs @@ -0,0 +1,223 @@ +using KaiROS.AI.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace KaiROS.AI.Services; + +public class ApiService +{ + private WebApplication? _app; + private readonly IChatService _chatService; + private readonly IModelManagerService _modelManager; + private const int Port = 11435; + + public ApiService(IChatService chatService, IModelManagerService modelManager) + { + _chatService = chatService; + _modelManager = modelManager; + } + + public async Task StartAsync() + { + if (_app != null) return; + + try + { + var builder = WebApplication.CreateBuilder(); + + // Configure Kestrel + builder.WebHost.ConfigureKestrel(options => + { + options.ListenLocalhost(Port); + }); + + // Add services + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + // Suppress logging to console (avoid messing with WPF stdout if any) + builder.Logging.ClearProviders(); + builder.Logging.AddDebug(); + + _app = builder.Build(); + + _app.UseCors(); + + // Endpoints + _app.MapGet("/api/status", HandleStatus); + _app.MapPost("/v1/chat/completions", HandleChatCompletions); + _app.MapGet("/v1/models", HandleListModels); // Standard OpenAI endpoint + + await _app.StartAsync(); + System.Diagnostics.Debug.WriteLine($"API Server started on port {Port}"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to start API Server: {ex.Message}"); + } + } + + public async Task StopAsync() + { + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + _app = null; + } + } + + private IResult HandleStatus() + { + return Results.Ok(new + { + status = "running", + model_loaded = _chatService.IsModelLoaded, + active_model = _modelManager.ActiveModel?.Name, + backend = _chatService.LastStats.BackendInUse ?? "Unknown" + }); + } + + private IResult HandleListModels() + { + var models = _modelManager.Models + .Where(m => m.IsDownloaded) + .Select(m => new + { + id = m.Name, + object_type = "model", + created = 0, + owned_by = "kairos-ai" + }); + + return Results.Ok(new { object_type = "list", data = models }); + } + + private async Task HandleChatCompletions(HttpContext context) + { + if (!_chatService.IsModelLoaded) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsJsonAsync(new { error = "No model loaded" }); + return; + } + + try + { + using var reader = new StreamReader(context.Request.Body); + var body = await reader.ReadToEndAsync(); + var request = JsonSerializer.Deserialize(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (request?.Messages == null || !request.Messages.Any()) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsJsonAsync(new { error = "Messages are required" }); + return; + } + + // Convert API messages to internal ChatMessage + var messages = request.Messages.Select(m => new ChatMessage + { + Role = ParseRole(m.Role), + Content = m.Content + }).ToList(); + + context.Response.Headers.Append("Content-Type", "text/event-stream"); + context.Response.Headers.Append("Cache-Control", "no-cache"); + context.Response.Headers.Append("Connection", "keep-alive"); + + var cancellationToken = context.RequestAborted; + var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var id = $"chatcmpl-{Guid.NewGuid()}"; + + await foreach (var token in _chatService.GenerateResponseStreamAsync(messages, cancellationToken)) + { + var chunk = new + { + id, + @object = "chat.completion.chunk", + created, + model = _modelManager.ActiveModel?.Name, + choices = new[] + { + new + { + index = 0, + delta = new { content = token }, + finish_reason = (string?)null + } + } + }; + + await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(chunk)}\n\n", cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + } + + // Send finish chunk + var finishChunk = new + { + id, + @object = "chat.completion.chunk", + created, + model = _modelManager.ActiveModel?.Name, + choices = new[] + { + new + { + index = 0, + delta = new { }, + finish_reason = "stop" + } + } + }; + await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(finishChunk)}\n\n", cancellationToken); + await context.Response.WriteAsync("data: [DONE]\n\n", cancellationToken); + } + catch (Exception ex) + { + // If we haven't started streaming, send error JSON + if (!context.Response.HasStarted) + { + context.Response.StatusCode = 500; + await context.Response.WriteAsJsonAsync(new { error = ex.Message }); + } + } + } + + private static ChatRole ParseRole(string role) + { + return role.ToLower() switch + { + "system" => ChatRole.System, + "assistant" => ChatRole.Assistant, + _ => ChatRole.User + }; + } +} + +// Request Models +public class ChatCompletionRequest +{ + public string Model { get; set; } = ""; + public List Messages { get; set; } = new(); + public bool Stream { get; set; } = true; +} + +public class MessageDto +{ + public string Role { get; set; } = ""; + public string Content { get; set; } = ""; +}