From 29a58e53e67437f3e3defcb37a423c7988c3c2fe Mon Sep 17 00:00:00 2001 From: "Robert J. Good" Date: Wed, 5 Aug 2026 23:20:21 -0700 Subject: [PATCH 1/3] Formatting implemented --- .github/copilot-instructions.md | 1 + src/Presentation.Web/ConfigureServices.cs | 2 + .../Chat/Components/ChatMessageList.razor | 19 +++++- .../Chat/Formatting/IChatMessageFormatter.cs | 6 ++ .../MarkdownChatMessageFormatter.cs | 59 +++++++++++++++++++ src/Presentation.Web/Presentation.Web.csproj | 1 + src/Presentation.Web/wwwroot/css/site.css | 33 +++++++++++ 7 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/Presentation.Web/Features/Chat/Formatting/IChatMessageFormatter.cs create mode 100644 src/Presentation.Web/Features/Chat/Formatting/MarkdownChatMessageFormatter.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 33f0afd..0c20e61 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -37,6 +37,7 @@ - **ChatPage Layout:** Implement distinct desktop and mobile layouts with a strict row/column structure on mobile. - **Input Visibility:** Ensure persistent visible input under a capped message list height. - **Component Usage:** Use only components in `.razor` files (no raw HTML elements), except in `App.razor` where raw HTML is allowed. +- **Markdown Rendering:** Implement formatting features with as-native-.NET approaches and minimal external dependencies for markdown-to-HTML chat rendering in Presentation.Web. ## References - [README.md](../README.md): Project overview and getting started diff --git a/src/Presentation.Web/ConfigureServices.cs b/src/Presentation.Web/ConfigureServices.cs index e9ced14..b771f33 100644 --- a/src/Presentation.Web/ConfigureServices.cs +++ b/src/Presentation.Web/ConfigureServices.cs @@ -2,6 +2,7 @@ using Goodtocode.AgentFramework.Presentation.Web.Infrastructure.Auth; using Goodtocode.AgentFramework.Presentation.Web.Infrastructure.Storage; using Goodtocode.AgentFramework.Presentation.Web.Features.Chat.Services; +using Goodtocode.AgentFramework.Presentation.Web.Features.Chat.Formatting; using Microsoft.Extensions.Options; using Microsoft.FluentUI.AspNetCore.Components; using Goodtocode.AgentFramework.Presentation.Web.Library.Auth.Services; @@ -33,6 +34,7 @@ public static void AddFrontendServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public static IServiceCollection AddUserClaimsSyncService(this IServiceCollection services) diff --git a/src/Presentation.Web/Features/Chat/Components/ChatMessageList.razor b/src/Presentation.Web/Features/Chat/Components/ChatMessageList.razor index 5dc3a3d..52112ee 100644 --- a/src/Presentation.Web/Features/Chat/Components/ChatMessageList.razor +++ b/src/Presentation.Web/Features/Chat/Components/ChatMessageList.razor @@ -1,7 +1,10 @@ @using Goodtocode.AgentFramework.Presentation.Web.Features.Chat.Models +@using Goodtocode.AgentFramework.Presentation.Web.Features.Chat.Formatting +@using Microsoft.AspNetCore.Components @using Microsoft.JSInterop @inject IJSRuntime JSRuntime +@inject IChatMessageFormatter ChatMessageFormatter @foreach (var message in Messages ?? Enumerable.Empty()) @@ -9,7 +12,16 @@ var isUser = IsUserMessage(message); - @message.Content + @if (isUser) + { + @message.Content + } + else + { + + @((MarkupString)FormatAssistantMessage(message)) + + } @@ -26,4 +38,7 @@ private bool IsUserMessage(ChatMessageModel message) => message?.Role?.ToLowerInvariant() == "user"; -} \ No newline at end of file + + private string FormatAssistantMessage(ChatMessageModel message) + => ChatMessageFormatter.FormatAssistantMessageAsHtml(message.Content ?? string.Empty); +} diff --git a/src/Presentation.Web/Features/Chat/Formatting/IChatMessageFormatter.cs b/src/Presentation.Web/Features/Chat/Formatting/IChatMessageFormatter.cs new file mode 100644 index 0000000..ab5dd86 --- /dev/null +++ b/src/Presentation.Web/Features/Chat/Formatting/IChatMessageFormatter.cs @@ -0,0 +1,6 @@ +namespace Goodtocode.AgentFramework.Presentation.Web.Features.Chat.Formatting; + +public interface IChatMessageFormatter +{ + string FormatAssistantMessageAsHtml(string content); +} diff --git a/src/Presentation.Web/Features/Chat/Formatting/MarkdownChatMessageFormatter.cs b/src/Presentation.Web/Features/Chat/Formatting/MarkdownChatMessageFormatter.cs new file mode 100644 index 0000000..85ba5e7 --- /dev/null +++ b/src/Presentation.Web/Features/Chat/Formatting/MarkdownChatMessageFormatter.cs @@ -0,0 +1,59 @@ +using System.Text.RegularExpressions; +using Markdig; + +namespace Goodtocode.AgentFramework.Presentation.Web.Features.Chat.Formatting; + +public sealed partial class MarkdownChatMessageFormatter : IChatMessageFormatter +{ + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .DisableHtml() + .UseAdvancedExtensions() + .Build(); + + public string FormatAssistantMessageAsHtml(string content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return string.Empty; + } + + var html = Markdown.ToHtml(content, Pipeline); + return SanitizeLinks(html); + } + + private static string SanitizeLinks(string html) + { + return HrefRegex().Replace(html, match => + { + var href = match.Groups[1].Value; + return IsSafeHref(href) ? match.Value : "href=\"#\""; + }); + } + + private static bool IsSafeHref(string href) + { + if (string.IsNullOrWhiteSpace(href)) + { + return false; + } + + if (!Uri.TryCreate(href, UriKind.RelativeOrAbsolute, out var uri)) + { + return false; + } + + if (!uri.IsAbsoluteUri) + { + var trimmed = href.TrimStart(); + return !trimmed.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) + && !trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase); + } + + return uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals(Uri.UriSchemeMailto, StringComparison.OrdinalIgnoreCase); + } + + [GeneratedRegex("href=\"([^\"]*)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex HrefRegex(); +} diff --git a/src/Presentation.Web/Presentation.Web.csproj b/src/Presentation.Web/Presentation.Web.csproj index f2258ff..85adbd1 100644 --- a/src/Presentation.Web/Presentation.Web.csproj +++ b/src/Presentation.Web/Presentation.Web.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Presentation.Web/wwwroot/css/site.css b/src/Presentation.Web/wwwroot/css/site.css index e69de29..5957875 100644 --- a/src/Presentation.Web/wwwroot/css/site.css +++ b/src/Presentation.Web/wwwroot/css/site.css @@ -0,0 +1,33 @@ +.chat-markdown-content { + line-height: 1.5; + word-break: break-word; +} + +.chat-markdown-content p { + margin: 0 0 0.5rem 0; +} + +.chat-markdown-content p:last-child { + margin-bottom: 0; +} + +.chat-markdown-content pre { + overflow-x: auto; + padding: 0.75rem; + border-radius: 6px; + background: var(--neutral-layer-2); +} + +.chat-markdown-content code { + font-family: Consolas, "Courier New", monospace; +} + +.chat-markdown-content ul, +.chat-markdown-content ol { + margin: 0.5rem 0; + padding-left: 1.25rem; +} + +.chat-markdown-content a { + color: var(--accent-foreground-rest); +} From 3a30868f77f906c0bfc597d0ec621863872461b9 Mon Sep 17 00:00:00 2001 From: "Robert J. Good" Date: Thu, 6 Aug 2026 22:33:15 -0700 Subject: [PATCH 2/3] 429 handling --- .../Common/Exceptions/ApiExceptionHandler.cs | 17 +++++++++++++++++ src/Presentation.Api/Presentation.Api.csproj | 3 +++ 2 files changed, 20 insertions(+) diff --git a/src/Presentation.Api/Common/Exceptions/ApiExceptionHandler.cs b/src/Presentation.Api/Common/Exceptions/ApiExceptionHandler.cs index c2924f3..9f15f17 100644 --- a/src/Presentation.Api/Common/Exceptions/ApiExceptionHandler.cs +++ b/src/Presentation.Api/Common/Exceptions/ApiExceptionHandler.cs @@ -1,3 +1,5 @@ +using System.ClientModel; + namespace Goodtocode.AgentFramework.Presentation.Api.Common.Exceptions; /// @@ -26,6 +28,8 @@ public async ValueTask TryHandleAsync( UnauthorizedAccessException => (IResult)BuildUnauthorizedResult(), CustomForbiddenAccessException => (IResult)BuildForbiddenResult(), CustomConflictException conflictException => (IResult)BuildConflictResult(conflictException.Message), + ClientResultException { Status: 429 } rateLimitException => + (IResult)BuildTooManyRequestsResult(rateLimitException.Message), _ => (IResult)BuildUnknownResult() }; @@ -92,6 +96,19 @@ private static JsonHttpResult BuildConflictResult(string detail) return TypedResults.Json(details, statusCode: StatusCodes.Status409Conflict); } + private static JsonHttpResult BuildTooManyRequestsResult(string detail) + { + var details = new ProblemDetails + { + Status = StatusCodes.Status429TooManyRequests, + Title = "Too Many Requests", + Detail = detail, + Type = "https://tools.ietf.org/html/rfc6585#section-4" + }; + + return TypedResults.Json(details, statusCode: StatusCodes.Status429TooManyRequests); + } + private static JsonHttpResult BuildUnknownResult() { var details = new ProblemDetails diff --git a/src/Presentation.Api/Presentation.Api.csproj b/src/Presentation.Api/Presentation.Api.csproj index fea183f..4504756 100644 --- a/src/Presentation.Api/Presentation.Api.csproj +++ b/src/Presentation.Api/Presentation.Api.csproj @@ -41,4 +41,7 @@ + + + \ No newline at end of file From ef88deed0a865384c98b2f3349608be11c9f6435 Mon Sep 17 00:00:00 2001 From: "Robert J. Good" Date: Fri, 7 Aug 2026 22:30:14 -0700 Subject: [PATCH 3/3] scroll to bottom --- .../Features/Chat/ChatPage.razor | 39 +++++++++++++++++-- .../Features/Chat/ChatPage.razor.js | 10 +++++ 2 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 src/Presentation.Web/Features/Chat/ChatPage.razor.js diff --git a/src/Presentation.Web/Features/Chat/ChatPage.razor b/src/Presentation.Web/Features/Chat/ChatPage.razor index 2743360..b807174 100644 --- a/src/Presentation.Web/Features/Chat/ChatPage.razor +++ b/src/Presentation.Web/Features/Chat/ChatPage.razor @@ -6,12 +6,15 @@ @using Goodtocode.AgentFramework.Presentation.Web.Library.Auth.Services @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.JSInterop @attribute [Authorize] +@implements IAsyncDisposable @inject IChatService chatService @inject IUserSyncService UserSyncService @inject AuthenticationStateProvider AuthStateProvider +@inject IJSRuntime JSRuntime Chat Session @@ -36,7 +39,7 @@ - + @@ -68,7 +71,7 @@ - + @@ -87,10 +90,14 @@ @code { + private const string ChatMessagesContainerId = "chat-messages-container"; + private ChatSessionsModel chatSessions = new ChatSessionsModel(); private ChatSessionList? chatSessionListRef; private ChatSessionStrip? chatSessionStripRef; private bool isMobileLayout; + private bool shouldScrollToBottom; + private IJSObjectReference? chatPageJsModule; protected override async Task OnInitializedAsync() { @@ -117,6 +124,7 @@ { chatSessions.Add(chatSession); chatSessions.SetActive(chatSession); + shouldScrollToBottom = true; StateHasChanged(); } @@ -124,19 +132,44 @@ { chatSessions.ClearActive(); chatSessions.SetActive(chatSession); + shouldScrollToBottom = true; StateHasChanged(); } private async Task HandleMessageSubmitted() { chatSessions.RefreshItem(await chatService.GetChatSessionAsync(chatSessions?.ActiveSession?.Id ?? Guid.Empty)); + shouldScrollToBottom = true; StateHasChanged(); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + chatPageJsModule = await JSRuntime.InvokeAsync("import", "./Features/Chat/ChatPage.razor.js"); + shouldScrollToBottom = true; + } + + if (shouldScrollToBottom && chatPageJsModule is not null) + { + shouldScrollToBottom = false; + await chatPageJsModule.InvokeVoidAsync("scrollToBottomById", ChatMessagesContainerId); + } + } + private void OnBreakpointEnterHandler(GridItemSize size) { isMobileLayout = size == GridItemSize.Xs || size == GridItemSize.Sm; StateHasChanged(); } -} \ No newline at end of file + public async ValueTask DisposeAsync() + { + if (chatPageJsModule is not null) + { + await chatPageJsModule.DisposeAsync(); + } + } + +} diff --git a/src/Presentation.Web/Features/Chat/ChatPage.razor.js b/src/Presentation.Web/Features/Chat/ChatPage.razor.js new file mode 100644 index 0000000..0c05e51 --- /dev/null +++ b/src/Presentation.Web/Features/Chat/ChatPage.razor.js @@ -0,0 +1,10 @@ +export function scrollToBottomById(elementId) { + const element = document.getElementById(elementId); + if (!element) { + return; + } + + requestAnimationFrame(() => { + element.scrollTop = element.scrollHeight; + }); +}