.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; }
}
}
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<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="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Azure.AI.OpenAI" />
<PackageReference Include="Aspire.Hosting.Azure.CognitiveServices" />
<PackageReference Include="Aspire.Microsoft.Azure.Cosmos" />
<PackageReference Include="CommunityToolkit.Aspire.OllamaSharp" />
<PackageReference Include="Microsoft.Extensions.AI" />
<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);
}
}
}
}
@@ -0,0 +1,136 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI.Agents.Runtime;
namespace AgentWebChat.AgentHost;
/// <summary>
/// High-performance logging messages using LoggerMessage source generator.
/// </summary>
internal static partial class Log
{
// API endpoint logging
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor invocation started: Name={ActorName}, SessionId={SessionId}, RequestId={RequestId}, Stream={StreamRequested}")]
public static partial void ActorInvocationStarted(ILogger logger, string actorName, string sessionId, string requestId, bool streamRequested);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor invocation completed: Name={ActorName}, SessionId={SessionId}, RequestId={RequestId}, Status={Status}, Duration={DurationMs}ms")]
public static partial void ActorInvocationCompleted(ILogger logger, string actorName, string sessionId, string requestId, RequestStatus status, long durationMs);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Actor invocation failed: Name={ActorName}, SessionId={SessionId}, RequestId={RequestId}, Duration={DurationMs}ms")]
public static partial void ActorInvocationFailed(ILogger logger, Exception exception, string actorName, string sessionId, string requestId, long durationMs);
// SSE streaming logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "SSE streaming started for request: {RequestId}")]
public static partial void SseStreamingStarted(ILogger logger, string requestId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "SSE progress update sent: RequestId={RequestId}, UpdateCount={UpdateCount}")]
public static partial void SseProgressUpdateSent(ILogger logger, string requestId, int updateCount);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "SSE streaming completed: RequestId={RequestId}, TotalUpdates={TotalUpdates}")]
public static partial void SseStreamingCompleted(ILogger logger, string requestId, int totalUpdates);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "SSE streaming cancelled: RequestId={RequestId}")]
public static partial void SseStreamingCancelled(ILogger logger, string requestId);
[LoggerMessage(
Level = LogLevel.Error,
Message = "SSE streaming error: RequestId={RequestId}")]
public static partial void SseStreamingError(ILogger logger, Exception exception, string requestId);
// Response processing logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Processing actor response: RequestId={RequestId}, Status={Status}, IsStreaming={IsStreaming}")]
public static partial void ProcessingActorResponse(ILogger logger, string requestId, RequestStatus status, bool isStreaming);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor response processed successfully: RequestId={RequestId}, ResponseType={ResponseType}")]
public static partial void ActorResponseProcessed(ILogger logger, string requestId, string responseType);
// Request/Response logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Actor request received: RequestId={RequestId}, PayloadSize={PayloadSize} bytes, Stream={StreamRequested}")]
public static partial void ActorRequestReceived(ILogger logger, string requestId, int payloadSize, bool streamRequested);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Actor request sent to runtime: RequestId={RequestId}, ActorName={ActorName}, SessionId={SessionId}")]
public static partial void ActorRequestSent(ILogger logger, string requestId, string actorName, string sessionId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Actor response handle obtained: RequestId={RequestId}, HasImmediateResponse={HasImmediateResponse}")]
public static partial void ActorResponseHandleObtained(ILogger logger, string requestId, bool hasImmediateResponse);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Waiting for actor response: RequestId={RequestId}")]
public static partial void WaitingForActorResponse(ILogger logger, string requestId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Actor response received: RequestId={RequestId}, Status={Status}")]
public static partial void ActorResponseReceived(ILogger logger, string requestId, RequestStatus status);
// ChatClientAgentActor logging
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor started: ActorId={ActorId}, AgentName={AgentName}")]
public static partial void ActorStarted(ILogger logger, string actorId, string agentName);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Thread state restored: ActorId={ActorId}, HasExistingThread={HasExistingThread}")]
public static partial void ThreadStateRestored(ILogger logger, string actorId, bool hasExistingThread);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Processing agent request: RequestId={RequestId}, ActorId={ActorId}, MessageCount={MessageCount}")]
public static partial void ProcessingAgentRequest(ILogger logger, string requestId, string actorId, int messageCount);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Agent streaming update: RequestId={RequestId}, UpdateNumber={UpdateNumber}")]
public static partial void AgentStreamingUpdate(ILogger logger, string requestId, int updateNumber);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Agent request completed: RequestId={RequestId}, TotalUpdates={TotalUpdates}")]
public static partial void AgentRequestCompleted(ILogger logger, string requestId, int totalUpdates);
[LoggerMessage(
Level = LogLevel.Error,
Message = "Agent request failed: RequestId={RequestId}, ActorId={ActorId}")]
public static partial void AgentRequestFailed(ILogger logger, Exception exception, string requestId, string actorId);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Unknown message type received: MessageType={MessageType}, ActorId={ActorId}")]
public static partial void UnknownMessageType(ILogger logger, string messageType, string actorId);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Error processing messages: ActorId={ActorId}")]
public static partial void ErrorProcessingMessages(ILogger logger, Exception exception, string actorId);
[LoggerMessage(
Level = LogLevel.Error,
Message = "Write operation failed: ActorId={ActorId}, RequestId={RequestId}")]
public static partial void WriteOperationFailed(ILogger logger, string actorId, string requestId);
}
@@ -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();
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5390",
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7373;http://localhost:5390",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
namespace AgentWebChat.AgentHost.Utilities;
public class ChatClientConnectionInfo
{
public Uri? Endpoint { get; init; }
public required string SelectedModel { get; init; }
public ClientChatProvider Provider { get; init; }
public string? AccessKey { get; init; }
// Example connection string:
// Endpoint=https://localhost:4523;Model=phi3.5;AccessKey=1234;Provider=ollama;
public static bool TryParse(string? connectionString, [NotNullWhen(true)] out ChatClientConnectionInfo? settings)
{
if (string.IsNullOrEmpty(connectionString))
{
settings = null;
return false;
}
var connectionBuilder = new DbConnectionStringBuilder
{
ConnectionString = connectionString
};
Uri? endpoint = null;
if (connectionBuilder.ContainsKey("Endpoint") && Uri.TryCreate(connectionBuilder["Endpoint"].ToString(), UriKind.Absolute, out endpoint))
{
}
string? model = null;
if (connectionBuilder.ContainsKey("Model"))
{
model = (string)connectionBuilder["Model"];
}
string? accessKey = null;
if (connectionBuilder.ContainsKey("AccessKey"))
{
accessKey = (string)connectionBuilder["AccessKey"];
}
var provider = ClientChatProvider.Unknown;
if (connectionBuilder.ContainsKey("Provider"))
{
var providerValue = (string)connectionBuilder["Provider"];
Enum.TryParse(providerValue, ignoreCase: true, out provider);
}
if (endpoint is null && provider != ClientChatProvider.OpenAI || model is null || provider == ClientChatProvider.Unknown)
{
settings = null;
return false;
}
settings = new ChatClientConnectionInfo
{
Endpoint = endpoint,
SelectedModel = model,
AccessKey = accessKey,
Provider = provider
};
return true;
}
}
public enum ClientChatProvider
{
Unknown,
Ollama,
OpenAI,
AzureOpenAI,
AzureAIInference,
}
@@ -0,0 +1,144 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentWebChat.AgentHost.Utilities;
using Azure;
using Azure.AI.Inference;
using Microsoft.Extensions.AI;
using OllamaSharp;
namespace AgentWebChat.AgentHost.Utilities;
public static class ChatClientExtensions
{
public static ChatClientBuilder AddChatClient(this IHostApplicationBuilder builder, string connectionName)
{
var cs = builder.Configuration.GetConnectionString(connectionName);
if (!ChatClientConnectionInfo.TryParse(cs, out var connectionInfo))
{
throw new InvalidOperationException($"Invalid connection string: {cs}. Expected format: 'Endpoint=endpoint;AccessKey=your_access_key;Model=model_name;Provider=ollama/openai/azureopenai;'.");
}
var chatClientBuilder = connectionInfo.Provider switch
{
ClientChatProvider.Ollama => builder.AddOllamaClient(connectionName, connectionInfo),
ClientChatProvider.OpenAI => builder.AddOpenAIClient(connectionName, connectionInfo),
ClientChatProvider.AzureOpenAI => builder.AddAzureOpenAIClient(connectionName).AddChatClient(connectionInfo.SelectedModel),
ClientChatProvider.AzureAIInference => builder.AddAzureInferenceClient(connectionName, connectionInfo),
_ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}")
};
// Add OpenTelemetry tracing for the ChatClient activity source
chatClientBuilder.UseOpenTelemetry().UseLogging();
builder.Services.AddOpenTelemetry().WithTracing(t => t.AddSource("Experimental.Microsoft.Extensions.AI"));
return chatClientBuilder;
}
private static ChatClientBuilder AddOpenAIClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
return builder.AddOpenAIClient(connectionName, settings =>
{
settings.Endpoint = connectionInfo.Endpoint;
settings.Key = connectionInfo.AccessKey;
})
.AddChatClient(connectionInfo.SelectedModel);
}
private static ChatClientBuilder AddAzureInferenceClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
return builder.Services.AddChatClient(sp =>
{
var credential = new AzureKeyCredential(connectionInfo.AccessKey!);
var client = new ChatCompletionsClient(connectionInfo.Endpoint, credential, new AzureAIInferenceClientOptions());
return client.AsIChatClient(connectionInfo.SelectedModel);
});
}
private static ChatClientBuilder AddOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
var httpKey = $"{connectionName}_http";
builder.Services.AddHttpClient(httpKey, c =>
{
c.BaseAddress = connectionInfo.Endpoint;
});
return builder.Services.AddChatClient(sp =>
{
// Create a client for the Ollama API using the http client factory
var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient(httpKey);
return new OllamaApiClient(client, connectionInfo.SelectedModel);
});
}
public static ChatClientBuilder AddKeyedChatClient(this IHostApplicationBuilder builder, string connectionName)
{
var cs = builder.Configuration.GetConnectionString(connectionName);
if (!ChatClientConnectionInfo.TryParse(cs, out var connectionInfo))
{
throw new InvalidOperationException($"Invalid connection string: {cs}. Expected format: 'Endpoint=endpoint;AccessKey=your_access_key;Model=model_name;Provider=ollama/openai/azureopenai;'.");
}
var chatClientBuilder = connectionInfo.Provider switch
{
ClientChatProvider.Ollama => builder.AddKeyedOllamaClient(connectionName, connectionInfo),
ClientChatProvider.OpenAI => builder.AddKeyedOpenAIClient(connectionName, connectionInfo),
ClientChatProvider.AzureOpenAI => builder.AddKeyedAzureOpenAIClient(connectionName).AddKeyedChatClient(connectionName, connectionInfo.SelectedModel),
ClientChatProvider.AzureAIInference => builder.AddKeyedAzureInferenceClient(connectionName, connectionInfo),
_ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}")
};
// Add OpenTelemetry tracing for the ChatClient activity source
chatClientBuilder.UseOpenTelemetry().UseLogging();
builder.Services.AddOpenTelemetry().WithTracing(t => t.AddSource("Experimental.Microsoft.Extensions.AI"));
return chatClientBuilder;
}
private static ChatClientBuilder AddKeyedOpenAIClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
return builder.AddKeyedOpenAIClient(connectionName, settings =>
{
settings.Endpoint = connectionInfo.Endpoint;
settings.Key = connectionInfo.AccessKey;
})
.AddKeyedChatClient(connectionName, connectionInfo.SelectedModel);
}
private static ChatClientBuilder AddKeyedAzureInferenceClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
return builder.Services.AddKeyedChatClient(connectionName, sp =>
{
var credential = new AzureKeyCredential(connectionInfo.AccessKey!);
var client = new ChatCompletionsClient(connectionInfo.Endpoint, credential, new AzureAIInferenceClientOptions());
return client.AsIChatClient(connectionInfo.SelectedModel);
});
}
private static ChatClientBuilder AddKeyedOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
var httpKey = $"{connectionName}_http";
builder.Services.AddHttpClient(httpKey, c =>
{
c.BaseAddress = connectionInfo.Endpoint;
});
return builder.Services.AddKeyedChatClient(connectionName, sp =>
{
// Create a client for the Ollama API using the http client factory
var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient(httpKey);
return new OllamaApiClient(client, connectionInfo.SelectedModel);
});
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.3.1" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<UserSecretsId>2969a84d-8ee6-4304-8737-6e469a315aa8</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" />
<PackageReference Include="Aspire.Hosting.Azure.CognitiveServices" />
<PackageReference Include="Aspire.Hosting.Azure.CosmosDB" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AgentWebChat.AgentHost\AgentWebChat.AgentHost.csproj" />
<ProjectReference Include="..\AgentWebChat.Web\AgentWebChat.Web.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,266 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AgentWebChat.AppHost;
public static class ModelExtensions
{
public static IResourceBuilder<AIModel> AddAIModel(this IDistributedApplicationBuilder builder, string name)
{
var model = new AIModel(name);
return builder.CreateResourceBuilder(model);
}
public static IResourceBuilder<AIModel> RunAsOpenAI(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder.AsOpenAI(modelName, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> PublishAsOpenAI(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder.AsOpenAI(modelName, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> RunAsAzureOpenAI(this IResourceBuilder<AIModel> builder, string modelName, Action<IResourceBuilder<AzureOpenAIResource>>? configure)
{
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder.AsAzureOpenAI(modelName, configure);
}
return builder;
}
public static IResourceBuilder<AIModel> PublishAsAzureOpenAI(this IResourceBuilder<AIModel> builder, string modelName, Action<IResourceBuilder<AzureOpenAIResource>>? configure)
{
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder.AsAzureOpenAI(modelName, configure);
}
return builder;
}
public static IResourceBuilder<AIModel> AsAzureOpenAI(this IResourceBuilder<AIModel> builder, string modelName, Action<IResourceBuilder<AzureOpenAIResource>>? configure)
{
builder.Reset();
var openAIModel = builder.ApplicationBuilder.AddAzureOpenAI(builder.Resource.Name);
configure?.Invoke(openAIModel);
builder.Resource.UnderlyingResource = openAIModel.Resource;
// Add the model name to the connection string
builder.Resource.ConnectionString = ReferenceExpression.Create($"{openAIModel.Resource.ConnectionStringExpression};Model={modelName}");
builder.Resource.Provider = "AzureOpenAI";
return builder;
}
public static IResourceBuilder<AIModel> RunAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> endpoint, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> PublishAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> endpoint, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> AsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> endpoint, IResourceBuilder<ParameterResource> apiKey)
{
builder.Reset();
// See: https://github.com/dotnet/aspire/issues/7641
var csb = new ReferenceExpressionBuilder();
csb.Append($"Endpoint={endpoint.Resource};");
csb.Append($"AccessKey={apiKey.Resource};");
csb.Append($"Model={modelName}");
var cs = csb.Build();
builder.ApplicationBuilder.AddResource(builder.Resource);
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
var csTask = cs.GetValueAsync(default).AsTask();
if (!csTask.IsCompletedSuccessfully)
{
throw new InvalidOperationException("Connection string could not be resolved!");
}
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
builder.WithInitialState(new CustomResourceSnapshot
{
ResourceType = "Azure AI Inference Model",
State = KnownResourceStates.Running,
Properties = [
new("ConnectionString", csTask.Result ) { IsSensitive = true }
]
});
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
}
builder.Resource.UnderlyingResource = builder.Resource;
builder.Resource.ConnectionString = cs;
builder.Resource.Provider = "AzureAIInference";
return builder;
}
public static IResourceBuilder<AIModel> RunAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, string endpoint, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> PublishAsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, string endpoint, IResourceBuilder<ParameterResource> apiKey)
{
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
return builder.AsAzureAIInference(modelName, endpoint, apiKey);
}
return builder;
}
public static IResourceBuilder<AIModel> AsAzureAIInference(this IResourceBuilder<AIModel> builder, string modelName, string endpoint, IResourceBuilder<ParameterResource> apiKey)
{
builder.Reset();
// See: https://github.com/dotnet/aspire/issues/7641
var csb = new ReferenceExpressionBuilder();
csb.Append($"Endpoint={endpoint};");
csb.Append($"AccessKey={apiKey.Resource};");
csb.Append($"Model={modelName}");
var cs = csb.Build();
builder.ApplicationBuilder.AddResource(builder.Resource);
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
var csTask = cs.GetValueAsync(default).AsTask();
if (!csTask.IsCompletedSuccessfully)
{
throw new InvalidOperationException("Connection string could not be resolved!");
}
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
builder.WithInitialState(new CustomResourceSnapshot
{
ResourceType = "Azure AI Inference Model",
State = KnownResourceStates.Running,
Properties = [
new("ConnectionString", csTask.Result ) { IsSensitive = true }
]
});
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
}
builder.Resource.UnderlyingResource = builder.Resource;
builder.Resource.ConnectionString = cs;
builder.Resource.Provider = "AzureAIInference";
return builder;
}
public static IResourceBuilder<AIModel> AsOpenAI(this IResourceBuilder<AIModel> builder, string modelName, IResourceBuilder<ParameterResource> apiKey)
{
builder.Reset();
// See: https://github.com/dotnet/aspire/issues/7641
var csb = new ReferenceExpressionBuilder();
csb.Append($"AccessKey={apiKey.Resource};");
csb.Append($"Model={modelName}");
var cs = csb.Build();
builder.ApplicationBuilder.AddResource(builder.Resource);
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
var csTask = cs.GetValueAsync(default).AsTask();
if (!csTask.IsCompletedSuccessfully)
{
throw new InvalidOperationException("Connection string could not be resolved!");
}
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
builder.WithInitialState(new CustomResourceSnapshot
{
ResourceType = "OpenAI Model",
State = KnownResourceStates.Running,
Properties = [
new("ConnectionString", csTask.Result ) { IsSensitive = true }
]
});
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
}
builder.Resource.UnderlyingResource = builder.Resource;
builder.Resource.ConnectionString = cs;
builder.Resource.Provider = "OpenAI";
return builder;
}
private static void Reset(this IResourceBuilder<AIModel> builder)
{
// Reset the properties of the AIModel resource
if (builder.Resource.UnderlyingResource is { } underlyingResource)
{
builder.ApplicationBuilder.Resources.Remove(underlyingResource);
if (underlyingResource is IResourceWithParent resourceWithParent)
{
builder.ApplicationBuilder.Resources.Remove(resourceWithParent.Parent);
}
}
builder.Resource.ConnectionString = null;
builder.Resource.Provider = null;
}
}
// A resource representing an AI model.
public class AIModel(string name) : Resource(name), IResourceWithConnectionString
{
internal string? Provider { get; set; }
internal IResourceWithConnectionString? UnderlyingResource { get; set; }
internal ReferenceExpression? ConnectionString { get; set; }
public ReferenceExpression ConnectionStringExpression =>
this.Build();
public ReferenceExpression Build()
{
var connectionString = this.ConnectionString ?? throw new InvalidOperationException("No connection string available.");
if (this.Provider is null)
{
throw new InvalidOperationException("No provider configured.");
}
return ReferenceExpression.Create($"{connectionString};Provider={this.Provider}");
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentWebChat.AppHost;
var builder = DistributedApplication.CreateBuilder(args);
var azOpenAiResource = builder.AddParameterFromConfiguration("AzureOpenAIName", "AzureOpenAI:Name");
var azOpenAiResourceGroup = builder.AddParameterFromConfiguration("AzureOpenAIResourceGroup", "AzureOpenAI:ResourceGroup");
var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o.AsExisting(azOpenAiResource, azOpenAiResourceGroup));
var cosmosDbResource = builder.AddParameterFromConfiguration("CosmosDbName", "CosmosDb:Name");
var cosmosDbResourceGroup = builder.AddParameterFromConfiguration("CosmosDbResourceGroup", "CosmosDb:ResourceGroup");
var cosmos = builder.AddAzureCosmosDB("agent-web-chat-cosmosdb").RunAsExisting(cosmosDbResource, cosmosDbResourceGroup);
var stateDb = cosmos.AddCosmosDatabase("actor-state-db");
var agentHost = builder.AddProject<Projects.AgentWebChat_AgentHost>("agenthost")
.WithReference(chatModel)
.WithReference(cosmos).WaitFor(cosmos);
builder.AddProject<Projects.AgentWebChat_Web>("webfrontend")
.WithExternalHttpEndpoints()
.WithReference(agentHost)
.WaitFor(agentHost);
builder.Build().Run();
@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17277;http://localhost:15143",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22278"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15143",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19242",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20010"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
</ItemGroup>
</Project>
@@ -0,0 +1,124 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting;
// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
public static class ServiceDefaultsExtensions
{
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.SetMinimumLevel(LogLevel.Trace);
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
// Uncomment the following to restrict the allowed schemes for service discovery.
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
// {
// options.AllowedSchemes = ["https"];
// });
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddSource("Microsoft.Extensions.AI.Agents")
.AddSource("Microsoft.Extensions.AI.Agents.Runtime.InProcess")
.AddSource("Microsoft.Extensions.AI.Agents.Runtime.Abstractions.InMemoryActorStateStorage")
.AddAspNetCoreInstrumentation()
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
//{
// builder.Services.AddOpenTelemetry()
// .UseAzureMonitor();
//}
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks("/health");
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks("/alive", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
return app;
}
}
@@ -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; }
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<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>
</Project>
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="AgentWebChat.Web.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@@ -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,38 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@requestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
public HttpContext? HttpContext { get; set; }
private string? requestId;
private bool ShowRequestId => !string.IsNullOrEmpty(requestId);
protected override void OnInitialized()
{
requestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
}
@@ -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;
});
}
};
");
}
}
}
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,11 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.OutputCaching
@using Microsoft.JSInterop
@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;
}
}
}
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentWebChat.Web;
using AgentWebChat.Web.Components;
using Microsoft.Extensions.AI.Agents.Runtime;
var builder = WebApplication.CreateBuilder(args);
// Add service defaults & Aspire client integrations.
builder.AddServiceDefaults();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddOutputCache();
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();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery();
app.UseOutputCache();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapDefaultEndpoints();
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5154",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7020;http://localhost:5154",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,16 @@
html, body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 0;
padding: 0;
background-color: #f9fafb;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB