Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/Presentation.Api/Common/Exceptions/ApiExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.ClientModel;

namespace Goodtocode.AgentFramework.Presentation.Api.Common.Exceptions;

/// <summary>
Expand Down Expand Up @@ -26,6 +28,8 @@ public async ValueTask<bool> TryHandleAsync(
UnauthorizedAccessException => (IResult)BuildUnauthorizedResult(),
CustomForbiddenAccessException => (IResult)BuildForbiddenResult(),
CustomConflictException conflictException => (IResult)BuildConflictResult(conflictException.Message),
ClientResultException { Status: 429 } rateLimitException =>
(IResult)BuildTooManyRequestsResult(rateLimitException.Message),
_ => (IResult)BuildUnknownResult()
};

Expand Down Expand Up @@ -92,6 +96,19 @@ private static JsonHttpResult<ProblemDetails> BuildConflictResult(string detail)
return TypedResults.Json(details, statusCode: StatusCodes.Status409Conflict);
}

private static JsonHttpResult<ProblemDetails> 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<ProblemDetails> BuildUnknownResult()
{
var details = new ProblemDetails
Expand Down
3 changes: 3 additions & 0 deletions src/Presentation.Api/Presentation.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,7 @@
<ProjectReference Include="..\Infrastructure.AgentFramework\Infrastructure.AgentFramework.csproj" />
<ProjectReference Include="..\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Common\Idempotency\" />
</ItemGroup>
</Project>
2 changes: 2 additions & 0 deletions src/Presentation.Web/ConfigureServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -33,6 +34,7 @@ public static void AddFrontendServices(this IServiceCollection services)
services.AddScoped<IDialogService, DialogService>();
services.AddScoped<ILocalStorageService, LocalStorageService>();
services.AddScoped<IChatService, ChatService>();
services.AddScoped<IChatMessageFormatter, MarkdownChatMessageFormatter>();
}

public static IServiceCollection AddUserClaimsSyncService(this IServiceCollection services)
Expand Down
39 changes: 36 additions & 3 deletions src/Presentation.Web/Features/Chat/ChatPage.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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

<PageTitle>Chat Session</PageTitle>

Expand All @@ -36,7 +39,7 @@
</FluentGridItem>

<FluentGridItem xs="12">
<FluentStack Style="max-height:65vh; overflow-y:auto; min-height:0; padding-bottom:10px;" Orientation="Orientation.Vertical">
<FluentStack id="@ChatMessagesContainerId" Style="max-height:65vh; overflow-y:auto; min-height:0; padding-bottom:10px;" Orientation="Orientation.Vertical">
<ChatMessageList Messages="chatSessions.ActiveSession?.Messages" />
</FluentStack>
</FluentGridItem>
Expand Down Expand Up @@ -68,7 +71,7 @@
</FluentGridItem>
<FluentGridItem xs="1" md="1"></FluentGridItem>
<FluentGridItem xs="8" md="8">
<FluentStack Style="max-height:65vh; overflow-y:auto; min-height:0; padding-bottom:10px;" Orientation="Orientation.Vertical">
<FluentStack id="@ChatMessagesContainerId" Style="max-height:65vh; overflow-y:auto; min-height:0; padding-bottom:10px;" Orientation="Orientation.Vertical">
<ChatMessageList Messages="chatSessions.ActiveSession?.Messages" />
</FluentStack>
</FluentGridItem>
Expand All @@ -87,10 +90,14 @@
</FluentGrid>

@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()
{
Expand All @@ -117,26 +124,52 @@
{
chatSessions.Add(chatSession);
chatSessions.SetActive(chatSession);
shouldScrollToBottom = true;
StateHasChanged();
}

private void HandleSessionSelected(ChatSessionModel chatSession)
{
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<IJSObjectReference>("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();
}

}
public async ValueTask DisposeAsync()
{
if (chatPageJsModule is not null)
{
await chatPageJsModule.DisposeAsync();
}
}

}
10 changes: 10 additions & 0 deletions src/Presentation.Web/Features/Chat/ChatPage.razor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function scrollToBottomById(elementId) {
const element = document.getElementById(elementId);
if (!element) {
return;
}

requestAnimationFrame(() => {
element.scrollTop = element.scrollHeight;
});
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
@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

<FluentStack Orientation="Orientation.Vertical" VerticalAlignment="VerticalAlignment.Top" VerticalGap="8" Style="@($"width:{Width}; max-width:100%; margin-inline:auto;")">
@foreach (var message in Messages ?? Enumerable.Empty<ChatMessageModel>())
{
var isUser = IsUserMessage(message);
<FluentStack HorizontalAlignment="@(isUser ? HorizontalAlignment.End : HorizontalAlignment.Start)" Style="max-width:90%;">
<FluentCard Appearance="CardAppearance.Filled" Style="@(isUser ? "background:var(--accent-fill-rest); color: var(--fill-color); width: 70%;" : "")">
@message.Content
@if (isUser)
{
@message.Content
}
else
{
<FluentStack Orientation="Orientation.Vertical" Class="chat-markdown-content" Style="width:100%;">
@((MarkupString)FormatAssistantMessage(message))
</FluentStack>
}
</FluentCard>

</FluentStack>
Expand All @@ -26,4 +38,7 @@

private bool IsUserMessage(ChatMessageModel message) =>
message?.Role?.ToLowerInvariant() == "user";
}

private string FormatAssistantMessage(ChatMessageModel message)
=> ChatMessageFormatter.FormatAssistantMessageAsHtml(message.Content ?? string.Empty);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Goodtocode.AgentFramework.Presentation.Web.Features.Chat.Formatting;

public interface IChatMessageFormatter
{
string FormatAssistantMessageAsHtml(string content);
}
Original file line number Diff line number Diff line change
@@ -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();
}
1 change: 1 addition & 0 deletions src/Presentation.Web/Presentation.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.8.3" />
<PackageReference Include="Goodtocode.SecuredHttpClient" Version="1.1.11" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="3.1.2" />
<PackageReference Include="Markdig" Version="0.42.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.8.0" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.14.4" />
Expand Down
33 changes: 33 additions & 0 deletions src/Presentation.Web/wwwroot/css/site.css
Original file line number Diff line number Diff line change
@@ -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);
}
Loading