diff --git a/.github/workflows/PR-Build-And-Test.yml b/.github/workflows/PR-Build-And-Test.yml index af2f3b70..4a4e0c3a 100644 --- a/.github/workflows/PR-Build-And-Test.yml +++ b/.github/workflows/PR-Build-And-Test.yml @@ -11,6 +11,11 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Set up .NET Core uses: actions/setup-dotnet@v5 with: diff --git a/Directory.Packages.props b/Directory.Packages.props index 604ddc38..091cb58d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -63,6 +63,9 @@ + + + diff --git a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj index 20ffba5d..44a36eb7 100644 --- a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj +++ b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj @@ -6,6 +6,9 @@ + + + diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs index 817a48ae..7fafb14b 100644 --- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.SemanticKernel; using Npgsql; @@ -14,6 +15,66 @@ public static class ServiceCollectionExtensions { private static readonly string[] _PostgresScopes = ["https://ossrdbms-aad.database.windows.net/.default"]; + /// + /// Resolves the AI mode once and applies environment-specific enforcement. + /// Development allows Disabled, Local, or Azure modes. Non-Development requires Azure. + /// + public static IHostApplicationBuilder AddAIServices( + this IHostApplicationBuilder builder, + IConfiguration configuration, + AIConfigurationState? aiConfigurationState = null) + { + builder.Services.Configure(configuration.GetSection("AIOptions")); + aiConfigurationState ??= AIConfigurationState.From(configuration.GetSection("AIOptions").Get()); + builder.Services.AddSingleton(aiConfigurationState); + + if (!builder.Environment.IsDevelopment() && !aiConfigurationState.UsesAzureAI) + { + throw new InvalidOperationException( + "Non-Development environments require AIOptions:Endpoint to be configured. Local AI is only supported in Development."); + } + + if (aiConfigurationState.UsesLocalAI) + { + builder.AddLocalAIServices(configuration); + } + else if (aiConfigurationState.UsesAzureAI) + { + builder.Services.AddAzureOpenAIServices(configuration); + } + else + { + builder.Services.AddSingleton(); + } + + return builder; + } + + /// + /// Registers the Ollama-backed local AI services. Uses IChatClient from + /// CommunityToolkit.Aspire.OllamaSharp. Vector search (RAG) is disabled in Phase 1 + /// due to the embedding dimension mismatch (Ollama nomic-embed-text = 768 dims, + /// pgvector schema expects 1536). + /// + public static IHostApplicationBuilder AddLocalAIServices( + this IHostApplicationBuilder builder, + IConfiguration configuration) + { + builder.Services.Configure(configuration.GetSection("AIOptions")); + + // Registers IChatClient backed by the Ollama "ollama-chat" resource. + // Connection string injected by Aspire: Endpoint=http://...:11434;Model=qwen2.5-coder:7b + builder.AddOllamaApiClient("ollama-chat") + .AddChatClient(); + + // NOTE: ollama-embed (nomic-embed-text, 768 dims) not registered in Phase 1. + // The pgvector schema hardcodes 1536 dims — incompatible without schema migration. + // Phase 2: register IEmbeddingGenerator + configure VectorStoreCollectionDefinition. + + builder.Services.AddSingleton(); + return builder; + } + /// /// Adds Azure OpenAI and related AI services to the service collection using Managed Identity /// @@ -65,10 +126,13 @@ public static IServiceCollection AddAzureOpenAIServices( .UseOpenTelemetry(); #pragma warning restore SKEXP0010 - // Register shared AI services + // Register shared AI services — forward IAIChatService to the concrete instance + // so the CLI tool (GetRequiredService()) and the web app + // (GetRequiredService()) share the same singleton. services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); return services; diff --git a/EssentialCSharp.Chat.Shared/Models/AIConfigurationState.cs b/EssentialCSharp.Chat.Shared/Models/AIConfigurationState.cs new file mode 100644 index 00000000..668d0c6c --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Models/AIConfigurationState.cs @@ -0,0 +1,36 @@ +namespace EssentialCSharp.Chat; + +public enum AIServiceMode +{ + Disabled, + Local, + Azure +} + +public sealed record AIConfigurationState(AIServiceMode Mode) +{ + public const string DevelopmentUnavailableMessage = + "AI chat is unavailable for this local run. Start the site with Aspire local AI or configure Azure AI to enable chat."; + + public bool IsAvailable => Mode is AIServiceMode.Local or AIServiceMode.Azure; + public bool IsDisabled => Mode == AIServiceMode.Disabled; + public bool UsesLocalAI => Mode == AIServiceMode.Local; + public bool UsesAzureAI => Mode == AIServiceMode.Azure; + + public static AIConfigurationState From(AIOptions? options) + { + options ??= new AIOptions(); + + if (!string.IsNullOrWhiteSpace(options.Endpoint)) + { + return new(AIServiceMode.Azure); + } + + if (options.UseLocalAI) + { + return new(AIServiceMode.Local); + } + + return new(AIServiceMode.Disabled); + } +} diff --git a/EssentialCSharp.Chat.Shared/Models/AIOptions.cs b/EssentialCSharp.Chat.Shared/Models/AIOptions.cs index 3d0123c6..4cee8e22 100644 --- a/EssentialCSharp.Chat.Shared/Models/AIOptions.cs +++ b/EssentialCSharp.Chat.Shared/Models/AIOptions.cs @@ -22,4 +22,10 @@ public class AIOptions /// public string Endpoint { get; set; } = string.Empty; + /// + /// When true, uses a local Ollama backend via IChatClient instead of Azure OpenAI. + /// Set by Aspire via the AIOptions__UseLocalAI environment variable. + /// + public bool UseLocalAI { get; set; } + } diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index 8dfab8c4..7bf35e8d 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -9,7 +9,7 @@ namespace EssentialCSharp.Chat.Common.Services; /// /// Service for handling AI chat completions using the OpenAI Responses API /// -public class AIChatService +public class AIChatService : IAIChatService { private readonly AIOptions _Options; private readonly AzureOpenAIClient _AzureClient; diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatUnavailableException.cs b/EssentialCSharp.Chat.Shared/Services/AIChatUnavailableException.cs new file mode 100644 index 00000000..d257ef76 --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/AIChatUnavailableException.cs @@ -0,0 +1,3 @@ +namespace EssentialCSharp.Chat.Common.Services; + +public sealed class AIChatUnavailableException(string message) : InvalidOperationException(message); diff --git a/EssentialCSharp.Chat.Shared/Services/IAIChatService.cs b/EssentialCSharp.Chat.Shared/Services/IAIChatService.cs new file mode 100644 index 00000000..4e223078 --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/IAIChatService.cs @@ -0,0 +1,31 @@ +using ModelContextProtocol.Client; +using OpenAI.Responses; + +namespace EssentialCSharp.Chat.Common.Services; + +public interface IAIChatService +{ + Task<(string response, string responseId)> GetChatCompletion( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + IMcpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + CancellationToken cancellationToken = default); + + IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + IMcpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + CancellationToken cancellationToken = default); +} diff --git a/EssentialCSharp.Chat.Shared/Services/LocalAIChatService.cs b/EssentialCSharp.Chat.Shared/Services/LocalAIChatService.cs new file mode 100644 index 00000000..02ac8594 --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/LocalAIChatService.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using OpenAI.Responses; + +namespace EssentialCSharp.Chat.Common.Services; + +/// +/// Local AI chat service using IChatClient (e.g. Ollama via CommunityToolkit.Aspire.OllamaSharp). +/// Compared to the Azure path: conversation history is in-memory only (lost on restart), +/// ResponseTool/ReasoningEffortLevel params are silently ignored, and vector search (RAG) +/// is disabled. Intended for local development without Azure credentials. +/// +public class LocalAIChatService : IAIChatService +{ + private readonly IChatClient _chatClient; + private readonly AIOptions _options; + private readonly ILogger _logger; + + // Synthetic conversation history keyed by GUID responseId. + // In-memory only — not shared across instances and lost on restart. + // ConcurrentDictionary prevents crashes from parallel requests (e.g., two chat tabs). + private readonly ConcurrentDictionary> _conversations = new(); + + public LocalAIChatService( + IOptions options, + IChatClient chatClient, + ILogger logger) + { + _options = options.Value; + _chatClient = chatClient; + _logger = logger; + } + + public async Task<(string response, string responseId)> GetChatCompletion( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + IMcpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + CancellationToken cancellationToken = default) + { + WarnUnsupportedFeatures(tools, reasoningEffortLevel, enableContextualSearch); + + var messages = BuildMessages(prompt, systemPrompt, previousResponseId); + var response = await _chatClient.GetResponseAsync(messages, cancellationToken: cancellationToken); + var responseText = response.Text ?? string.Empty; + var responseId = SaveConversation(messages, responseText, previousResponseId); + return (responseText, responseId); + } + + public async IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + IMcpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + WarnUnsupportedFeatures(tools, reasoningEffortLevel, enableContextualSearch); + + var messages = BuildMessages(prompt, systemPrompt, previousResponseId); + var fullResponse = new System.Text.StringBuilder(); + + await foreach (var update in _chatClient.GetStreamingResponseAsync(messages, cancellationToken: cancellationToken)) + { + if (!string.IsNullOrEmpty(update.Text)) + { + fullResponse.Append(update.Text); + yield return (update.Text, null); + } + } + + var responseId = SaveConversation(messages, fullResponse.ToString(), previousResponseId); + yield return (string.Empty, responseId); + } + +#pragma warning disable OPENAI001 + private void WarnUnsupportedFeatures( + IEnumerable? tools, + ResponseReasoningEffortLevel? reasoningEffortLevel, + bool enableContextualSearch) +#pragma warning restore OPENAI001 + { + if (tools is not null || reasoningEffortLevel is not null) + { + _logger.LogWarning("LocalAIChatService: ResponseTool and ReasoningEffortLevel are Azure-specific and are ignored in local mode."); + } + + if (enableContextualSearch) + { + _logger.LogWarning("LocalAIChatService: Vector search (RAG) is disabled in local mode (Phase 1). Run in Azure mode to enable contextual search."); + } + } + + private List BuildMessages(string prompt, string? systemPrompt, string? previousResponseId) + { + var messages = new List(); + + var sys = string.IsNullOrWhiteSpace(systemPrompt) ? _options.SystemPrompt : systemPrompt; + if (!string.IsNullOrWhiteSpace(sys)) + messages.Add(new ChatMessage(ChatRole.System, sys)); + + if (previousResponseId is not null && _conversations.TryGetValue(previousResponseId, out var history)) + messages.AddRange(history); + + messages.Add(new ChatMessage(ChatRole.User, prompt)); + return messages; + } + + private string SaveConversation(List messages, string assistantResponse, string? previousResponseId) + { + var history = messages.Where(m => m.Role != ChatRole.System).ToList(); + history.Add(new ChatMessage(ChatRole.Assistant, assistantResponse)); + + var newId = Guid.NewGuid().ToString("N"); + _conversations[newId] = history; + + // Remove previous entry to avoid unbounded memory growth. + // TryRemove is safe on ConcurrentDictionary. + if (previousResponseId is not null) + _conversations.TryRemove(previousResponseId, out _); + + return newId; + } +} diff --git a/EssentialCSharp.Chat.Shared/Services/UnavailableAIChatService.cs b/EssentialCSharp.Chat.Shared/Services/UnavailableAIChatService.cs new file mode 100644 index 00000000..c065e45c --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/UnavailableAIChatService.cs @@ -0,0 +1,46 @@ +using ModelContextProtocol.Client; +using OpenAI.Responses; + +namespace EssentialCSharp.Chat.Common.Services; + +public sealed class UnavailableAIChatService : IAIChatService +{ + private static AIChatUnavailableException CreateException() => + new(AIConfigurationState.DevelopmentUnavailableMessage); + + public Task<(string response, string responseId)> GetChatCompletion( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + IMcpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + CancellationToken cancellationToken = default) => + Task.FromException<(string response, string responseId)>(CreateException()); + + public IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + IMcpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + CancellationToken cancellationToken = default) => + ThrowUnavailable(cancellationToken); + + private static async IAsyncEnumerable<(string text, string? responseId)> ThrowUnavailable( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + throw CreateException(); +#pragma warning disable CS0162 + yield break; +#pragma warning restore CS0162 + } +} diff --git a/EssentialCSharp.Chat.Tests/ServiceCollectionExtensionsTests.cs b/EssentialCSharp.Chat.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..f3faefff --- /dev/null +++ b/EssentialCSharp.Chat.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,117 @@ +using EssentialCSharp.Chat.Common.Extensions; +using EssentialCSharp.Chat.Common.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace EssentialCSharp.Chat.Tests; + +public class ServiceCollectionExtensionsTests +{ + [Test] + public async Task AddAIServices_WhenDevelopmentWithoutConfiguration_RegistersUnavailableAIService() + { + var builder = CreateBuilder(Environments.Development); + + builder.AddAIServices(builder.Configuration); + + var descriptor = builder.Services.LastOrDefault(service => service.ServiceType == typeof(IAIChatService)); + await Assert.That(descriptor).IsNotNull(); + await Assert.That(descriptor!.ImplementationType).IsEqualTo(typeof(UnavailableAIChatService)); + } + + [Test] + public async Task AddAIServices_WhenUseLocalAI_RegistersLocalAIService() + { + var builder = CreateBuilder( + Environments.Development, + new Dictionary + { + ["AIOptions:UseLocalAI"] = bool.TrueString, + ["ConnectionStrings:ollama-chat"] = "Endpoint=http://localhost:11434;Model=qwen2.5-coder:7b" + }); + + builder.AddAIServices(builder.Configuration); + + var descriptor = builder.Services.LastOrDefault(service => service.ServiceType == typeof(IAIChatService)); + await Assert.That(descriptor).IsNotNull(); + await Assert.That(descriptor!.ImplementationType).IsEqualTo(typeof(LocalAIChatService)); + } + + [Test] + public async Task AddAIServices_WhenAzureEndpointConfigured_RegistersAzureAIService() + { + var builder = CreateBuilder( + Environments.Production, + new Dictionary + { + ["AIOptions:Endpoint"] = "https://example.openai.azure.com/", + ["AIOptions:ChatDeploymentName"] = "chat", + ["AIOptions:VectorGenerationDeploymentName"] = "embeddings", + ["ConnectionStrings:PostgresVectorStore"] = "Host=test.postgres.database.azure.com;Database=app;Username=user" + }); + + builder.AddAIServices(builder.Configuration); + + await Assert.That(builder.Services.Any(service => service.ServiceType == typeof(AIChatService))).IsTrue(); + await Assert.That(builder.Services.Any(service => service.ServiceType == typeof(IAIChatService))).IsTrue(); + } + + [Test] + public async Task AddAIServices_WhenEndpointAndUseLocalAIConfigured_UsesAzureAI() + { + var builder = CreateBuilder( + Environments.Development, + new Dictionary + { + ["AIOptions:UseLocalAI"] = bool.TrueString, + ["AIOptions:Endpoint"] = "https://example.openai.azure.com/", + ["AIOptions:ChatDeploymentName"] = "chat", + ["AIOptions:VectorGenerationDeploymentName"] = "embeddings", + ["ConnectionStrings:PostgresVectorStore"] = "Host=test.postgres.database.azure.com;Database=app;Username=user", + ["ConnectionStrings:ollama-chat"] = "Endpoint=http://localhost:11434;Model=qwen2.5-coder:7b" + }); + + builder.AddAIServices(builder.Configuration); + + await Assert.That(builder.Services.Any(service => service.ServiceType == typeof(AIChatService))).IsTrue(); + await Assert.That(builder.Services.Any(service => service.ImplementationType == typeof(LocalAIChatService))).IsFalse(); + } + + [Test] + public async Task AddAIServices_WhenProductionWithoutConfiguration_ThrowsInvalidOperationException() + { + var builder = CreateBuilder(Environments.Production); + + await Assert.That(() => builder.AddAIServices(builder.Configuration)) + .Throws(); + } + + [Test] + public async Task AddAIServices_WhenProductionUsesLocalAI_ThrowsInvalidOperationException() + { + var builder = CreateBuilder( + Environments.Production, + new Dictionary + { + ["AIOptions:UseLocalAI"] = bool.TrueString + }); + + await Assert.That(() => builder.AddAIServices(builder.Configuration)) + .Throws(); + } + + private static HostApplicationBuilder CreateBuilder( + string environmentName, + Dictionary? settings = null) + { + var builder = new HostApplicationBuilder(new HostApplicationBuilderSettings + { + EnvironmentName = environmentName + }); + + builder.Configuration.Sources.Clear(); + builder.Configuration.AddInMemoryCollection(settings ?? []); + return builder; + } +} diff --git a/EssentialCSharp.Web.Tests/ChatControllerTests.cs b/EssentialCSharp.Web.Tests/ChatControllerTests.cs new file mode 100644 index 00000000..db85b176 --- /dev/null +++ b/EssentialCSharp.Web.Tests/ChatControllerTests.cs @@ -0,0 +1,121 @@ +using System.Security.Claims; +using System.Text.Json; +using EssentialCSharp.Chat; +using EssentialCSharp.Chat.Common.Services; +using EssentialCSharp.Web.Controllers; +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace EssentialCSharp.Web.Tests; + +public class ChatControllerTests +{ + [Test] + public async Task StreamMessage_MissingCaptchaToken_Returns403WithCaptchaRequired() + { + var controller = CreateController(); + + await controller.StreamMessage(new ChatMessageRequest { Message = "hello" }); + + var body = await ReadJsonResponse(controller.HttpContext.Response); + await Assert.That(controller.HttpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status403Forbidden); + await Assert.That(body["errorCode"].GetString()).IsEqualTo("captcha_required"); + } + + [Test] + public async Task StreamMessage_InvalidCaptcha_Returns403WithCaptchaFailed() + { + var captchaService = new Mock(); + captchaService + .Setup(service => service.VerifyAsync("bad-token", It.IsAny(), It.IsAny())) + .ReturnsAsync(new HCaptchaResult { Success = false }); + + var controller = CreateController(captchaService: captchaService.Object); + + await controller.StreamMessage(new ChatMessageRequest { Message = "hello", CaptchaToken = "bad-token" }); + + var body = await ReadJsonResponse(controller.HttpContext.Response); + await Assert.That(controller.HttpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status403Forbidden); + await Assert.That(body["errorCode"].GetString()).IsEqualTo("captcha_failed"); + } + + [Test] + public async Task StreamMessage_CaptchaServiceUnavailable_Returns503WithCaptchaUnavailable() + { + var captchaService = new Mock(); + captchaService + .Setup(service => service.VerifyAsync("token", It.IsAny(), It.IsAny())) + .ReturnsAsync((HCaptchaResult?)null); + + var controller = CreateController(captchaService: captchaService.Object); + + await controller.StreamMessage(new ChatMessageRequest { Message = "hello", CaptchaToken = "token" }); + + var body = await ReadJsonResponse(controller.HttpContext.Response); + await Assert.That(controller.HttpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status503ServiceUnavailable); + await Assert.That(body["errorCode"].GetString()).IsEqualTo("captcha_unavailable"); + } + + [Test] + public async Task SendMessage_WhenAIUnavailable_Returns503WithAIUnavailable() + { + var controller = CreateController(aiConfiguration: new AIConfigurationState(AIServiceMode.Disabled)); + + var result = await controller.SendMessage(new ChatMessageRequest { Message = "hello" }); + + await Assert.That(result).IsTypeOf(); + var objectResult = (ObjectResult)result; + await Assert.That(objectResult.StatusCode).IsEqualTo(StatusCodes.Status503ServiceUnavailable); + + var payload = JsonSerializer.Serialize(objectResult.Value); + var body = JsonSerializer.Deserialize>(payload)!; + await Assert.That(body["errorCode"].GetString()).IsEqualTo("ai_unavailable"); + } + + [Test] + public async Task StreamMessage_WhenAIUnavailable_Returns503WithAIUnavailable() + { + var controller = CreateController(aiConfiguration: new AIConfigurationState(AIServiceMode.Disabled)); + + await controller.StreamMessage(new ChatMessageRequest { Message = "hello" }); + + var body = await ReadJsonResponse(controller.HttpContext.Response); + await Assert.That(controller.HttpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status503ServiceUnavailable); + await Assert.That(body["errorCode"].GetString()).IsEqualTo("ai_unavailable"); + } + + private static ChatController CreateController( + IAIChatService? aiChatService = null, + ICaptchaService? captchaService = null, + AIConfigurationState? aiConfiguration = null) + { + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "test-user")], "TestAuth")) + }; + httpContext.Response.Body = new MemoryStream(); + + var controller = new ChatController( + Mock.Of>(), + aiConfiguration ?? new AIConfigurationState(AIServiceMode.Local), + aiChatService ?? new Mock(MockBehavior.Strict).Object, + captchaService ?? new Mock(MockBehavior.Strict).Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + + return controller; + } + + private static async Task> ReadJsonResponse(HttpResponse response) + { + response.Body.Position = 0; + using var reader = new StreamReader(response.Body, leaveOpen: true); + var json = await reader.ReadToEndAsync(); + return JsonSerializer.Deserialize>(json)!; + } +} diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs index a64d3b63..0cf3ca4c 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs @@ -1,4 +1,5 @@ -using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Areas.Identity.Data; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -13,5 +14,19 @@ public class EssentialCSharpWebContext(DbContextOptions>(login => + { + login.Property(entry => entry.LoginProvider).HasMaxLength(EssentialCSharpWebIdentitySchema.KeyMaxLength); + login.Property(entry => entry.ProviderKey).HasMaxLength(EssentialCSharpWebIdentitySchema.KeyMaxLength); + }); + + builder.Entity>(token => + { + token.Property(entry => entry.LoginProvider).HasMaxLength(EssentialCSharpWebIdentitySchema.KeyMaxLength); + token.Property(entry => entry.Name).HasMaxLength(EssentialCSharpWebIdentitySchema.KeyMaxLength); + }); } } diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index fbba17c0..9f488458 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using EssentialCSharp.Chat; using EssentialCSharp.Chat.Common.Services; +using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -12,18 +14,36 @@ namespace EssentialCSharp.Web.Controllers; [EnableRateLimiting("ChatEndpoint")] public class ChatController : ControllerBase { - private readonly AIChatService _AiChatService; + private const string AIUnavailableErrorCode = "ai_unavailable"; + + private readonly AIConfigurationState _AiConfiguration; + private readonly IAIChatService _AiChatService; + private readonly ICaptchaService _CaptchaService; private readonly ILogger _Logger; - public ChatController(ILogger logger, AIChatService aiChatService) + public ChatController( + ILogger logger, + AIConfigurationState aiConfiguration, + IAIChatService aiChatService, + ICaptchaService captchaService) { + _AiConfiguration = aiConfiguration; _AiChatService = aiChatService; + _CaptchaService = captchaService; _Logger = logger; } [HttpPost("message")] public async Task SendMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default) { + if (!_AiConfiguration.IsAvailable) + { + return CreateAIUnavailableResult(); + } + + var (captchaOk, captchaError) = await VerifyCaptchaAsync(request.CaptchaToken, cancellationToken); + if (!captchaOk) return captchaError!; + request.Message = request.Message.Trim(); if (string.IsNullOrEmpty(request.Message)) return BadRequest(new { error = "Message cannot be empty." }); @@ -32,23 +52,50 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque ? null : request.PreviousResponseId.Trim(); - var (response, responseId) = await _AiChatService.GetChatCompletion( - prompt: request.Message, - previousResponseId: previousResponseId, - enableContextualSearch: request.EnableContextualSearch, - cancellationToken: cancellationToken); + try + { + var (response, responseId) = await _AiChatService.GetChatCompletion( + prompt: request.Message, + previousResponseId: previousResponseId, + enableContextualSearch: request.EnableContextualSearch, + cancellationToken: cancellationToken); - return Ok(new ChatMessageResponse + return Ok(new ChatMessageResponse + { + Response = response, + ResponseId = responseId, + Timestamp = DateTime.UtcNow + }); + } + catch (AIChatUnavailableException ex) { - Response = response, - ResponseId = responseId, - Timestamp = DateTime.UtcNow - }); + _Logger.LogInformation(ex, "Chat unavailable for user {User}", User.Identity?.Name); + return CreateAIUnavailableResult(ex.Message); + } } [HttpPost("stream")] public async Task StreamMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default) { + if (!_AiConfiguration.IsAvailable) + { + Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await Response.WriteAsJsonAsync(CreateAIUnavailablePayload(), CancellationToken.None); + return; + } + + // Captcha and input validation must happen before SSE headers are set, + // so we can still return a proper HTTP status code on failure. + var (captchaOk, captchaError) = await VerifyCaptchaAsync(request.CaptchaToken, cancellationToken); + if (!captchaOk) + { + Response.StatusCode = captchaError is ObjectResult obj ? obj.StatusCode ?? 403 : 403; + await Response.WriteAsJsonAsync( + captchaError is ObjectResult { Value: not null } r ? r.Value : new { error = "Captcha verification failed." }, + CancellationToken.None); + return; + } + request.Message = request.Message.Trim(); if (string.IsNullOrEmpty(request.Message)) { @@ -63,7 +110,6 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat Response.ContentType = "text/event-stream"; Response.Headers.CacheControl = "no-cache"; - Response.Headers.Connection = "keep-alive"; try { @@ -95,6 +141,33 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat { _Logger.LogDebug("Chat stream cancelled for user {User}", User.Identity?.Name); } + catch (AIChatUnavailableException ex) + { + _Logger.LogInformation(ex, "Chat unavailable for user {User}", User.Identity?.Name); + if (!Response.HasStarted) + { + Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + Response.ContentType = "application/json"; + await Response.WriteAsJsonAsync(CreateAIUnavailablePayload(ex.Message), CancellationToken.None); + return; + } + + try + { + var eventData = JsonSerializer.Serialize(new + { + type = "error", + errorCode = AIUnavailableErrorCode, + message = ex.Message + }); + await Response.WriteAsync($"data: {eventData}\n\n", CancellationToken.None); + await Response.Body.FlushAsync(CancellationToken.None); + } + catch + { + // The client may already be gone; there's nothing else to do. + } + } catch (Exception ex) when (!Response.HasStarted) { _Logger.LogError(ex, "Chat streaming error before response started for user {User}", User.Identity?.Name); @@ -113,4 +186,57 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat catch { /* client already disconnected */ } } } + + /// + /// Verifies the hCaptcha token and denies chat access when verification cannot be completed. + /// + private async Task<(bool Success, IActionResult? Error)> VerifyCaptchaAsync( + string? captchaToken, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(captchaToken)) + return (false, CreateCaptchaRequiredResult()); + + var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var result = await _CaptchaService.VerifyAsync(captchaToken, remoteIp, cancellationToken); + + if (result is null) + { + _Logger.LogWarning("hCaptcha service unavailable for user {User} — denying request", User.Identity?.Name); + return (false, CreateCaptchaUnavailableResult()); + } + + if (!result.Success) + return (false, CreateCaptchaFailedResult()); + + return (true, null); + } + + private ObjectResult CreateCaptchaRequiredResult() => + StatusCode(StatusCodes.Status403Forbidden, + new { error = "Captcha verification required.", errorCode = "captcha_required", retryable = true }); + + private ObjectResult CreateCaptchaFailedResult() => + StatusCode(StatusCodes.Status403Forbidden, + new { error = "Captcha verification failed.", errorCode = "captcha_failed", retryable = true }); + + private ObjectResult CreateCaptchaUnavailableResult() => + StatusCode(StatusCodes.Status503ServiceUnavailable, + new + { + error = "Captcha verification is temporarily unavailable. Please try again later.", + errorCode = "captcha_unavailable", + retryable = true + }); + + private static object CreateAIUnavailablePayload(string? message = null) => + new + { + error = string.IsNullOrWhiteSpace(message) ? AIConfigurationState.DevelopmentUnavailableMessage : message, + errorCode = AIUnavailableErrorCode, + retryable = false + }; + + private ObjectResult CreateAIUnavailableResult(string? message = null) => + StatusCode(StatusCodes.Status503ServiceUnavailable, CreateAIUnavailablePayload(message)); + } diff --git a/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs b/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs index c797febd..7fa21fee 100644 --- a/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs +++ b/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs @@ -10,5 +10,7 @@ public class ChatMessageRequest [StringLength(200)] public string? PreviousResponseId { get; set; } public bool EnableContextualSearch { get; set; } = true; - public string? CaptchaResponse { get; set; } // For future captcha implementation + + [StringLength(4096)] + public string? CaptchaToken { get; set; } } diff --git a/EssentialCSharp.Web/Data/EssentialCSharpWebContextFactory.cs b/EssentialCSharp.Web/Data/EssentialCSharpWebContextFactory.cs new file mode 100644 index 00000000..0389d0d3 --- /dev/null +++ b/EssentialCSharp.Web/Data/EssentialCSharpWebContextFactory.cs @@ -0,0 +1,66 @@ +using EssentialCSharp.Web; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace EssentialCSharp.Web.Data; + +public sealed class EssentialCSharpWebContextFactory : IDesignTimeDbContextFactory +{ + private const string ConnectionStringName = "EssentialCSharpWebContextConnection"; + private const string FallbackConnectionString = + "Server=localhost;Database=EssentialCSharp.Web.DesignTime;User Id=sa;Password=NotUsed123!;TrustServerCertificate=true;"; + + public EssentialCSharpWebContext CreateDbContext(string[] args) + { + string environmentName = + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + ?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") + ?? "Production"; + + IConfigurationRoot configuration = new ConfigurationBuilder() + .SetBasePath(ResolveProjectPath()) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile($"appsettings.{environmentName}.json", optional: true) + .AddUserSecrets(optional: true) + .AddEnvironmentVariables() + .Build(); + + string? configuredConnectionString = configuration.GetConnectionString(ConnectionStringName); + string connectionString = string.IsNullOrWhiteSpace(configuredConnectionString) + ? FallbackConnectionString + : configuredConnectionString; + + DbContextOptionsBuilder options = new(); + options.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure(5)); + + return new EssentialCSharpWebContext(options.Options); + } + + private static string ResolveProjectPath() + { + string currentDirectory = Directory.GetCurrentDirectory(); + if (File.Exists(Path.Combine(currentDirectory, "appsettings.json"))) + { + return currentDirectory; + } + + string childProjectDirectory = Path.Combine(currentDirectory, "EssentialCSharp.Web"); + if (File.Exists(Path.Combine(childProjectDirectory, "appsettings.json"))) + { + return childProjectDirectory; + } + + string? parentDirectory = Directory.GetParent(currentDirectory)?.FullName; + if (parentDirectory is not null) + { + string siblingProjectDirectory = Path.Combine(parentDirectory, "EssentialCSharp.Web"); + if (File.Exists(Path.Combine(siblingProjectDirectory, "appsettings.json"))) + { + return siblingProjectDirectory; + } + } + + return currentDirectory; + } +} diff --git a/EssentialCSharp.Web/Data/EssentialCSharpWebIdentitySchema.cs b/EssentialCSharp.Web/Data/EssentialCSharpWebIdentitySchema.cs new file mode 100644 index 00000000..bbad3c7d --- /dev/null +++ b/EssentialCSharp.Web/Data/EssentialCSharpWebIdentitySchema.cs @@ -0,0 +1,18 @@ +namespace EssentialCSharp.Web.Data; + +/// +/// Mirrors the ASP.NET Core Identity AddDefaultIdentity default for +/// IdentityOptions.Stores.MaxLengthForKeys. +/// EF model building does not derive that runtime option automatically, so the +/// schema contract keeps the explicit model configuration and runtime options aligned. +/// +public static class EssentialCSharpWebIdentitySchema +{ + /// + /// ASP.NET Core Identity defaults login/token key lengths to 128. + /// Source: https://github.com/dotnet/aspnetcore/blob/c4db2306aad327f8c45c546f82625082156f73bb/src/Identity/UI/src/IdentityServiceCollectionUIExtensions.cs#L48-L51 + /// Keep this in sync with options.Stores.MaxLengthForKeys and the explicit + /// Identity model configuration so migrations continue to match the existing schema. + /// + public const int KeyMaxLength = 128; +} diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index ba88dc5a..e5c5180a 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -1,4 +1,5 @@ using System.Threading.RateLimiting; +using EssentialCSharp.Chat; using EssentialCSharp.Chat.Common.Extensions; using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators; @@ -19,6 +20,7 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Http.Resilience; using OpenTelemetry; using OpenTelemetry.Instrumentation.AspNetCore; using OpenTelemetry.Metrics; @@ -159,6 +161,7 @@ private static void Main(string[] args) options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30); options.Lockout.MaxFailedAccessAttempts = 3; + options.Stores.MaxLengthForKeys = EssentialCSharpWebIdentitySchema.KeyMaxLength; //TODO: Implement IProtectedUserStore //options.Stores.ProtectPersonalData = true; @@ -239,10 +242,23 @@ private static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddScoped(); - // Add AI Chat services - if (!builder.Environment.IsDevelopment()) + AIConfigurationState aiConfiguration = AIConfigurationState.From( + configuration.GetSection("AIOptions").Get()); + + // Development supports Disabled, Local, and Azure modes. Non-Development remains strict. + builder.AddAIServices(configuration, aiConfiguration); + + // When using local Ollama in development, Polly's default 30s TotalRequestTimeout fires + // before LLM inference completes (qwen2.5-coder:7b consistently takes >30s). + if (builder.Environment.IsDevelopment() && aiConfiguration.UsesLocalAI) { - builder.Services.AddAzureOpenAIServices(configuration); + builder.Services.PostConfigureAll(options => + { + options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); + options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(5); + // Polly requires SamplingDuration >= 2x AttemptTimeout; default 30s is now invalid. + options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(11); + }); } // Add Rate Limiting for API endpoints diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 899b3e45..dd66c356 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -1,14 +1,18 @@ @using EssentialCSharp.Web.Extensions @using System.Globalization +@using EssentialCSharp.Chat @using EssentialCSharp.Web.Services @using IntelliTect.Multitool @using EssentialCSharp.Common @using Microsoft.AspNetCore.Identity @using EssentialCSharp.Web.Areas.Identity.Data @using Microsoft.Extensions.Configuration +@using Microsoft.Extensions.Hosting @inject ISiteMappingService _SiteMappings @inject SignInManager SignInManager @inject IConfiguration Configuration +@inject AIConfigurationState CurrentAIConfiguration +@inject IHostEnvironment HostEnvironment @@ -178,6 +182,11 @@ var buildLabel = ReleaseDateAttribute.GetReleaseDate() is DateTime date ? TimeZoneInfo.ConvertTimeFromUtc(date, TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time")).ToString("d MMM, yyyy h:mm:ss tt", CultureInfo.InvariantCulture) : null; + var enableChatWidget = !Context.Request.Path.StartsWithSegments("/Identity") + && (CurrentAIConfiguration.IsAvailable || HostEnvironment.IsDevelopment()); + var chatWidgetUnavailableMessage = !CurrentAIConfiguration.IsAvailable && HostEnvironment.IsDevelopment() + ? AIConfigurationState.DevelopmentUnavailableMessage + : null; } window.PERCENT_COMPLETE = @Json.Serialize(percentComplete); window.PREVIOUS_PAGE = @Json.Serialize(ViewBag.PreviousPage); @@ -187,8 +196,15 @@ window.IS_AUTHENTICATED = @Json.Serialize(SignInManager.IsSignedIn(User)); window.TRYDOTNET_ORIGIN = @Json.Serialize(Configuration["TryDotNet:Origin"]); window.BUILD_LABEL = @Json.Serialize(buildLabel); - window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity")); + window.CHAT_WIDGET = @Json.Serialize(new + { + enabled = enableChatWidget, + available = CurrentAIConfiguration.IsAvailable, + unavailableMessage = chatWidgetUnavailableMessage + }); + window.HCAPTCHA_SITE_KEY = @Json.Serialize(Configuration["HCaptcha:SiteKey"]); + diff --git a/EssentialCSharp.Web/appsettings.Development.json b/EssentialCSharp.Web/appsettings.Development.json index f7e1d576..c92c76f5 100644 --- a/EssentialCSharp.Web/appsettings.Development.json +++ b/EssentialCSharp.Web/appsettings.Development.json @@ -11,5 +11,9 @@ }, "SiteSettings": { "BaseUrl": "https://localhost:7184" + }, + "HCaptcha": { + "SiteKey": "10000000-ffff-ffff-ffff-000000000001", + "SecretKey": "0x0000000000000000000000000000000000000000" } } diff --git a/EssentialCSharp.Web/package-lock.json b/EssentialCSharp.Web/package-lock.json index 96c40344..a6386886 100644 --- a/EssentialCSharp.Web/package-lock.json +++ b/EssentialCSharp.Web/package-lock.json @@ -75,6 +75,7 @@ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", @@ -86,6 +87,7 @@ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" diff --git a/EssentialCSharp.Web/src/components/ChatWidget.vue b/EssentialCSharp.Web/src/components/ChatWidget.vue index b7b0820f..f33d6628 100644 --- a/EssentialCSharp.Web/src/components/ChatWidget.vue +++ b/EssentialCSharp.Web/src/components/ChatWidget.vue @@ -9,12 +9,15 @@ const { chatMessages, chatInput, isTyping, + isSubmitting, chatMessagesEl, chatInputField, + captchaContainerEl, openChatDialog, closeChatDialog, clearChatHistory, formatMessage, + getErrorHeading, getErrorMessageClass, getErrorIconClass, sendChatMessage @@ -25,10 +28,13 @@ const {