Files
radzen-blazor/Radzen.Blazor/AIChatService.cs
Vladimir Enchev 9019bfba64 Fix more anonymous-object serialization crashes under trimming
GoogleMap (createMap/updateMap JS interop) and AIChatService (chat API
payload via ConversationSession) both serialized anonymous types, which
fail with System.Text.Json ConstructorContainsNullParameterNames under
trimming. Replaced with named types and preserved them in LinkerConfig.xml.
2026-06-24 18:30:22 +03:00

261 lines
8.3 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Net.Http;
using System.Net.Http.Headers;
using System.IO;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Linq;
namespace Radzen;
/// <summary>
/// Service for interacting with AI chat models to get completions with conversation memory.
/// </summary>
[UnconditionalSuppressMessage(TrimMessages.Trimming, TrimMessages.IL2026, Justification = TrimMessages.DataTypePreserved)]
public class AIChatService(IServiceProvider serviceProvider, IOptions<AIChatServiceOptions> options) : IAIChatService
{
private readonly Dictionary<string, ConversationSession> sessions = new();
private readonly object sessionsLock = new();
// Add this static field to cache the JsonSerializerOptions instance
private static readonly JsonSerializerOptions CachedJsonSerializerOptions = new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
/// <summary>
/// Gets the configuration options for the chat streaming service.
/// </summary>
public AIChatServiceOptions Options => options.Value;
/// <inheritdoc />
public async IAsyncEnumerable<string> GetCompletionsAsync(string userInput, string? sessionId = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default, string? model = null, string? systemPrompt = null, double? temperature = null, int? maxTokens = null, string? endpoint = null, string? proxy = null, string? apiKey = null, string? apiKeyHeader = null)
{
if (string.IsNullOrWhiteSpace(userInput))
{
throw new ArgumentException("User input cannot be null or empty.", nameof(userInput));
}
// Get or create session
var session = GetOrCreateSession(sessionId);
// Add user message to conversation history
session.AddMessage("user", userInput);
// Use runtime parameters or fall back to configured options
var url = proxy ?? Options.Proxy ?? endpoint ?? Options.Endpoint;
var effectiveApiKey = apiKey ?? Options.ApiKey;
var effectiveApiKeyHeader = apiKeyHeader ?? Options.ApiKeyHeader;
// Get formatted messages including conversation history
var messages = session.GetFormattedMessages(systemPrompt ?? Options.SystemPrompt);
var payload = new ChatCompletionRequest
{
Model = model ?? Options.Model,
Messages = messages,
Temperature = temperature ?? Options.Temperature,
MaxTokens = maxTokens ?? Options.MaxTokens,
Stream = true
};
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(JsonSerializer.Serialize(payload, CachedJsonSerializerOptions), Encoding.UTF8, "application/json")
};
if (!string.IsNullOrEmpty(effectiveApiKey))
{
if (string.IsNullOrWhiteSpace(effectiveApiKeyHeader))
{
throw new InvalidOperationException("API key header must be specified when an API key is provided.");
}
if (string.Equals(effectiveApiKeyHeader, "Authorization", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", effectiveApiKey);
}
else
{
request.Headers.Add(effectiveApiKeyHeader, effectiveApiKey);
}
}
var httpClient = serviceProvider.GetRequiredService<HttpClient>();
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Chat stream failed: {await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)}");
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var reader = new StreamReader(stream);
var assistantResponse = new StringBuilder();
string? line;
while ((line = await reader.ReadLineAsync()) is not null && !cancellationToken.IsCancellationRequested)
{
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:", StringComparison.Ordinal))
{
continue;
}
var json = line["data:".Length..].Trim();
if (json == "[DONE]")
{
break;
}
var content = ParseStreamingResponse(json);
if (!string.IsNullOrEmpty(content))
{
assistantResponse.Append(content);
yield return content;
}
}
// Add assistant response to conversation history
if (assistantResponse.Length > 0)
{
session.AddMessage("assistant", assistantResponse.ToString());
}
}
/// <inheritdoc />
public ConversationSession GetOrCreateSession(string? sessionId = null)
{
lock (sessionsLock)
{
if (string.IsNullOrEmpty(sessionId))
{
sessionId = Guid.NewGuid().ToString();
}
if (!sessions.TryGetValue(sessionId, out var session))
{
session = new ConversationSession
{
Id = sessionId,
MaxMessages = Options.MaxMessages
};
sessions[sessionId] = session;
}
return session;
}
}
/// <inheritdoc />
public void ClearSession(string sessionId)
{
lock (sessionsLock)
{
if (sessions.TryGetValue(sessionId, out var session))
{
session.Clear();
}
}
}
/// <inheritdoc />
public IEnumerable<ConversationSession> GetActiveSessions()
{
lock (sessionsLock)
{
return sessions.Values.ToList();
}
}
/// <inheritdoc />
public void CleanupOldSessions(int maxAgeHours = 24)
{
lock (sessionsLock)
{
var cutoffTime = DateTime.Now.AddHours(-maxAgeHours);
var sessionsToRemove = sessions.Values
.Where(s => s.LastUpdated < cutoffTime)
.Select(s => s.Id)
.ToList();
foreach (var sessionId in sessionsToRemove)
{
sessions.Remove(sessionId);
}
}
}
private static string ParseStreamingResponse(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return string.Empty;
}
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("choices", out var choices) || choices.GetArrayLength() == 0)
{
return string.Empty;
}
var firstChoice = choices[0];
if (!firstChoice.TryGetProperty("delta", out var delta))
{
return string.Empty;
}
if (delta.TryGetProperty("content", out var contentElement))
{
return contentElement.GetString() ?? string.Empty;
}
return string.Empty;
}
catch (JsonException)
{
return string.Empty;
}
catch (FormatException)
{
return string.Empty;
}
}
}
internal class ChatCompletionMessage
{
[JsonPropertyName("role")]
public string? Role { get; set; }
[JsonPropertyName("content")]
public string? Content { get; set; }
}
internal class ChatCompletionRequest
{
[JsonPropertyName("model")]
public string? Model { get; set; }
[JsonPropertyName("messages")]
public List<object>? Messages { get; set; }
[JsonPropertyName("temperature")]
public double Temperature { get; set; }
[JsonPropertyName("max_tokens")]
public int? MaxTokens { get; set; }
[JsonPropertyName("stream")]
public bool Stream { get; set; }
}