RadzenChat component added (#2254)

This commit is contained in:
Vladimir Enchev
2025-08-13 15:37:01 +03:00
committed by GitHub
parent 864bc8c7f9
commit d2071102c5
30 changed files with 2058 additions and 4 deletions

View File

@@ -0,0 +1,172 @@
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using Radzen;
using System;
using System.Net.Http;
using Microsoft.Extensions.Options;
using Xunit;
namespace Radzen.Blazor.Tests
{
public class AIChatTests
{
private void RegisterChatService(TestContext ctx)
{
// Register a dummy HttpClient and default options for AIChatService
ctx.Services.AddSingleton(new HttpClient());
ctx.Services.AddScoped<IAIChatService, AIChatService>();
}
[Fact]
public void RadzenAIChat_ShouldRenderWithDefaultProperties()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>();
Assert.Contains("Chat", component.Markup);
Assert.Contains("Type your message...", component.Markup);
}
[Fact]
public void RadzenAIChat_ShouldRenderWithCustomTitle()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
.Add(p => p.Title, "Custom Chat"));
Assert.Contains("Custom Chat", component.Markup);
}
[Fact]
public void RadzenAIChat_ShouldRenderWithCustomPlaceholder()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
.Add(p => p.Placeholder, "Enter your message here..."));
Assert.Contains("Enter your message here...", component.Markup);
}
[Fact]
public void RadzenAIChat_ShouldRenderWithCustomEmptyMessage()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
.Add(p => p.EmptyMessage, "No messages yet"));
Assert.Contains("No messages yet", component.Markup);
}
[Fact]
public void RadzenAIChat_ShouldShowClearButtonByDefault()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>();
Assert.Contains("clear_all", component.Markup);
}
[Fact]
public void RadzenAIChat_ShouldHideClearButtonWhenShowClearButtonIsFalse()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
.Add(p => p.ShowClearButton, false));
Assert.DoesNotContain("clear_all", component.Markup);
}
[Fact]
public void RadzenAIChat_ShouldBeDisabledWhenDisabledIsTrue()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
.Add(p => p.Disabled, true));
Assert.Contains("disabled", component.Markup);
}
[Fact]
public void RadzenAIChat_ShouldBeReadOnlyWhenReadOnlyIsTrue()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters
.Add(p => p.ReadOnly, true));
Assert.Contains("readonly", component.Markup);
}
[Fact]
public void RadzenAIChat_ShouldHaveCorrectCssClass()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>();
Assert.Contains("rz-chat", component.Markup);
}
[Fact]
public void ChatMessage_ShouldHaveCorrectProperties()
{
// Arrange
var message = new ChatMessage
{
Content = "Test message",
IsUser = true,
Timestamp = DateTime.Now
};
// Assert
Assert.NotEmpty(message.Id);
Assert.Equal("Test message", message.Content);
Assert.True(message.IsUser);
Assert.False(message.IsStreaming);
}
[Fact]
public void RadzenAIChat_AddMessage_ShouldAddMessageToList()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>();
// Act
component.Instance.AddMessage("Test message", true);
// Assert
var messages = component.Instance.GetMessages();
Assert.Single(messages);
Assert.Equal("Test message", messages[0].Content);
Assert.True(messages[0].IsUser);
}
[Fact]
public void RadzenAIChat_ClearChat_ShouldRemoveAllMessages()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>();
component.Instance.AddMessage("Test message 1", true);
component.Instance.AddMessage("Test message 2", false);
// Act
component.InvokeAsync(async () => await component.Instance.ClearChat()).Wait();
// Assert
Assert.Empty(component.Instance.GetMessages());
}
[Fact]
public void RadzenAIChat_ShouldLimitMessagesToMaxMessages()
{
using var ctx = new TestContext();
RegisterChatService(ctx);
var component = ctx.RenderComponent<RadzenAIChat>(parameters => parameters.Add(p => p.MaxMessages, 3));
component.Instance.AddMessage("Message 1", true);
component.Instance.AddMessage("Message 2", false);
component.Instance.AddMessage("Message 3", true);
component.Instance.AddMessage("Message 4", false);
// Assert
var messages = component.Instance.GetMessages();
Assert.Equal(3, messages.Count);
Assert.Equal("Message 2", messages[0].Content);
Assert.Equal("Message 3", messages[1].Content);
Assert.Equal("Message 4", messages[2].Content);
}
}
}

View File

@@ -0,0 +1,459 @@
using System;
using System.Collections.Generic;
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>
/// Represents a chat message in the conversation history.
/// </summary>
public class ChatMessage
{
/// <summary>
/// Gets or sets the role of the message sender (system, user, or assistant).
/// </summary>
public string Role { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the content of the message.
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the timestamp when the message was created.
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.Now;
}
/// <summary>
/// Represents a conversation session with memory.
/// </summary>
public class ConversationSession
{
/// <summary>
/// Gets or sets the unique identifier for the conversation session.
/// </summary>
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// Gets or sets the list of messages in the conversation.
/// </summary>
public List<ChatMessage> Messages { get; set; } = new();
/// <summary>
/// Gets or sets the timestamp when the conversation was created.
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.Now;
/// <summary>
/// Gets or sets the timestamp when the conversation was last updated.
/// </summary>
public DateTime LastUpdated { get; set; } = DateTime.Now;
/// <summary>
/// Gets or sets the maximum number of messages to keep in memory.
/// </summary>
public int MaxMessages { get; set; } = 50;
/// <summary>
/// Adds a message to the conversation and manages memory limits.
/// </summary>
/// <param name="role">The role of the message sender.</param>
/// <param name="content">The message content.</param>
public void AddMessage(string role, string content)
{
Messages.Add(new ChatMessage
{
Role = role,
Content = content,
Timestamp = DateTime.Now
});
LastUpdated = DateTime.Now;
// Remove oldest messages if we exceed the limit
while (Messages.Count > MaxMessages)
{
Messages.RemoveAt(0);
}
}
/// <summary>
/// Clears all messages from the conversation.
/// </summary>
public void Clear()
{
Messages.Clear();
LastUpdated = DateTime.Now;
}
/// <summary>
/// Gets the conversation messages formatted for the AI API.
/// </summary>
/// <param name="systemPrompt">The system prompt to include.</param>
/// <returns>A list of message objects for the AI API.</returns>
public List<object> GetFormattedMessages(string systemPrompt)
{
var messages = new List<object>();
// Add system message
messages.Add(new { role = "system", content = systemPrompt });
// Add conversation messages
foreach (var message in Messages)
{
messages.Add(new { role = message.Role, content = message.Content });
}
return messages;
}
}
/// <summary>
/// Interface for getting chat completions from an AI model with conversation memory.
/// </summary>
public interface IAIChatService
{
/// <summary>
/// Streams chat completion responses from the AI model asynchronously with conversation memory.
/// </summary>
/// <param name="userInput">The user's input message to send to the AI model.</param>
/// <param name="sessionId">Optional session ID to maintain conversation context. If null, a new session will be created.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</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>
/// <returns>An async enumerable that yields streaming response chunks from the AI model.</returns>
IAsyncEnumerable<string> GetCompletionsAsync(string userInput, string? sessionId = null, CancellationToken cancellationToken = default, string? model = null, string? systemPrompt = null, double? temperature = null, int? maxTokens = null);
/// <summary>
/// Gets or creates a conversation session.
/// </summary>
/// <param name="sessionId">The session ID. If null, a new session will be created.</param>
/// <returns>The conversation session.</returns>
ConversationSession GetOrCreateSession(string? sessionId = null);
/// <summary>
/// Clears the conversation history for a specific session.
/// </summary>
/// <param name="sessionId">The session ID to clear.</param>
void ClearSession(string sessionId);
/// <summary>
/// Gets all active conversation sessions.
/// </summary>
/// <returns>A list of active conversation sessions.</returns>
IEnumerable<ConversationSession> GetActiveSessions();
/// <summary>
/// Removes old conversation sessions based on age.
/// </summary>
/// <param name="maxAgeHours">Maximum age in hours for sessions to keep.</param>
void CleanupOldSessions(int maxAgeHours = 24);
}
/// <summary>
/// Configuration options for the <see cref="AIChatService"/>.
/// </summary>
public class AIChatServiceOptions
{
/// <summary>
/// Gets or sets the endpoint URL for the AI service.
/// </summary>
public string Endpoint { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the proxy URL for the AI service, if any. If set, this will override the Endpoint.
/// </summary>
public string Proxy { get; set; } = null;
/// <summary>
/// Gets or sets the API key for authentication with the AI service.
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the header name for the API key (e.g., 'Authorization' or 'api-key').
/// </summary>
public string ApiKeyHeader { get; set; } = "Authorization";
/// <summary>
/// Gets or sets the model name to use for executing chat completions (e.g., 'gpt-3.5-turbo').
/// </summary>
public string Model { get; set; }
/// <summary>
/// Gets or sets the system prompt for the AI assistant.
/// </summary>
public string SystemPrompt { get; set; } = "You are a helpful AI code assistant.";
/// <summary>
/// Gets or sets the temperature for the AI model (0.0 to 2.0). Set to 0.0 for deterministic responses, higher values for more creative outputs.
/// </summary>
public double Temperature { get; set; } = 0.7;
/// <summary>
/// Gets or sets the maximum number of tokens to generate in the response.
/// </summary>
public int? MaxTokens { get; set; }
/// <summary>
/// Gets or sets the maximum number of messages to keep in conversation memory.
/// </summary>
public int MaxMessages { get; set; } = 50;
/// <summary>
/// Gets or sets the maximum age in hours for conversation sessions before cleanup.
/// </summary>
public int SessionMaxAgeHours { get; set; } = 24;
}
/// <summary>
/// Service for interacting with AI chat models to get completions with conversation memory.
/// </summary>
public class AIChatService(HttpClient httpClient, IOptions<AIChatServiceOptions> options) : IAIChatService
{
private readonly Dictionary<string, ConversationSession> _sessions = new();
private readonly object _sessionsLock = new();
/// <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)
{
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);
var url = Options.Proxy ?? Options.Endpoint;
// Get formatted messages including conversation history
var messages = session.GetFormattedMessages(systemPrompt ?? Options.SystemPrompt);
var payload = new
{
model = model ?? Options.Model,
messages = messages,
temperature = temperature ?? Options.Temperature,
max_tokens = maxTokens ?? Options.MaxTokens,
stream = true
};
var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }), Encoding.UTF8, "application/json")
};
if (!string.IsNullOrEmpty(Options.ApiKey))
{
if (string.Equals(Options.ApiKeyHeader, "Authorization", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Options.ApiKey);
}
else
{
request.Headers.Add(Options.ApiKeyHeader, Options.ApiKey);
}
}
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new Exception($"Chat stream failed: {await response.Content.ReadAsStringAsync(cancellationToken)}");
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream);
var assistantResponse = new StringBuilder();
while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:"))
{
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)
{
try
{
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
{
return string.Empty;
}
}
}
/// <summary>
/// Extension methods for configuring AIChatService in the dependency injection container.
/// </summary>
public static class AIChatServiceExtensions
{
/// <summary>
/// Adds the AIChatService to the service collection with the specified configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">The action to configure the AIChatService options.</param>
/// <returns>The updated service collection.</returns>
public static IServiceCollection AddAIChatService(this IServiceCollection services, Action<AIChatServiceOptions> configureOptions)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
if (configureOptions == null)
{
throw new ArgumentNullException(nameof(configureOptions));
}
services.Configure(configureOptions);
services.AddScoped<IAIChatService, AIChatService>();
return services;
}
/// <summary>
/// Adds the AIChatService to the service collection with default options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The updated service collection.</returns>
public static IServiceCollection AddAIChatService(this IServiceCollection services)
{
services.AddOptions<AIChatServiceOptions>();
services.AddScoped<IAIChatService, AIChatService>();
return services;
}
}

View File

@@ -0,0 +1,100 @@
@using Radzen
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Web
@using System.Net.Http
@inherits RadzenComponent
@inject IAIChatService ChatService
@inject HttpClient Http
@if (Visible)
{
<div @ref="@Element" @attributes="Attributes" class="@GetCssClass()" style="@Style" id="@GetId()" >
<!-- Chat Header -->
@if (!string.IsNullOrWhiteSpace(Title) || ShowClearButton)
{
<RadzenStack class="rz-chat-header" Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" Gap="0.5rem">
@if (!string.IsNullOrWhiteSpace(Title))
{
<span class="rz-chat-header-title">@Title</span>
}
<RadzenStack class="rz-chat-header-actions" Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.Center" Gap="0.5rem">
@if (ShowClearButton)
{
<RadzenButton Icon="delete_history" Variant="Variant.Text" ButtonStyle="ButtonStyle.Base" Size="ButtonSize.Small"
Click="@OnClearChat" Title="Clear chat history" class="rz-chat-header-clear" />
}
</RadzenStack>
</RadzenStack>
}
<!-- Chat Messages -->
<div class="rz-chat-messages" @ref="messagesContainer">
@if (Messages.Count == 0)
{
<RadzenStack class="rz-chat-empty" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.Center" Gap="1rem">
<RadzenIcon Icon="chat_bubble_outline" class="rz-text-secondary-color" />
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2 rz-text-tertiary-color">@EmptyMessage</RadzenText>
</RadzenStack>
}
else
{
@foreach (var message in Messages)
{
<div class="rz-chat-message @(message.IsUser ? "rz-chat-message-user" : "rz-chat-message-assistant")" @key="message.Id">
<RadzenStack class="rz-chat-message-avatar" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.Center" Gap="0">
@if (message.IsUser)
{
<span class="rz-chat-avatar-initials" title="@UserAvatarText">@UserAvatarText</span>
}
else
{
<span class="rz-chat-avatar-initials" title="@AssistantAvatarText">@AssistantAvatarText</span>
}
</RadzenStack>
<RadzenStack Orientation="Orientation.Vertical" AlignItems="@(message.IsUser ? AlignItems.End : AlignItems.Start)" Gap="0.125rem" class="rz-chat-message-content">
<div class="rz-chat-message-text">
@if (message.IsStreaming)
{
@message.Content <RadzenIcon Icon="more_horiz" class="rz-chat-message-streaming-icon" />
}
else
{
<RadzenMarkdown Text=@message.Content />
}
</div>
<div class="rz-chat-message-time">
@message.Timestamp.ToString("HH:mm")
</div>
</RadzenStack>
</div>
}
}
</div>
<!-- Chat Input -->
<div class="rz-chat-input">
<div class="rz-chat-input-inner">
<textarea @ref="inputElement"
value="@CurrentInput"
@oninput="OnInput"
@onkeydown="OnKeyDown"
@onkeydown:preventDefault="preventDefault"
class="rz-textarea rz-chat-textarea"
placeholder="@Placeholder"
disabled="@Disabled"
readonly="@ReadOnly"
rows="1"
@attributes="@InputAttributes"></textarea>
<RadzenButton Icon="send"
ButtonStyle="ButtonStyle.Primary"
Variant="Variant.Text"
Disabled="@(string.IsNullOrWhiteSpace(CurrentInput) || IsLoading || Disabled)"
Click="@OnSendMessage"
Title="Send message"
class="rz-chat-send-btn" />
</div>
</div>
</div>
}

View File

@@ -0,0 +1,460 @@
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;
using System.Net.Http.Json;
namespace Radzen.Blazor
{
/// <summary>
/// Represents a chat message in the RadzenAIChat component.
/// </summary>
public class ChatMessage
{
/// <summary>
/// Gets or sets the unique identifier for the message.
/// </summary>
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// Gets or sets the content of the message.
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// Gets or sets whether this message is from the user.
/// </summary>
public bool IsUser { get; set; }
/// <summary>
/// Gets or sets the timestamp when the message was created.
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.Now;
/// <summary>
/// Gets or sets whether this message is currently streaming.
/// </summary>
public bool IsStreaming { get; set; }
}
/// <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; } = false;
private bool preventDefault = false;
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 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 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,
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);
}
/// <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>
public async Task SendMessage(string content, string? model = null, string? systemPrompt = null, double? temperature = null, int? maxTokens = 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);
}
/// <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)
{
var isUser = message.Role.Equals("user", StringComparison.OrdinalIgnoreCase);
AddMessage(message.Content, isUser);
}
await InvokeAsync(StateHasChanged);
}
private async Task GetAIResponse(string userInput)
{
if (string.IsNullOrWhiteSpace(userInput))
return;
IsLoading = true;
cts.Cancel();
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))
{
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);
}
}
private async Task GetAIResponse(string userInput, string? model = null, string? systemPrompt = null, double? temperature = null, int? maxTokens = null)
{
if (string.IsNullOrWhiteSpace(userInput))
return;
IsLoading = true;
cts.Cancel();
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))
{
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);
}
}
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Initialize session ID
currentSessionId = SessionId ?? Guid.NewGuid().ToString();
if (currentSessionId != SessionId)
{
await SessionIdChanged.InvokeAsync(currentSessionId);
}
}
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
// Update session ID if it changed
if (SessionId != currentSessionId)
{
currentSessionId = SessionId ?? Guid.NewGuid().ToString();
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)
{
await JSRuntime.InvokeAsync<string>("Radzen.setInputValue", inputElement, "");
preventDefault = true;
await OnSendMessage();
}
else
{
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)
{
// 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();
}
}
}

View File

@@ -80,4 +80,5 @@
@import 'components/blazor/timespanpicker';
@import 'components/blazor/toc';
@import 'components/blazor/expander';
@import 'components/blazor/chat';
@import 'css-variables';

View File

@@ -193,6 +193,30 @@
--rz-chart-tooltip-item-hover-background-color: #{$chart-tooltip-item-hover-background-color};
--rz-chart-marker-stroke: #{$chart-marker-stroke};
/* Chat */
--rz-chat-background-color: #{$rz-chat-background-color};
--rz-chat-border: #{$rz-chat-border};
--rz-chat-border-radius: #{$rz-chat-border-radius};
--rz-chat-header-padding-block: #{$rz-chat-header-padding-block};
--rz-chat-header-padding-inline: #{$rz-chat-header-padding-inline};
--rz-chat-header-background-color: #{$rz-chat-header-background-color};
--rz-chat-header-bottom-border: #{$rz-chat-header-bottom-border};
--rz-chat-header-shadow: #{$rz-chat-header-shadow};
--rz-chat-header-title-font-size: #{$rz-chat-header-title-font-size};
--rz-chat-header-title-font-weight: #{$rz-chat-header-title-font-weight};
--rz-chat-message-user-color: #{$rz-chat-message-user-color};
--rz-chat-message-user-background-color: #{$rz-chat-message-user-background-color};
--rz-chat-message-assistant-color: #{$rz-chat-message-assistant-color};
--rz-chat-message-assistant-background-color: #{$rz-chat-message-assistant-background-color};
--rz-chat-message-text-padding: #{$rz-chat-message-text-padding};
--rz-chat-message-text-border-radius: #{$rz-chat-message-text-border-radius};
--rz-chat-avatar-size: #{$rz-chat-avatar-size};
--rz-chat-avatar-border-radius: #{$rz-chat-avatar-border-radius};
--rz-chat-avatar-font-size: #{$rz-chat-avatar-font-size};
--rz-chat-avatar-font-weight: #{$rz-chat-avatar-font-weight};
--rz-chat-message-time-padding: #{$rz-chat-message-time-padding};
--rz-chat-message-callout-radius: #{$rz-chat-message-callout-radius};
/* Checkbox */
--rz-checkbox-width: #{$checkbox-width};
--rz-checkbox-height: #{$checkbox-height};

View File

@@ -31,4 +31,8 @@
50% {
opacity: 1;
}
}
@keyframes rz-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}

View File

@@ -0,0 +1,196 @@
$rz-chat-background-color: var(--rz-base-background-color) !default;
$rz-chat-border: var(--rz-border-normal) !default;
$rz-chat-border-radius: var(--rz-border-radius) !default;
$rz-chat-header-padding-block: 0.5rem !default;
$rz-chat-header-padding-inline: 1rem 0.5rem !default;
$rz-chat-header-background-color: $rz-chat-background-color !default;
$rz-chat-header-bottom-border: var(--rz-border-normal) !default;
$rz-chat-header-shadow: none !default;
$rz-chat-header-title-font-size: 1rem !default;
$rz-chat-header-title-font-weight: 600 !default;
$rz-chat-message-user-color: var(--rz-on-primary-lighter) !default;
$rz-chat-message-user-background-color: var(--rz-primary-lighter) !default;
$rz-chat-message-assistant-color: var(--rz-on-base) !default;
$rz-chat-message-assistant-background-color: var(--rz-base) !default;
$rz-chat-message-text-padding: 0.25rem 0.5rem !default;
$rz-chat-message-text-border-radius: var(--rz-border-radius) !default;
$rz-chat-avatar-size: 2rem !default;
$rz-chat-avatar-border-radius: var(--rz-border-radius) !default;
$rz-chat-avatar-font-size: 1rem !default;
$rz-chat-avatar-font-weight: 700 !default;
$rz-chat-message-time-padding: 0 !default;
$rz-chat-message-callout-radius: calc(var(--rz-border-radius) / 4) !default;
// Chat Component Styles - ChatGPT-like UI
.rz-chat {
display: flex;
flex-direction: column;
background-color: var(--rz-chat-background-color);
border: var(--rz-chat-border);
border-radius: var(--rz-chat-border-radius);
overflow: hidden;
}
.rz-chat-header {
z-index: 1;
padding-block: var(--rz-chat-header-padding-block);
padding-inline: var(--rz-chat-header-padding-inline);
background-color: var(--rz-chat-header-background-color);
border-bottom: var(--rz-chat-header-bottom-border);
box-shadow: var(--rz-chat-header-shadow);
}
.rz-chat-header-title {
flex: 1;
font-size: var(--rz-chat-header-title-font-size);
font-weight: var(--rz-chat-header-title-font-weight);
}
.rz-chat-messages {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
padding: 0.5rem;
}
.rz-chat-empty {
height: 100%;
.rzi {
font-size: 3rem;
opacity: 0.3;
}
}
.rz-chat-message {
display: flex;
align-items: flex-end;
gap: 0.5rem;
max-width: 90%;
}
.rz-chat-message-text {
padding: var(--rz-chat-message-text-padding);
border-radius: var(--rz-chat-message-text-border-radius);
word-wrap: break-word;
white-space: pre-wrap;
font-size: 1rem;
line-height: 1;
--rz-text-h1-margin-block-end: 0;
--rz-text-h2-margin-block-end: 0;
--rz-text-h3-margin-block-end: 0;
--rz-text-h4-margin-block-end: 0;
--rz-text-h5-margin-block-end: 0;
--rz-text-h6-margin-block-end: 0;
--rz-text-body1-margin-block-end: 0;
--rz-text-body2-margin-block-end: 0;
}
.rz-chat-message-avatar {
width: var(--rz-chat-avatar-size);
height: var(--rz-chat-avatar-size);
border-radius: var(--rz-chat-avatar-border-radius);
font-size: var(--rz-chat-avatar-font-size);
font-weight: var(--rz-chat-avatar-font-weight);
background-color: var(--rz-base);
color: var(--rz-on-base);
flex-shrink: 0;
}
.rz-chat-avatar-initials {
width: 100%;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.rz-chat-message-time {
font-size: 0.75rem;
color: var(--rz-text-disabled-color);
padding: var(--rz-chat-message-time-padding);
}
.rz-chat-message-user {
align-self: flex-end;
flex-direction: row-reverse;
.rz-chat-message-avatar {
background-color: var(--rz-chat-message-user-background-color);
color: var(--rz-chat-message-user-color);
}
.rz-chat-message-text {
background-color: var(--rz-chat-message-user-background-color);
color: var(--rz-chat-message-user-color);
border-end-end-radius: var(--rz-chat-message-callout-radius);
/* --rz-text-h1-color: var(--rz-chat-message-user-color);
--rz-text-h2-color: var(--rz-chat-message-user-color);
--rz-text-h3-color: var(--rz-chat-message-user-color);
--rz-text-h4-color: var(--rz-chat-message-user-color);
--rz-text-h5-color: var(--rz-chat-message-user-color);
--rz-text-h6-color: var(--rz-chat-message-user-color);
--rz-text-body1-color: var(--rz-chat-message-user-color);
--rz-text-body2-color: var(--rz-chat-message-user-color); */
}
}
.rz-chat-message-assistant {
align-self: flex-start;
.rz-chat-message-avatar {
background-color: var(--rz-chat-message-assistant-background-color);
color: var(--rz-chat-message-assistant-color);
}
.rz-chat-message-text {
background-color: var(--rz-chat-message-assistant-background-color);
color: var(--rz-chat-message-assistant-color);
border-end-start-radius: var(--rz-chat-message-callout-radius);
/* --rz-text-h1-color: var(--rz-chat-message-assistant-color);
--rz-text-h2-color: var(--rz-chat-message-assistant-color);
--rz-text-h3-color: var(--rz-chat-message-assistant-color);
--rz-text-h4-color: var(--rz-chat-message-assistant-color);
--rz-text-h5-color: var(--rz-chat-message-assistant-color);
--rz-text-h6-color: var(--rz-chat-message-assistant-color);
--rz-text-body1-color: var(--rz-chat-message-assistant-color);
--rz-text-body2-color: var(--rz-chat-message-assistant-color); */
}
}
.rz-chat-input {
position: sticky;
bottom: 0;
z-index: 2;
}
.rz-chat-input-inner {
display: grid;
grid-template-columns: 1fr auto;
align-items: stretch;
gap: 0.5rem;
padding: 0.5rem;
height: fit-content;
}
.rz-chat-textarea {
width: 100%;
height: fit-content;
max-height: 10rem;
overflow-y: scroll;
padding-block: var(--rz-text-area-padding-block);
padding-inline: var(--rz-text-area-padding-inline);
field-sizing: content;
resize: none;
}
.rz-chat-message-streaming-icon {
animation: rz-pulse 1.5s infinite;
}
/* Right-to-left send button */
*[dir="rtl"] .rz-chat-send-btn {
transform: scaleX(-1);
}

View File

@@ -563,6 +563,30 @@ $rz-toc-link-selected-color: var(--rz-primary) !default;
$rz-toc-link-selected-indicator-color: var(--rz-primary) !default;
$rz-toc-horizontal-link-selected-color: var(--rz-primary) !default;
// Chat
$rz-chat-background-color: var(--rz-base-background-color) !default;
$rz-chat-border: var(--rz-border-normal) !default;
$rz-chat-border-radius: var(--rz-border-radius) !default;
$rz-chat-header-padding-block: 0.5rem !default;
$rz-chat-header-padding-inline: 1rem 0.5rem !default;
$rz-chat-header-background-color: $rz-chat-background-color !default;
$rz-chat-header-bottom-border: var(--rz-border-normal) !default;
$rz-chat-header-shadow: none !default;
$rz-chat-header-title-font-size: 1rem !default;
$rz-chat-header-title-font-weight: 600 !default;
$rz-chat-message-user-color: var(--rz-on-primary-lighter) !default;
$rz-chat-message-user-background-color: var(--rz-primary-lighter) !default;
$rz-chat-message-assistant-color: var(--rz-on-base-light) !default;
$rz-chat-message-assistant-background-color: var(--rz-base-light) !default;
$rz-chat-message-text-padding: 0.5rem 0.75rem !default;
$rz-chat-message-text-border-radius: var(--rz-border-radius) !default;
$rz-chat-avatar-size: 2rem !default;
$rz-chat-avatar-border-radius: var(--rz-border-radius) !default;
$rz-chat-avatar-font-size: 1rem !default;
$rz-chat-avatar-font-weight: 700 !default;
$rz-chat-message-time-padding: 0 !default;
$rz-chat-message-callout-radius: calc(var(--rz-border-radius) / 4) !default;
@import 'fonts';
@import 'components';

View File

@@ -774,6 +774,30 @@ $rz-toc-link-selected-color: var(--rz-primary-light) !default;
$rz-toc-link-selected-indicator-color: var(--rz-primary-light) !default;
$rz-toc-horizontal-link-selected-color: var(--rz-primary-light) !default;
// Chat
$rz-chat-background-color: var(--rz-base-background-color) !default;
$rz-chat-border: var(--rz-border-normal) !default;
$rz-chat-border-radius: var(--rz-border-radius) !default;
$rz-chat-header-padding-block: 0.5rem !default;
$rz-chat-header-padding-inline: 1rem 0.5rem !default;
$rz-chat-header-background-color: $rz-chat-background-color !default;
$rz-chat-header-bottom-border: var(--rz-border-normal) !default;
$rz-chat-header-shadow: none !default;
$rz-chat-header-title-font-size: 1rem !default;
$rz-chat-header-title-font-weight: 600 !default;
$rz-chat-message-user-color: var(--rz-on-primary-lighter) !default;
$rz-chat-message-user-background-color: var(--rz-primary-lighter) !default;
$rz-chat-message-assistant-color: var(--rz-on-base-dark) !default;
$rz-chat-message-assistant-background-color: var(--rz-base-dark) !default;
$rz-chat-message-text-padding: 0.5rem 0.75rem !default;
$rz-chat-message-text-border-radius: var(--rz-border-radius) !default;
$rz-chat-avatar-size: 2rem !default;
$rz-chat-avatar-border-radius: var(--rz-border-radius) !default;
$rz-chat-avatar-font-size: 1rem !default;
$rz-chat-avatar-font-weight: 700 !default;
$rz-chat-message-time-padding: 0 !default;
$rz-chat-message-callout-radius: calc(var(--rz-border-radius) / 4) !default;
@import 'fonts';
@import 'components';

View File

@@ -773,6 +773,30 @@ $rz-toc-link-selected-color: var(--rz-primary-light) !default;
$rz-toc-link-selected-indicator-color: var(--rz-primary-light) !default;
$rz-toc-horizontal-link-selected-color: var(--rz-primary-light) !default;
// Chat
$rz-chat-background-color: var(--rz-base-background-color) !default;
$rz-chat-border: var(--rz-border-normal) !default;
$rz-chat-border-radius: var(--rz-border-radius) !default;
$rz-chat-header-padding-block: 0.5rem !default;
$rz-chat-header-padding-inline: 1rem 0.5rem !default;
$rz-chat-header-background-color: $rz-chat-background-color !default;
$rz-chat-header-bottom-border: var(--rz-border-normal) !default;
$rz-chat-header-shadow: none !default;
$rz-chat-header-title-font-size: 1rem !default;
$rz-chat-header-title-font-weight: 600 !default;
$rz-chat-message-user-color: var(--rz-on-primary-lighter) !default;
$rz-chat-message-user-background-color: var(--rz-primary-lighter) !default;
$rz-chat-message-assistant-color: var(--rz-on-base-dark) !default;
$rz-chat-message-assistant-background-color: var(--rz-base-dark) !default;
$rz-chat-message-text-padding: 0.5rem 0.75rem !default;
$rz-chat-message-text-border-radius: var(--rz-border-radius) !default;
$rz-chat-avatar-size: 2rem !default;
$rz-chat-avatar-border-radius: var(--rz-border-radius) !default;
$rz-chat-avatar-font-size: 1rem !default;
$rz-chat-avatar-font-weight: 700 !default;
$rz-chat-message-time-padding: 0 !default;
$rz-chat-message-callout-radius: calc(var(--rz-border-radius) / 4) !default;
@import 'fonts';
@import 'components';

View File

@@ -565,6 +565,30 @@ $rz-toc-link-selected-color: var(--rz-primary) !default;
$rz-toc-link-selected-indicator-color: var(--rz-primary) !default;
$rz-toc-horizontal-link-selected-color: var(--rz-primary) !default;
// Chat
$rz-chat-background-color: var(--rz-base-background-color) !default;
$rz-chat-border: var(--rz-border-normal) !default;
$rz-chat-border-radius: var(--rz-border-radius) !default;
$rz-chat-header-padding-block: 0.5rem !default;
$rz-chat-header-padding-inline: 1rem 0.5rem !default;
$rz-chat-header-background-color: $rz-chat-background-color !default;
$rz-chat-header-bottom-border: var(--rz-border-normal) !default;
$rz-chat-header-shadow: none !default;
$rz-chat-header-title-font-size: 1rem !default;
$rz-chat-header-title-font-weight: 600 !default;
$rz-chat-message-user-color: var(--rz-on-primary-lighter) !default;
$rz-chat-message-user-background-color: var(--rz-primary-lighter) !default;
$rz-chat-message-assistant-color: var(--rz-on-base-light) !default;
$rz-chat-message-assistant-background-color: var(--rz-base-light) !default;
$rz-chat-message-text-padding: 0.5rem 0.75rem !default;
$rz-chat-message-text-border-radius: var(--rz-border-radius) !default;
$rz-chat-avatar-size: 2rem !default;
$rz-chat-avatar-border-radius: var(--rz-border-radius) !default;
$rz-chat-avatar-font-size: 1rem !default;
$rz-chat-avatar-font-weight: 700 !default;
$rz-chat-message-time-padding: 0 !default;
$rz-chat-message-callout-radius: calc(var(--rz-border-radius) / 4) !default;
@import 'fonts';
@import 'components';

View File

@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Radzen;
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
namespace RadzenBlazorDemos
{
[ApiController]
[Route("api/[controller]")]
public class ChatController(HttpClient httpClient, IOptions<AIChatServiceOptions> chatStreamingServiceOptions) : ControllerBase
{
private readonly AIChatServiceOptions options = chatStreamingServiceOptions.Value;
[HttpPost("completions")]
public async Task<IActionResult> Completions()
{
var request = new HttpRequestMessage
{
Method = new HttpMethod(Request.Method),
RequestUri = new Uri(options.Endpoint),
Content = new StreamContent(Request.Body)
};
request.RequestUri = new UriBuilder(request.RequestUri) { Query = Request.QueryString.ToString() }.Uri;
foreach (var header in Request.Headers)
{
request.Content.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
}
// Use configurable API key header
if (string.Equals(options.ApiKeyHeader, "Authorization", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
}
else
{
request.Headers.Add(options.ApiKeyHeader, options.ApiKey);
}
request.Headers.Host = request.RequestUri.Host;
try
{
var response = await httpClient.SendAsync(request);
Response.StatusCode = (int)response.StatusCode;
foreach (var header in response.Headers)
{
Response.Headers[header.Key] = header.Value.ToArray();
}
foreach (var contentHeader in response.Content.Headers)
{
Response.Headers[contentHeader.Key] = contentHeader.Value.ToArray();
}
Response.Headers.Remove("transfer-encoding");
if (!response.IsSuccessStatusCode && response.Content.Headers.ContentLength == 0)
{
return new StatusCodeResult((int)response.StatusCode);
}
var responseContentStream = await response.Content.ReadAsStreamAsync();
return new FileStreamResult(responseContentStream, response.Content.Headers.ContentType?.ToString());
}
catch (HttpRequestException ex)
{
return StatusCode(500, ex.Message);
}
}
}
}

View File

@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Radzen;
@@ -46,6 +47,9 @@ builder.Services.AddScoped<NorthwindService>();
builder.Services.AddScoped<NorthwindODataService>();
builder.Services.AddSingleton<GitHubService>();
builder.Services.AddAIChatService(options =>
builder.Configuration.GetSection("AIChatService").Bind(options));
builder.Services.AddLocalization();
/* --> Uncomment to enable localization

View File

@@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ErrorOnDuplicatePublishOutputFiles>False</ErrorOnDuplicatePublishOutputFiles>
<UserSecretsId>d4f5f92a-c1c5-47b2-bb94-becc7f09133c</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\RadzenBlazorDemos\RadzenBlazorDemos.csproj" />

View File

@@ -5,5 +5,14 @@
"System": "Information",
"Microsoft": "Information"
}
},
"AIChatService": {
"Endpoint": "https://api.cloudflare.com/client/v4/accounts/dac31e6601b57aa9edbead03210a6fd6/ai/v1/chat/completions",
"ApiKey": "",
"ApiKeyHeader": "Authorization",
"Model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
"SystemPrompt": "You are a helpful AI code assistant.",
"Temperature": 0.7,
"MaxTokens": 50
}
}

View File

@@ -6,5 +6,14 @@
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"AIChatService": {
"Endpoint": "https://api.cloudflare.com/client/v4/accounts/dac31e6601b57aa9edbead03210a6fd6/ai/v1/chat/completions",
"ApiKey": "",
"ApiKeyHeader": "Authorization",
"Model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
"SystemPrompt": "You are a helpful AI code assistant.",
"Temperature": 0.7,
"MaxTokens": 2048
}
}

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.Options;
using Radzen;
using RadzenBlazorDemos;
using RadzenBlazorDemos.Data;
@@ -39,6 +40,10 @@ builder.Services.AddScoped<NorthwindService>();
builder.Services.AddScoped<NorthwindODataService>();
builder.Services.AddSingleton<GitHubService>();
builder.Services.AddAIChatService(options =>
builder.Configuration.GetSection("AIChatService").Bind(options));
builder.Services.AddLocalization();
/* --> Uncomment to enable localization

View File

@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>cf316844-9d34-42f9-a2a7-72a167736c64</UserSecretsId>
</PropertyGroup>
<ItemGroup>

View File

@@ -5,5 +5,14 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AIChatService": {
"Endpoint": "https://api.cloudflare.com/client/v4/accounts/dac31e6601b57aa9edbead03210a6fd6/ai/v1/chat/completions",
"ApiKey": "",
"ApiKeyHeader": "Authorization",
"Model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
"SystemPrompt": "You are a helpful AI code assistant.",
"Temperature": 0.7,
"MaxTokens": 2048
}
}

View File

@@ -5,5 +5,14 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"AIChatService": {
"Endpoint": "https://api.cloudflare.com/client/v4/accounts/dac31e6601b57aa9edbead03210a6fd6/ai/v1/chat/completions",
"ApiKey": "",
"ApiKeyHeader": "Authorization",
"Model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
"SystemPrompt": "You are a helpful AI code assistant.",
"Temperature": 0.7,
"MaxTokens": 2048
}
}

View File

@@ -0,0 +1,14 @@
@using Radzen
@using Radzen.Blazor
<RadzenStack class="rz-p-0 rz-p-md-12">
<RadzenAIChat @ref="compactChat"
Placeholder="Quick message..."
ShowClearButton="false"
Style="height: 300px; max-width: 100%;"
/>
</RadzenStack>
@code {
RadzenAIChat compactChat;
}

View File

@@ -0,0 +1,70 @@
@using Radzen
@using Radzen.Blazor
<RadzenStack class="rz-p-0 rz-p-md-12">
<RadzenCard class="rz-p-4" Variant="Variant.Outlined">
<RadzenStack Orientation="Orientation.Vertical" Gap="0.5rem">
<RadzenLabel Text="Chat Controls" />
<RadzenStack Orientation="Orientation.Horizontal" Wrap="FlexWrap.Wrap" Gap="0.5rem">
<RadzenButton Text="Add Welcome"
Icon="add"
Click="@(() => basicChat?.AddMessage("Hello! How can I help you today?", false))"
Variant="Variant.Flat" />
<RadzenButton Text="Add User Message"
Icon="person_add"
Click="@(() => basicChat?.AddMessage("This is a test message from the user.", true))"
Variant="Variant.Flat" />
<RadzenButton Text="Clear Chat"
Icon="delete_history"
ButtonStyle="ButtonStyle.Light"
Click="@(() => basicChat?.ClearChat())"
Variant="Variant.Flat" />
<RadzenButton Text="Send Test"
Icon="send"
ButtonStyle="ButtonStyle.Primary"
Click="@(() => basicChat?.SendMessage("What is Blazor?"))"
Variant="Variant.Flat" />
</RadzenStack>
</RadzenStack>
</RadzenCard>
<RadzenAIChat @ref="basicChat"
Title="AI Assistant"
Placeholder="Ask me anything..."
Style="height: 500px;"
MessageAdded="@OnMessageAdded"
MessageSent="@OnMessageSent"
ResponseReceived="@OnResponseReceived"
ChatCleared="@OnChatCleared"
/>
</RadzenStack>
<EventConsole @ref="console" Style="min-height: 230px;" />
@code {
RadzenAIChat basicChat;
EventConsole console;
void OnMessageAdded(Radzen.Blazor.ChatMessage message)
{
console.Log($"Message added: {(message.IsUser ? "User" : "Assistant")} - {message.Content.Substring(0, Math.Min(50, message.Content.Length))}...",
message.IsUser ? AlertStyle.Info : AlertStyle.Success);
}
void OnMessageSent(string message)
{
console.Log($"Message sent: {message}", AlertStyle.Info);
}
void OnResponseReceived(string response)
{
console.Log($"AI Response received: {response.Substring(0, Math.Min(50, response.Length))}...", AlertStyle.Success);
}
void OnChatCleared()
{
console.Log("Chat cleared", AlertStyle.Warning);
}
}

View File

@@ -0,0 +1,40 @@
@using Radzen
@using Radzen.Blazor
<RadzenStack class="rz-p-0 rz-p-md-12">
<RadzenCard class="rz-p-4" Variant="Variant.Outlined">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Start" Wrap="FlexWrap.Wrap" Gap="1rem">
<RadzenStack Orientation="Orientation.Vertical" Gap="8px">
<RadzenLabel Text="Show Clear Button" />
<RadzenSwitch @bind-Value="showClearButton" />
</RadzenStack>
<RadzenStack Orientation="Orientation.Vertical" Gap="8px">
<RadzenLabel Text="Disabled" />
<RadzenSwitch @bind-Value="isDisabled" />
</RadzenStack>
<RadzenStack Orientation="Orientation.Vertical" Gap="8px">
<RadzenLabel Text="Read Only" />
<RadzenSwitch @bind-Value="isReadOnly" />
</RadzenStack>
</RadzenStack>
</RadzenCard>
<RadzenAIChat @ref="customChat"
Title="Custom Assistant"
Placeholder="Type your message here..."
EmptyMessage="Welcome! Start chatting with me."
ShowClearButton="@showClearButton"
Disabled="@isDisabled"
ReadOnly="@isReadOnly"
Style="height: 400px; border: 2px solid var(--rz-primary);"
/>
</RadzenStack>
@code {
RadzenAIChat customChat;
private bool showClearButton = true;
private bool isDisabled = false;
private bool isReadOnly = false;
}

View File

@@ -0,0 +1,42 @@
@using Radzen
@using Radzen.Blazor
<RadzenStack class="rz-p-0 rz-p-md-12">
<RadzenAIChat @ref="eventsChat"
Title="Event Demo"
Placeholder="Type a message to see events..."
Style="height: 400px;"
MessageAdded="@OnMessageAdded"
MessageSent="@OnMessageSent"
ResponseReceived="@OnResponseReceived"
ChatCleared="@OnChatCleared"
/>
</RadzenStack>
<EventConsole @ref="console" Style="min-height: 200px;" />
@code {
RadzenAIChat eventsChat;
EventConsole console;
void OnMessageAdded(Radzen.Blazor.ChatMessage message)
{
console.Log($"Message added: {(message.IsUser ? "User" : "Assistant")} - {message.Content.Substring(0, Math.Min(50, message.Content.Length))}...",
message.IsUser ? AlertStyle.Info : AlertStyle.Success);
}
void OnMessageSent(string message)
{
console.Log($"Message sent: {message}", AlertStyle.Info);
}
void OnResponseReceived(string response)
{
console.Log($"AI Response received: {response.Substring(0, Math.Min(50, response.Length))}...", AlertStyle.Success);
}
void OnChatCleared()
{
console.Log("Chat cleared", AlertStyle.Warning);
}
}

View File

@@ -0,0 +1,59 @@
@page "/aichat"
@page "/docs/guides/components/aichat.html"
@using Radzen
@using Radzen.Blazor
<RadzenText TextStyle="TextStyle.H2" TagName="TagName.H1" class="rz-pt-8">
AIChat
</RadzenText>
<RadzenText TextStyle="TextStyle.Subtitle1" TagName="TagName.P" class="rz-pb-4">
The component requires the <strong>IAIChatService</strong> to be registered in your application, please use our demos source for reference.
This service supports any OpenAI-compatible endpoint, including OpenAI, Azure OpenAI, Cloudflare AI, and other compatible providers.
For WebAssembly applications, you'll need to use a server-side proxy endpoint since browsers cannot make direct requests to external APIs due to CORS restrictions, refer to AIChatController.
</RadzenText>
<RadzenExample ComponentName="AIChat" Example="AIChatConfig">
<AIChatConfig />
</RadzenExample>
<RadzenText Anchor="aichat#custom-styling" TextStyle="TextStyle.H5" TagName="TagName.H2" class="rz-pt-12">
Custom styling
</RadzenText>
<RadzenText TextStyle="TextStyle.Body1" class="rz-mb-8">
Customize the appearance of the <strong>AIChat</strong> component using <code>Style</code>, <code>ShowClearButton</code>, <code>Disabled</code>, and <code>ReadOnly</code> properties.
</RadzenText>
<RadzenExample ComponentName="AIChat" Example="AIChatCustomStyling">
<AIChatCustomStyling />
</RadzenExample>
<RadzenText Anchor="aichat#compact-aichat" TextStyle="TextStyle.H5" TagName="TagName.H2" class="rz-pt-12">
Compact aichat
</RadzenText>
<RadzenText TextStyle="TextStyle.Body1" class="rz-mb-8">
Create a more compact chat interface suitable for smaller spaces or sidebar implementations.
</RadzenText>
<RadzenExample ComponentName="AIChat" Example="AIChatCompact">
<AIChatCompact />
</RadzenExample>
<RadzenText Anchor="aichat#events" TextStyle="TextStyle.H5" TagName="TagName.H2" class="rz-pt-12">
Events and interactions
</RadzenText>
<RadzenText TextStyle="TextStyle.Body1" class="rz-mb-8">
Handle chat events like <code>MessageAdded</code>, <code>MessageSent</code>, <code>ResponseReceived</code>, and <code>ChatCleared</code> to integrate with your application logic.
</RadzenText>
<RadzenExample ComponentName="AIChat" Example="AIChatEvents">
<AIChatEvents />
</RadzenExample>
<RadzenText Anchor="aichat#memory" TextStyle="TextStyle.H5" TagName="TagName.H2" class="rz-pt-12">
AI Chat with Memory
</RadzenText>
<RadzenText TextStyle="TextStyle.Body1" class="rz-mb-8">
The <strong>AIChat</strong> component supports conversation memory that remembers previous questions and maintains context across multiple interactions.
Use <code>SessionId</code> to maintain conversation state and <code>SessionIdChanged</code> to track session changes.
</RadzenText>
<RadzenExample ComponentName="AIChat" Example="AIChatWithMemory">
<AIChatWithMemory />
</RadzenExample>

View File

@@ -0,0 +1,170 @@
@using Radzen.Blazor
@using Radzen
@inherits ComponentBase
<RadzenRow Gap="1rem">
<RadzenColumn Size="12" SizeLG="6">
<RadzenAIChat Title="AI Assistant with Memory"
Placeholder="Ask me anything... I'll remember our conversation!"
SessionId="@currentSessionId"
SessionIdChanged="@OnSessionIdChanged"
MessageAdded="@OnMessageAdded"
ResponseReceived="@OnResponseReceived"
ShowClearButton="true"
class="rz-h-100" />
</RadzenColumn>
<RadzenColumn Size="12" SizeLG="6">
<RadzenRow>
<RadzenColumn Size="12" SizeLG="12">
<RadzenCard Variant="Variant.Outlined">
<RadzenText TextStyle="TextStyle.Subtitle1">Memory Statistics</RadzenText>
<RadzenRow>
<RadzenColumn Size="6">
<RadzenText TextStyle="TextStyle.H4">@activeSessionsCount</RadzenText>
<RadzenText TextStyle="TextStyle.Caption">Active Sessions</RadzenText>
</RadzenColumn>
<RadzenColumn Size="6">
<RadzenText TextStyle="TextStyle.H4">@totalMessagesCount</RadzenText>
<RadzenText TextStyle="TextStyle.Caption">Total Messages</RadzenText>
</RadzenColumn>
</RadzenRow>
</RadzenCard>
</RadzenColumn>
<RadzenColumn Size="12">
<RadzenCard Variant="Variant.Outlined">
<RadzenText TextStyle="TextStyle.Subtitle1">Session Management</RadzenText>
<RadzenStack Orientation="Orientation.Vertical">
<RadzenStack Orientation="Orientation.Horizontal" Wrap="FlexWrap.Wrap">
<RadzenButton Text="New Session" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@OnNewSession" Variant="Variant.Flat" />
<RadzenButton Text="Load Session" Icon="folder_open" ButtonStyle="ButtonStyle.Base" Click="@OnLoadSession" Variant="Variant.Flat" />
<RadzenButton Text="Clear Session" Icon="clear_all" ButtonStyle="ButtonStyle.Danger" Click="@OnClearSession" Variant="Variant.Flat" />
</RadzenStack>
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Wrap="FlexWrap.Wrap">
<RadzenLabel Style="white-space: nowrap;">Current Session ID:</RadzenLabel>
<RadzenTextBox @bind-Value="@currentSessionId" ReadOnly="true" Style="flex: 1; min-width: 360px;" />
</RadzenStack>
</RadzenStack>
</RadzenCard>
</RadzenColumn>
<RadzenColumn Size="12">
<RadzenCard Variant="Variant.Outlined">
<RadzenText TextStyle="TextStyle.Subtitle1">Session History</RadzenText>
<RadzenStack Orientation="Orientation.Vertical">
<RadzenDropDown TValue="string" Data="@sessionIds" @bind-Value="@selectedSessionId" Placeholder="Select a session to load" Style="width: 100%;" />
<RadzenButton Text="Load Selected Session" Icon="folder_open" ButtonStyle="ButtonStyle.Base" Variant="Variant.Flat"
Click="@OnLoadSelectedSession" Style="width: 100%;" Disabled="@(string.IsNullOrEmpty(selectedSessionId))" />
</RadzenStack>
</RadzenCard>
</RadzenColumn>
</RadzenRow>
</RadzenColumn>
<RadzenColumn Size="12">
<RadzenCard Variant="Variant.Outlined">
<RadzenText TextStyle="TextStyle.Subtitle1">Memory Features</RadzenText>
<RadzenRow>
<RadzenColumn Size="12" SizeMD="6">
<RadzenText TextStyle="TextStyle.H6">Conversation Memory</RadzenText>
<ul>
<li>The AI remembers all previous messages in the conversation</li>
<li>Context is maintained across multiple questions</li>
<li>You can ask follow-up questions naturally</li>
<li>The AI can reference previous parts of the conversation</li>
</ul>
</RadzenColumn>
<RadzenColumn Size="12" SizeMD="6">
<RadzenText TextStyle="TextStyle.H6">Session Management</RadzenText>
<ul>
<li>Create new sessions for different conversations</li>
<li>Switch between different conversation contexts</li>
<li>Clear sessions to start fresh</li>
<li>Session data persists during the application session</li>
</ul>
</RadzenColumn>
</RadzenRow>
</RadzenCard>
</RadzenColumn>
</RadzenRow>
@code {
private string currentSessionId;
private string selectedSessionId;
private List<string> sessionIds = new();
private int activeSessionsCount = 0;
private int totalMessagesCount = 0;
[Inject]
private IAIChatService ChatService { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Create a new session by default
currentSessionId = Guid.NewGuid().ToString();
// Update session statistics
await UpdateSessionStatistics();
}
private async Task OnSessionIdChanged(string sessionId)
{
currentSessionId = sessionId;
await UpdateSessionStatistics();
}
private async Task OnMessageAdded(Radzen.Blazor.ChatMessage message)
{
// Update statistics when a message is added
await UpdateSessionStatistics();
}
private async Task OnResponseReceived(string response)
{
// Update statistics when a response is received
await UpdateSessionStatistics();
}
private async Task OnNewSession()
{
currentSessionId = Guid.NewGuid().ToString();
await UpdateSessionStatistics();
}
private async Task OnLoadSession()
{
// This would typically open a dialog to select a session
// For demo purposes, we'll just create a new session
currentSessionId = Guid.NewGuid().ToString();
await UpdateSessionStatistics();
}
private async Task OnClearSession()
{
if (!string.IsNullOrEmpty(currentSessionId))
{
ChatService.ClearSession(currentSessionId);
await UpdateSessionStatistics();
}
}
private async Task OnLoadSelectedSession()
{
if (!string.IsNullOrEmpty(selectedSessionId))
{
currentSessionId = selectedSessionId;
await UpdateSessionStatistics();
}
}
private async Task UpdateSessionStatistics()
{
var sessions = ChatService.GetActiveSessions().ToList();
activeSessionsCount = sessions.Count;
totalMessagesCount = sessions.Sum(s => s.Messages.Count);
// Update session IDs list
sessionIds = sessions.Select(s => s.Id).ToList();
await InvokeAsync(StateHasChanged);
}
}

View File

@@ -7,6 +7,7 @@ using RadzenBlazorDemos.Services;
using Radzen;
using RadzenBlazorDemos.Data;
using RadzenBlazorDemos;
using Microsoft.Extensions.Configuration;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
@@ -23,4 +24,12 @@ builder.Services.AddScoped<NorthwindService>();
builder.Services.AddScoped<NorthwindODataService>();
builder.Services.AddSingleton<GitHubService>();
builder.Services.AddAIChatService(options =>
{
options.Proxy = "api/chat/completions";
options.Model = "@cf/meta/llama-3.1-8b-instruct";
options.SystemPrompt = "You are a helpful AI code assistant.";
options.Temperature = 0.7;
});
await builder.Build().RunAsync();

View File

@@ -1737,6 +1737,15 @@ namespace RadzenBlazorDemos
Tags = new [] { "input", "form", "edit" }
},
new Example
{
Name = "AIChat",
Path = "aichat",
New = true,
Description = "A modern chat component with AI integration that provides a conversational interface similar to popular chat applications.",
Icon = "\ue0b7",
Tags = new [] { "chat", "ai", "conversation", "message", "streaming" }
},
new Example
{
Name = "TextBox",
Path = "textbox",

View File

@@ -3,7 +3,8 @@
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"