mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-07-08 18:16:08 +00:00
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.
261 lines
8.3 KiB
C#
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; }
|
|
}
|