diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 8ea8825027bb..b960a6214166 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -47,6 +47,7 @@ + diff --git a/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj b/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj index 595ef9d24f6a..911139b69872 100644 --- a/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj +++ b/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj @@ -31,6 +31,7 @@ + diff --git a/dotnet/samples/GettingStartedWithProcesses/Step01/Step01_Processes.cs b/dotnet/samples/GettingStartedWithProcesses/Step01/Step01_Processes.cs index 635ab27f33fc..eda87b18cd7e 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step01/Step01_Processes.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step01/Step01_Processes.cs @@ -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; @@ -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 }); } diff --git a/dotnet/samples/GettingStartedWithProcesses/Step03/Step03a_FoodPreparation.cs b/dotnet/samples/GettingStartedWithProcesses/Step03/Step03a_FoodPreparation.cs index c299960c07a9..3f5eb04cd7e0 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step03/Step03a_FoodPreparation.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step03/Step03a_FoodPreparation.cs @@ -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; @@ -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); } diff --git a/dotnet/samples/GettingStartedWithProcesses/Utilities/MermaidRenderer.cs b/dotnet/samples/GettingStartedWithProcesses/Utilities/MermaidRenderer.cs new file mode 100644 index 000000000000..9b09d8d3873c --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Utilities/MermaidRenderer.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Reflection; +using PuppeteerSharp; + +namespace Utilities; + +/// +/// Renders Mermaid diagrams to images using Puppeteer-Sharp. +/// +public static class MermaidRenderer +{ + /// + /// Generates a Mermaid diagram image from the provided Mermaid code. + /// + /// + /// + /// + /// + 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 = $@" + + + + + + +
+ {mermaidCode} +
+ + "; + + // 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}"); + } +} diff --git a/dotnet/src/Experimental/Process.Core/ProcessVisualizationExtensions.cs b/dotnet/src/Experimental/Process.Core/ProcessVisualizationExtensions.cs new file mode 100644 index 000000000000..dd93011dc181 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/ProcessVisualizationExtensions.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text; + +namespace Microsoft.SemanticKernel.Process; + +/// +/// Provides extension methods to visualize a process as a Mermaid diagram. +/// +public static class ProcessVisualizationExtensions +{ + /// + /// Generates a Mermaid diagram from a process builder. + /// + /// + /// + /// + public static string ToMermaid(this ProcessBuilder processBuilder, int maxLevel = 2) + { + var process = processBuilder.Build(); + return process.ToMermaid(maxLevel); + } + + /// + /// Generates a Mermaid diagram from a kernel process. + /// + /// + /// + /// + 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(); + } + + /// + /// Renders a process and its nested processes recursively as a Mermaid flowchart. + /// + /// The process to render. + /// The indentation level for nested processes. + /// Indicates if the current process is a sub-process. + /// + /// A string representation of the process in Mermaid syntax. + 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) + { + 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(); + } +}