Skip to content

feat: Local Chat for development#1018

Open
BenjaminMichaelis wants to merge 12 commits intomainfrom
bmichaelis/localai
Open

feat: Local Chat for development#1018
BenjaminMichaelis wants to merge 12 commits intomainfrom
bmichaelis/localai

Conversation

@BenjaminMichaelis
Copy link
Copy Markdown
Member

Description

Describe your changes here.

Fixes #Issue_Number (if available)

Ensure that your pull request has followed all the steps below:

  • Code compilation
  • Created tests which fail without the change (if possible)
  • All tests passing
  • Extended the README / documentation, if necessary

Copilot AI review requested due to automatic review settings April 25, 2026 16:40
Comment thread EssentialCSharp.Web/wwwroot/js/chat-module.js Fixed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 IAIChatService plus LocalAIChatService and 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.

Comment on lines +90 to +101
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.");
}
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 55 to +68
[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;
}
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +144 to +151
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,
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
Comment on lines +150 to +156
captchaWidgetId = window.hcaptcha.render(captchaContainerEl.value, {
sitekey: window.HCAPTCHA_SITE_KEY,
size: 'invisible',
callback: onCaptchaSuccess,
'expired-callback': onCaptchaExpired,
'error-callback': onCaptchaError
});
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@@ -109,6 +115,67 @@ export function useChatWidget() {
}

// Remove captcha callback functions as they're no longer needed for chat
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// Remove captcha callback functions as they're no longer needed for chat
// Captcha callbacks used by the hCaptcha invisible widget during chat requests.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +46
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;
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread EssentialCSharp.Web/Program.cs Outdated
Comment on lines +247 to +259
// 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);
});
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +148 to +153
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);
}
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread EssentialCSharp.Web/wwwroot/js/chat-module.js Fixed
- 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)
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.
@BenjaminMichaelis
Copy link
Copy Markdown
Member Author

@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>
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 26, 2026

@copilot run npm install and get my lock file up to date

Done in 0a37ade. I ran npm install in /EssentialCSharp.Web, refreshed package-lock.json, and verified the fix with npm ci, npm run build, and dotnet build for the web project.

Comment on lines +166 to +169
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")))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants