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 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d5686d0
initial process markdown generation
joslat Nov 13, 2024
828cace
mermaid flowchart code generation, image generation on flowchart and …
joslat Nov 14, 2024
d7b13fa
Merge branch 'main' into joslat-process-framework-markdown-renderer
joslat Nov 14, 2024
5f4f404
improvement: nested sub processes
joslat Nov 14, 2024
bc44cf4
Merge branch 'joslat-process-framework-markdown-renderer' of https://…
joslat Nov 14, 2024
57bcdb6
Merge branch 'main' into joslat-process-framework-markdown-renderer
joslat Nov 15, 2024
0c8ec9c
Merge branch 'main' into joslat-process-framework-markdown-renderer
crickman Nov 19, 2024
0d402bc
Merge branch 'microsoft:main' into joslat-process-framework-markdown-…
joslat Nov 20, 2024
12d4140
usings ordering fix
joslat Nov 20, 2024
9cb8249
Merge branch 'main' into joslat-process-framework-markdown-renderer
joslat Nov 21, 2024
c7cbdb9
Merge branch 'main' into joslat-process-framework-markdown-renderer
joslat Nov 25, 2024
95cbfec
Merge branch 'main' into joslat-process-framework-markdown-renderer
crickman Dec 2, 2024
9ea0dc3
Merge branch 'main' into joslat-process-framework-markdown-renderer
crickman Dec 8, 2024
794a5de
Merge branch 'main' into joslat-process-framework-markdown-renderer
joslat Dec 10, 2024
d8a8c1e
Merge branch 'microsoft:main' into joslat-process-framework-markdown-…
joslat Dec 12, 2024
9eaed21
some improvements
joslat Dec 12, 2024
26ef5e5
more improvements, filepath of rendered workflow returned.
joslat Dec 12, 2024
90bdb09
Merge branch 'main' into joslat-process-framework-markdown-renderer
joslat Dec 12, 2024
d1e1541
PR minor adjustments
joslat Dec 17, 2024
55937a0
some defensive coding
joslat Dec 17, 2024
8dbb90d
Merge branch 'main' into joslat-process-framework-markdown-renderer
joslat Dec 17, 2024
2190449
Merge branch 'main' into joslat-process-framework-markdown-renderer
joslat Dec 18, 2024
ef1e63a
Merge branch 'main' into joslat-process-framework-markdown-renderer
joslat Dec 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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");
joslat marked this conversation as resolved.
Show resolved Hide resolved

// 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)
joslat marked this conversation as resolved.
Show resolved Hide resolved
{
// Locate the current assembly's directory
string? assemblyPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
joslat marked this conversation as resolved.
Show resolved Hide resolved
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);
joslat marked this conversation as resolved.
Show resolved Hide resolved
Console.WriteLine($"Diagram generated at: {outputFilePath}");
}
}
159 changes: 159 additions & 0 deletions dotnet/src/Experimental/Process.Core/ProcessVisualizationExtensions.cs
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;
joslat marked this conversation as resolved.
Show resolved Hide resolved

/// <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>
joslat marked this conversation as resolved.
Show resolved Hide resolved
/// <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)
joslat marked this conversation as resolved.
Show resolved Hide resolved
{
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