mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add DevUI package for .NET (#1603)
* Implement DevUI * Review feedback * Fix build
This commit is contained in:
committed by
GitHub
Unverified
parent
94a5ba3448
commit
8855bfb065
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for configuring the Microsoft Agents AI DevUI in ASP.NET applications.
|
||||
/// </summary>
|
||||
public static class DevUIExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the necessary services for the DevUI to the application builder.
|
||||
/// </summary>
|
||||
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
builder.Services.AddOpenAIConversations();
|
||||
builder.Services.AddOpenAIResponses();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an endpoint that serves the DevUI from the '/devui' path.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
|
||||
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="endpoints"/> is null.</exception>
|
||||
public static IEndpointConventionBuilder MapDevUI(
|
||||
this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("");
|
||||
group.MapDevUI(pattern: "/devui");
|
||||
group.MapEntities();
|
||||
group.MapOpenAIConversations();
|
||||
group.MapOpenAIResponses();
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an endpoint that serves the DevUI.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
|
||||
/// <param name="pattern">
|
||||
/// The route pattern for the endpoint (e.g., "/devui", "/agent-ui").
|
||||
/// Defaults to "/devui" if not specified. This is the path where DevUI will be accessible.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="endpoints"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="pattern"/> is null or whitespace.</exception>
|
||||
internal static IEndpointConventionBuilder MapDevUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
[StringSyntax("Route")] string pattern = "/devui")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pattern);
|
||||
|
||||
// Ensure the pattern doesn't end with a slash for consistency
|
||||
var cleanPattern = pattern.TrimEnd('/');
|
||||
|
||||
// Create the DevUI handler
|
||||
var logger = endpoints.ServiceProvider.GetRequiredService<ILogger<DevUIMiddleware>>();
|
||||
var devUIHandler = new DevUIMiddleware(logger, cleanPattern);
|
||||
|
||||
return endpoints.MapGet($"{cleanPattern}/{{*path}}", devUIHandler.HandleRequestAsync)
|
||||
.WithName($"DevUI at {cleanPattern}")
|
||||
.WithDescription("Interactive developer interface for Microsoft Agent Framework");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
/// <summary>
|
||||
/// Handler that serves embedded DevUI resource files from the 'resources' directory.
|
||||
/// </summary>
|
||||
internal sealed class DevUIMiddleware
|
||||
{
|
||||
private const string GZipEncodingValue = "gzip";
|
||||
private static readonly StringValues s_gzipEncodingHeader = new(GZipEncodingValue);
|
||||
private static readonly Assembly s_assembly = typeof(DevUIMiddleware).Assembly;
|
||||
private static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new();
|
||||
private static readonly StringValues s_cacheControl = new(new CacheControlHeaderValue()
|
||||
{
|
||||
NoCache = true,
|
||||
NoStore = true,
|
||||
}.ToString());
|
||||
|
||||
private readonly ILogger<DevUIMiddleware> _logger;
|
||||
private readonly FrozenDictionary<string, ResourceEntry> _resourceCache;
|
||||
private readonly string _basePath;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DevUIMiddleware"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="basePath">The base path where DevUI is mounted.</param>
|
||||
public DevUIMiddleware(ILogger<DevUIMiddleware> logger, string basePath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentException.ThrowIfNullOrEmpty(basePath);
|
||||
this._logger = logger;
|
||||
this._basePath = basePath.TrimEnd('/');
|
||||
|
||||
// Build resource cache
|
||||
var resourceNamePrefix = $"{s_assembly.GetName().Name}.resources.";
|
||||
this._resourceCache = s_assembly
|
||||
.GetManifestResourceNames()
|
||||
.Where(p => p.StartsWith(resourceNamePrefix, StringComparison.Ordinal))
|
||||
.ToFrozenDictionary(
|
||||
p => p[resourceNamePrefix.Length..].Replace('.', '/'),
|
||||
CreateResourceEntry,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles an HTTP request for DevUI resources.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context.</param>
|
||||
public async Task HandleRequestAsync(HttpContext context)
|
||||
{
|
||||
var path = context.Request.Path.Value;
|
||||
|
||||
if (path == null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
// If requesting the base path without a trailing slash, redirect to include it
|
||||
// This ensures relative URLs in the HTML work correctly
|
||||
if (string.Equals(path, this._basePath, StringComparison.OrdinalIgnoreCase) && !path.EndsWith('/'))
|
||||
{
|
||||
var redirectUrl = $"{path}/";
|
||||
if (context.Request.QueryString.HasValue)
|
||||
{
|
||||
redirectUrl += context.Request.QueryString.Value;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
|
||||
context.Response.Headers.Location = redirectUrl;
|
||||
this._logger.LogDebug("Redirecting {OriginalPath} to {RedirectUrl}", path, redirectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the base path to get the resource path
|
||||
var resourcePath = path.StartsWith(this._basePath, StringComparison.OrdinalIgnoreCase)
|
||||
? path.Substring(this._basePath.Length).TrimStart('/')
|
||||
: path.TrimStart('/');
|
||||
|
||||
// If requesting the base path, serve index.html
|
||||
if (string.IsNullOrEmpty(resourcePath))
|
||||
{
|
||||
resourcePath = "index.html";
|
||||
}
|
||||
|
||||
// Try to serve the embedded resource
|
||||
if (await this.TryServeResourceAsync(context, resourcePath).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// If resource not found, try serving index.html for client-side routing
|
||||
if (!resourcePath.Contains('.', StringComparison.Ordinal) || resourcePath.EndsWith('/'))
|
||||
{
|
||||
if (await this.TryServeResourceAsync(context, "index.html").ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Resource not found
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
}
|
||||
|
||||
private async Task<bool> TryServeResourceAsync(HttpContext context, string resourcePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!this._resourceCache.TryGetValue(resourcePath.Replace('.', '/'), out var cacheEntry))
|
||||
{
|
||||
this._logger.LogDebug("Embedded resource not found: {ResourcePath}", resourcePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
var response = context.Response;
|
||||
|
||||
// Check if client has cached version
|
||||
if (context.Request.Headers.IfNoneMatch == cacheEntry.ETag)
|
||||
{
|
||||
response.StatusCode = StatusCodes.Status304NotModified;
|
||||
this._logger.LogDebug("Resource not modified (304): {ResourcePath}", resourcePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
var responseHeaders = response.Headers;
|
||||
|
||||
byte[] content;
|
||||
bool serveCompressed;
|
||||
if (cacheEntry.CompressedContent is not null && IsGZipAccepted(context.Request))
|
||||
{
|
||||
serveCompressed = true;
|
||||
responseHeaders.ContentEncoding = s_gzipEncodingHeader;
|
||||
responseHeaders.ContentLength = cacheEntry.CompressedContent.Length;
|
||||
content = cacheEntry.CompressedContent;
|
||||
}
|
||||
else
|
||||
{
|
||||
serveCompressed = false;
|
||||
responseHeaders.ContentLength = cacheEntry.DecompressedContent!.Length;
|
||||
content = cacheEntry.DecompressedContent;
|
||||
}
|
||||
|
||||
responseHeaders.CacheControl = s_cacheControl;
|
||||
responseHeaders.ContentType = cacheEntry.ContentType;
|
||||
responseHeaders.ETag = cacheEntry.ETag;
|
||||
|
||||
await response.Body.WriteAsync(content, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
this._logger.LogDebug("Served embedded resource: {ResourcePath} (compressed: {Compressed})", resourcePath, serveCompressed);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Error serving embedded resource: {ResourcePath}", resourcePath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsGZipAccepted(HttpRequest httpRequest)
|
||||
{
|
||||
if (httpRequest.GetTypedHeaders().AcceptEncoding is not { Count: > 0 } acceptEncoding)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < acceptEncoding.Count; i++)
|
||||
{
|
||||
var encoding = acceptEncoding[i];
|
||||
|
||||
if (encoding.Quality is not 0 &&
|
||||
string.Equals(encoding.Value.Value, GZipEncodingValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ResourceEntry CreateResourceEntry(string resourceName)
|
||||
{
|
||||
using var resourceStream = s_assembly.GetManifestResourceStream(resourceName)!;
|
||||
using var decompressedContent = new MemoryStream();
|
||||
|
||||
// Read and cache the original resource content
|
||||
resourceStream.CopyTo(decompressedContent);
|
||||
var decompressedArray = decompressedContent.ToArray();
|
||||
|
||||
// Compress the content
|
||||
using var compressedContent = new MemoryStream();
|
||||
using (var gzip = new GZipStream(compressedContent, CompressionMode.Compress, leaveOpen: true))
|
||||
{
|
||||
// This is a synchronous write to a memory stream.
|
||||
// There is no benefit to asynchrony here.
|
||||
gzip.Write(decompressedArray);
|
||||
}
|
||||
|
||||
// Only use compression if it actually reduces size
|
||||
byte[]? compressedArray = compressedContent.Length < decompressedArray.Length
|
||||
? compressedContent.ToArray()
|
||||
: null;
|
||||
|
||||
var hash = SHA256.HashData(compressedArray ?? decompressedArray);
|
||||
var eTag = $"\"{Convert.ToBase64String(hash)}\"";
|
||||
|
||||
// Determine content type from resource name
|
||||
var contentType = s_contentTypeProvider.TryGetContentType(resourceName, out var ct)
|
||||
? ct
|
||||
: "application/octet-stream";
|
||||
|
||||
return new ResourceEntry(resourceName, decompressedArray, compressedArray, eTag, contentType);
|
||||
}
|
||||
|
||||
private sealed class ResourceEntry(string resourceName, byte[] decompressedContent, byte[]? compressedContent, string eTag, string contentType)
|
||||
{
|
||||
public byte[]? CompressedContent { get; } = compressedContent;
|
||||
|
||||
public string ContentType { get; } = contentType;
|
||||
|
||||
public byte[] DecompressedContent { get; } = decompressedContent;
|
||||
|
||||
public string ETag { get; } = eTag;
|
||||
|
||||
public string ResourceName { get; } = resourceName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization context for entity-related types.
|
||||
/// Enables AOT-compatible JSON serialization using source generators.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
JsonSerializerDefaults.Web,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(EntityInfo))]
|
||||
[JsonSerializable(typeof(DiscoveryResponse))]
|
||||
[JsonSerializable(typeof(EnvVarRequirement))]
|
||||
[JsonSerializable(typeof(List<EntityInfo>))]
|
||||
[JsonSerializable(typeof(List<JsonElement>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed partial class EntitiesJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Information about an environment variable required by an entity.
|
||||
/// </summary>
|
||||
internal sealed record EnvVarRequirement(
|
||||
[property: JsonPropertyName("name")]
|
||||
string Name,
|
||||
|
||||
[property: JsonPropertyName("description")]
|
||||
string? Description = null,
|
||||
|
||||
[property: JsonPropertyName("required")]
|
||||
bool Required = true,
|
||||
|
||||
[property: JsonPropertyName("example")]
|
||||
string? Example = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Information about an entity (agent or workflow).
|
||||
/// </summary>
|
||||
internal sealed record EntityInfo(
|
||||
[property: JsonPropertyName("id")]
|
||||
string Id,
|
||||
|
||||
[property: JsonPropertyName("type")]
|
||||
string Type,
|
||||
|
||||
[property: JsonPropertyName("name")]
|
||||
string Name,
|
||||
|
||||
[property: JsonPropertyName("description")]
|
||||
string? Description = null,
|
||||
|
||||
[property: JsonPropertyName("framework")]
|
||||
string Framework = "dotnet",
|
||||
|
||||
[property: JsonPropertyName("tools")]
|
||||
List<string>? Tools = null,
|
||||
|
||||
[property: JsonPropertyName("metadata")]
|
||||
Dictionary<string, JsonElement>? Metadata = null
|
||||
)
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; } = "di";
|
||||
|
||||
[JsonPropertyName("original_url")]
|
||||
public string? OriginalUrl { get; init; }
|
||||
|
||||
// Workflow-specific fields
|
||||
[JsonPropertyName("required_env_vars")]
|
||||
public List<EnvVarRequirement>? RequiredEnvVars { get; init; }
|
||||
|
||||
[JsonPropertyName("executors")]
|
||||
public List<string>? Executors { get; init; }
|
||||
|
||||
[JsonPropertyName("workflow_dump")]
|
||||
public JsonElement? WorkflowDump { get; init; }
|
||||
|
||||
[JsonPropertyName("input_schema")]
|
||||
public JsonElement? InputSchema { get; init; }
|
||||
|
||||
[JsonPropertyName("input_type_name")]
|
||||
public string? InputTypeName { get; init; }
|
||||
|
||||
[JsonPropertyName("start_executor_id")]
|
||||
public string? StartExecutorId { get; init; }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Response containing a list of discovered entities.
|
||||
/// </summary>
|
||||
internal sealed record DiscoveryResponse(
|
||||
[property: JsonPropertyName("entities")]
|
||||
List<EntityInfo> Entities
|
||||
);
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for serializing workflows to DevUI-compatible format
|
||||
/// </summary>
|
||||
internal static class WorkflowSerializationExtensions
|
||||
{
|
||||
// The frontend max iterations default value expected by the DevUI frontend
|
||||
private const int MaxIterationsDefault = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a workflow to a dictionary representation compatible with DevUI frontend.
|
||||
/// This matches the Python workflow.to_dict() format expected by the UI.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object> ToDevUIDict(this Workflow workflow)
|
||||
{
|
||||
var result = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = workflow.Name ?? Guid.NewGuid().ToString(),
|
||||
["start_executor_id"] = workflow.StartExecutorId,
|
||||
["max_iterations"] = MaxIterationsDefault
|
||||
};
|
||||
|
||||
// Add optional fields
|
||||
if (!string.IsNullOrEmpty(workflow.Name))
|
||||
{
|
||||
result["name"] = workflow.Name;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(workflow.Description))
|
||||
{
|
||||
result["description"] = workflow.Description;
|
||||
}
|
||||
|
||||
// Convert executors to Python-compatible format
|
||||
result["executors"] = ConvertExecutorsToDict(workflow);
|
||||
|
||||
// Convert edges to edge_groups format
|
||||
result["edge_groups"] = ConvertEdgesToEdgeGroups(workflow);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts workflow executors to a dictionary format compatible with Python
|
||||
/// </summary>
|
||||
private static Dictionary<string, object> ConvertExecutorsToDict(Workflow workflow)
|
||||
{
|
||||
var executors = new Dictionary<string, object>();
|
||||
|
||||
// Extract executor IDs from edges and start executor
|
||||
// (Registrations is internal, so we infer executors from the graph structure)
|
||||
var executorIds = new HashSet<string> { workflow.StartExecutorId };
|
||||
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
executorIds.Add(sourceId);
|
||||
foreach (var edge in edgeSet)
|
||||
{
|
||||
foreach (var sinkId in edge.Connection.SinkIds)
|
||||
{
|
||||
executorIds.Add(sinkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create executor entries (we can't access internal Registrations for type info)
|
||||
foreach (var executorId in executorIds)
|
||||
{
|
||||
executors[executorId] = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = executorId,
|
||||
["type"] = "Executor"
|
||||
};
|
||||
}
|
||||
|
||||
return executors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts workflow edges to edge_groups format expected by the UI
|
||||
/// </summary>
|
||||
private static List<object> ConvertEdgesToEdgeGroups(Workflow workflow)
|
||||
{
|
||||
var edgeGroups = new List<object>();
|
||||
var edgeGroupId = 0;
|
||||
|
||||
// Get edges using the public ReflectEdges method
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
foreach (var edgeInfo in edgeSet)
|
||||
{
|
||||
if (edgeInfo is DirectEdgeInfo directEdge)
|
||||
{
|
||||
// Single edge group for direct edges
|
||||
var edges = new List<object>();
|
||||
|
||||
foreach (var source in directEdge.Connection.SourceIds)
|
||||
{
|
||||
foreach (var sink in directEdge.Connection.SinkIds)
|
||||
{
|
||||
var edge = new Dictionary<string, object>
|
||||
{
|
||||
["source_id"] = source,
|
||||
["target_id"] = sink
|
||||
};
|
||||
|
||||
// Add condition name if this is a conditional edge
|
||||
if (directEdge.HasCondition)
|
||||
{
|
||||
edge["condition_name"] = "predicate";
|
||||
}
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
edgeGroups.Add(new Dictionary<string, object>
|
||||
{
|
||||
["id"] = $"edge_group_{edgeGroupId++}",
|
||||
["type"] = "SingleEdgeGroup",
|
||||
["edges"] = edges
|
||||
});
|
||||
}
|
||||
else if (edgeInfo is FanOutEdgeInfo fanOutEdge)
|
||||
{
|
||||
// FanOut edge group
|
||||
var edges = new List<object>();
|
||||
|
||||
foreach (var source in fanOutEdge.Connection.SourceIds)
|
||||
{
|
||||
foreach (var sink in fanOutEdge.Connection.SinkIds)
|
||||
{
|
||||
edges.Add(new Dictionary<string, object>
|
||||
{
|
||||
["source_id"] = source,
|
||||
["target_id"] = sink
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var fanOutGroup = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = $"edge_group_{edgeGroupId++}",
|
||||
["type"] = "FanOutEdgeGroup",
|
||||
["edges"] = edges
|
||||
};
|
||||
|
||||
if (fanOutEdge.HasAssigner)
|
||||
{
|
||||
fanOutGroup["selection_func_name"] = "selector";
|
||||
}
|
||||
|
||||
edgeGroups.Add(fanOutGroup);
|
||||
}
|
||||
else if (edgeInfo is FanInEdgeInfo fanInEdge)
|
||||
{
|
||||
// FanIn edge group
|
||||
var edges = new List<object>();
|
||||
|
||||
foreach (var source in fanInEdge.Connection.SourceIds)
|
||||
{
|
||||
foreach (var sink in fanInEdge.Connection.SinkIds)
|
||||
{
|
||||
edges.Add(new Dictionary<string, object>
|
||||
{
|
||||
["source_id"] = source,
|
||||
["target_id"] = sink
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
edgeGroups.Add(new Dictionary<string, object>
|
||||
{
|
||||
["id"] = $"edge_group_{edgeGroupId++}",
|
||||
["type"] = "FanInEdgeGroup",
|
||||
["edges"] = edges
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edgeGroups;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
using Microsoft.Agents.AI.DevUI.Entities;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for mapping entity discovery and management endpoints to an <see cref="IEndpointRouteBuilder"/>.
|
||||
/// </summary>
|
||||
internal static class EntitiesApiExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps HTTP API endpoints for entity discovery and management.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the routes to.</param>
|
||||
/// <returns>The <see cref="IEndpointRouteBuilder"/> for method chaining.</returns>
|
||||
/// <remarks>
|
||||
/// This extension method registers the following endpoints:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>GET /v1/entities - List all registered entities (agents and workflows)</description></item>
|
||||
/// <item><description>GET /v1/entities/{entityId}/info - Get detailed information about a specific entity</description></item>
|
||||
/// </list>
|
||||
/// The endpoints are compatible with the Python DevUI frontend and automatically discover entities
|
||||
/// from the registered <see cref="AgentCatalog"/> and <see cref="WorkflowCatalog"/> services.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/v1/entities")
|
||||
.WithTags("Entities");
|
||||
|
||||
// List all entities
|
||||
group.MapGet("", ListEntitiesAsync)
|
||||
.WithName("ListEntities")
|
||||
.WithSummary("List all registered entities (agents and workflows)")
|
||||
.Produces<DiscoveryResponse>(StatusCodes.Status200OK, contentType: "application/json");
|
||||
|
||||
// Get detailed entity information
|
||||
group.MapGet("{entityId}/info", GetEntityInfoAsync)
|
||||
.WithName("GetEntityInfo")
|
||||
.WithSummary("Get detailed information about a specific entity")
|
||||
.Produces<EntityInfo>(StatusCodes.Status200OK, contentType: "application/json")
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListEntitiesAsync(
|
||||
AgentCatalog? agentCatalog,
|
||||
WorkflowCatalog? workflowCatalog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entities = new List<EntityInfo>();
|
||||
|
||||
// Discover agents from the agent catalog
|
||||
if (agentCatalog is not null)
|
||||
{
|
||||
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (agent.GetType().Name == "WorkflowHostAgent")
|
||||
{
|
||||
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
|
||||
// and workflows are handled below.
|
||||
continue;
|
||||
}
|
||||
|
||||
entities.Add(new EntityInfo(
|
||||
Id: agent.Name ?? agent.Id,
|
||||
Type: "agent",
|
||||
Name: agent.Name ?? agent.Id,
|
||||
Description: agent.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: null,
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Discover workflows from the workflow catalog
|
||||
if (workflowCatalog is not null)
|
||||
{
|
||||
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Extract executor IDs from the workflow structure
|
||||
var executorIds = new HashSet<string> { workflow.StartExecutorId };
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
executorIds.Add(sourceId);
|
||||
foreach (var edge in edgeSet)
|
||||
{
|
||||
foreach (var sinkId in edge.Connection.SinkIds)
|
||||
{
|
||||
executorIds.Add(sinkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a default input schema (string type)
|
||||
var defaultInputSchema = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "string"
|
||||
};
|
||||
|
||||
entities.Add(new EntityInfo(
|
||||
Id: workflow.Name ?? workflow.StartExecutorId,
|
||||
Type: "workflow",
|
||||
Name: workflow.Name ?? workflow.StartExecutorId,
|
||||
Description: workflow.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: [.. executorIds],
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory",
|
||||
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
|
||||
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
|
||||
InputTypeName = "string",
|
||||
StartExecutorId = workflow.StartExecutorId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Json(new DiscoveryResponse(entities), EntitiesJsonContext.Default.DiscoveryResponse);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Error listing entities");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEntityInfoAsync(
|
||||
string entityId,
|
||||
AgentCatalog? agentCatalog,
|
||||
WorkflowCatalog? workflowCatalog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to find the entity among discovered agents
|
||||
if (agentCatalog is not null)
|
||||
{
|
||||
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (agent.GetType().Name == "WorkflowHostAgent")
|
||||
{
|
||||
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
|
||||
// and workflows are handled below.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(agent.Name, entityId, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(agent.Id, entityId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var entityInfo = new EntityInfo(
|
||||
Id: agent.Name ?? agent.Id,
|
||||
Type: "agent",
|
||||
Name: agent.Name ?? agent.Id,
|
||||
Description: agent.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: null,
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory"
|
||||
};
|
||||
|
||||
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the entity among discovered workflows
|
||||
if (workflowCatalog is not null)
|
||||
{
|
||||
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var workflowId = workflow.Name ?? workflow.StartExecutorId;
|
||||
if (string.Equals(workflowId, entityId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Extract executor IDs from the workflow structure
|
||||
var executorIds = new HashSet<string> { workflow.StartExecutorId };
|
||||
var reflectedEdges = workflow.ReflectEdges();
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
executorIds.Add(sourceId);
|
||||
foreach (var edge in edgeSet)
|
||||
{
|
||||
foreach (var sinkId in edge.Connection.SinkIds)
|
||||
{
|
||||
executorIds.Add(sinkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a default input schema (string type)
|
||||
var defaultInputSchema = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = "string"
|
||||
};
|
||||
|
||||
var entityInfo = new EntityInfo(
|
||||
Id: workflowId,
|
||||
Type: "workflow",
|
||||
Name: workflow.Name ?? workflow.StartExecutorId,
|
||||
Description: workflow.Description,
|
||||
Framework: "agent-framework",
|
||||
Tools: [.. executorIds],
|
||||
Metadata: []
|
||||
)
|
||||
{
|
||||
Source = "in_memory",
|
||||
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
|
||||
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
|
||||
InputTypeName = "Input",
|
||||
StartExecutorId = workflow.StartExecutorId
|
||||
};
|
||||
|
||||
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Results.NotFound(new { error = new { message = $"Entity '{entityId}' not found.", type = "invalid_request_error" } });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Error getting entity info");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Frontend paths - pointing to the Python package's frontend -->
|
||||
<FrontendRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\python\packages\devui\frontend'))</FrontendRoot>
|
||||
<FrontendBuildOutput>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\python\packages\devui\agent_framework_devui\ui'))</FrontendBuildOutput>
|
||||
<FrontendPackageJson>$(FrontendRoot)\package.json</FrontendPackageJson>
|
||||
<FrontendNodeModules>$(FrontendRoot)\node_modules</FrontendNodeModules>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Ensure npm packages are installed before building -->
|
||||
<Target Name="EnsureNodeModules" BeforeTargets="BeforeBuild" Condition="!Exists('$(FrontendNodeModules)')">
|
||||
<Exec Command="npm install" WorkingDirectory="$(FrontendRoot)" />
|
||||
</Target>
|
||||
|
||||
<!-- Collect frontend source files for incremental build tracking -->
|
||||
<ItemGroup>
|
||||
<FrontendSourceFiles Include="$(FrontendRoot)\src\**\*" />
|
||||
<FrontendSourceFiles Include="$(FrontendPackageJson);$(FrontendRoot)\vite.config.ts;$(FrontendRoot)\tsconfig.json;$(FrontendRoot)\index.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Define the required frontend assets -->
|
||||
<ItemGroup>
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\index.html" />
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\assets\index.js" />
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\assets\index.css" />
|
||||
<FrontendAsset Include="$(FrontendBuildOutput)\agentframework.svg" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Use a marker file for incremental build tracking -->
|
||||
<PropertyGroup>
|
||||
<FrontendBuildMarker>$(BaseIntermediateOutputPath)\frontend.build.marker</FrontendBuildMarker>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Build the frontend -->
|
||||
<Target Name="BuildFrontend" BeforeTargets="AssignTargetPaths" DependsOnTargets="EnsureNodeModules" Inputs="@(FrontendSourceFiles)" Outputs="$(FrontendBuildMarker)">
|
||||
<!-- Set VITE_API_BASE_URL to empty string for relative URLs -->
|
||||
<Exec Command="npm run build" WorkingDirectory="$(FrontendRoot)" EnvironmentVariables="VITE_API_BASE_URL=" />
|
||||
<!-- Create marker file to track successful build -->
|
||||
<Touch Files="$(FrontendBuildMarker)" AlwaysCreate="true" />
|
||||
</Target>
|
||||
|
||||
<!-- Statically include frontend assets as embedded resources for VS to show them -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="$(FrontendBuildOutput)\**\*" Condition="Exists('$(FrontendBuildOutput)')">
|
||||
<Link>resources\$([MSBuild]::MakeRelative('$(FrontendBuildOutput)', '%(Identity)'))</Link>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Verify required frontend assets are present -->
|
||||
<Target Name="ValidateFrontendAssets" BeforeTargets="CoreCompile" DependsOnTargets="BuildFrontend">
|
||||
<ItemGroup>
|
||||
<MissingAsset Include="@(FrontendAsset)" Condition="!Exists('%(Identity)')" />
|
||||
</ItemGroup>
|
||||
|
||||
<Error Condition="'@(MissingAsset)' != ''" Text="Required frontend assets are missing: @(MissingAsset, ', '). Frontend build may have failed." />
|
||||
</Target>
|
||||
|
||||
<!-- Verify assets are present before packing -->
|
||||
<Target Name="ValidateFrontendAssetsBeforePack" BeforeTargets="GenerateNuspec">
|
||||
<ItemGroup>
|
||||
<MissingPackageAsset Include="@(FrontendAsset)" Condition="!Exists('%(Identity)')" />
|
||||
</ItemGroup>
|
||||
|
||||
<Error Condition="'@(MissingPackageAsset)' != ''" Text="Cannot create NuGet package: Required frontend assets are missing: @(MissingPackageAsset, ', ')" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net9.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Microsoft.Agents.AI.DevUI</RootNamespace>
|
||||
<OutputType>Library</OutputType>
|
||||
<Title>Microsoft Agent Framework Developer UI</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for developer UI.</Description>
|
||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
||||
<!-- Suppress warnings for internal DevUI implementation -->
|
||||
<NoWarn>$(NoWarn);CS1591;CA1852;CA1050;RCS1037;RCS1036;RCS1124;RCS1021;RCS1146;RCS1211;CA2007;CA1308;IL2026;IL3050;CA1812</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Import nuget packaging properties -->
|
||||
<Import Project="..\..\nuget\nuget-package.props" />
|
||||
|
||||
<!-- Import frontend web assets build targets -->
|
||||
<Import Project="Microsoft.Agents.AI.DevUI.Frontend.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Microsoft.Agents.AI.DevUI": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:57966;http://localhost:57967"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Microsoft.Agents.AI.DevUI
|
||||
|
||||
This package provides a web interface for testing and debugging AI agents during development.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
dotnet add package Microsoft.Agents.AI.DevUI
|
||||
dotnet add package Microsoft.Agents.AI.Hosting
|
||||
dotnet add package Microsoft.Agents.AI.Hosting.OpenAI
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Add DevUI services and map the endpoint in your ASP.NET Core application:
|
||||
|
||||
```csharp
|
||||
using Microsoft.Agents.AI.DevUI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register your agents
|
||||
builder.AddAIAgent("assistant", "You are a helpful assistant.");
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
// Add DevUI services
|
||||
builder.AddDevUI();
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
// Map DevUI endpoint to /devui
|
||||
app.MapDevUI();
|
||||
}
|
||||
|
||||
app.Run();
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./agentframework.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent Framework Dev UI</title>
|
||||
<script type="module" crossorigin src="./assets/index.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -153,13 +153,27 @@ internal sealed class WorkflowThread : AgentThread
|
||||
case AgentRunUpdateEvent agentUpdate:
|
||||
yield return agentUpdate.Update;
|
||||
break;
|
||||
|
||||
case RequestInfoEvent requestInfo:
|
||||
FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall();
|
||||
AgentRunResponseUpdate update = this.CreateUpdate(this.LastResponseId, fcContent);
|
||||
yield return update;
|
||||
break;
|
||||
|
||||
case SuperStepCompletedEvent stepCompleted:
|
||||
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
|
||||
goto default;
|
||||
|
||||
default:
|
||||
// Emit all other workflow events for observability (DevUI, logging, etc.)
|
||||
yield return new AgentRunResponseUpdate(ChatRole.Assistant, [])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Assistant,
|
||||
ResponseId = this.LastResponseId,
|
||||
RawRepresentation = evt
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user