feat: add basic chat interface with MudBlazor and propose responses API integration

Install MudBlazor 9.2.0, replace Bootstrap layout with MudLayout/MudAppBar,
create Chat.razor with message list, text input, auto-scroll, and hardcoded
responses. Add ChatMessage shared model. Remove template pages (Counter,
Weather), move health check to /health. Include OpenSpec change artifacts
for the upcoming wire-responses-api work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
local
2026-04-04 01:24:40 +01:00
parent a462b7dbc7
commit 1614a61617
27 changed files with 819 additions and 375 deletions

View File

@@ -10,6 +10,7 @@
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.14" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.14" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.4" />
<PackageReference Include="MudBlazor" Version="9.2.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,23 +1,31 @@
@* MainLayout.razor -- The root layout component for the application.
@* MainLayout.razor -- The root layout component using MudBlazor.
In Blazor, layout components wrap page content. Every routed page (@page)
is rendered inside the layout's @Body placeholder. This is similar to
_Layout.cshtml in MVC or master pages in Web Forms.
MudBlazor requires three providers in the component tree for its components to work:
- MudThemeProvider: Supplies the Material Design theme (colors, typography, spacing)
- MudPopoverProvider: Manages popover/dropdown positioning (used by MudSelect, MudMenu, etc.)
- MudDialogProvider: Enables the dialog service to render modal dialogs
Phase 1 uses a minimal layout -- just centered content with padding.
Later phases will add a sidebar for conversation management.
The layout uses MudLayout + MudAppBar + MudMainContent to create a standard
Material Design app shell. MudMainContent automatically accounts for the AppBar
height so page content doesn't render underneath it.
*@
@* @inherits LayoutComponentBase makes this a layout component.
LayoutComponentBase provides the Body property, which is a RenderFragment
containing the routed page's content. Without this base class, @Body
would not be available. *@
@inherits LayoutComponentBase
<main>
@* @Body is where the routed page content renders.
When the user navigates to "/", the Home.razor component's markup
appears here. When they navigate to another @page, that component
renders here instead. The layout stays the same -- only @Body changes. *@
@Body
</main>
<MudThemeProvider />
<MudPopoverProvider />
<MudDialogProvider />
<MudLayout>
@* MudAppBar provides the top application bar. Dense reduces its height.
The fixed position keeps it visible while scrolling. *@
<MudAppBar Elevation="1" Dense="true">
<MudText Typo="Typo.h6">Chat Agent</MudText>
</MudAppBar>
@* MudMainContent renders the routed page content (same role as @Body in plain Blazor).
It automatically adds top padding to clear the AppBar. *@
<MudMainContent>
@Body
</MudMainContent>
</MudLayout>

View File

@@ -1,77 +0,0 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}

View File

@@ -1,39 +0,0 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">ChatAgent.Client</a>
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="weather">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
</NavLink>
</div>
</nav>
</div>
@code {
private bool collapseNavMenu = true;
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}

View File

@@ -1,83 +0,0 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
.nav-scrollable {
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -0,0 +1,164 @@
@* Chat.razor -- The main chat interface.
This is the primary page of the application, mapped to the root route "/".
It displays a vertically scrolling message list and a text input at the bottom,
styled after ChatGPT/Gemini.
Key Blazor concepts demonstrated:
- @page routing (this component owns "/")
- Two-way binding with @bind-Value on MudTextField
- Event handling with @onclick and OnKeyDown
- List rendering with @foreach over a List<T>
- StateHasChanged() for manual re-render triggers
- IJSRuntime for calling JavaScript (auto-scroll)
- Conditional CSS classes based on data (user vs assistant styling)
*@
@page "/"
@* IJSRuntime lets us call JavaScript from C#. We use it to scroll the message
container to the bottom after adding a new message, because Blazor has no
built-in scroll API. *@
@inject IJSRuntime JS
<PageTitle>Chat Agent</PageTitle>
@* Chat container: uses flexbox to fill available height.
The message area grows to fill space; the input stays pinned at the bottom. *@
<div class="chat-container">
@* Message list: scrollable area that grows to fill available space.
The @ref directive captures a reference to this DOM element so we can
scroll it programmatically via JavaScript interop. *@
<div class="message-list" @ref="_messageListRef">
@if (_messages.Count == 0)
{
@* Empty state shown before any messages are sent *@
<div class="empty-state">
<MudText Typo="Typo.h5" Align="Align.Center" Class="mb-2"
Style="color: var(--mud-palette-text-secondary);">
Chat Agent
</MudText>
<MudText Typo="Typo.body2" Align="Align.Center"
Style="color: var(--mud-palette-text-disabled);">
Type a message to get started
</MudText>
</div>
}
else
{
@* Render each message as a MudPaper card.
@foreach iterates the list; Blazor re-renders this block when _messages changes.
The CSS class changes based on Role to align user messages right, assistant left. *@
@foreach (var message in _messages)
{
<div class="message-row @(message.Role == "user" ? "message-user" : "message-assistant")">
<MudPaper Class="@($"message-bubble {(message.Role == "user" ? "bubble-user" : "bubble-assistant")}")"
Elevation="0">
<MudText Typo="Typo.body1">@message.Content</MudText>
</MudPaper>
</div>
}
}
</div>
@* Input area: pinned at the bottom of the chat container.
MudTextField with an Adornment provides the send button inside the text field,
similar to ChatGPT's input design. *@
<div class="input-area">
<MudTextField @bind-Value="_userInput"
Placeholder="Type a message..."
Variant="Variant.Outlined"
Adornment="Adornment.End"
AdornmentIcon="@Icons.Material.Filled.Send"
AdornmentColor="Color.Primary"
OnAdornmentClick="SendMessage"
OnKeyDown="HandleKeyDown"
Immediate="true"
FullWidth="true"
AutoFocus="true" />
</div>
</div>
@code {
// The conversation messages, displayed in the message list.
// Using a simple List<T> since we only add to the end — no complex state management needed.
private List<ChatMessage> _messages = new();
// The current text in the input field. Bound two-way via @bind-Value.
private string _userInput = string.Empty;
// DOM reference to the message list div, used for auto-scrolling via JS interop.
private ElementReference _messageListRef;
/// <summary>
/// Handles the Enter key press to submit the message.
/// KeyboardEventArgs gives us the key that was pressed.
/// </summary>
private async Task HandleKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !e.ShiftKey)
{
await SendMessage();
}
}
/// <summary>
/// Sends the user's message and appends a hardcoded assistant response.
/// In future phases, this will call the API instead of using a hardcoded reply.
/// </summary>
private async Task SendMessage()
{
// Block empty or whitespace-only submissions
if (string.IsNullOrWhiteSpace(_userInput))
return;
// Add the user's message
_messages.Add(new ChatMessage
{
Role = "user",
Content = _userInput.Trim(),
Timestamp = DateTime.UtcNow
});
// Clear the input field
_userInput = string.Empty;
// Add a hardcoded assistant response.
// This is the stub that will be replaced with an API call in the next phase.
_messages.Add(new ChatMessage
{
Role = "assistant",
Content = "This is a placeholder response. AI integration coming soon!",
Timestamp = DateTime.UtcNow
});
// StateHasChanged() tells Blazor to re-render this component.
// It's needed here because we modified _messages after the initial render cycle.
// Without this call, the new messages wouldn't appear until the next UI event.
StateHasChanged();
// Auto-scroll to the bottom after rendering the new messages.
// We use a small delay to ensure the DOM has updated before scrolling.
await Task.Delay(50);
await ScrollToBottom();
}
/// <summary>
/// Scrolls the message list to the bottom using JavaScript interop.
/// Blazor has no built-in scroll API, so we call a tiny JS snippet directly.
/// InvokeVoidAsync calls a JS function that returns nothing (void).
/// </summary>
private async Task ScrollToBottom()
{
try
{
await JS.InvokeVoidAsync("eval",
"document.querySelector('.message-list').scrollTop = document.querySelector('.message-list').scrollHeight");
}
catch
{
// Ignore scroll errors — non-critical UI enhancement
}
}
}

View File

@@ -0,0 +1,79 @@
/* Chat.razor.css -- Scoped styles for the chat interface.
*
* Blazor CSS isolation: this file is automatically scoped to Chat.razor.
* Styles here only apply to elements rendered by this component, preventing
* conflicts with other pages. The build system adds a unique attribute
* (e.g., b-abc123) to both the CSS selectors and the rendered HTML.
*
* ::deep is needed for styles that target child component markup (like MudPaper)
* because those elements are rendered by MudBlazor, not directly by this component.
*/
/* Chat container: flexbox column that fills the viewport below the AppBar.
* The message-list grows to fill available space; input-area stays at the bottom. */
.chat-container {
display: flex;
flex-direction: column;
height: calc(100vh - 48px); /* 48px = MudAppBar Dense height */
max-width: 800px;
margin: 0 auto;
}
/* Scrollable message area */
.message-list {
flex: 1;
overflow-y: auto;
padding: 1rem 1rem 0.5rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Empty state centered in the message area */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* Message row: controls horizontal alignment */
.message-row {
display: flex;
}
.message-user {
justify-content: flex-end;
}
.message-assistant {
justify-content: flex-start;
}
/* Message bubbles — ::deep is required because MudPaper renders its own elements */
::deep .message-bubble {
max-width: 75%;
padding: 0.75rem 1rem;
border-radius: 1rem;
word-wrap: break-word;
}
::deep .bubble-user {
background-color: var(--mud-palette-primary);
color: white;
border-bottom-right-radius: 0.25rem;
}
::deep .bubble-assistant {
background-color: var(--mud-palette-surface);
border: 1px solid var(--mud-palette-lines-default);
border-bottom-left-radius: 0.25rem;
}
/* Input area pinned at the bottom */
.input-area {
padding: 0.75rem 1rem 1rem 1rem;
border-top: 1px solid var(--mud-palette-lines-default);
background-color: var(--mud-palette-background);
}

View File

@@ -1,18 +0,0 @@
@page "/counter"
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}

View File

@@ -1,7 +1,7 @@
@* Home.razor -- The landing page for ChatAgent.
@* Home.razor -- The health check page for ChatAgent.
@page "/" maps this component to the root URL. When a user navigates to "/",
the Blazor router renders this component inside MainLayout's @Body placeholder.
@page "/health" maps this component to the /health URL. Chat.razor now owns "/".
The Blazor router renders this component inside MainLayout's @Body placeholder.
This page demonstrates the health check round-trip:
1. On load, it calls the API's /api/health endpoint via ChatApiClient
@@ -10,8 +10,8 @@
*@
@* @page directive maps this component to a URL route.
"/" means this is the default/home page. *@
@page "/"
"/health" provides access to the health check page (Chat.razor now owns "/"). *@
@page "/health"
@* Import the service and model namespaces for this component.
These could also be in _Imports.razor for global access. *@

View File

@@ -1,57 +0,0 @@
@page "/weather"
@inject HttpClient Http
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates fetching data from the server.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Farenheit">Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.json");
}
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}

View File

@@ -11,6 +11,7 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
using ChatAgent.Client;
using ChatAgent.Client.Services;
@@ -38,6 +39,10 @@ var apiBaseUrl = isHttps
? builder.Configuration["ApiBaseUrl_Https"] ?? "https://localhost:7100"
: builder.Configuration["ApiBaseUrl_Http"] ?? "http://localhost:7000";
// AddMudServices registers MudBlazor's internal services (snackbar, dialog, popover, etc.)
// into the DI container. This is required before any MudBlazor component will work.
builder.Services.AddMudServices();
// AddHttpClient<ChatApiClient> registers a typed HttpClient using IHttpClientFactory.
// IHttpClientFactory manages the underlying HttpMessageHandler lifetime to prevent
// socket exhaustion (a common problem with raw HttpClient in long-running apps).

View File

@@ -13,6 +13,7 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using MudBlazor
@using ChatAgent.Client
@using ChatAgent.Client.Layout
@using ChatAgent.Client.Services

View File

@@ -1,53 +1,11 @@
/* app.css -- Application styles for ChatAgent (Phase 1).
/* app.css -- Application-wide styles for ChatAgent.
*
* Phase 1 uses plain HTML/CSS (D-10) with a light theme (D-11).
* MudBlazor will be introduced in Phase 5 for UI polish.
* These styles provide a clean, minimal appearance for the health check page.
* MudBlazor handles most styling via its component library.
* This file contains only:
* - Blazor framework styles (error UI, loading progress) that must stay
* - Global overrides if needed
*/
html, body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background-color: #ffffff;
color: #333333;
}
main {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
color: #1a1a1a;
margin-bottom: 1.5rem;
}
.health-status {
padding: 1rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
background-color: #f9f9f9;
}
.health-status p {
margin: 0.5rem 0;
}
.error-message {
color: #d32f2f;
padding: 1rem;
border: 1px solid #d32f2f;
border-radius: 8px;
background-color: #fce4ec;
}
.loading {
color: #666666;
font-style: italic;
}
/* Blazor error UI -- shown when an unhandled exception occurs.
* This is built into the Blazor template's index.html and should be kept. */
#blazor-error-ui {

View File

@@ -1,32 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatAgent.Client</title>
<base href="/" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="ChatAgent.Client.styles.css" rel="stylesheet" />
</head>
<body>
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chat Agent</title>
<base href="/" />
<!-- MudBlazor CSS — provides all component styles and Material Design baseline -->
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="ChatAgent.Client.styles.css" rel="stylesheet" />
</head>
<body>
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
<script src="_framework/blazor.webassembly.js"></script>
<!-- MudBlazor JS — required for popover positioning, scroll handling, etc. -->
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
// ChatMessage.cs -- Shared DTO representing a single message in a conversation.
//
// This model lives in ChatAgent.Shared so both the WASM client and the API can use it.
// In Phase 1, messages only exist in-memory on the client. Later phases will serialize
// these to JSON files on the server for persistence.
namespace ChatAgent.Shared.Models
{
/// <summary>
/// Represents a single chat message with a sender role, text content, and timestamp.
/// Role is "user" for messages the human typed, "assistant" for AI (or hardcoded) replies.
/// </summary>
public class ChatMessage
{
/// <summary>
/// Who sent the message: "user" or "assistant".
/// Uses string rather than enum so it matches the OpenAI API's role format directly.
/// </summary>
public string Role { get; set; } = string.Empty;
/// <summary>
/// The text content of the message.
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// When the message was created (UTC).
/// </summary>
public DateTime Timestamp { get; set; }
}
}