mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Initial draft of actor runtime abstractions (#197)
* Initial draft of actor runtime abstractions
This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
// 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;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using HelloHttpApi.ApiService;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
/// <summary>
|
||||
/// Source-generated JSON type information for use by all Agents implementations.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.Web,
|
||||
UseStringEnumConverter = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false)]
|
||||
[JsonSerializable(typeof(ChatMessage))]
|
||||
[JsonSerializable(typeof(List<ChatMessage>))]
|
||||
[JsonSerializable(typeof(ChatClientAgentThread))]
|
||||
[JsonSerializable(typeof(ChatClientAgentRunRequest))]
|
||||
[JsonSerializable(typeof(AgentRunResponseUpdate))]
|
||||
internal sealed partial class AgentsJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using HelloHttpApi.ApiService;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
using Microsoft.Extensions.AI.Agents.Runtime;
|
||||
|
||||
internal sealed class ChatClientAgentActor(ChatClientAgent agent, JsonSerializerOptions jsonSerializerOptions, IActorRuntimeContext context, ILogger<ChatClientAgentActor> logger) : IActor
|
||||
{
|
||||
private string? _etag;
|
||||
private ChatClientAgentThread? _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 = threadJson.Deserialize<ChatClientAgentThread>(
|
||||
(JsonTypeInfo<ChatClientAgentThread>)jsonSerializerOptions.GetTypeInfo(typeof(ChatClientAgentThread)));
|
||||
}
|
||||
}
|
||||
|
||||
this._thread ??= (ChatClientAgentThread)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<ChatClientAgentRunRequest>(
|
||||
(JsonTypeInfo<ChatClientAgentRunRequest>)jsonSerializerOptions.GetTypeInfo(typeof(ChatClientAgentRunRequest)));
|
||||
messages = arg?.Messages;
|
||||
}
|
||||
|
||||
messages ??= [];
|
||||
|
||||
Log.ProcessingAgentRequest(logger, requestId, context.ActorId.ToString(), messages.Count);
|
||||
try
|
||||
{
|
||||
var typeInfo = (JsonTypeInfo<AgentRunResponseUpdate>)jsonSerializerOptions.GetTypeInfo(typeof(AgentRunResponseUpdate));
|
||||
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, typeInfo);
|
||||
context.OnProgressUpdate(requestId, i++, updateJson);
|
||||
updates.Add(update);
|
||||
Log.AgentStreamingUpdate(logger, requestId, i);
|
||||
}
|
||||
|
||||
var serializedRunResponse = JsonSerializer.SerializeToElement(
|
||||
updates.ToAgentRunResponse(),
|
||||
(JsonTypeInfo<AgentRunResponse>)jsonSerializerOptions.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?
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.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="..\HelloHttpApi.ServiceDefaults\HelloHttpApi.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Aspire.Hosting.Azure.CognitiveServices" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
using Microsoft.Extensions.AI.Agents.Runtime;
|
||||
|
||||
namespace HelloHttpApi.ApiService;
|
||||
|
||||
public static class HostApplicationBuilderAgentExtensions
|
||||
{
|
||||
public static IHostApplicationBuilder AddChatClientAgent(this IHostApplicationBuilder builder, string name, string instructions, string? chatClientKey = null)
|
||||
{
|
||||
var agentKey = $"agent:{name}";
|
||||
builder.Services.AddKeyedSingleton(agentKey, (sp, key) =>
|
||||
{
|
||||
var chatClient = chatClientKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientKey);
|
||||
return new ChatClientAgent(chatClient, instructions, name);
|
||||
});
|
||||
var actorBuilder = builder.AddActorRuntime();
|
||||
|
||||
actorBuilder.AddActorType(
|
||||
new ActorType(agentKey),
|
||||
(sp, ctx) => new ChatClientAgentActor(
|
||||
sp.GetRequiredKeyedService<ChatClientAgent>(agentKey),
|
||||
sp.GetService<JsonSerializerOptions>() ?? JsonSerializerOptions.Web,
|
||||
ctx,
|
||||
sp.GetRequiredService<ILogger<ChatClientAgentActor>>()));
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// 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";
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using HelloHttpApi.ApiService;
|
||||
using Microsoft.Extensions.AI.Agents.Runtime;
|
||||
/// <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);
|
||||
|
||||
// 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,
|
||||
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,14 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace HelloHttpApi.ApiService;
|
||||
|
||||
public enum PingResponseStatus
|
||||
{
|
||||
Healthy,
|
||||
HealthyBusy,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// 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 services to the container.
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
// Configure the chat model and our agent.
|
||||
builder.AddKeyedChatClient("chat-model");
|
||||
|
||||
builder.AddChatClientAgent(
|
||||
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();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5390",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7373;http://localhost:5390",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace HelloHttpApi.ApiService.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,
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.Inference;
|
||||
using HelloHttpApi.ApiService.Utilities;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OllamaSharp;
|
||||
|
||||
namespace HelloHttpApi.ApiService.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": "Trace",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HelloHttpApi.ApiService\HelloHttpApi.ApiService.csproj" />
|
||||
<ProjectReference Include="..\HelloHttpApi.Web\HelloHttpApi.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace HelloHttpApi.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,19 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using HelloHttpApi.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 apiService = builder.AddProject<Projects.HelloHttpApi_ApiService>("apiservice")
|
||||
.WithReference(chatModel);
|
||||
|
||||
builder.AddProject<Projects.HelloHttpApi_Web>("webfrontend")
|
||||
.WithExternalHttpEndpoints()
|
||||
.WithReference(apiService)
|
||||
.WaitFor(apiService);
|
||||
|
||||
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": "Trace",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Aspire.Hosting.Dcp": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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>
|
||||
+124
@@ -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,215 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
|
||||
namespace HelloHttpApi.Web;
|
||||
|
||||
public class AgentClient(HttpClient httpClient, ILogger<AgentClient> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
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, s_jsonOptions.GetTypeInfo<ChatClientAgentRunRequest>(AgentClientJsonContext.Default));
|
||||
|
||||
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();
|
||||
|
||||
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, s_jsonOptions.GetTypeInfo<ChatClientAgentRunRequest>(AgentClientJsonContext.Default));
|
||||
|
||||
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(s_jsonOptions.GetTypeInfo<AgentResponse>(AgentClientJsonContext.Default), 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, s_jsonOptions.GetTypeInfo<EventData>(AgentClientJsonContext.Default));
|
||||
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(), s_jsonOptions);
|
||||
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>
|
||||
/// Provides extension methods for JSON serialization with source generation support.
|
||||
/// </summary>
|
||||
internal static class JsonSerializerExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the JsonTypeInfo for a type, preferring the one from options if available,
|
||||
/// otherwise falling back to the source-generated context.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to get JsonTypeInfo for.</typeparam>
|
||||
/// <param name="options">The JsonSerializerOptions to check first.</param>
|
||||
/// <param name="fallbackContext">The fallback JsonSerializerContext to use if not found in options.</param>
|
||||
/// <returns>The JsonTypeInfo for the requested type.</returns>
|
||||
public static JsonTypeInfo<T> GetTypeInfo<T>(this JsonSerializerOptions options, JsonSerializerContext fallbackContext)
|
||||
{
|
||||
// Try to get from the options first (if a context is configured)
|
||||
if (options.TypeInfoResolver?.GetTypeInfo(typeof(T), options) is JsonTypeInfo<T> typeInfo)
|
||||
{
|
||||
return typeInfo;
|
||||
}
|
||||
|
||||
// Fall back to the provided source-generated context
|
||||
return (JsonTypeInfo<T>)fallbackContext.GetTypeInfo(typeof(T))!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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(ChatMessage))]
|
||||
[JsonSerializable(typeof(List<ChatMessage>))]
|
||||
[JsonSerializable(typeof(EventData))]
|
||||
[JsonSerializable(typeof(AgentRunResponseUpdate))]
|
||||
[JsonSerializable(typeof(AgentResponse))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
internal sealed partial class AgentClientJsonContext : JsonSerializerContext;
|
||||
@@ -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="HelloHttpApi.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,23 @@
|
||||
@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>
|
||||
@@ -0,0 +1,96 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
@@ -0,0 +1,102 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@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++;
|
||||
}
|
||||
}
|
||||
@@ -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,7 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
Welcome to your new app.
|
||||
@@ -0,0 +1,208 @@
|
||||
@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; }
|
||||
}
|
||||
}
|
||||
@@ -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 HelloHttpApi.Web
|
||||
@using HelloHttpApi.Web.Components
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HelloHttpApi.ServiceDefaults\HelloHttpApi.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using HelloHttpApi.Web;
|
||||
using HelloHttpApi.Web.Components;
|
||||
|
||||
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<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");
|
||||
});
|
||||
|
||||
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.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.UseOutputCache();
|
||||
|
||||
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": "Trace",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.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."
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Reference in New Issue
Block a user