Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

.Net: Process mermaid flowchart code generation, image generation on flowchart and sample usage. #9705

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
<PackageVersion Include="FastBertTokenizer" Version="1.0.28" />
<PackageVersion Include="PdfPig" Version="0.1.9" />
<PackageVersion Include="Pinecone.NET" Version="2.1.1" />
<PackageVersion Include="PuppeteerSharp" Version="20.0.5" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="8.0.1" />
<PackageVersion Include="System.Formats.Asn1" Version="8.0.1" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="6.34.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="PuppeteerSharp" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.abstractions" />
<PackageReference Include="xunit.runner.visualstudio" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Process;
using SharedSteps;
using Utilities;

namespace Step01;

Expand Down Expand Up @@ -64,6 +66,15 @@ public async Task UseSimpleProcessAsync()
// Build the process to get a handle that can be started
KernelProcess kernelProcess = process.Build();

// Generate a Mermaid diagram for the process and print it to the console
string mermaidGraph = kernelProcess.ToMermaid();
Console.WriteLine($"=== Start - Mermaid Diagram for '{process.Name}' ===");
Console.WriteLine(mermaidGraph);
Console.WriteLine($"=== End - Mermaid Diagram for '{process.Name}' ===");

// Generate an image from the Mermaid diagram
await MermaidRenderer.GenerateMermaidImageAsync(mermaidGraph, "ChatBotProcess.png");

// Start the process with an initial external event
using var runningProcess = await kernelProcess.StartAsync(kernel, new KernelProcessEvent() { Id = ChatBotEvents.StartProcess, Data = null });
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Process;
using Microsoft.SemanticKernel.Process.Models;
using Step03.Processes;
using Utilities;
Expand Down Expand Up @@ -36,6 +37,12 @@ public async Task UsePreparePotatoFriesProcessAsync()
public async Task UsePrepareFishSandwichProcessAsync()
{
var process = FishSandwichProcess.CreateProcess();

string mermaidGraph = process.ToMermaid(2);
Console.WriteLine($"=== Start - Mermaid Diagram for '{process.Name}' ===");
Console.WriteLine(mermaidGraph);
Console.WriteLine($"=== End - Mermaid Diagram for '{process.Name}' ===");

await UsePrepareSpecificProductAsync(process, FishSandwichProcess.ProcessEvents.PrepareFishSandwich);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Reflection;
using PuppeteerSharp;

namespace Utilities;

/// <summary>
/// Renders Mermaid diagrams to images using Puppeteer-Sharp.
/// </summary>
public static class MermaidRenderer
{
/// <summary>
/// Generates a Mermaid diagram image from the provided Mermaid code.
/// </summary>
/// <param name="mermaidCode"></param>
/// <param name="filename"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public static async Task GenerateMermaidImageAsync(string mermaidCode, string filename)
{
// Locate the current assembly's directory
string? assemblyPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
if (assemblyPath == null)
{
throw new InvalidOperationException("Could not determine the assembly path.");
}

// Define the output folder path and create it if it doesn't exist
string outputPath = Path.Combine(assemblyPath, "output");
Directory.CreateDirectory(outputPath);

// Full path for the output file
string outputFilePath = Path.Combine(outputPath, filename);

// Download Chromium if it hasn't been installed yet
BrowserFetcher browserFetcher = new();
browserFetcher.Browser = SupportedBrowser.Chrome;
await browserFetcher.DownloadAsync();
//await new BrowserFetcher().DownloadAsync(BrowserFetcher.DefaultChromiumRevision);

// Define the HTML template with Mermaid.js CDN
string htmlContent = $@"
<html>
<head>
<style>
body {{
display: flex;
align-items: center;
justify-content: center;
margin: 0;
height: 100vh;
}}
</style>
<script type=""module"">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
mermaid.initialize({{ startOnLoad: true }});
</script>
</head>
<body>
<div class=""mermaid"">
{mermaidCode}
</div>
</body>
</html>";

// Create a temporary HTML file with the Mermaid code
string tempHtmlFile = Path.Combine(Path.GetTempPath(), "mermaid_temp.html");
await File.WriteAllTextAsync(tempHtmlFile, htmlContent);

// Launch Puppeteer-Sharp with a headless browser to render the Mermaid diagram
using (var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true }))
using (var page = await browser.NewPageAsync())
{
await page.GoToAsync($"file://{tempHtmlFile}");
await page.WaitForSelectorAsync(".mermaid"); // Wait for Mermaid to render
await page.ScreenshotAsync(outputFilePath, new ScreenshotOptions { FullPage = true });
}

// Clean up the temporary HTML file
File.Delete(tempHtmlFile);
Console.WriteLine($"Diagram generated at: {outputFilePath}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Linq;
using System.Text;

namespace Microsoft.SemanticKernel.Process;

/// <summary>
/// Provides extension methods to visualize a process as a Mermaid diagram.
/// </summary>
public static class ProcessVisualizationExtensions
{
/// <summary>
/// Generates a Mermaid diagram from a process builder.
/// </summary>
/// <param name="processBuilder"></param>
/// <param name="maxLevel"></param>
/// <returns></returns>
public static string ToMermaid(this ProcessBuilder processBuilder, int maxLevel = 2)
{
var process = processBuilder.Build();
return process.ToMermaid(maxLevel);
}

/// <summary>
/// Generates a Mermaid diagram from a kernel process.
/// </summary>
/// <param name="process"></param>
/// <param name="maxLevel"></param>
/// <returns></returns>
public static string ToMermaid(this KernelProcess process, int maxLevel = 2)
{
StringBuilder sb = new();
sb.AppendLine("flowchart LR");

// Generate the Mermaid flowchart content with indentation
string flowchartContent = RenderProcess(process, 1, isSubProcess: false, maxLevel);

// Append the formatted content to the main StringBuilder
sb.Append(flowchartContent);

return sb.ToString();
}

/// <summary>
/// Renders a process and its nested processes recursively as a Mermaid flowchart.
/// </summary>
/// <param name="process">The process to render.</param>
/// <param name="level">The indentation level for nested processes.</param>
/// <param name="isSubProcess">Indicates if the current process is a sub-process.</param>
/// <param name="maxLevel"></param>
/// <returns>A string representation of the process in Mermaid syntax.</returns>
private static string RenderProcess(KernelProcess process, int level, bool isSubProcess, int maxLevel = 2)
{
StringBuilder sb = new();
string indentation = new(' ', 4 * level);

// Dictionary to map step IDs to step names
var stepNames = process.Steps
.Where(step => step.State.Id != null && step.State.Name != null)
.ToDictionary(
step => step.State.Id!,
step => step.State.Name!
);

// Add Start and End nodes only if this is not a sub-process
if (!isSubProcess)
{
sb.AppendLine($"{indentation}Start[\"Start\"]");
sb.AppendLine($"{indentation}End[\"End\"]");
}

// Process each step
foreach (var step in process.Steps)
{
var stepId = step.State.Id;
var stepName = step.State.Name;

// Check if the step is a nested process (sub-process)
if (step is KernelProcess nestedProcess && level < maxLevel)
{
sb.AppendLine($"{indentation}subgraph {stepName.Replace(" ", "")}[\"{stepName}\"]");
sb.AppendLine($"{indentation} direction LR");

// Render the nested process content without its own Start/End nodes
string nestedFlowchart = RenderProcess(nestedProcess, level + 1, isSubProcess: true, maxLevel);

sb.Append(nestedFlowchart);
sb.AppendLine($"{indentation}end");
}
else if (step is KernelProcess nestedProcess2 && level >= maxLevel)
{
// Render a subprocess step
sb.AppendLine($"{indentation}{stepName}[[\"{stepName}\"]]");
}
else
{
// Render the regular step
sb.AppendLine($"{indentation}{stepName}[\"{stepName}\"]");
}

// Handle edges from this step
if (step.Edges != null)
joslat marked this conversation as resolved.
Show resolved Hide resolved
{
foreach (var kvp in step.Edges)
{
var eventId = kvp.Key;
var stepEdges = kvp.Value;

// Skip drawing edges that point to a nested process as an entry point
if (stepNames.ContainsKey(eventId) && process.Steps.Any(s => s.State.Name == eventId && s is KernelProcess))
{
continue;
}

foreach (var edge in stepEdges)
{
string source = $"{stepName}[\"{stepName}\"]";
string target;

// Check if the target step is the end node by function name
if (edge.OutputTarget.FunctionName.Equals("end", StringComparison.OrdinalIgnoreCase) && !isSubProcess)
{
target = "End[\"End\"]";
}
else if (stepNames.TryGetValue(edge.OutputTarget.StepId, out string? targetStepName))
{
target = $"{targetStepName}[\"{targetStepName}\"]";
}
else
{
// Handle cases where the target step is not in the current dictionary, possibly a nested step or placeholder
// As we have events from the step that, when it is a subprocess, that go to a step in the subprocess
// Those are triggered by events and do not have an origin step, also they are not connected to the Start node
// So we need to handle them separately - we ignore them for now
continue;
}

// Append the connection
sb.AppendLine($"{indentation}{source} --> {target}");
}
}
}
}

// Connect Start to the first step and the last step to End (only for the main process)
if (!isSubProcess && process.Steps.Count > 0)
{
var firstStepName = process.Steps.First().State.Name;
var lastStepName = process.Steps.Last().State.Name;

sb.AppendLine($"{indentation}Start --> {firstStepName}[\"{firstStepName}\"]");
sb.AppendLine($"{indentation}{lastStepName}[\"{lastStepName}\"] --> End");
}

return sb.ToString();
}
}
Loading