Files
radzen-blazor/Radzen.Blazor/RadzenAIChat.razor.cs

462 lines
16 KiB
C#

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using Radzen.Blazor.Rendering;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Radzen.Blazor
{
/// <summary>
/// RadzenAIChat component that provides a modern chat interface with AI integration and conversation memory.
/// </summary>
/// <example>
/// <code>
/// &lt;RadzenAIChat Title="AI Assistant" Placeholder="Type your message..." @bind-Messages="@chatMessages" SessionId="@sessionId" /&gt;
/// </code>
/// </example>
public partial class RadzenAIChat : RadzenComponent
{
private List<ChatMessage> Messages { get; set; } = new();
private string CurrentInput { get; set; } = string.Empty;
private bool IsLoading { get; set; }
private bool preventDefault;
private ElementReference inputElement;
private ElementReference messagesContainer;
private CancellationTokenSource cts = new();
private string? currentSessionId;
/// <summary>
/// Gets or sets the session ID for maintaining conversation memory. If null, a new session will be created.
/// </summary>
[Parameter]
public string? SessionId { get; set; }
/// <summary>
/// Event callback that is invoked when a session ID is created or retrieved.
/// </summary>
[Parameter]
public EventCallback<string> SessionIdChanged { get; set; }
/// <summary>
/// Specifies additional custom attributes that will be rendered by the input.
/// </summary>
/// <value>The attributes.</value>
[Parameter]
public IReadOnlyDictionary<string, object>? InputAttributes { get; set; }
/// <summary>
/// Gets or sets the title displayed in the chat header.
/// </summary>
[Parameter]
public string? Title { get; set; }
/// <summary>
/// Gets or sets the placeholder text for the input field.
/// </summary>
[Parameter]
public string Placeholder { get; set; } = "Type your message...";
/// <summary>
/// Gets or sets the message displayed when there are no messages.
/// </summary>
[Parameter]
public string EmptyMessage { get; set; } = "No messages yet. Start a conversation!";
/// <summary>
/// Gets or sets the text displayed in the user avatar.
/// </summary>
[Parameter]
public string UserAvatarText { get; set; } = "U";
/// <summary>
/// Gets or sets the text displayed in the assistant avatar.
/// </summary>
[Parameter]
public string AssistantAvatarText { get; set; } = "AI";
/// <summary>
/// Gets or sets the model name.
/// </summary>
[Parameter]
public string? Model { get; set; }
/// <summary>
/// Gets or sets the system prompt.
/// </summary>
[Parameter]
public string? SystemPrompt { get; set; }
/// <summary>
/// Gets or sets the temperature.
/// </summary>
[Parameter]
public double? Temperature { get; set; }
/// <summary>
/// Gets or sets the max tokens.
/// </summary>
[Parameter]
public int? MaxTokens { get; set; }
/// <summary>
/// Gets or sets the endpoint URL for the AI service.
/// </summary>
[Parameter]
public string? Endpoint { get; set; }
/// <summary>
/// Gets or sets the proxy URL for the AI service.
/// </summary>
[Parameter]
public string? Proxy { get; set; }
/// <summary>
/// Gets or sets the API key for authentication.
/// </summary>
[Parameter]
public string? ApiKey { get; set; }
/// <summary>
/// Gets or sets the API key header name.
/// </summary>
[Parameter]
public string? ApiKeyHeader { get; set; }
/// <summary>
/// Gets or sets whether to show the clear chat button.
/// </summary>
[Parameter]
public bool ShowClearButton { get; set; } = true;
/// <summary>
/// Gets or sets whether the chat is disabled.
/// </summary>
[Parameter]
public bool Disabled { get; set; }
/// <summary>
/// Gets or sets whether the input is read-only.
/// </summary>
[Parameter]
public bool ReadOnly { get; set; }
/// <summary>
/// Gets or sets the message template.
/// </summary>
/// <value>The message template.</value>
[Parameter]
public RenderFragment<ChatMessage>? MessageTemplate { get; set; }
/// <summary>
/// Gets or sets the empty template shown when there are no messages.
/// </summary>
/// <value>The empty template.</value>
[Parameter]
public RenderFragment? EmptyTemplate { get; set; }
/// <summary>
/// Gets or sets the maximum number of messages to keep in the chat.
/// </summary>
[Parameter]
public int MaxMessages { get; set; } = 100;
/// <summary>
/// Event callback that is invoked when a new message is added.
/// </summary>
[Parameter]
public EventCallback<ChatMessage> MessageAdded { get; set; }
/// <summary>
/// Event callback that is invoked when the chat is cleared.
/// </summary>
[Parameter]
public EventCallback ChatCleared { get; set; }
/// <summary>
/// Event callback that is invoked when a message is sent.
/// </summary>
[Parameter]
public EventCallback<string> MessageSent { get; set; }
/// <summary>
/// Event callback that is invoked when the AI response is received.
/// </summary>
[Parameter]
public EventCallback<string> ResponseReceived { get; set; }
/// <summary>
/// Gets the current list of messages.
/// </summary>
public IReadOnlyList<ChatMessage> GetMessages() => Messages.AsReadOnly();
/// <summary>
/// Gets the current session ID.
/// </summary>
public string? GetSessionId() => currentSessionId;
/// <summary>
/// Adds a message to the chat.
/// </summary>
/// <param name="content">The message content.</param>
/// <param name="isUser">Whether the message is from the user.</param>
/// <returns>The created message.</returns>
public ChatMessage AddMessage(string content, bool isUser = false)
{
var message = new ChatMessage
{
Content = content,
UserId = isUser ? "user" : "system",
IsUser = isUser,
Timestamp = DateTime.Now
};
Messages.Add(message);
// Limit the number of messages
if (Messages.Count > MaxMessages)
{
Messages.RemoveAt(0);
}
InvokeAsync(StateHasChanged);
return message;
}
/// <summary>
/// Clears all messages from the chat.
/// </summary>
public async Task ClearChat()
{
Messages.Clear();
// Clear the session in the AI service
if (!string.IsNullOrEmpty(currentSessionId))
{
ChatService.ClearSession(currentSessionId);
}
await ChatCleared.InvokeAsync();
await InvokeAsync(StateHasChanged);
}
/// <summary>
/// Sends a message programmatically.
/// </summary>
/// <param name="content">The message content to send.</param>
public async Task SendMessage(string content)
{
if (string.IsNullOrWhiteSpace(content) || Disabled || IsLoading)
return;
// Add user message
var userMessage = AddMessage(content, true);
await MessageAdded.InvokeAsync(userMessage);
await MessageSent.InvokeAsync(content);
// Clear input
CurrentInput = string.Empty;
await InvokeAsync(StateHasChanged);
// Get AI response
await GetAIResponse(content, Model, SystemPrompt, Temperature, MaxTokens, Endpoint, Proxy, ApiKey, ApiKeyHeader);
}
/// <summary>
/// Sends a message programmatically with custom AI parameters.
/// </summary>
/// <param name="content">The message content to send.</param>
/// <param name="model">Optional model name to override the configured model.</param>
/// <param name="systemPrompt">Optional system prompt to override the configured system prompt.</param>
/// <param name="temperature">Optional temperature to override the configured temperature.</param>
/// <param name="maxTokens">Optional maximum tokens to override the configured max tokens.</param>
/// <param name="endpoint">Optional endpoint URL to override the configured endpoint.</param>
/// <param name="proxy">Optional proxy URL to override the configured proxy.</param>
/// <param name="apiKey">Optional API key to override the configured API key.</param>
/// <param name="apiKeyHeader">Optional API key header name to override the configured header.</param>
public async Task SendMessage(string content, 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(content) || Disabled || IsLoading)
return;
// Add user message
var userMessage = AddMessage(content, true);
await MessageAdded.InvokeAsync(userMessage);
await MessageSent.InvokeAsync(content);
// Clear input
CurrentInput = string.Empty;
await InvokeAsync(StateHasChanged);
// Get AI response with custom parameters
await GetAIResponse(content, model, systemPrompt, temperature, maxTokens, endpoint, proxy, apiKey, apiKeyHeader);
}
/// <summary>
/// Loads conversation history from the AI service session.
/// </summary>
public async Task LoadConversationHistory()
{
if (string.IsNullOrEmpty(currentSessionId))
return;
var session = ChatService.GetOrCreateSession(currentSessionId);
// Clear current messages
Messages.Clear();
// Add messages from session history
foreach (var message in session.Messages)
{
AddMessage(message.Content, message.IsUser);
}
await InvokeAsync(StateHasChanged);
}
private async Task GetAIResponse(string userInput, 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))
return;
IsLoading = true;
#if NET8_0_OR_GREATER
await cts.CancelAsync();
#else
cts.Cancel();
#endif
cts = new CancellationTokenSource();
// Ensure we have a session ID
if (string.IsNullOrEmpty(currentSessionId))
{
currentSessionId = SessionId ?? Guid.NewGuid().ToString();
await SessionIdChanged.InvokeAsync(currentSessionId);
}
// Add assistant message placeholder
var assistantMessage = AddMessage("", false);
assistantMessage.IsStreaming = true;
try
{
var response = "";
await foreach (var token in ChatService.GetCompletionsAsync(userInput, currentSessionId, cts.Token, model, systemPrompt, temperature, maxTokens, endpoint, proxy, apiKey, apiKeyHeader))
{
response += token;
assistantMessage.Content = response;
await InvokeAsync(StateHasChanged);
}
assistantMessage.IsStreaming = false;
await ResponseReceived.InvokeAsync(response);
await MessageAdded.InvokeAsync(assistantMessage);
}
catch (Exception ex)
{
assistantMessage.Content = $"Sorry, I encountered an error: {ex.Message}";
assistantMessage.IsStreaming = false;
await InvokeAsync(StateHasChanged);
}
finally
{
IsLoading = false;
await InvokeAsync(StateHasChanged);
}
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Initialize session ID
currentSessionId = SessionId ?? Guid.NewGuid().ToString();
if (currentSessionId != SessionId)
{
await SessionIdChanged.InvokeAsync(currentSessionId);
}
}
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
// Update session ID if it changed
if (!string.IsNullOrEmpty(SessionId) && SessionId != currentSessionId)
{
currentSessionId = SessionId;
await SessionIdChanged.InvokeAsync(currentSessionId);
// Load conversation history for the new session
await LoadConversationHistory();
}
}
private async Task OnInput(ChangeEventArgs e)
{
CurrentInput = e.Value?.ToString() ?? "";
await InvokeAsync(StateHasChanged);
}
private async Task OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !e.ShiftKey && JSRuntime != null)
{
await JSRuntime.InvokeAsync<string>("Radzen.setInputValue", inputElement, "");
preventDefault = true;
await OnSendMessage();
}
preventDefault = false;
}
private async Task OnSendMessage()
{
if (!string.IsNullOrWhiteSpace(CurrentInput))
{
await SendMessage(CurrentInput);
}
}
private async Task OnClearChat()
{
await ClearChat();
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender && messagesContainer.Context != null && JSRuntime != null)
{
// Scroll to bottom when new messages are added
await JSRuntime.InvokeVoidAsync("eval",
"setTimeout(() => { " +
"const container = document.querySelector('.rz-chat-messages'); " +
"if (container) container.scrollTop = container.scrollHeight; " +
"}, 100);");
}
}
/// <inheritdoc />
protected override string GetComponentCssClass()
{
return ClassList.Create("rz-chat").ToString();
}
/// <inheritdoc />
public override void Dispose()
{
base.Dispose();
cts?.Cancel();
cts?.Dispose();
GC.SuppressFinalize(this);
}
}
}