Skip to content

Commit bea1fb9

Browse files
feat: add local AI (Ollama) support for web chat feature
- 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.
1 parent 8d872d4 commit bea1fb9

9 files changed

Lines changed: 243 additions & 9 deletions

File tree

Directory.Packages.props

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@
6363
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
6464
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
6565
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.15.2" />
66+
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.1.1" />
67+
<!-- Pin transitive OpenTelemetry.Api to non-vulnerable version (1.12.0 from OllamaSharp is GHSA-g94r-2vxg-569j) -->
68+
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.3" />
6669
<PackageVersion Include="DotnetSitemapGenerator" Version="2.0.0" />
6770
</ItemGroup>
6871
</Project>

EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
<ItemGroup>
88
<PackageReference Include="Azure.Identity" />
9+
<PackageReference Include="CommunityToolkit.Aspire.OllamaSharp" />
10+
<!-- Explicit pin: prevents CommunityToolkit.Aspire.OllamaSharp from pulling in vulnerable OpenTelemetry.Api 1.12.0 -->
11+
<PackageReference Include="OpenTelemetry.Api" />
912
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
1013
<PackageReference Include="Microsoft.SemanticKernel" />
1114
<PackageReference Include="Microsoft.SemanticKernel.Connectors.PgVector" />

EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.Extensions.AI;
66
using Microsoft.Extensions.Configuration;
77
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Hosting;
89
using Microsoft.SemanticKernel;
910
using Npgsql;
1011

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

18+
/// <summary>
19+
/// Dispatches to <see cref="AddLocalAIServices"/> or <see cref="AddAzureOpenAIServices"/>
20+
/// based on <c>AIOptions:UseLocalAI</c>. Replaces the <c>if (!IsDevelopment())</c> guard in
21+
/// Program.cs so that AI services are always registered regardless of environment.
22+
/// </summary>
23+
public static IHostApplicationBuilder AddAIServices(
24+
this IHostApplicationBuilder builder,
25+
IConfiguration configuration)
26+
{
27+
var aiOptions = configuration.GetSection("AIOptions").Get<AIOptions>() ?? new AIOptions();
28+
29+
if (aiOptions.UseLocalAI)
30+
{
31+
builder.AddLocalAIServices(configuration);
32+
}
33+
else if (!string.IsNullOrEmpty(aiOptions.Endpoint))
34+
{
35+
builder.Services.AddAzureOpenAIServices(configuration);
36+
}
37+
else if (!builder.Environment.IsDevelopment())
38+
{
39+
// Non-development without an endpoint is a misconfiguration — fail loudly.
40+
throw new InvalidOperationException(
41+
"AIOptions:Endpoint is required when UseLocalAI=false in non-development environments. " +
42+
"Set the endpoint or enable local AI mode with aspire secret set Parameters:UseLocalAI true");
43+
}
44+
// else: development + no config — graceful degradation, chat endpoints unavailable.
45+
46+
return builder;
47+
}
48+
49+
/// <summary>
50+
/// Registers the Ollama-backed local AI services. Uses IChatClient from
51+
/// CommunityToolkit.Aspire.OllamaSharp. Vector search (RAG) is disabled in Phase 1
52+
/// due to the embedding dimension mismatch (Ollama nomic-embed-text = 768 dims,
53+
/// pgvector schema expects 1536).
54+
/// </summary>
55+
public static IHostApplicationBuilder AddLocalAIServices(
56+
this IHostApplicationBuilder builder,
57+
IConfiguration configuration)
58+
{
59+
builder.Services.Configure<AIOptions>(configuration.GetSection("AIOptions"));
60+
61+
// Registers IChatClient backed by the Ollama "ollama-chat" resource.
62+
// Connection string injected by Aspire: Endpoint=http://...:11434;Model=qwen2.5-coder:7b
63+
builder.AddOllamaApiClient("ollama-chat")
64+
.AddChatClient();
65+
66+
// NOTE: ollama-embed (nomic-embed-text, 768 dims) not registered in Phase 1.
67+
// The pgvector schema hardcodes 1536 dims — incompatible without schema migration.
68+
// Phase 2: register IEmbeddingGenerator + configure VectorStoreCollectionDefinition.
69+
70+
builder.Services.AddSingleton<IAIChatService, LocalAIChatService>();
71+
return builder;
72+
}
73+
1774
/// <summary>
1875
/// Adds Azure OpenAI and related AI services to the service collection using Managed Identity
1976
/// </summary>
@@ -65,10 +122,13 @@ public static IServiceCollection AddAzureOpenAIServices(
65122
.UseOpenTelemetry();
66123
#pragma warning restore SKEXP0010
67124

68-
// Register shared AI services
125+
// Register shared AI services — forward IAIChatService to the concrete instance
126+
// so the CLI tool (GetRequiredService<AIChatService>()) and the web app
127+
// (GetRequiredService<IAIChatService>()) share the same singleton.
69128
services.AddSingleton<EmbeddingService>();
70129
services.AddSingleton<AISearchService>();
71130
services.AddSingleton<AIChatService>();
131+
services.AddSingleton<IAIChatService>(sp => sp.GetRequiredService<AIChatService>());
72132
services.AddSingleton<MarkdownChunkingService>();
73133

74134
return services;

EssentialCSharp.Chat.Shared/Models/AIOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,10 @@ public class AIOptions
2222
/// </summary>
2323
public string Endpoint { get; set; } = string.Empty;
2424

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

EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace EssentialCSharp.Chat.Common.Services;
99
/// <summary>
1010
/// Service for handling AI chat completions using the OpenAI Responses API
1111
/// </summary>
12-
public class AIChatService
12+
public class AIChatService : IAIChatService
1313
{
1414
private readonly AIOptions _Options;
1515
private readonly AzureOpenAIClient _AzureClient;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using ModelContextProtocol.Client;
2+
using OpenAI.Responses;
3+
4+
namespace EssentialCSharp.Chat.Common.Services;
5+
6+
public interface IAIChatService
7+
{
8+
Task<(string response, string responseId)> GetChatCompletion(
9+
string prompt,
10+
string? systemPrompt = null,
11+
string? previousResponseId = null,
12+
IMcpClient? mcpClient = null,
13+
#pragma warning disable OPENAI001
14+
IEnumerable<ResponseTool>? tools = null,
15+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
16+
#pragma warning restore OPENAI001
17+
bool enableContextualSearch = false,
18+
CancellationToken cancellationToken = default);
19+
20+
IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream(
21+
string prompt,
22+
string? systemPrompt = null,
23+
string? previousResponseId = null,
24+
IMcpClient? mcpClient = null,
25+
#pragma warning disable OPENAI001
26+
IEnumerable<ResponseTool>? tools = null,
27+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
28+
#pragma warning restore OPENAI001
29+
bool enableContextualSearch = false,
30+
CancellationToken cancellationToken = default);
31+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using System.Collections.Concurrent;
2+
using System.Runtime.CompilerServices;
3+
using Microsoft.Extensions.AI;
4+
using Microsoft.Extensions.Logging;
5+
using Microsoft.Extensions.Options;
6+
using ModelContextProtocol.Client;
7+
using OpenAI.Responses;
8+
9+
namespace EssentialCSharp.Chat.Common.Services;
10+
11+
/// <summary>
12+
/// Local AI chat service using IChatClient (e.g. Ollama via CommunityToolkit.Aspire.OllamaSharp).
13+
/// Compared to the Azure path: conversation history is in-memory only (lost on restart),
14+
/// ResponseTool/ReasoningEffortLevel params are silently ignored, and vector search (RAG)
15+
/// is disabled. Intended for local development without Azure credentials.
16+
/// </summary>
17+
public class LocalAIChatService : IAIChatService
18+
{
19+
private readonly IChatClient _chatClient;
20+
private readonly AIOptions _options;
21+
private readonly ILogger<LocalAIChatService> _logger;
22+
23+
// Synthetic conversation history keyed by GUID responseId.
24+
// In-memory only — not shared across instances and lost on restart.
25+
// ConcurrentDictionary prevents crashes from parallel requests (e.g., two chat tabs).
26+
private readonly ConcurrentDictionary<string, List<ChatMessage>> _conversations = new();
27+
28+
public LocalAIChatService(
29+
IOptions<AIOptions> options,
30+
IChatClient chatClient,
31+
ILogger<LocalAIChatService> logger)
32+
{
33+
_options = options.Value;
34+
_chatClient = chatClient;
35+
_logger = logger;
36+
}
37+
38+
public async Task<(string response, string responseId)> GetChatCompletion(
39+
string prompt,
40+
string? systemPrompt = null,
41+
string? previousResponseId = null,
42+
IMcpClient? mcpClient = null,
43+
#pragma warning disable OPENAI001
44+
IEnumerable<ResponseTool>? tools = null,
45+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
46+
#pragma warning restore OPENAI001
47+
bool enableContextualSearch = false,
48+
CancellationToken cancellationToken = default)
49+
{
50+
WarnUnsupportedFeatures(tools, reasoningEffortLevel, enableContextualSearch);
51+
52+
var messages = BuildMessages(prompt, systemPrompt, previousResponseId);
53+
var response = await _chatClient.GetResponseAsync(messages, cancellationToken: cancellationToken);
54+
var responseText = response.Text ?? string.Empty;
55+
var responseId = SaveConversation(messages, responseText, previousResponseId);
56+
return (responseText, responseId);
57+
}
58+
59+
public async IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream(
60+
string prompt,
61+
string? systemPrompt = null,
62+
string? previousResponseId = null,
63+
IMcpClient? mcpClient = null,
64+
#pragma warning disable OPENAI001
65+
IEnumerable<ResponseTool>? tools = null,
66+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
67+
#pragma warning restore OPENAI001
68+
bool enableContextualSearch = false,
69+
[EnumeratorCancellation] CancellationToken cancellationToken = default)
70+
{
71+
WarnUnsupportedFeatures(tools, reasoningEffortLevel, enableContextualSearch);
72+
73+
var messages = BuildMessages(prompt, systemPrompt, previousResponseId);
74+
var fullResponse = new System.Text.StringBuilder();
75+
76+
await foreach (var update in _chatClient.GetStreamingResponseAsync(messages, cancellationToken: cancellationToken))
77+
{
78+
if (!string.IsNullOrEmpty(update.Text))
79+
{
80+
fullResponse.Append(update.Text);
81+
yield return (update.Text, null);
82+
}
83+
}
84+
85+
var responseId = SaveConversation(messages, fullResponse.ToString(), previousResponseId);
86+
yield return (string.Empty, responseId);
87+
}
88+
89+
#pragma warning disable OPENAI001
90+
private void WarnUnsupportedFeatures(
91+
IEnumerable<ResponseTool>? tools,
92+
ResponseReasoningEffortLevel? reasoningEffortLevel,
93+
bool enableContextualSearch)
94+
#pragma warning restore OPENAI001
95+
{
96+
if (tools is not null || reasoningEffortLevel is not null)
97+
_logger.LogWarning("LocalAIChatService: ResponseTool and ReasoningEffortLevel are Azure-specific and are ignored in local mode.");
98+
99+
if (enableContextualSearch)
100+
_logger.LogWarning("LocalAIChatService: Vector search (RAG) is disabled in local mode (Phase 1). Run in Azure mode to enable contextual search.");
101+
}
102+
103+
private List<ChatMessage> BuildMessages(string prompt, string? systemPrompt, string? previousResponseId)
104+
{
105+
var messages = new List<ChatMessage>();
106+
107+
var sys = string.IsNullOrWhiteSpace(systemPrompt) ? _options.SystemPrompt : systemPrompt;
108+
if (!string.IsNullOrWhiteSpace(sys))
109+
messages.Add(new ChatMessage(ChatRole.System, sys));
110+
111+
if (previousResponseId is not null && _conversations.TryGetValue(previousResponseId, out var history))
112+
messages.AddRange(history);
113+
114+
messages.Add(new ChatMessage(ChatRole.User, prompt));
115+
return messages;
116+
}
117+
118+
private string SaveConversation(List<ChatMessage> messages, string assistantResponse, string? previousResponseId)
119+
{
120+
var history = messages.Where(m => m.Role != ChatRole.System).ToList();
121+
history.Add(new ChatMessage(ChatRole.Assistant, assistantResponse));
122+
123+
var newId = Guid.NewGuid().ToString("N");
124+
_conversations[newId] = history;
125+
126+
// Remove previous entry to avoid unbounded memory growth.
127+
// TryRemove is safe on ConcurrentDictionary.
128+
if (previousResponseId is not null)
129+
_conversations.TryRemove(previousResponseId, out _);
130+
131+
return newId;
132+
}
133+
}

EssentialCSharp.Web/Controllers/ChatController.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ namespace EssentialCSharp.Web.Controllers;
1212
[EnableRateLimiting("ChatEndpoint")]
1313
public class ChatController : ControllerBase
1414
{
15-
private readonly AIChatService _AiChatService;
15+
private readonly IAIChatService _AiChatService;
1616
private readonly ILogger<ChatController> _Logger;
1717

18-
public ChatController(ILogger<ChatController> logger, AIChatService aiChatService)
18+
public ChatController(ILogger<ChatController> logger, IAIChatService aiChatService)
1919
{
2020
_AiChatService = aiChatService;
2121
_Logger = logger;

EssentialCSharp.Web/Program.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,11 +239,9 @@ private static void Main(string[] args)
239239
builder.Services.AddSingleton<IListingSourceCodeService, ListingSourceCodeService>();
240240
builder.Services.AddScoped<IReferralService, ReferralService>();
241241

242-
// Add AI Chat services
243-
if (!builder.Environment.IsDevelopment())
244-
{
245-
builder.Services.AddAzureOpenAIServices(configuration);
246-
}
242+
// Add AI Chat services — always registered (Ollama in local mode, Azure OpenAI in production).
243+
// AIOptions__UseLocalAI=true enables Ollama local mode (set via aspire secret or dashboard).
244+
builder.AddAIServices(configuration);
247245

248246
// Add Rate Limiting for API endpoints
249247
builder.Services.AddRateLimiter(options =>

0 commit comments

Comments
 (0)