Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/PR-Build-And-Test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.15.2" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.1.1" />
<!-- Pin transitive OpenTelemetry.Api to non-vulnerable version (1.12.0 from OllamaSharp is GHSA-g94r-2vxg-569j) -->
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.3" />
<PackageVersion Include="DotnetSitemapGenerator" Version="2.0.0" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="CommunityToolkit.Aspire.OllamaSharp" />
<!-- Explicit pin: prevents CommunityToolkit.Aspire.OllamaSharp from pulling in vulnerable OpenTelemetry.Api 1.12.0 -->
<PackageReference Include="OpenTelemetry.Api" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.SemanticKernel" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.PgVector" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -14,6 +15,66 @@ public static class ServiceCollectionExtensions
{
private static readonly string[] _PostgresScopes = ["https://ossrdbms-aad.database.windows.net/.default"];

/// <summary>
/// Resolves the AI mode once and applies environment-specific enforcement.
/// Development allows Disabled, Local, or Azure modes. Non-Development requires Azure.
/// </summary>
public static IHostApplicationBuilder AddAIServices(
this IHostApplicationBuilder builder,
IConfiguration configuration,
AIConfigurationState? aiConfigurationState = null)
{
builder.Services.Configure<AIOptions>(configuration.GetSection("AIOptions"));
aiConfigurationState ??= AIConfigurationState.From(configuration.GetSection("AIOptions").Get<AIOptions>());
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<IAIChatService, UnavailableAIChatService>();
}

return builder;
Comment on lines +22 to +50
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.
}

/// <summary>
/// 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).
/// </summary>
public static IHostApplicationBuilder AddLocalAIServices(
this IHostApplicationBuilder builder,
IConfiguration configuration)
{
builder.Services.Configure<AIOptions>(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<IAIChatService, LocalAIChatService>();
return builder;
}

/// <summary>
/// Adds Azure OpenAI and related AI services to the service collection using Managed Identity
/// </summary>
Expand Down Expand Up @@ -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<AIChatService>()) and the web app
// (GetRequiredService<IAIChatService>()) share the same singleton.
services.AddSingleton<EmbeddingService>();
services.AddSingleton<AISearchService>();
services.AddSingleton<AIChatService>();
services.AddSingleton<IAIChatService>(sp => sp.GetRequiredService<AIChatService>());
services.AddSingleton<MarkdownChunkingService>();

return services;
Expand Down
36 changes: 36 additions & 0 deletions EssentialCSharp.Chat.Shared/Models/AIConfigurationState.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
6 changes: 6 additions & 0 deletions EssentialCSharp.Chat.Shared/Models/AIOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,10 @@ public class AIOptions
/// </summary>
public string Endpoint { get; set; } = string.Empty;

/// <summary>
/// When true, uses a local Ollama backend via IChatClient instead of Azure OpenAI.
/// Set by Aspire via the AIOptions__UseLocalAI environment variable.
/// </summary>
public bool UseLocalAI { get; set; }

}
2 changes: 1 addition & 1 deletion EssentialCSharp.Chat.Shared/Services/AIChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace EssentialCSharp.Chat.Common.Services;
/// <summary>
/// Service for handling AI chat completions using the OpenAI Responses API
/// </summary>
public class AIChatService
public class AIChatService : IAIChatService
{
private readonly AIOptions _Options;
private readonly AzureOpenAIClient _AzureClient;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace EssentialCSharp.Chat.Common.Services;

public sealed class AIChatUnavailableException(string message) : InvalidOperationException(message);
31 changes: 31 additions & 0 deletions EssentialCSharp.Chat.Shared/Services/IAIChatService.cs
Original file line number Diff line number Diff line change
@@ -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<ResponseTool>? 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<ResponseTool>? tools = null,
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
#pragma warning restore OPENAI001
bool enableContextualSearch = false,
CancellationToken cancellationToken = default);
}
137 changes: 137 additions & 0 deletions EssentialCSharp.Chat.Shared/Services/LocalAIChatService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public class LocalAIChatService : IAIChatService
{
private readonly IChatClient _chatClient;
private readonly AIOptions _options;
private readonly ILogger<LocalAIChatService> _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<string, List<ChatMessage>> _conversations = new();

public LocalAIChatService(
IOptions<AIOptions> options,
IChatClient chatClient,
ILogger<LocalAIChatService> 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<ResponseTool>? 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<ResponseTool>? 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<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.");
}
}
Comment on lines +90 to +105
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.

private List<ChatMessage> BuildMessages(string prompt, string? systemPrompt, string? previousResponseId)
{
var messages = new List<ChatMessage>();

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<ChatMessage> 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;
}
}
46 changes: 46 additions & 0 deletions EssentialCSharp.Chat.Shared/Services/UnavailableAIChatService.cs
Original file line number Diff line number Diff line change
@@ -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<ResponseTool>? 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<ResponseTool>? 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
}
}
Loading
Loading