Conversation
There was a problem hiding this comment.
Pull request overview
Adds a local-development chat path (Ollama) and wires hCaptcha verification into the chat request flow, so the web chat feature can be used during development without Azure OpenAI credentials while still enforcing captcha on chat endpoints.
Changes:
- Introduces
IAIChatServiceplusLocalAIChatServiceand dispatching registration (AddAIServices) to select local vs Azure OpenAI. - Adds hCaptcha token acquisition on the client and server-side captcha verification in
ChatController. - Updates web host configuration for local AI timeouts and adds required packages for Ollama integration.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| EssentialCSharp.Web/wwwroot/js/chat-module.js | Adds invisible hCaptcha token acquisition + retry logic and sends captchaToken to the chat stream endpoint. |
| EssentialCSharp.Web/appsettings.Development.json | Adds dev-time hCaptcha configuration values. |
| EssentialCSharp.Web/Views/Shared/_Layout.cshtml | Minor formatting-only change. |
| EssentialCSharp.Web/Program.cs | Always registers AI services and widens HttpClient resilience timeouts when local AI mode is enabled. |
| EssentialCSharp.Web/Controllers/ChatMessageRequest.cs | Renames/introduces request property CaptchaToken (length-bounded). |
| EssentialCSharp.Web/Controllers/ChatController.cs | Switches to IAIChatService and enforces hCaptcha verification for chat endpoints. |
| EssentialCSharp.Chat.Shared/Services/LocalAIChatService.cs | New local Ollama-backed IAIChatService implementation with in-memory conversation history. |
| EssentialCSharp.Chat.Shared/Services/IAIChatService.cs | New abstraction for chat completion + streaming completion. |
| EssentialCSharp.Chat.Shared/Services/AIChatService.cs | Implements IAIChatService for the Azure OpenAI Responses API path. |
| EssentialCSharp.Chat.Shared/Models/AIOptions.cs | Adds UseLocalAI option flag. |
| EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs | Adds AddAIServices + AddLocalAIServices and registers IAIChatService in Azure mode. |
| EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj | Adds OllamaSharp package and pins OpenTelemetry.Api dependency. |
| Directory.Packages.props | Central package versions for OllamaSharp and OpenTelemetry.Api pin. |
| private void WarnUnsupportedFeatures( | ||
| IEnumerable<ResponseTool>? 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."); | ||
| } |
There was a problem hiding this comment.
WarnUnsupportedFeatures() logs warnings whenever enableContextualSearch is true. The web chat client always sends enableContextualSearch: true, so local mode will emit a warning per request, which can spam dev logs. Consider logging this only once per process/conversation (or use Debug/Information), and/or default enableContextualSearch to false on the client when local mode is active.
| [HttpPost("stream")] | ||
| public async Task StreamMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default) | ||
| { | ||
| // 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; | ||
| } |
There was a problem hiding this comment.
New/changed behavior (local AI dispatch, captcha enforcement and 403/429 handling) isn’t covered by tests. There are existing Web and Chat test projects (e.g., controller/service tests), so it would be good to add coverage for: (1) ChatController returning 403 with captcha_required / captcha_failed, and (2) AddAIServices selecting Local vs Azure registrations (and the “not configured” path).
| async function ensureCaptchaWidget() { | ||
| if (!window.HCAPTCHA_SITE_KEY) throw new Error('Captcha is not configured.'); | ||
| await nextTick(); | ||
| if (!window.hcaptcha?.render) throw new Error('Captcha script is not ready.'); | ||
| if (captchaWidgetId !== null) return; | ||
|
|
||
| captchaWidgetId = window.hcaptcha.render(captchaContainerEl.value, { | ||
| sitekey: window.HCAPTCHA_SITE_KEY, |
There was a problem hiding this comment.
ensureCaptchaWidget() requires window.HCAPTCHA_SITE_KEY, but this global isn’t set anywhere (e.g., _Layout.cshtml only sets IS_AUTHENTICATED/ENABLE_CHAT_WIDGET). As-is, chat will always throw “Captcha is not configured.”. Consider injecting the site key into the page (e.g., window.HCAPTCHA_SITE_KEY = ... from the HCaptcha options) or reading it from an existing DOM attribute/partial so the widget can render.
| async function ensureCaptchaWidget() { | |
| if (!window.HCAPTCHA_SITE_KEY) throw new Error('Captcha is not configured.'); | |
| await nextTick(); | |
| if (!window.hcaptcha?.render) throw new Error('Captcha script is not ready.'); | |
| if (captchaWidgetId !== null) return; | |
| captchaWidgetId = window.hcaptcha.render(captchaContainerEl.value, { | |
| sitekey: window.HCAPTCHA_SITE_KEY, | |
| function resolveCaptchaSiteKey() { | |
| if (typeof window.HCAPTCHA_SITE_KEY === 'string' && window.HCAPTCHA_SITE_KEY.trim()) { | |
| return window.HCAPTCHA_SITE_KEY.trim(); | |
| } | |
| const siteKeySources = [ | |
| () => document.querySelector('[data-hcaptcha-site-key]')?.getAttribute('data-hcaptcha-site-key'), | |
| () => document.querySelector('meta[name="hcaptcha-site-key"]')?.getAttribute('content'), | |
| () => document.getElementById('hcaptcha-site-key')?.textContent | |
| ]; | |
| for (const getSiteKey of siteKeySources) { | |
| const siteKey = getSiteKey()?.trim(); | |
| if (siteKey) { | |
| window.HCAPTCHA_SITE_KEY = siteKey; | |
| return siteKey; | |
| } | |
| } | |
| return null; | |
| } | |
| async function ensureCaptchaWidget() { | |
| const siteKey = resolveCaptchaSiteKey(); | |
| if (!siteKey) throw new Error('Captcha is not configured.'); | |
| await nextTick(); | |
| if (!window.hcaptcha?.render) throw new Error('Captcha script is not ready.'); | |
| if (captchaWidgetId !== null) return; | |
| captchaWidgetId = window.hcaptcha.render(captchaContainerEl.value, { | |
| sitekey: siteKey, |
| captchaWidgetId = window.hcaptcha.render(captchaContainerEl.value, { | ||
| sitekey: window.HCAPTCHA_SITE_KEY, | ||
| size: 'invisible', | ||
| callback: onCaptchaSuccess, | ||
| 'expired-callback': onCaptchaExpired, | ||
| 'error-callback': onCaptchaError | ||
| }); |
There was a problem hiding this comment.
window.hcaptcha.render(captchaContainerEl.value, ...) will throw if captchaContainerEl.value is null. captchaContainerEl is returned from the composable, but the only consumer (ChatWidget.vue) doesn’t bind it to any element, so there is currently no container for hCaptcha to render into. Add a hidden container element in the widget template and bind ref="captchaContainerEl", or render by a known element id that exists in the DOM.
| @@ -109,6 +115,67 @@ export function useChatWidget() { | |||
| } | |||
|
|
|||
| // Remove captcha callback functions as they're no longer needed for chat | |||
There was a problem hiding this comment.
The comment says “Remove captcha callback functions as they're no longer needed for chat”, but captcha callbacks are now implemented below. Please update/remove this comment to avoid misleading future changes.
| // Remove captcha callback functions as they're no longer needed for chat | |
| // Captcha callbacks used by the hCaptcha invisible widget during chat requests. |
| public static IHostApplicationBuilder AddAIServices( | ||
| this IHostApplicationBuilder builder, | ||
| IConfiguration configuration) | ||
| { | ||
| var aiOptions = configuration.GetSection("AIOptions").Get<AIOptions>() ?? new AIOptions(); | ||
|
|
||
| if (aiOptions.UseLocalAI) | ||
| { | ||
| builder.AddLocalAIServices(configuration); | ||
| } | ||
| else if (!string.IsNullOrEmpty(aiOptions.Endpoint)) | ||
| { | ||
| builder.Services.AddAzureOpenAIServices(configuration); | ||
| } | ||
| else if (!builder.Environment.IsDevelopment()) | ||
| { | ||
| // Non-development without an endpoint is a misconfiguration — fail loudly. | ||
| throw new InvalidOperationException( | ||
| "AIOptions:Endpoint is required when UseLocalAI=false in non-development environments. " + | ||
| "Set the endpoint or enable local AI mode with aspire secret set Parameters:UseLocalAI true"); | ||
| } | ||
| // else: development + no config — graceful degradation, chat endpoints unavailable. | ||
|
|
||
| return builder; |
There was a problem hiding this comment.
AddAIServices() can exit without registering any IAIChatService (development + AIOptions.Endpoint empty + UseLocalAI=false). Since ChatController now requires IAIChatService via DI, hitting /api/chat/* will result in a runtime 500 (service resolution failure), not a “graceful degradation”. Consider registering a no-op IAIChatService that returns a clear 503/feature-disabled error, or conditionally disable the chat endpoints/widget when AI isn’t configured.
| // When using local Ollama, Polly's default 30s TotalRequestTimeout fires before LLM inference | ||
| // completes (qwen2.5-coder:7b consistently takes >30s). Override globally — this code path | ||
| // is only reached in local dev when UseLocalAI=true, so widening all clients is acceptable. | ||
| var aiOptsForTimeout = configuration.GetSection("AIOptions").Get<EssentialCSharp.Chat.AIOptions>(); | ||
| if (aiOptsForTimeout?.UseLocalAI == true) | ||
| { | ||
| builder.Services.AddAzureOpenAIServices(configuration); | ||
| builder.Services.PostConfigureAll<HttpStandardResilienceOptions>(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); | ||
| }); |
There was a problem hiding this comment.
This widens HttpStandardResilienceOptions timeouts for all HttpClients whenever AIOptions.UseLocalAI=true. The comment says this only happens in local dev, but the condition doesn’t check the environment—so a mis-set flag in production would silently change timeouts app-wide. Consider additionally guarding with builder.Environment.IsDevelopment() (or a dedicated option) or adjust the comment to match the actual behavior.
| if (result is null) | ||
| { | ||
| // hCaptcha service is unreachable — fail-open since [Authorize] + rate limiting still protect the endpoint. | ||
| _Logger.LogWarning("hCaptcha service unavailable for user {User} — allowing request", User.Identity?.Name); | ||
| return (true, null); | ||
| } |
There was a problem hiding this comment.
VerifyCaptchaAsync() currently fails open when the hCaptcha verification call errors (i.e., result is null). This is inconsistent with the Identity flows (e.g., Register/Login treat null as a failure) and can allow bypassing captcha during an hCaptcha outage, potentially increasing abuse/cost. Consider failing closed (403) or returning 503 (captcha temporarily unavailable), and only allowing fail-open in Development if needed.
37ffe85 to
89e29e9
Compare
- Extract IAIChatService interface from AIChatService (slim match, Azure-specific params preserved) - Add LocalAIChatService backed by IChatClient (CommunityToolkit.Aspire.OllamaSharp) - ConcurrentDictionary for thread-safe in-memory conversation history - Ignores ResponseTool/RAG with LogWarning (Phase 1: no vector search) - Add AddAIServices(IHostApplicationBuilder) dispatcher with 3-branch logic: - UseLocalAI=true -> AddLocalAIServices (Ollama via IChatClient) - Endpoint set -> AddAzureOpenAIServices (existing Azure path) - Dev + no config -> graceful skip (no AI registered) - Prod + no config -> throw InvalidOperationException - Fix AIChatService double-registration: AddSingleton<AIChatService>() + forwarding AddSingleton<IAIChatService>(sp => sp.GetRequiredService<AIChatService>()) so CLI and web share the same singleton - ChatController injects IAIChatService instead of AIChatService - Program.cs uses builder.AddAIServices(configuration) instead of IsDevelopment guard - Add CommunityToolkit.Aspire.OllamaSharp 13.1.1 package reference - Pin OpenTelemetry.Api 1.15.3 to resolve GHSA-g94r-2vxg-569j vulnerability (OllamaSharp -> OpenTelemetry.Api 1.12.0 is vulnerable) Live tested end-to-end: LocalAIChatService confirmed via structured logs, streaming chat works with markdown/code rendering, conversation continuity maintained via responseId, /health and /alive endpoints healthy.
qwen2.5-coder:7b consistently takes >30s, causing Polly's default TotalRequestTimeout to reject every chat response. Override via PostConfigureAll<HttpStandardResilienceOptions> when UseLocalAI=true (dev-only path): - TotalRequestTimeout: 30s 10min - AttemptTimeout: 10s 5min - CircuitBreaker.SamplingDuration: 30s 11min (Polly requires >= 2x AttemptTimeout) The global override is acceptable here: this code path only runs when the Ollama local-AI flag is set, which is developer-only.
- ChatController: inject IOptions<CaptchaOptions>; skip captcha check entirely when SiteKey is not configured (local dev without hCaptcha secrets) - ChatController: wrap CaptchaService.VerifyAsync in try-catch to fail-open on InvalidOperationException (missing SecretKey) - ChatController: remove Response.Headers.Connection = keep-alive (invalid in HTTP/2, generated ASP.NET warnings) - chat-module.js: getFreshCaptchaToken returns null (not throws) when HCAPTCHA_SITE_KEY is falsy - chat-module.js: fetchChatStream omits captchaToken from body when null so server bypass fires correctly feat: add hCaptcha test keys for local development Use official hCaptcha test keypair (https://docs.hcaptcha.com/#integration-testing-test-keys) in appsettings.Development.json so all devs get working captcha out of the box without configuring secrets. Test keys always pass silently no challenge is shown. - SiteKey: 10000000-ffff-ffff-ffff-000000000001 - SecretKey: 0x0000000000000000000000000000000000000000 These are public constants from hCaptcha docs; committing them is intentional and safe. Production keys must be set via 'aspire secret set' and will override these defaults. fix: remove unsafe captcha bypass Now that appsettings.Development.json has official hCaptcha test keys, the 'skip when SiteKey not configured' bypass is both unnecessary and dangerous a misconfigured production deploy would silently allow all requests. - ChatController: remove IOptions<CaptchaOptions> injection and SiteKey bypass block - ChatController: remove try-catch around VerifyAsync (InvalidOperationException from missing SecretKey should surface as 500, not be silently swallowed with fail-open) - chat-module.js: remove null-return bypass in getFreshCaptchaToken - chat-module.js: restore direct captchaToken in fetchChatStream body If hCaptcha is misconfigured in production: server: throws InvalidOperationException -> 500 (loud, ops must fix) client: throws 'Captcha is not configured.' -> shows error to user (not silent)
…aptcha container - _Layout.cshtml: emit window.HCAPTCHA_SITE_KEY from HCaptcha:SiteKey config - ChatWidget.vue: destructure captchaContainerEl ref, add hidden container div - chat-module.js: use whenHcaptchaReady() from hcaptcha-form.js for robustness (waits for hcaptcha.js to load before rendering the invisible widget)
89e29e9 to
edbac68
Compare
npm ci fails on ubuntu-latest (Node 20) because package.json requires node >=22.0.0. Build-Test-And-Deploy.yml already uses Node 24 via setup-node@v6 apply the same step to the PR workflow.
|
@copilot run npm install and get my lock file up to date |
Agent-Logs-Url: https://github.com/IntelliTect/EssentialCSharp.Web/sessions/f6003b4b-0dd2-45a6-80c8-28c78fdafbb6 Co-authored-by: BenjaminMichaelis <22186029+BenjaminMichaelis@users.noreply.github.com>
| catch | ||
| { | ||
| // The client may already be gone; there's nothing else to do. | ||
| } |
| 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"); |
| } | ||
|
|
||
| string childProjectDirectory = Path.Combine(currentDirectory, "EssentialCSharp.Web"); | ||
| if (File.Exists(Path.Combine(childProjectDirectory, "appsettings.json"))) |
| string? parentDirectory = Directory.GetParent(currentDirectory)?.FullName; | ||
| if (parentDirectory is not null) | ||
| { | ||
| string siblingProjectDirectory = Path.Combine(parentDirectory, "EssentialCSharp.Web"); |
| if (parentDirectory is not null) | ||
| { | ||
| string siblingProjectDirectory = Path.Combine(parentDirectory, "EssentialCSharp.Web"); | ||
| if (File.Exists(Path.Combine(siblingProjectDirectory, "appsettings.json"))) |
Description
Describe your changes here.
Fixes #Issue_Number (if available)
Ensure that your pull request has followed all the steps below: