.NET: Add agent hosting package and update sample (#296)

* Add agent hosting package and update sample

* Review feedback and cleanup

* Include the narrator

* wip

* wip

* Remove workaround for empty state writes.

* Handle changes to AgentThread.

* One more.

* Fix.

---------

Co-authored-by: Aditya Mandaleeka <adityam@microsoft.com>
This commit is contained in:
Reuben Bond
2025-08-06 21:26:36 +00:00
committed by GitHub
co-authored by Aditya Mandaleeka
parent 8dcc8533a6
commit e7441ee29e
86 changed files with 4388 additions and 1229 deletions
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI.Agents.Hosting;
namespace AgentWebChat.AgentHost;
internal static class ActorFrameworkWebApplicationExtensions
{
public static void MapAgentDiscovery(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path)
{
var routeGroup = endpoints.MapGroup(path);
routeGroup.MapGet("/", async (
AgentCatalog agentCatalog,
CancellationToken cancellationToken) =>
{
var results = new List<AgentDiscoveryCard>();
await foreach (var result in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(new AgentDiscoveryCard
{
Name = result.Name!,
Description = result.Description,
});
}
return Results.Ok(results);
})
.WithName("GetAgents");
}
internal sealed class AgentDiscoveryCard
{
public required string Name { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; set; }
}
}
@@ -4,16 +4,18 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.Orchestration\Microsoft.Agents.Orchestration.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Hosting\Microsoft.Extensions.AI.Agents.Hosting.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime\Microsoft.Extensions.AI.Agents.Runtime.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB\Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.csproj" />
<ProjectReference Include="..\HelloHttpApi.ServiceDefaults\HelloHttpApi.ServiceDefaults.csproj" />
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
@@ -25,6 +27,9 @@
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.AzureAIInference" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.AspNetCore.OpenAPI" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
<PackageReference Include="System.Net.ServerSentEvents" />
</ItemGroup>
</Project>
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.AI.Agents.Runtime;
namespace AgentWebChat.AgentHost;
internal static partial class HttpActorApiRouteBuilderExtensions
{
private const string BasePath = "/actors/v1";
public static void MapActors(this IEndpointRouteBuilder endpoints, IActorClient? actorClient = null, [StringSyntax("Route")] string? path = default)
{
path ??= BasePath;
actorClient ??= endpoints.ServiceProvider.GetRequiredService<IActorClient>();
var routeGroup = endpoints.MapGroup(path);
// GET /actors/v1/{actorType}/{actorKey}/{messageId}
routeGroup.MapGet(
"/{actorType}/{actorKey}/{messageId}", async (
string actorType,
string actorKey,
string messageId,
[FromQuery] bool? blocking,
[FromQuery] bool? streaming,
HttpContext context,
CancellationToken cancellationToken) =>
await HttpActorProcessor.GetResponseAsync(
actorType,
actorKey,
messageId,
blocking: blocking,
streaming: streaming,
context,
actorClient,
cancellationToken))
.WithName("GetActorResponse");
// POST /actors/v1/{actorType}/{actorKey}/{messageId}
routeGroup.MapPost(
"/{actorType}/{actorKey}/{messageId}", async (
string actorType,
string actorKey,
string messageId,
[FromQuery] bool? blocking,
[FromQuery] bool? streaming,
[FromBody] ActorRequest request,
CancellationToken cancellationToken) =>
await HttpActorProcessor.SendRequestAsync(
actorType,
actorKey,
messageId,
blocking: blocking,
streaming: streaming,
request,
actorClient,
cancellationToken))
.WithName("SendActorRequest");
// POST /actors/v1/{actorType}/{actorKey}/{messageId}:cancel
routeGroup.MapPost(
"/{actorType}/{actorKey}/{messageId}:cancel", async (
string actorType,
string actorKey,
string messageId,
CancellationToken cancellationToken) =>
await HttpActorProcessor.CancelRequestAsync(actorType, actorKey, messageId, actorClient, cancellationToken))
.WithName("CancelActorRequest");
}
}
@@ -0,0 +1,145 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.AI.Agents.Hosting;
using Microsoft.Extensions.AI.Agents.Runtime;
namespace AgentWebChat.AgentHost;
internal static class HttpActorProcessor
{
public static async Task<IResult> GetResponseAsync(
string actorType,
string actorKey,
string messageId,
bool? blocking,
bool? streaming,
HttpContext context,
IActorClient actorClient,
CancellationToken cancellationToken)
{
var actorId = new ActorId(actorType, actorKey);
var responseHandle = await actorClient.GetResponseAsync(actorId, messageId, cancellationToken);
if (responseHandle.TryGetResponse(out var response))
{
return GetResult(response);
}
if (streaming == true)
{
return new ActorUpdateStreamingResult(responseHandle);
}
if (blocking == true)
{
response = await responseHandle.GetResponseAsync(cancellationToken);
return GetResult(response);
}
return Results.Ok(new ActorResponse
{
ActorId = actorId,
MessageId = messageId,
Status = RequestStatus.Pending,
Data = JsonDocument.Parse("{}").RootElement
});
}
public static async Task<IResult> SendRequestAsync(
string actorType,
string actorKey,
string messageId,
bool? blocking,
bool? streaming,
ActorRequest request,
IActorClient actorClient,
CancellationToken cancellationToken)
{
var responseHandle = await actorClient.SendRequestAsync(request, cancellationToken);
if (responseHandle.TryGetResponse(out var response))
{
return GetResult(response);
}
if (streaming == true)
{
return new ActorUpdateStreamingResult(responseHandle);
}
if (blocking == true)
{
response = await responseHandle.GetResponseAsync(cancellationToken);
return GetResult(response);
}
return Results.Accepted();
}
private static IResult GetResult(ActorResponse response)
{
if (response.Status == RequestStatus.NotFound)
{
return Results.NotFound();
}
return Results.Ok(response);
}
public static async Task<IResult> CancelRequestAsync(
string actorType,
string actorKey,
string messageId,
IActorClient actorClient,
CancellationToken cancellationToken)
{
var actorId = new ActorId(actorType, actorKey);
var responseHandle = await actorClient.GetResponseAsync(actorId, messageId, cancellationToken);
if (responseHandle.TryGetResponse(out var response))
{
if (response.Status is RequestStatus.NotFound)
{
return Results.NotFound();
}
else if (response.Status is RequestStatus.Completed or RequestStatus.Failed)
{
return Results.Conflict("The request has already completed and cannot be cancelled.");
}
}
await responseHandle.CancelAsync(cancellationToken);
return Results.NoContent();
}
private sealed class ActorUpdateStreamingResult(
ActorResponseHandle responseHandle) : IResult
{
public async Task ExecuteAsync(HttpContext httpContext)
{
var cancellationToken = httpContext.RequestAborted;
var response = httpContext.Response;
response.Headers.ContentType = "text/event-stream";
response.Headers.CacheControl = "no-cache,no-store";
response.Headers.Connection = "keep-alive";
// Make sure we disable all response buffering for SSE.
response.Headers.ContentEncoding = "identity";
httpContext.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
await response.Body.FlushAsync(cancellationToken);
var updateTypeInfo = AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ActorRequestUpdate));
await foreach (var update in responseHandle.WatchUpdatesAsync(cancellationToken).ConfigureAwait(false))
{
var eventData = JsonSerializer.Serialize(update, updateTypeInfo);
var eventText = $"data: {eventData}\n\n";
await response.WriteAsync(eventText, cancellationToken);
await response.Body.FlushAsync(cancellationToken);
}
}
}
}
@@ -2,7 +2,7 @@
using Microsoft.Extensions.AI.Agents.Runtime;
namespace HelloHttpApi.ApiService;
namespace AgentWebChat.AgentHost;
/// <summary>
/// High-performance logging messages using LoggerMessage source generator.
@@ -62,12 +62,6 @@ internal static partial class Log
Message = "Actor response processed successfully: RequestId={RequestId}, ResponseType={ResponseType}")]
public static partial void ActorResponseProcessed(ILogger logger, string requestId, string responseType);
// Ping endpoint logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Ping endpoint accessed: Status={Status}, TimeOfLastUpdate={TimeOfLastUpdate}")]
public static partial void PingEndpointAccessed(ILogger logger, PingResponseStatus status, long timeOfLastUpdate);
// Request/Response logging
[LoggerMessage(
Level = LogLevel.Debug,
@@ -0,0 +1,99 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using AgentWebChat.AgentHost;
using AgentWebChat.AgentHost.Utilities;
using Microsoft.Agents.Orchestration;
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Hosting;
using Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
var builder = WebApplication.CreateBuilder(args);
// Add service defaults & Aspire client integrations.
builder.AddServiceDefaults();
builder.Services.AddOpenApi();
// Add services to the container.
builder.Services.AddProblemDetails();
// Add CosmosDB client integration
builder.AddAzureCosmosClient("agent-web-chat-cosmosdb", null, CosmosClientOptions =>
{
CosmosClientOptions.ApplicationName = "AgentWebChat";
CosmosClientOptions.ConnectionMode = ConnectionMode.Direct;
CosmosClientOptions.ConsistencyLevel = ConsistencyLevel.Session;
CosmosClientOptions.UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
TypeInfoResolver = CosmosActorStateJsonContext.Default
};
});
// Configure the chat model and our agent.
builder.AddKeyedChatClient("chat-model");
builder.AddAIAgent(
"pirate",
instructions: "You are a pirate. Speak like a pirate",
description: "An agent that speaks like a pirate.",
chatClientServiceKey: "chat-model");
builder.AddAIAgent("knights-and-knaves", (sp, key) =>
{
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
ChatClientAgent knight = new(
chatClient,
"""
You are a knight. This means that you must always tell the truth. Your name is Alice.
Bob is standing next to you. Bob is a knave, which means he always lies.
When replying, always start with your name (Alice). Eg, "Alice: I am a knight."
""", "Alice");
ChatClientAgent knave = new(
chatClient,
"""
You are a knave. This means that you must always lie. Your name is Bob.
Alice is standing next to you. Alice is a knight, which means she always tells the truth.
When replying, always include your name (Bob). Eg, "Bob: I am a knight."
""", "Bob");
ChatClientAgent narrator = new(
chatClient,
"""
You are are the narrator of a puzzle involving knights (who always tell the truth) and knaves (who always lie).
The user is going to ask questions and guess whether Alice or Bob is the knight or knave.
Alice is standing to one side of you. Alice is a knight, which means she always tells the truth.
Bob is standing to the other side of you. Bob is a knave, which means he always lies.
When replying, always include your name (Narrator).
Once the user has deduced what type (knight or knave) both Alice and Bob are, tell them whether they are right or wrong.
If the user asks a general question about their surrounding, make something up which is consistent with the scenario.
""", "Narrator");
return new ConcurrentOrchestration([knight, knave, narrator], name: key);
});
// Add CosmosDB state storage to override default storage
builder.Services.AddCosmosActorStateStorage("actor-state-db", "ActorState");
var app = builder.Build();
app.MapOpenApi();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/openapi/v1.json", "Agents API");
});
// Configure the HTTP request pipeline.
app.UseExceptionHandler();
app.MapActors();
// Map the agents HTTP endpoints
app.MapAgentDiscovery("/agents");
app.MapDefaultEndpoints();
app.Run();
@@ -6,6 +6,7 @@
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5390",
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -14,6 +15,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7373;http://localhost:5390",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -3,7 +3,7 @@
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
namespace HelloHttpApi.ApiService.Utilities;
namespace AgentWebChat.AgentHost.Utilities;
public class ChatClientConnectionInfo
{
@@ -1,12 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentWebChat.AgentHost.Utilities;
using Azure;
using Azure.AI.Inference;
using HelloHttpApi.ApiService.Utilities;
using Microsoft.Extensions.AI;
using OllamaSharp;
namespace HelloHttpApi.ApiService.Utilities;
namespace AgentWebChat.AgentHost.Utilities;
public static class ChatClientExtensions
{
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
@@ -18,8 +18,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HelloHttpApi.ApiService\HelloHttpApi.ApiService.csproj" />
<ProjectReference Include="..\HelloHttpApi.Web\HelloHttpApi.Web.csproj" />
<ProjectReference Include="..\AgentWebChat.AgentHost\AgentWebChat.AgentHost.csproj" />
<ProjectReference Include="..\AgentWebChat.Web\AgentWebChat.Web.csproj" />
</ItemGroup>
</Project>
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
namespace HelloHttpApi.AppHost;
namespace AgentWebChat.AppHost;
public static class ModelExtensions
{
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using HelloHttpApi.AppHost;
using AgentWebChat.AppHost;
var builder = DistributedApplication.CreateBuilder(args);
@@ -10,17 +10,17 @@ var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o.
var cosmosDbResource = builder.AddParameterFromConfiguration("CosmosDbName", "CosmosDb:Name");
var cosmosDbResourceGroup = builder.AddParameterFromConfiguration("CosmosDbResourceGroup", "CosmosDb:ResourceGroup");
var cosmos = builder.AddAzureCosmosDB("hello-http-api-cosmosdb").RunAsExisting(cosmosDbResource, cosmosDbResourceGroup);
var cosmos = builder.AddAzureCosmosDB("agent-web-chat-cosmosdb").RunAsExisting(cosmosDbResource, cosmosDbResourceGroup);
var stateDb = cosmos.AddCosmosDatabase("actor-state-db");
var apiService = builder.AddProject<Projects.HelloHttpApi_ApiService>("apiservice")
var agentHost = builder.AddProject<Projects.AgentWebChat_AgentHost>("agenthost")
.WithReference(chatModel)
.WithReference(cosmos).WaitFor(cosmos);
builder.AddProject<Projects.HelloHttpApi_Web>("webfrontend")
builder.AddProject<Projects.AgentWebChat_Web>("webfrontend")
.WithExternalHttpEndpoints()
.WithReference(apiService)
.WaitFor(apiService);
.WithReference(agentHost)
.WaitFor(agentHost);
builder.Build().Run();
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI.Agents.Hosting;
namespace AgentWebChat.Web;
public class AgentDiscoveryClient(HttpClient httpClient, ILogger<AgentDiscoveryClient> logger)
{
public async Task<List<AgentDiscoveryCard>> GetAgentsAsync(CancellationToken cancellationToken = default)
{
var response = await httpClient.GetAsync(new Uri("/agents", UriKind.Relative), cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var agents = JsonSerializer.Deserialize<List<AgentDiscoveryCard>>(json, AgentHostingJsonUtilities.DefaultOptions) ?? [];
logger.LogInformation("Retrieved {AgentCount} agents from the API", agents.Count);
_ = new HttpActorClient(null!);
return agents;
}
public class AgentDiscoveryCard
{
public string? Name { get; set; }
public string? Description { get; set; }
}
}
@@ -7,7 +7,12 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\HelloHttpApi.ServiceDefaults\HelloHttpApi.ServiceDefaults.csproj" />
<PackageReference Include="System.Net.ServerSentEvents" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Hosting\Microsoft.Extensions.AI.Agents.Hosting.csproj" />
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
</ItemGroup>
@@ -7,7 +7,7 @@
<base href="/" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="HelloHttpApi.Web.styles.css" />
<link rel="stylesheet" href="AgentWebChat.Web.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
@@ -0,0 +1,15 @@
@inherits LayoutComponentBase
<div class="page">
<main>
<article class="content">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@@ -0,0 +1,33 @@
.page {
position: relative;
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
flex: 1;
}
.content {
padding: 0;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
@@ -0,0 +1,741 @@
@page "/"
@attribute [StreamRendering(true)]
@inject AgentDiscoveryClient AgentClient
@inject IJSRuntime JSRuntime
@inject ILogger<Home> Logger
@inject IActorClient ActorClient
@rendermode InteractiveServer
@using System.Text
@using System.Text.Json
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.AI.Agents
@using Microsoft.Extensions.AI.Agents.Hosting
@using Microsoft.Extensions.AI.Agents.Runtime
<PageTitle>Agent Web Chat</PageTitle>
<div class="chat-app-container">
<div class="chat-header">
<h1 class="chat-title">
<svg class="chat-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
Agent Web Chat
</h1>
<p class="chat-subtitle">The best hypertext-based chat on the Web!</p>
</div>
<div class="agent-selection-card">
<label for="agent-select" class="agent-select-label">Choose your AI agent:</label>
<div class="agent-select-wrapper">
<select id="agent-select" class="agent-select" @bind="selectedAgentName" disabled="@(isLoadingAgents || isStreaming)">
<option value="">-- Select an agent --</option>
@foreach (var agent in availableAgents)
{
<option value="@agent.Name">@GetAgentDisplayName(agent.Name!) - @agent.Description</option>
}
</select>
@if (!string.IsNullOrEmpty(selectedAgentName) && currentConversation == null)
{
<button class="start-chat-btn" @onclick="StartNewConversation">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Start Chat
</button>
}
</div>
</div>
@if (conversations.Any())
{
<div class="conversations-section">
<div class="conversation-tabs">
@foreach (var conv in conversations)
{
<div class="conversation-tab @(conv.SessionId == currentConversation?.SessionId ? "active" : "")"
@onclick="() => SelectConversation(conv.SessionId)">
<span class="tab-icon">@GetAgentIcon(conv.AgentName)</span>
<span class="tab-name">@GetAgentDisplayName(conv.AgentName)</span>
<button type="button" class="tab-close"
aria-label="Close"
@onclick:stopPropagation="true"
@onclick="() => CloseConversation(conv.SessionId)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
}
</div>
</div>
}
@if (currentConversation != null)
{
<div class="chat-container">
<div class="chat-messages" id="chat-messages">
@foreach (var message in currentConversation.Messages)
{
<div class="message-wrapper @(message.Role == ChatRole.User ? "user" : "agent")">
@if (message.Role != ChatRole.User)
{
<div class="message-avatar">@GetAgentIcon(currentConversation.AgentName)</div>
}
<div class="message-bubble">
<div class="message-content">@message.Text</div>
<div class="message-meta">
@(message.Role == ChatRole.User ? "You" : GetAgentDisplayName(currentConversation.AgentName))
</div>
</div>
</div>
}
@if (isStreaming && currentStreamedMessage.Length > 0)
{
<div class="message-wrapper agent">
<div class="message-avatar">@GetAgentIcon(currentConversation.AgentName)</div>
<div class="message-bubble streaming">
<div class="message-content">
@currentStreamedMessage
<span class="typing-indicator"></span>
</div>
</div>
</div>
}
</div>
<div class="chat-input-container">
<div class="chat-input-wrapper">
<input @bind="currentMessage"
@bind:event="oninput"
@onkeydown="HandleKeyPress"
@onkeydown:preventDefault="ShouldPreventDefault"
class="chat-input"
placeholder="Type your message..."
disabled="@isStreaming" />
<button @onclick="SendMessage"
class="send-button"
disabled="@(isStreaming || string.IsNullOrWhiteSpace(currentMessage))">
@if (isStreaming)
{
<div class="spinner"></div>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
}
</button>
</div>
</div>
</div>
}
</div>
<style>
* {
box-sizing: border-box;
}
.chat-app-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.chat-header {
text-align: center;
margin-bottom: 2rem;
}
.chat-title {
font-size: 2.5rem;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.chat-icon {
color: #6366f1;
}
.chat-subtitle {
color: #6b7280;
font-size: 1.125rem;
margin: 0;
}
.agent-selection-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.agent-select-label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 0.75rem;
}
.agent-select-wrapper {
display: flex;
gap: 1rem;
align-items: center;
}
.agent-select {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
background: white;
color: #374151;
cursor: pointer;
transition: all 0.2s;
}
.agent-select:hover:not(:disabled) {
border-color: #6366f1;
}
.agent-select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.agent-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.start-chat-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.start-chat-btn:hover {
background: #4f46e5;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.conversations-section {
margin-bottom: 1.5rem;
}
.conversation-tabs {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.conversation-tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: white;
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
position: relative;
white-space: nowrap;
}
.conversation-tab:hover {
border-color: #6366f1;
background: #f9fafb;
}
.conversation-tab.active {
background: #6366f1;
color: white;
border-color: #6366f1;
}
.tab-icon {
font-size: 1.25rem;
}
.tab-name {
font-weight: 500;
}
.tab-close {
margin-left: 0.5rem;
background: none;
border: none;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.tab-close:hover {
opacity: 1;
}
.conversation-tab.active .tab-close {
color: white;
}
.chat-container {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
height: 600px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
background: #f9fafb;
}
.message-wrapper {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
animation: fadeIn 0.3s ease-in-out;
}
.message-wrapper.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #6366f1;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
flex-shrink: 0;
}
.message-bubble {
max-width: 70%;
background: white;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.message-wrapper.user .message-bubble {
background: #6366f1;
color: white;
}
.message-content {
line-height: 1.5;
word-wrap: break-word;
}
.message-meta {
font-size: 0.75rem;
opacity: 0.6;
margin-top: 0.5rem;
}
.message-bubble.streaming {
background: #e0e7ff;
}
.typing-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #6366f1;
margin-left: 4px;
animation: pulse 1.4s infinite;
}
@@keyframes pulse {
0%, 60%, 100% {
opacity: 0.2;
}
30% {
opacity: 1;
}
}
@@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chat-input-container {
border-top: 1px solid #e5e7eb;
padding: 1rem;
background: white;
}
.chat-input-wrapper {
display: flex;
gap: 0.75rem;
}
.chat-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s;
}
.chat-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.chat-input:disabled {
opacity: 0.5;
background: #f9fafb;
}
.send-button {
padding: 0.75rem 1rem;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
min-width: 50px;
}
.send-button:hover:not(:disabled) {
background: #4f46e5;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@media (max-width: 768px) {
.chat-app-container {
padding: 1rem;
}
.chat-title {
font-size: 2rem;
}
.message-bubble {
max-width: 85%;
}
.chat-container {
height: 500px;
}
}
</style>
@code {
private string currentMessage = "";
private bool isStreaming = false;
private bool isLoadingAgents = true;
private string currentStreamedMessage = "";
private string selectedAgentName = "";
private List<AgentDiscoveryClient.AgentDiscoveryCard> availableAgents = new();
private List<Conversation> conversations = new();
private Conversation? currentConversation;
private class Conversation
{
public string SessionId { get; set; } = Guid.NewGuid().ToString();
public string AgentName { get; set; } = "";
public List<ChatMessage> Messages { get; set; } = new();
}
protected override async Task OnInitializedAsync()
{
Logger.LogDebug("Initializing Agent Chat component");
// Load agents
try
{
availableAgents = await AgentClient.GetAgentsAsync();
Logger.LogInformation("Loaded {AgentCount} agents", availableAgents.Count);
// Default to first agent and start a conversation
if (availableAgents.Any())
{
selectedAgentName = availableAgents.First().Name!;
StartNewConversation();
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load agents");
}
finally
{
isLoadingAgents = false;
}
// Conversations start fresh on page load
}
private string GetAgentIcon(string agentName)
{
return agentName?.ToLower() switch
{
"pirate" => "🏴‍☠️",
"knights-and-knaves" => "⚔️",
_ => "🤖"
};
}
private string GetAgentDisplayName(string agentName)
{
return agentName?.ToLower() switch
{
"pirate" => "Pirate",
"knights-and-knaves" => "Knights & Knaves",
_ => agentName ?? "Agent"
};
}
private void StartNewConversation()
{
if (string.IsNullOrEmpty(selectedAgentName))
return;
var newConversation = new Conversation
{
AgentName = selectedAgentName
};
conversations.Add(newConversation);
currentConversation = newConversation;
Logger.LogInformation("Started new conversation with agent: {AgentName}, session: {SessionId}",
newConversation.AgentName, newConversation.SessionId);
StateHasChanged();
}
private void SelectConversation(string sessionId)
{
currentConversation = conversations.FirstOrDefault(c => c.SessionId == sessionId);
if (currentConversation != null)
{
selectedAgentName = currentConversation.AgentName;
Logger.LogDebug("Selected conversation with session: {SessionId}", sessionId);
}
StateHasChanged();
}
private void CloseConversation(string sessionId)
{
var conversationToRemove = conversations.FirstOrDefault(c => c.SessionId == sessionId);
if (conversationToRemove != null)
{
conversations.Remove(conversationToRemove);
if (currentConversation?.SessionId == sessionId)
{
currentConversation = conversations.FirstOrDefault();
if (currentConversation != null)
{
selectedAgentName = currentConversation.AgentName;
}
}
Logger.LogInformation("Closed conversation with session: {SessionId}", sessionId);
}
StateHasChanged();
}
private async Task SendMessage()
{
if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming || currentConversation == null)
return;
var userMessage = currentMessage.Trim();
currentMessage = "";
Logger.LogInformation("User sending message: '{UserMessage}' to agent {AgentName} in session {SessionId}",
userMessage, currentConversation.AgentName, currentConversation.SessionId);
// Add user message to chat
currentConversation.Messages.Add(new ChatMessage(ChatRole.User, userMessage));
StateHasChanged();
await ScrollToBottom();
// Start streaming response
isStreaming = true;
currentStreamedMessage = "";
StateHasChanged();
try
{
var responseContent = new StringBuilder();
var agent = new AgentProxy(currentConversation.AgentName, ActorClient);
var thread = agent.GetNewThread();
thread.ConversationId = currentConversation.SessionId;
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, userMessage)],
thread,
cancellationToken: CancellationToken.None))
{
var content = update.Text ?? "";
if (!string.IsNullOrEmpty(content))
{
responseContent.Append(content);
currentStreamedMessage = responseContent.ToString();
StateHasChanged();
await ScrollToBottom();
}
}
// Add the complete agent response to chat messages
if (responseContent.Length > 0)
{
currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, responseContent.ToString()));
}
else
{
currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, "Sorry, I couldn't generate a response."));
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}",
currentConversation.SessionId, ex.Message);
currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, $"Error: {ex.Message}"));
}
finally
{
isStreaming = false;
currentStreamedMessage = "";
StateHasChanged();
await ScrollToBottom();
}
}
private bool ShouldPreventDefault = false;
private async Task HandleKeyPress(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !e.ShiftKey)
{
ShouldPreventDefault = true;
await SendMessage();
ShouldPreventDefault = false;
}
else if (e.Key == "Escape")
{
currentMessage = ""; // Clear input on Escape
ShouldPreventDefault = true;
StateHasChanged();
ShouldPreventDefault = false; // Reset after clearing
}
else
{
ShouldPreventDefault = false;
}
}
private async Task ScrollToBottom()
{
try
{
await JSRuntime.InvokeVoidAsync("scrollToBottom", "chat-messages");
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to scroll to bottom");
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JSRuntime.InvokeVoidAsync("eval", @"
window.scrollToBottom = function(elementId) {
const element = document.getElementById(elementId);
if (element) {
requestAnimationFrame(() => {
element.scrollTop = element.scrollHeight;
});
}
};
");
}
}
}
@@ -7,5 +7,5 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.OutputCaching
@using Microsoft.JSInterop
@using HelloHttpApi.Web
@using HelloHttpApi.Web.Components
@using AgentWebChat.Web
@using AgentWebChat.Web.Components
@@ -0,0 +1,190 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Hosting;
using Microsoft.Extensions.AI.Agents.Runtime;
namespace AgentWebChat.Web;
internal sealed class HttpActorClient(HttpClient httpClient) : IActorClient
{
private const string BaseUri = "/actors/v1";
public async ValueTask<ActorResponseHandle> GetResponseAsync(ActorId actorId, string messageId, CancellationToken cancellationToken)
{
var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}", UriKind.Relative);
var response = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
return new HttpActorResponseHandle(httpClient, actorId, messageId, initialResponseMessage: response);
}
public async ValueTask<ActorResponseHandle> SendRequestAsync(ActorRequest request, CancellationToken cancellationToken)
{
var actorId = request.ActorId;
var messageId = request.MessageId;
var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}?streaming=true", UriKind.Relative);
var jsonContent = JsonContent.Create(request, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ActorRequest)));
var message = new HttpRequestMessage(HttpMethod.Post, uri) { Content = jsonContent };
var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
return new HttpActorResponseHandle(httpClient, actorId, messageId, response);
}
private sealed class HttpActorResponseHandle(
HttpClient httpClient,
ActorId actorId,
string messageId,
HttpResponseMessage? initialResponseMessage) : ActorResponseHandle
{
private HttpResponseMessage? _responseMessage = initialResponseMessage;
private ActorResponse? _lastResponse;
public override async ValueTask CancelAsync(CancellationToken cancellationToken)
{
this._responseMessage?.Dispose();
this._responseMessage = null;
var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}:cancel", UriKind.Relative);
await httpClient.PostAsync(uri, null, cancellationToken).ConfigureAwait(false);
}
public override async ValueTask<ActorResponse> GetResponseAsync(CancellationToken cancellationToken)
{
try
{
// If the response is already completed, don't bother requesting the response again;
if (this._lastResponse is { } response && response.Status.IsTerminated())
{
return response;
}
if (IsStreamingResponse(this._responseMessage))
{
try
{
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in EnumerateAsync(this._responseMessage, cancellationToken).ConfigureAwait(false))
{
if (!update.Status.IsTerminated())
{
continue;
}
response = new ActorResponse { ActorId = actorId, MessageId = messageId, Status = update.Status, Data = update.Data };
this._lastResponse = response;
return response;
}
}
finally
{
this._responseMessage?.Dispose();
this._responseMessage = null;
}
}
var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}?blocking=true", UriKind.Relative);
using var responseMessage = this._responseMessage ?? await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response = await this.ReadResponseAsync(responseMessage, cancellationToken).ConfigureAwait(false);
this._lastResponse = response;
return response;
}
finally
{
this._responseMessage = null;
}
}
public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response)
{
response = this._lastResponse;
return response != null;
}
public override async IAsyncEnumerable<ActorRequestUpdate> WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
// If the response is already completed, don't bother streaming anything.
if (this._lastResponse is { } response && response.Status.IsTerminated())
{
yield return new ActorRequestUpdate(response.Status, response.Data);
yield break;
}
try
{
var uri = new Uri($"{BaseUri}/{actorId.Type}/{actorId.Key}/{messageId}?streaming=true", UriKind.Relative);
using var responseMessage = this._responseMessage ?? await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (IsJsonResponse(responseMessage))
{
// If the response is JSON, read it as a single response and yield it.
response = await this.ReadResponseAsync(responseMessage, cancellationToken).ConfigureAwait(false);
yield return new ActorRequestUpdate(response.Status, response.Data);
yield break;
}
await foreach (var update in EnumerateAsync(responseMessage, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
finally
{
this._responseMessage = null;
}
}
private static async IAsyncEnumerable<ActorRequestUpdate> EnumerateAsync(HttpResponseMessage responseMessage, [EnumeratorCancellation] CancellationToken cancellationToken)
{
var responseStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var sseParser = SseParser.Create<ActorRequestUpdate?>(responseStream, (eventType, data) =>
{
if (eventType != "message")
{
// Only process default message events
return null;
}
var reader = new Utf8JsonReader(data);
return JsonSerializer.Deserialize(ref reader, AgentHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ActorRequestUpdate))) as ActorRequestUpdate;
});
await foreach (var item in sseParser.EnumerateAsync(cancellationToken).ConfigureAwait(false))
{
if (item.Data is not null)
{
yield return item.Data;
}
}
}
private async Task<ActorResponse> ReadResponseAsync(HttpResponseMessage responseMessage, CancellationToken cancellationToken)
{
var response = await responseMessage.Content.ReadFromJsonAsync<ActorResponse>(AgentRuntimeJsonUtilities.DefaultOptions, cancellationToken).ConfigureAwait(false);
if (response == null)
{
throw new InvalidOperationException($"No response found for actor '{actorId}' with message ID '{messageId}'.");
}
return response;
}
private static bool IsJsonResponse([NotNullWhen(true)] HttpResponseMessage? response)
{
return response?.Content.Headers.ContentType?.MediaType == "application/json";
}
private static bool IsStreamingResponse([NotNullWhen(true)] HttpResponseMessage? response)
{
return response?.Content.Headers.ContentType?.MediaType == "text/event-stream";
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
this._responseMessage?.Dispose();
this._responseMessage = null;
}
}
}
@@ -1,7 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using HelloHttpApi.Web;
using HelloHttpApi.Web.Components;
using AgentWebChat.Web;
using AgentWebChat.Web.Components;
using Microsoft.Extensions.AI.Agents.Runtime;
var builder = WebApplication.CreateBuilder(args);
@@ -14,12 +15,8 @@ builder.Services.AddRazorComponents()
builder.Services.AddOutputCache();
builder.Services.AddHttpClient<AgentClient>(client =>
{
// This URL uses "https+http://" to indicate HTTPS is preferred over HTTP.
// Learn more about service discovery scheme resolution at https://aka.ms/dotnet/sdschemes.
client.BaseAddress = new("https+http://apiservice");
});
builder.Services.AddHttpClient<IActorClient, HttpActorClient>(client => client.BaseAddress = new("https+http://agenthost"));
builder.Services.AddHttpClient<AgentDiscoveryClient>(client => client.BaseAddress = new("https+http://agenthost"));
var app = builder.Build();
@@ -32,11 +29,12 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.UseOutputCache();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
@@ -1,39 +1,8 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e51540;
}
.validation-message {
color: #e51540;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 0;
padding: 0;
background-color: #f9fafb;
}
.blazor-error-boundary {
@@ -42,15 +11,6 @@ h1:focus {
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,174 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents.Runtime;
namespace HelloHttpApi.ApiService;
internal static class ActorFrameworkWebApplicationExtensions
{
public static void MapAgents(this WebApplication app)
{
app.MapPost(
"/invocations/actor/{name}/{sessionId}/{requestId}", async (
string name,
string sessionId,
string requestId,
[FromQuery] bool? stream,
[FromBody] JsonElement request,
HttpContext context,
ILogger<Program> logger,
IActorClient actorClient,
CancellationToken cancellationToken) =>
{
var stopwatch = Stopwatch.StartNew();
var streamRequested = stream == true;
Log.ActorInvocationStarted(logger, name, sessionId, requestId, streamRequested);
Log.ActorRequestReceived(logger, requestId, request.GetRawText().Length, streamRequested);
try
{
var responseHandle = await actorClient.SendRequestAsync(new ActorRequest(new ActorId(name, sessionId), requestId, method: "run", @params: request), cancellationToken);
Log.ActorRequestSent(logger, requestId, name, sessionId);
if (!responseHandle.TryGetResponse(out var response))
{
Log.ActorResponseHandleObtained(logger, requestId, false);
if (stream == true)
{
Log.SseStreamingStarted(logger, requestId);
// If no response is available and streaming is requested, stream the response handle.
var result = await StreamResponse(context, responseHandle, cancellationToken);
Log.ActorInvocationCompleted(logger, name, sessionId, requestId, RequestStatus.Pending, stopwatch.ElapsedMilliseconds);
return result;
}
// Otherwise, wait for a response to become available.
Log.WaitingForActorResponse(logger, requestId);
response = await responseHandle.GetResponseAsync(cancellationToken);
}
else
{
Log.ActorResponseHandleObtained(logger, requestId, true);
}
Log.ActorResponseReceived(logger, requestId, response.Status);
var processResult = await ProcessResponse(name, sessionId, requestId, stream, context, responseHandle, response, cancellationToken);
Log.ActorInvocationCompleted(logger, name, sessionId, requestId, response.Status, stopwatch.ElapsedMilliseconds);
return processResult;
}
catch (Exception ex)
{
Log.ActorInvocationFailed(logger, ex, name, sessionId, requestId, stopwatch.ElapsedMilliseconds);
return Results.Problem("An error occurred processing the request.", statusCode: 500);
}
static async Task<IResult> StreamResponse(HttpContext context, ActorResponseHandle responseHandle, CancellationToken cancellationToken)
{
var requestId = context.Request.RouteValues["requestId"]?.ToString() ?? "unknown";
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
Log.SseStreamingStarted(logger, requestId);
InitializeSseResponse(context);
await context.Response.Body.FlushAsync(cancellationToken);
var updateCount = 0;
try
{
await foreach (var progress in responseHandle.WatchUpdatesAsync(cancellationToken))
{
// Properly serialize the progress data as JSON and escape for SSE
var progressJson = JsonSerializer.Serialize(progress.Data, (JsonSerializerOptions?)null);
var eventData = JsonSerializer.Serialize(new { @event = JsonDocument.Parse(progressJson).RootElement });
var eventText = $"data: {eventData}\n\n";
await context.Response.WriteAsync(eventText, cancellationToken);
await context.Response.Body.FlushAsync(cancellationToken);
updateCount++;
Log.SseProgressUpdateSent(logger, requestId, updateCount);
}
// Send completion marker
await context.Response.WriteAsync("data: completed\n\n", cancellationToken);
await context.Response.Body.FlushAsync(cancellationToken);
Log.SseStreamingCompleted(logger, requestId, updateCount);
}
catch (OperationCanceledException)
{
Log.SseStreamingCancelled(logger, requestId);
}
catch (Exception ex)
{
Log.SseStreamingError(logger, ex, requestId);
}
// TODO: refactor the enclosing method so we don't need to return a result here.
return Results.Empty;
}
static void InitializeSseResponse(HttpContext context)
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache,no-store";
context.Response.Headers.Connection = "keep-alive";
// Make sure we disable all response buffering for SSE.
context.Response.Headers.ContentEncoding = "identity";
context.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
}
static async Task<IResult> ProcessResponse(
string name,
string sessionId,
string requestId,
bool? stream,
HttpContext context,
ActorResponseHandle responseHandle,
ActorResponse response,
CancellationToken cancellationToken)
{
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
var isStreaming = stream != false && response.Status == RequestStatus.Pending;
Log.ProcessingActorResponse(logger, requestId, response.Status, isStreaming);
var result = response.Status switch
{
// If the response is pending & streaming is disabled, return a 202 Accepted with the messageId.
RequestStatus.Pending when stream == false => Results.Accepted($"/invocations/actor/{name}/{sessionId}/{requestId}"),
// If streaming is not explicitly disabled, stream the response back.
RequestStatus.Pending => await StreamResponse(context, responseHandle, cancellationToken),
RequestStatus.Completed => Results.Ok(response.Data),
// If the response failed, we can return a 500 Internal Server Error.
RequestStatus.Failed => Results.Problem("The invocation failed.", statusCode: 500),
RequestStatus.NotFound => Results.NotFound(new { message = "Not found." }),// If the actor is not found, we can return a 404 Not Found.
_ => throw new NotSupportedException($"Unsupported request status: {response.Status}"),
};
var responseType = response.Status switch
{
RequestStatus.Pending when stream == false => "Accepted",
RequestStatus.Pending => "Streaming",
RequestStatus.Completed => "Ok",
RequestStatus.Failed => "Problem",
RequestStatus.NotFound => "NotFound",
_ => "Unknown"
};
Log.ActorResponseProcessed(logger, requestId, responseType);
return result;
}
})
.WithName("Invocations");
}
}
@@ -1,124 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
namespace HelloHttpApi.ApiService;
internal sealed class ChatClientAgentActor(
AIAgent agent,
IActorRuntimeContext context,
ILogger<ChatClientAgentActor> logger) : IActor
{
private string? _etag;
private AgentThread? _thread;
public ValueTask DisposeAsync() => default;
public async ValueTask RunAsync(CancellationToken cancellationToken)
{
Log.ActorStarted(logger, context.ActorId.ToString(), agent.Name ?? "Unknown");
await Task.Yield();
// Restore thread state
var response = await context.ReadAsync(
new ActorReadOperationBatch([new GetValueOperation("thread")]),
cancellationToken).ConfigureAwait(false);
this._etag = response.ETag;
if (response.Results[0] is GetValueResult threadResult)
{
if (threadResult.Value is { } threadJson)
{
// Deserialize the thread state if it exist
this._thread = await agent.DeserializeThreadAsync(threadJson, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
this._thread ??= agent.GetNewThread();
Log.ThreadStateRestored(logger, context.ActorId.ToString(), response.Results[0] is GetValueResult { Value: not null });
while (!cancellationToken.IsCancellationRequested)
{
try
{
await foreach (var message in context.WatchMessagesAsync(cancellationToken).ConfigureAwait(false))
{
switch (message.Type)
{
case ActorMessageType.Request:
await this.HandleAgentRequestAsync((ActorRequestMessage)message, cancellationToken).ConfigureAwait(false);
break;
case ActorMessageType.Response:
// Handle response messages if needed
break;
default:
Log.UnknownMessageType(logger, message.Type.ToString(), context.ActorId.ToString());
break;
}
}
}
catch (Exception ex)
{
Log.ErrorProcessingMessages(logger, ex, context.ActorId.ToString());
}
}
}
private async Task HandleAgentRequestAsync(ActorRequestMessage message, CancellationToken cancellationToken)
{
var requestId = message.MessageId;
Debug.Assert(this._thread is not null);
Debug.Assert(this._etag is not null);
// Parse the request to get the agent run parameters
List<ChatMessage>? messages;
if (message.Params is { } payload)
{
var arg = payload.Deserialize(ChatClientAgentActorJsonContext.Default.ChatClientAgentRunRequest);
messages = arg?.Messages;
}
messages ??= [];
Log.ProcessingAgentRequest(logger, requestId, context.ActorId.ToString(), messages.Count);
try
{
var i = 0;
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, this._thread, cancellationToken: cancellationToken).ConfigureAwait(false))
{
var updateJson = JsonSerializer.SerializeToElement(update, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)));
context.OnProgressUpdate(requestId, i++, updateJson);
updates.Add(update);
Log.AgentStreamingUpdate(logger, requestId, i);
}
var serializedRunResponse = JsonSerializer.SerializeToElement(
updates.ToAgentRunResponse(),
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponse)));
var writeResponse = await context.WriteAsync(
new(this._etag, [new UpdateRequestOperation(requestId, RequestStatus.Completed, serializedRunResponse)]), cancellationToken)
.ConfigureAwait(false);
if (!writeResponse.Success)
{
Log.WriteOperationFailed(logger, context.ActorId.ToString(), requestId);
}
else
{
Log.AgentRequestCompleted(logger, requestId, updates.Count);
}
this._etag = writeResponse.ETag;
}
catch (Exception exception)
{
Log.AgentRequestFailed(logger, exception, requestId, context.ActorId.ToString());
// TODO: Retry later?
}
}
}
@@ -1,17 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace HelloHttpApi.ApiService;
/// <summary>
/// Source-generated JSON type information for use by ChatClientAgentActor.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ChatClientAgentRunRequest))]
internal sealed partial class ChatClientAgentActorJsonContext : JsonSerializerContext;
@@ -1,12 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace HelloHttpApi.ApiService;
public sealed class ChatClientAgentRunRequest
{
[JsonPropertyName("messages")]
public List<ChatMessage> Messages { get; set; } = [];
}
@@ -1,45 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.Orchestration;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB;
namespace HelloHttpApi.ApiService;
public static class HostApplicationBuilderAgentExtensions
{
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string instructions, string? chatClientKey = null)
{
var agentKey = $"agent:{name}";
builder.Services.AddKeyedSingleton<AIAgent>(agentKey, (sp, key) =>
{
var chatClient = chatClientKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientKey);
ChatClientAgent triage = new(chatClient, "You are a triage agent. You will determine which agent to hand off the conversation to based on the user's input.", $"{name}_triageAgent");
ChatClientAgent target = new(chatClient, instructions, $"{name}_targetAgent");
ChatClientAgent customerService = new(chatClient, "You are a customer service agent. You will handle rude, angry, or upset customer inquiries, asking them to be more calm and polite.", $"{name}_customerServiceAgent");
return Handoffs
.StartWith(triage)
.Add(triage, target, "Hand off to the target agent for handling normal customer requests.")
.Add(triage, customerService, "Hand off to the customer service agent for handling rude customer inquiries.")
.Build("PirateWorkflow");
});
var actorBuilder = builder.AddActorRuntime();
// Add CosmosDB state storage to override default storage
builder.Services.AddCosmosActorStateStorage("actor-state-db", "ActorState");
actorBuilder.AddActorType(
new ActorType(agentKey),
(sp, ctx) => new ChatClientAgentActor(
sp.GetRequiredKeyedService<AIAgent>(agentKey),
ctx,
sp.GetRequiredService<ILogger<ChatClientAgentActor>>()));
return builder;
}
}
@@ -1,15 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace HelloHttpApi.ApiService;
public class InvocationResponse
{
[JsonPropertyName("response")]
public JsonElement Response { get; set; }
[JsonPropertyName("status")]
public string? Status { get; set; } = "success";
}
@@ -1,14 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace HelloHttpApi.ApiService;
public class PingResponse(PingResponseStatus status, long timeOfLastUpdate)
{
[JsonPropertyName("status")]
public PingResponseStatus Status { get; } = status;
[JsonPropertyName("time_of_last_update")]
public long TimeOfLastUpdate { get; } = timeOfLastUpdate;
}
@@ -1,9 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace HelloHttpApi.ApiService;
public enum PingResponseStatus
{
Healthy,
HealthyBusy,
}
@@ -1,35 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using HelloHttpApi.ApiService;
using HelloHttpApi.ApiService.Utilities;
var builder = WebApplication.CreateBuilder(args);
// Add service defaults & Aspire client integrations.
builder.AddServiceDefaults();
// Add CosmosDB client integration
builder.AddAzureCosmosClient("hello-http-api-cosmosdb");
// Add services to the container.
builder.Services.AddProblemDetails();
// Configure the chat model and our agent.
builder.AddKeyedChatClient("chat-model");
builder.AddAIAgent(
name: "pirate",
instructions: "You are a pirate. Speak like a pirate.",
chatClientKey: "chat-model");
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseExceptionHandler();
// Map the agents HTTP endpoints
app.MapAgents();
app.MapDefaultEndpoints();
app.Run();
@@ -1,179 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
namespace HelloHttpApi.Web;
public class AgentClient(HttpClient httpClient, ILogger<AgentClient> logger)
{
public async IAsyncEnumerable<AgentRunResponseUpdate> SendMessageStreamAsync(
string agentName,
string message,
string sessionId = "default",
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var requestId = Guid.NewGuid().ToString();
var request = new ChatClientAgentRunRequest
{
Messages = [new ChatMessage(ChatRole.User, message)]
};
var content = JsonContent.Create(request, AgentClientJsonContext.Default.ChatClientAgentRunRequest);
var requestUri = new Uri($"/invocations/actor/{agentName}/{sessionId}/{requestId}?stream=true", UriKind.Relative);
var requestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
Content = content
};
using var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
{
// If this indicates completion, break the loop
if (IsCompletionEvent(line))
{
yield break;
}
if (line.StartsWith("data: ", StringComparison.Ordinal))
{
var jsonData = line.Substring(6); // Remove "data: " prefix
if (TryParseEventData(jsonData, logger, out var responseUpdate))
{
if (responseUpdate != null)
{
yield return responseUpdate;
}
}
else
{
logger.LogWarning("Received unrecognized event data: {JsonData}", jsonData);
}
}
}
}
public async Task<AgentResponse> SendMessageAsync(
string agentName,
string message,
string sessionId = "default",
CancellationToken cancellationToken = default)
{
var requestId = Guid.NewGuid().ToString();
var request = new ChatClientAgentRunRequest
{
Messages = [new ChatMessage(ChatRole.User, message)]
};
var content = JsonContent.Create(request, AgentClientJsonContext.Default.ChatClientAgentRunRequest);
var requestUri = new Uri($"/invocations/actor/{agentName}/{sessionId}/{requestId}?stream=false", UriKind.Relative);
var requestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
Content = content
};
using var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
try
{
var agentResponse = await response.Content.ReadFromJsonAsync(AgentClientJsonContext.Default.AgentResponse, cancellationToken);
return agentResponse ?? new AgentResponse { Content = "No response received", Status = "error" };
}
catch (JsonException ex)
{
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
logger.LogError(ex, "Failed to parse agent response JSON: {ResponseContent}", responseContent);
return new AgentResponse { Content = "Failed to parse response", Status = "error" };
}
}
private static bool TryParseEventData(string jsonData, ILogger logger, out AgentRunResponseUpdate? responseUpdate)
{
responseUpdate = null;
try
{
var eventData = JsonSerializer.Deserialize(jsonData, AgentClientJsonContext.Default.EventData);
if (eventData?.Event != null)
{
var eventElement = eventData.Event.Value;
// Try to deserialize as AgentRunResponseUpdate for intermediate updates
try
{
var update = JsonSerializer.Deserialize<AgentRunResponseUpdate>(eventElement.GetRawText(), AgentAbstractionsJsonUtilities.DefaultOptions);
if (update != null)
{
responseUpdate = update;
return true;
}
}
catch (JsonException)
{
// If it fails to deserialize as AgentRunResponseUpdate, it might be something else
logger.LogDebug("Failed to deserialize event as AgentRunResponseUpdate, might be final response or other data");
}
// Fallback: create a simple update with the raw content
responseUpdate = new AgentRunResponseUpdate(ChatRole.Assistant, eventElement.ToString());
return true;
}
}
catch (JsonException ex)
{
logger.LogError(ex, "Failed to parse event data JSON: {JsonData}", jsonData);
}
return false;
}
private static bool IsCompletionEvent(string line) => string.Equals("data: completed", line, StringComparison.Ordinal);
}
public class ChatClientAgentRunRequest
{
[JsonPropertyName("messages")]
public List<ChatMessage> Messages { get; set; } = [];
}
public class EventData
{
[JsonPropertyName("event")]
public JsonElement? Event { get; set; }
}
public class AgentResponse
{
[JsonPropertyName("content")]
public string Content { get; set; } = "";
[JsonPropertyName("status")]
public string Status { get; set; } = "";
}
/// <summary>
/// Source-generated JSON type information for use by AgentClient.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ChatClientAgentRunRequest))]
[JsonSerializable(typeof(EventData))]
[JsonSerializable(typeof(AgentResponse))]
internal sealed partial class AgentClientJsonContext : JsonSerializerContext;
@@ -1,23 +0,0 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@@ -1,96 +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;
}
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
@@ -1,29 +0,0 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">HelloHttpApi</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<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" 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" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="pirate-talk">
<span class="bi bi-chat-dots-fill" aria-hidden="true"></span> Pirate Talk
</NavLink>
</div>
</nav>
</div>
@@ -1,102 +0,0 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.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 {
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 {
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 {
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;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}
@@ -1,19 +0,0 @@
@page "/counter"
@rendermode InteractiveServer
<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++;
}
}
@@ -1,7 +0,0 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
@@ -1,208 +0,0 @@
@page "/pirate-talk"
@attribute [StreamRendering(true)]
@inject AgentClient AgentClient
@inject IJSRuntime JSRuntime
@inject ILogger<PirateTalk> Logger
@rendermode InteractiveServer
@using System.Text
@using System.Text.Json
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.AI.Agents
<PageTitle>Pirate Talk</PageTitle>
<h1>🏴‍☠️ Pirate Talk</h1>
<p>Chat with a pirate agent! Send a message and get a response in pirate speak.</p>
<div class="chat-container">
<div class="chat-messages" id="chat-messages" style="height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; margin-bottom: 10px; background-color: #f8f9fa;">
@foreach (var message in chatMessages)
{
<div class="message @(message.IsUser ? "user-message" : "pirate-message")" style="margin-bottom: 10px; padding: 8px; border-radius: 8px; @(message.IsUser ? "background-color: #007bff; color: white; text-align: right;" : "background-color: #e9ecef;")">
<strong>@(message.IsUser ? "You" : "🏴‍☠️ Pirate"):</strong>
<div style="margin-top: 4px;">@message.Content</div>
</div>
}
@if (isStreaming && currentStreamedMessage.Length > 0)
{
<div class="message pirate-message streaming" style="margin-bottom: 10px; padding: 8px; border-radius: 8px; background-color: #e9ecef;">
<strong>🏴‍☠️ Pirate:</strong>
<div style="margin-top: 4px;">@currentStreamedMessage<span class="typing-indicator">▋</span></div>
</div>
}
</div>
<div class="input-group">
<input @bind="currentMessage" @onkeypress="HandleKeyPress" class="form-control" placeholder="Type your message here..." disabled="@isStreaming" />
<button @onclick="SendMessage" class="btn btn-primary" disabled="@(isStreaming || string.IsNullOrWhiteSpace(currentMessage))">
@if (isStreaming)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Sending...</span>
}
else
{
<span>Send</span>
}
</button>
</div>
</div>
<style>
.typing-indicator {
animation: blink 1s infinite;
}
@@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.chat-container {
max-width: 800px;
}
.user-message {
margin-left: 20%;
}
.pirate-message {
margin-right: 20%;
}
</style>
@code {
private string currentMessage = "";
private bool isStreaming = false;
private string currentStreamedMessage = "";
private List<ChatMessage> chatMessages = new();
private string sessionId = Guid.NewGuid().ToString();
private const string AgentName = "agent:pirate";
protected override void OnInitialized()
{
Logger.LogDebug("Initializing PirateTalk component with session ID: {SessionId}", sessionId);
}
private async Task SendMessage()
{
if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming)
return;
var userMessage = currentMessage.Trim();
currentMessage = "";
Logger.LogInformation("User sending message: '{UserMessage}' in session {SessionId}", userMessage, sessionId);
// Add user message to chat
chatMessages.Add(new ChatMessage { Content = userMessage, IsUser = true });
Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, true);
Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
StateHasChanged();
await ScrollToBottom();
// Start streaming response
isStreaming = true;
currentStreamedMessage = "";
Logger.LogDebug("Starting streaming response for session {SessionId}", sessionId);
Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
StateHasChanged();
try
{
var responseContent = new StringBuilder();
await foreach (var update in AgentClient.SendMessageStreamAsync(AgentName, userMessage, sessionId))
{
Logger.LogTrace("Received streaming update with text length: {TextLength} for session {SessionId}", update.Text?.Length ?? 0, sessionId);
// Extract text content from the AgentRunResponseUpdate
var content = update.Text ?? "";
if (!string.IsNullOrEmpty(content))
{
Logger.LogDebug("Extracted content from update: '{ExtractedContent}' for session {SessionId}", content, sessionId);
responseContent.Append(content);
currentStreamedMessage = responseContent.ToString();
Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
StateHasChanged();
await ScrollToBottom();
}
}
// Add the complete pirate response to chat messages
if (responseContent.Length > 0)
{
Logger.LogInformation("Streaming completed with total response length: {ResponseLength} for session {SessionId}", responseContent.Length, sessionId);
chatMessages.Add(new ChatMessage { Content = responseContent.ToString(), IsUser = false });
Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false);
}
else
{
Logger.LogWarning("Empty response received from agent for session {SessionId}", sessionId);
chatMessages.Add(new ChatMessage { Content = "Arrr, something went wrong with me response, matey!", IsUser = false });
Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}", sessionId, ex.Message);
chatMessages.Add(new ChatMessage { Content = $"Arrr, encountered rough seas: {ex.Message}", IsUser = false });
Logger.LogDebug("Chat message added to collection. Total messages: {MessageCount}, Is user message: {IsUserMessage}", chatMessages.Count, false);
}
finally
{
isStreaming = false;
currentStreamedMessage = "";
Logger.LogTrace("StateHasChanged called during streaming for session {SessionId}", sessionId);
StateHasChanged();
await ScrollToBottom();
}
}
private async Task HandleKeyPress(KeyboardEventArgs e)
{
Logger.LogDebug("Handling key press event: {Key} for session {SessionId}", e.Key, sessionId);
if (e.Key == "Enter" && !e.ShiftKey)
{
await SendMessage();
}
}
private async Task ScrollToBottom()
{
try
{
Logger.LogTrace("Scrolling chat to bottom for session {SessionId}", sessionId);
await JSRuntime.InvokeVoidAsync("scrollToBottom", "chat-messages");
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to scroll to bottom due to JavaScript error for session {SessionId}", sessionId);
// Ignore JS errors
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
Logger.LogDebug("Component first render completed, JavaScript functions initialized for session {SessionId}", sessionId);
await JSRuntime.InvokeVoidAsync("eval", @"
window.scrollToBottom = function(elementId) {
const element = document.getElementById(elementId);
if (element) {
element.scrollTop = element.scrollHeight;
}
};
");
}
}
private class ChatMessage
{
public string Content { get; set; } = "";
public bool IsUser { get; set; }
}
}