From 5544788917e2180504d0d3c1b80a596b7575c14d Mon Sep 17 00:00:00 2001 From: luoqiang <2769838458@qq.com> Date: Wed, 26 Aug 2026 18:06:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 369 +++++++++++++++++ CliHost.cs | 93 +++++ Data/cities.txt | 3 + Data/not-cities.txt | 3 + MAF1.Core/Agents/AgentStepResult.cs | 79 ++++ MAF1.Core/Agents/FileCity/CityExtraction.cs | 15 + MAF1.Core/Agents/FileCity/FileCityAgent.cs | 81 ++++ MAF1.Core/Agents/Weather/WeatherAgent.cs | 43 ++ MAF1.Core/MAF1.Core.csproj | 19 + MAF1.Core/PluginContract/PluginManifest.cs | 68 ++++ MAF1.Core/PluginContract/PluginStdio.cs | 143 +++++++ MAF1.Core/Tools/FileTools.cs | 75 ++++ MAF1.Core/Tools/WeatherOptions.cs | 22 + MAF1.Core/Tools/WeatherTools.cs | 100 +++++ MAF1.Core/Utils/AgentFactory.cs | 113 ++++++ MAF1.Core/Utils/JsonText.cs | 27 ++ MAF1.Core/Utils/LlmOptions.cs | 8 + MAF1.Core/Utils/WindowsConsole.cs | 55 +++ MAF1.csproj | 63 +++ MAF1.slnx | 11 + Orchestration/WorkflowOrchestration.cs | 50 +++ PluginHost/CredentialStore.cs | 115 ++++++ PluginHost/NodeCatalogService.cs | 111 ++++++ PluginHost/PluginOptions.cs | 36 ++ PluginHost/PluginProcessRunner.cs | 249 ++++++++++++ PluginHost/PluginScanner.cs | 117 ++++++ Plugins/.gitignore | 3 + Plugins/README.md | 20 + Plugins/file-city/FileCityPlugin.csproj | 23 ++ Plugins/file-city/Program.cs | 11 + Plugins/file-city/README.md | 34 ++ Plugins/file-city/plugin.json | 33 ++ Plugins/weather/Program.cs | 18 + Plugins/weather/README.md | 12 + Plugins/weather/WeatherPlugin.csproj | 23 ++ Plugins/weather/plugin.json | 30 ++ Program.cs | 47 +++ Properties/launchSettings.json | 32 ++ README.md | 421 ++++++++++++++++++++ Web/AgentCatalog.cs | 103 +++++ Web/AgentRuntime.cs | 41 ++ Web/ConfigurableWorkflowRunner.cs | 287 +++++++++++++ Web/WorkflowModels.cs | 45 +++ Workflows/CityGateExecutor.cs | 47 +++ Workflows/CityParseExecutor.cs | 33 ++ Workflows/CityWeatherWorkflow.cs | 51 +++ Workflows/SkipWeatherExecutor.cs | 28 ++ Workflows/ToWeatherPromptExecutor.cs | 28 ++ app.manifest | 9 + appsettings.json | 29 ++ wwwroot/css/app.css | 245 ++++++++++++ wwwroot/index.html | 42 ++ wwwroot/js/app.js | 338 ++++++++++++++++ 53 files changed, 4101 insertions(+) create mode 100644 .gitignore create mode 100644 CliHost.cs create mode 100644 Data/cities.txt create mode 100644 Data/not-cities.txt create mode 100644 MAF1.Core/Agents/AgentStepResult.cs create mode 100644 MAF1.Core/Agents/FileCity/CityExtraction.cs create mode 100644 MAF1.Core/Agents/FileCity/FileCityAgent.cs create mode 100644 MAF1.Core/Agents/Weather/WeatherAgent.cs create mode 100644 MAF1.Core/MAF1.Core.csproj create mode 100644 MAF1.Core/PluginContract/PluginManifest.cs create mode 100644 MAF1.Core/PluginContract/PluginStdio.cs create mode 100644 MAF1.Core/Tools/FileTools.cs create mode 100644 MAF1.Core/Tools/WeatherOptions.cs create mode 100644 MAF1.Core/Tools/WeatherTools.cs create mode 100644 MAF1.Core/Utils/AgentFactory.cs create mode 100644 MAF1.Core/Utils/JsonText.cs create mode 100644 MAF1.Core/Utils/LlmOptions.cs create mode 100644 MAF1.Core/Utils/WindowsConsole.cs create mode 100644 MAF1.csproj create mode 100644 MAF1.slnx create mode 100644 Orchestration/WorkflowOrchestration.cs create mode 100644 PluginHost/CredentialStore.cs create mode 100644 PluginHost/NodeCatalogService.cs create mode 100644 PluginHost/PluginOptions.cs create mode 100644 PluginHost/PluginProcessRunner.cs create mode 100644 PluginHost/PluginScanner.cs create mode 100644 Plugins/.gitignore create mode 100644 Plugins/README.md create mode 100644 Plugins/file-city/FileCityPlugin.csproj create mode 100644 Plugins/file-city/Program.cs create mode 100644 Plugins/file-city/README.md create mode 100644 Plugins/file-city/plugin.json create mode 100644 Plugins/weather/Program.cs create mode 100644 Plugins/weather/README.md create mode 100644 Plugins/weather/WeatherPlugin.csproj create mode 100644 Plugins/weather/plugin.json create mode 100644 Program.cs create mode 100644 Properties/launchSettings.json create mode 100644 README.md create mode 100644 Web/AgentCatalog.cs create mode 100644 Web/AgentRuntime.cs create mode 100644 Web/ConfigurableWorkflowRunner.cs create mode 100644 Web/WorkflowModels.cs create mode 100644 Workflows/CityGateExecutor.cs create mode 100644 Workflows/CityParseExecutor.cs create mode 100644 Workflows/CityWeatherWorkflow.cs create mode 100644 Workflows/SkipWeatherExecutor.cs create mode 100644 Workflows/ToWeatherPromptExecutor.cs create mode 100644 app.manifest create mode 100644 appsettings.json create mode 100644 wwwroot/css/app.css create mode 100644 wwwroot/index.html create mode 100644 wwwroot/js/app.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7163bb7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,369 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd +/ZETMP.rar +.gitnexus +/.claude/skills/gitnexus +/AGENTS.md +/CLAUDE.md +.vscode diff --git a/CliHost.cs b/CliHost.cs new file mode 100644 index 0000000..f3a1217 --- /dev/null +++ b/CliHost.cs @@ -0,0 +1,93 @@ +using MAF1.Agents.FileCity; +using MAF1.Agents.Weather; +using MAF1.Orchestration; +using MAF1.Tools; +using MAF1.Utils; +using MAF1.Workflows; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.Configuration; + +namespace MAF1; + +internal static class CliHost +{ + public static async Task RunAsync(string[] args) + { + IConfiguration config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + + AgentFactory factory = new(AgentFactory.Load(config)); + WeatherOptions weatherOptions = config.GetSection("Weather").Get() ?? new WeatherOptions(); + using HttpClient http = WeatherTools.CreateHttpClient(); + WeatherTools weatherTools = new(weatherOptions, http); + AIAgent fileCityAgent = FileCityAgent.Create(factory); + AIAgent weatherAgent = WeatherAgent.Create(factory, weatherTools); + + if (!TryParseArgs(args, out string mode, out string filePath)) + { + PrintUsage(); + return; + } + + Console.WriteLine($"天气数据源: {weatherOptions.Provider}"); + Console.WriteLine($"目标文件: {filePath}"); + Console.WriteLine(); + + Workflow workflow = mode == "edge" + ? CityWeatherWorkflow.BuildEdgeCondition(fileCityAgent, weatherAgent) + : CityWeatherWorkflow.BuildNodeCondition(fileCityAgent, weatherAgent); + string label = mode == "edge" + ? "模式 edge:判断写在边上" + : "模式 node:判断写在节点里"; + await WorkflowOrchestration.RunAsync(workflow, label, filePath); + } + + private static bool TryParseArgs(string[] args, out string mode, out string filePath) + { + mode = "node"; + filePath = Path.Combine("Data", "cities.txt"); + IEnumerable rest = args[0] is "--cli" ? args.Skip(1) : args; + string[] list = rest.ToArray(); + if (list.Length == 0) + { + return true; + } + + string first = list[0]; + if (first is "node" or "edge") + { + mode = first; + if (list.Length > 1) + { + filePath = list[1]; + } + + return true; + } + + if (first is "-h" or "--help") + { + return false; + } + + filePath = first; + return true; + } + + private static void PrintUsage() + { + Console.WriteLine( + """ + 可视化编排(默认): + dotnet run + + 命令行工作流对比: + dotnet run -- node Data/cities.txt + dotnet run -- edge Data/cities.txt + """); + } +} diff --git a/Data/cities.txt b/Data/cities.txt new file mode 100644 index 0000000..3233868 --- /dev/null +++ b/Data/cities.txt @@ -0,0 +1,3 @@ +成都 +阿姆斯特丹 +北京 diff --git a/Data/not-cities.txt b/Data/not-cities.txt new file mode 100644 index 0000000..dcd167e --- /dev/null +++ b/Data/not-cities.txt @@ -0,0 +1,3 @@ +香蕉 +hello +不是城市 diff --git a/MAF1.Core/Agents/AgentStepResult.cs b/MAF1.Core/Agents/AgentStepResult.cs new file mode 100644 index 0000000..6db28cc --- /dev/null +++ b/MAF1.Core/Agents/AgentStepResult.cs @@ -0,0 +1,79 @@ +using System.Text.Json; + +namespace MAF1.Agents; + +public sealed class AgentStepResult +{ + public string Message { get; init; } = ""; + public Dictionary Inputs { get; init; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary Outputs { get; init; } = new(StringComparer.OrdinalIgnoreCase); +} + +internal static class AgentInputs +{ + public static string? ReadString(IReadOnlyDictionary inputs, string key) + { + if (!inputs.TryGetValue(key, out object? value) || value is null) + { + return null; + } + + if (value is JsonElement el) + { + return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString(); + } + + return value.ToString(); + } + + public static List ReadStringList(IReadOnlyDictionary inputs, string key) + { + if (!inputs.TryGetValue(key, out object? value) || value is null) + { + return []; + } + + if (value is JsonElement el) + { + if (el.ValueKind == JsonValueKind.Array) + { + return el.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : item.ToString()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s!.Trim()) + .ToList(); + } + + if (el.ValueKind == JsonValueKind.String) + { + return Split(el.GetString()); + } + } + + if (value is IEnumerable typed) + { + return typed.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.Trim()).ToList(); + } + + if (value is IEnumerable objects) + { + return objects.Select(o => o?.ToString()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s!.Trim()) + .ToList(); + } + + return Split(value.ToString()); + } + + private static List Split(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return []; + } + + return text.Split(['、', ',', ';', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + } +} diff --git a/MAF1.Core/Agents/FileCity/CityExtraction.cs b/MAF1.Core/Agents/FileCity/CityExtraction.cs new file mode 100644 index 0000000..097f2d1 --- /dev/null +++ b/MAF1.Core/Agents/FileCity/CityExtraction.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace MAF1.Agents.FileCity; + +public sealed class CityExtraction +{ + [JsonPropertyName("hasValidCities")] + public bool HasValidCities { get; set; } + + [JsonPropertyName("cities")] + public List Cities { get; set; } = []; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = ""; +} diff --git a/MAF1.Core/Agents/FileCity/FileCityAgent.cs b/MAF1.Core/Agents/FileCity/FileCityAgent.cs new file mode 100644 index 0000000..b610454 --- /dev/null +++ b/MAF1.Core/Agents/FileCity/FileCityAgent.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using MAF1.Tools; +using MAF1.Utils; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace MAF1.Agents.FileCity; + +public static class FileCityAgent +{ + public static AIAgent Create(AgentFactory factory) + { + return factory.CreateAgent( + name: "FileCityAgent", + instructions: + """ + 你负责读取用户指定的本地文件,并从中提取有效城市名。 + 必须先调用 ReadTextFile 读取文件,不要猜测文件内容。 + 有效城市名:现实世界中真实存在的城市(如 成都、北京、Amsterdam)。 + 忽略空行、注释、人名、水果、随意单词等不是城市的内容。 + 只输出一个 JSON 对象,不要 Markdown,不要其它说明: + {"hasValidCities": true, "cities": ["成都"], "reason": "说明"} + 若没有有效城市:{"hasValidCities": false, "cities": [], "reason": "原因"} + cities 去重,保持文件中的出现顺序。 + """, + tools: [AIFunctionFactory.Create(FileTools.ReadTextFile)]); + } + + public static async Task RunAsync( + AIAgent agent, + IReadOnlyDictionary inputs, + CancellationToken cancellationToken = default) + { + string filePath = AgentInputs.ReadString(inputs, "filePath") ?? "Data/cities.txt"; + AgentResponse response = await agent.RunAsync( + $"请读取这个文件并提取有效城市名:{filePath}", + cancellationToken: cancellationToken); + CityExtraction extraction = Parse(response.Text); + return new AgentStepResult + { + Message = response.Text, + Inputs = new Dictionary { ["filePath"] = filePath }, + Outputs = new Dictionary + { + ["hasValidCities"] = extraction.HasValidCities, + ["cities"] = extraction.Cities, + ["reason"] = extraction.Reason, + ["raw"] = response.Text, + }, + }; + } + + public static CityExtraction Parse(string text) + { + try + { + CityExtraction? result = JsonSerializer.Deserialize( + JsonText.UnwrapObject(text), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (result is null) + { + return Invalid("无法解析文件 Agent 的输出。"); + } + + result.Cities = result.Cities + .Where(city => !string.IsNullOrWhiteSpace(city)) + .Select(city => city.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + result.HasValidCities = result.HasValidCities && result.Cities.Count > 0; + return result; + } + catch (JsonException ex) + { + return Invalid($"文件 Agent 没有返回合法 JSON:{ex.Message}"); + } + } + + private static CityExtraction Invalid(string reason) => + new() { HasValidCities = false, Reason = reason }; +} diff --git a/MAF1.Core/Agents/Weather/WeatherAgent.cs b/MAF1.Core/Agents/Weather/WeatherAgent.cs new file mode 100644 index 0000000..489d464 --- /dev/null +++ b/MAF1.Core/Agents/Weather/WeatherAgent.cs @@ -0,0 +1,43 @@ +using MAF1.Tools; +using MAF1.Utils; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace MAF1.Agents.Weather; + +public static class WeatherAgent +{ + public static AIAgent Create(AgentFactory factory, WeatherTools weatherTools) + { + return factory.CreateAgent( + name: "WeatherAgent", + instructions: + """ + 你负责查询天气。用户给出的每个城市都必须调用一次 GetWeather,不要编造天气。 + 用中文汇总所有城市的天气。 + """, + tools: [AIFunctionFactory.Create(weatherTools.GetWeather)]); + } + + public static async Task RunAsync( + AIAgent agent, + IReadOnlyDictionary inputs, + CancellationToken cancellationToken = default) + { + List cities = AgentInputs.ReadStringList(inputs, "cities"); + if (cities.Count == 0) + { + throw new InvalidOperationException("输入 cities 为空。请把上一节点的 cities 连到本节点的 cities。"); + } + + AgentResponse response = await agent.RunAsync( + $"请查询这些城市的天气:{string.Join("、", cities)}", + cancellationToken: cancellationToken); + return new AgentStepResult + { + Message = response.Text, + Inputs = new Dictionary { ["cities"] = cities }, + Outputs = new Dictionary { ["summary"] = response.Text }, + }; + } +} diff --git a/MAF1.Core/MAF1.Core.csproj b/MAF1.Core/MAF1.Core.csproj new file mode 100644 index 0000000..5ce7b94 --- /dev/null +++ b/MAF1.Core/MAF1.Core.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + MAF1 + + + + + + + + + + + + diff --git a/MAF1.Core/PluginContract/PluginManifest.cs b/MAF1.Core/PluginContract/PluginManifest.cs new file mode 100644 index 0000000..2592323 --- /dev/null +++ b/MAF1.Core/PluginContract/PluginManifest.cs @@ -0,0 +1,68 @@ +using System.Text.Json.Serialization; + +namespace MAF1.PluginContract; + +public sealed class PluginManifest +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public string Version { get; set; } = "1.0.0"; + public PluginLaunch Launch { get; set; } = new(); + public int TimeoutSeconds { get; set; } + public Dictionary Env { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public List Credentials { get; set; } = []; + public List Inputs { get; set; } = []; + public List Outputs { get; set; } = []; +} + +public sealed class PluginLaunch +{ + public string Command { get; set; } = ""; + public List Args { get; set; } = []; +} + +public sealed class PluginPort +{ + public string Name { get; set; } = ""; + public string Type { get; set; } = "string"; + public string Description { get; set; } = ""; + public bool Required { get; set; } +} + +public sealed class PluginCredentialNeed +{ + public string Name { get; set; } = ""; + public string Type { get; set; } = ""; + public bool Required { get; set; } = true; + public string Description { get; set; } = ""; +} + +public sealed class PluginRequest +{ + public Dictionary Inputs { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + [JsonPropertyName("credentials")] + public Dictionary Credentials { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed class PluginCredentialPayload +{ + public string Id { get; set; } = ""; + public string Type { get; set; } = ""; + public string? Endpoint { get; set; } + public string? ApiKey { get; set; } + public string? Model { get; set; } + public Dictionary Extra { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public static class PluginJson +{ + public static readonly System.Text.Json.JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; +} diff --git a/MAF1.Core/PluginContract/PluginStdio.cs b/MAF1.Core/PluginContract/PluginStdio.cs new file mode 100644 index 0000000..f8890e6 --- /dev/null +++ b/MAF1.Core/PluginContract/PluginStdio.cs @@ -0,0 +1,143 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; + +namespace MAF1.PluginContract; + +public static class PluginStdio +{ + public static async Task ReadRequestAsync(CancellationToken cancellationToken = default) + { + using Stream stdin = Console.OpenStandardInput(); + using StreamReader reader = new(stdin, Encoding.UTF8); + string json = await reader.ReadToEndAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(json)) + { + throw new InvalidOperationException("插件没有从标准输入读到 JSON 请求。"); + } + + PluginRequest? request = JsonSerializer.Deserialize(json, PluginJson.Options); + return request ?? new PluginRequest(); + } + + public static async Task WriteOutputsAsync(Dictionary outputs, CancellationToken cancellationToken = default) + { + string json = JsonSerializer.Serialize(outputs, PluginJson.Options); + await Console.Out.WriteAsync(json.AsMemory(), cancellationToken); + await Console.Out.FlushAsync(cancellationToken); + } + + public static void ApplyCredentialsToEnvironment(IReadOnlyDictionary credentials) + { + foreach (KeyValuePair pair in credentials) + { + PluginCredentialPayload cred = pair.Value; + if (cred.Type is "openai-compatible" or "llm" || pair.Key.Equals("llm", StringComparison.OrdinalIgnoreCase)) + { + SetIfNotEmpty("OPENAI_ENDPOINT", cred.Endpoint); + SetIfNotEmpty("OPENAI_API_KEY", cred.ApiKey); + SetIfNotEmpty("OPENAI_CHAT_MODEL", cred.Model); + } + + foreach (KeyValuePair extra in cred.Extra) + { + if (!string.IsNullOrWhiteSpace(extra.Value)) + { + Environment.SetEnvironmentVariable(extra.Key, extra.Value); + } + } + } + } + + public static IConfiguration LoadHostConfiguration() + { + string contentRoot = Environment.GetEnvironmentVariable("MAF1_CONTENT_ROOT"); + if (string.IsNullOrWhiteSpace(contentRoot) || !Directory.Exists(contentRoot)) + { + contentRoot = AppContext.BaseDirectory; + DirectoryInfo? parent = Directory.GetParent(contentRoot.TrimEnd(Path.DirectorySeparatorChar)); + if (parent?.Name.Equals("plugins", StringComparison.OrdinalIgnoreCase) == false + && parent?.Parent is not null + && File.Exists(Path.Combine(parent.Parent.FullName, "appsettings.json"))) + { + contentRoot = parent.Parent.FullName; + } + else if (parent is not null && File.Exists(Path.Combine(parent.FullName, "appsettings.json"))) + { + contentRoot = parent.FullName; + } + } + + return new ConfigurationBuilder() + .SetBasePath(contentRoot) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + } + + public static string? ReadString(Dictionary inputs, string key) + { + if (!inputs.TryGetValue(key, out object? value) || value is null) + { + return null; + } + + if (value is JsonElement el) + { + return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString(); + } + + return value.ToString(); + } + + public static List ReadStringList(Dictionary inputs, string key) + { + if (!inputs.TryGetValue(key, out object? value) || value is null) + { + return []; + } + + if (value is JsonElement el) + { + if (el.ValueKind == JsonValueKind.Array) + { + return el.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : item.ToString()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s!.Trim()) + .ToList(); + } + + if (el.ValueKind == JsonValueKind.String) + { + return Split(el.GetString()); + } + } + + if (value is IEnumerable typed) + { + return typed.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.Trim()).ToList(); + } + + return Split(value.ToString()); + } + + private static List Split(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return []; + } + + return text.Split(['、', ',', ';', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + } + + private static void SetIfNotEmpty(string name, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + Environment.SetEnvironmentVariable(name, value); + } + } +} diff --git a/MAF1.Core/Tools/FileTools.cs b/MAF1.Core/Tools/FileTools.cs new file mode 100644 index 0000000..f1e7403 --- /dev/null +++ b/MAF1.Core/Tools/FileTools.cs @@ -0,0 +1,75 @@ +using System.ComponentModel; +using System.Text; + +namespace MAF1.Tools; + +public static class FileTools +{ + [Description("Read the full UTF-8 text of a local file. Always call this before judging city names.")] + public static string ReadTextFile([Description("Absolute or relative path of the file to read.")] string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return "未提供文件路径。"; + } + + string? resolved = Resolve(path.Trim().Trim('"')); + if (resolved is null) + { + return $"找不到文件:{path}"; + } + + FileInfo info = new(resolved); + if (info.Length > 64 * 1024) + { + return $"文件过大({info.Length} 字节),请换一个不超过 64KB 的文本文件。"; + } + + return File.ReadAllText(resolved, Encoding.UTF8); + } + + private static string? Resolve(string path) + { + if (Path.IsPathRooted(path) && File.Exists(path)) + { + return Path.GetFullPath(path); + } + + string fromCwd = Path.GetFullPath(path); + if (File.Exists(fromCwd)) + { + return fromCwd; + } + + foreach (string root in SearchRoots()) + { + string candidate = Path.GetFullPath(Path.Combine(root, path)); + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + + private static IEnumerable SearchRoots() + { + yield return AppContext.BaseDirectory; + string? contentRoot = Environment.GetEnvironmentVariable("MAF1_CONTENT_ROOT"); + if (!string.IsNullOrWhiteSpace(contentRoot)) + { + yield return contentRoot; + } + + DirectoryInfo? parent = Directory.GetParent(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar)); + if (parent is not null) + { + yield return parent.FullName; + if (parent.Parent is not null) + { + yield return parent.Parent.FullName; + } + } + } +} diff --git a/MAF1.Core/Tools/WeatherOptions.cs b/MAF1.Core/Tools/WeatherOptions.cs new file mode 100644 index 0000000..8fa4d37 --- /dev/null +++ b/MAF1.Core/Tools/WeatherOptions.cs @@ -0,0 +1,22 @@ +namespace MAF1.Tools; + +public sealed class WeatherOptions +{ + public string Provider { get; set; } = "Wttr"; + public string Language { get; set; } = "zh"; + public WttrOptions Wttr { get; set; } = new(); + public OpenWeatherOptions OpenWeather { get; set; } = new(); +} + +public sealed class WttrOptions +{ + public string UrlTemplate { get; set; } = "https://wttr.in/{location}?lang={lang}&format=3"; +} + +public sealed class OpenWeatherOptions +{ + public string UrlTemplate { get; set; } = + "https://api.openweathermap.org/data/2.5/weather?q={location}&appid={apiKey}&units=metric&lang={lang}"; + + public string ApiKey { get; set; } = ""; +} diff --git a/MAF1.Core/Tools/WeatherTools.cs b/MAF1.Core/Tools/WeatherTools.cs new file mode 100644 index 0000000..9775b68 --- /dev/null +++ b/MAF1.Core/Tools/WeatherTools.cs @@ -0,0 +1,100 @@ +using System.ComponentModel; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace MAF1.Tools; + +public sealed class WeatherTools(WeatherOptions options, HttpClient http) +{ + [Description("Look up live weather for a city or location. Always call this instead of guessing.")] + public async Task GetWeather( + [Description("City or location name, for example Amsterdam or 北京.")] string location) + { + if (string.IsNullOrWhiteSpace(location)) + { + return "未提供地点,无法查询天气。"; + } + + string provider = options.Provider.Trim(); + try + { + if (provider.Equals("OpenWeather", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(options.OpenWeather.ApiKey)) + { + return "OpenWeather 已启用,但 appsettings.json 里 Weather:OpenWeather:ApiKey 为空。请填入密钥,或把 Provider 改回 Wttr。"; + } + + return await QueryOpenWeatherAsync(location); + } + + return await QueryWttrAsync(location); + } + catch (Exception ex) + { + return $"查询天气失败({provider} / {location}):{ex.Message}"; + } + } + + private async Task QueryWttrAsync(string location) + { + string url = Expand(options.Wttr.UrlTemplate, location, apiKey: null); + using HttpResponseMessage response = await http.GetAsync(url); + string body = (await response.Content.ReadAsStringAsync()).Trim(); + if (!response.IsSuccessStatusCode) + { + return $"wttr.in 返回 {(int)response.StatusCode}:{body}"; + } + + return string.IsNullOrWhiteSpace(body) + ? $"wttr.in 没有返回 {location} 的天气。" + : body; + } + + private async Task QueryOpenWeatherAsync(string location) + { + string url = Expand(options.OpenWeather.UrlTemplate, location, options.OpenWeather.ApiKey); + using HttpResponseMessage response = await http.GetAsync(url); + string body = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + return $"OpenWeather 返回 {(int)response.StatusCode}:{body}"; + } + + using JsonDocument doc = JsonDocument.Parse(body); + JsonElement root = doc.RootElement; + string name = root.TryGetProperty("name", out JsonElement nameEl) ? nameEl.GetString() ?? location : location; + string description = root.TryGetProperty("weather", out JsonElement weather) + && weather.ValueKind == JsonValueKind.Array + && weather.GetArrayLength() > 0 + && weather[0].TryGetProperty("description", out JsonElement descEl) + ? descEl.GetString() ?? "" + : ""; + double temp = root.TryGetProperty("main", out JsonElement main) && main.TryGetProperty("temp", out JsonElement tempEl) + ? tempEl.GetDouble() + : double.NaN; + int humidity = main.ValueKind == JsonValueKind.Object && main.TryGetProperty("humidity", out JsonElement humidityEl) + ? humidityEl.GetInt32() + : 0; + + return double.IsNaN(temp) + ? body + : $"{name}:{description},气温 {temp:0.#}°C,湿度 {humidity}%"; + } + + private string Expand(string template, string location, string? apiKey) + { + return template + .Replace("{location}", Uri.EscapeDataString(location), StringComparison.OrdinalIgnoreCase) + .Replace("{lang}", Uri.EscapeDataString(options.Language), StringComparison.OrdinalIgnoreCase) + .Replace("{apiKey}", apiKey ?? "", StringComparison.OrdinalIgnoreCase); + } + + public static HttpClient CreateHttpClient() + { + HttpClient http = new() { Timeout = TimeSpan.FromSeconds(20) }; + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("MAF1", "1.0")); + http.DefaultRequestHeaders.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8"); + return http; + } +} diff --git a/MAF1.Core/Utils/AgentFactory.cs b/MAF1.Core/Utils/AgentFactory.cs new file mode 100644 index 0000000..446b5f0 --- /dev/null +++ b/MAF1.Core/Utils/AgentFactory.cs @@ -0,0 +1,113 @@ +using System.ClientModel; +using Azure.AI.OpenAI; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using OpenAI; +using OpenAI.Chat; + +namespace MAF1.Utils; + +public sealed class AgentFactory +{ + private readonly ChatClient _chatClient; + + public AgentFactory(LlmOptions options) + { + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + throw new InvalidOperationException( + """ + 还没有配置模型。请在 appsettings.json 的 Llm 节点,或环境变量中设置: + OPENAI_API_KEY / Llm:ApiKey + OPENAI_ENDPOINT / Llm:Endpoint + OPENAI_CHAT_MODEL / Llm:Model + """); + } + + string model = string.IsNullOrWhiteSpace(options.Model) + ? DefaultModelFor(options.Endpoint) + : options.Model; + + if (LooksLikeAzureOpenAI(options.Endpoint)) + { + Console.Error.WriteLine($"使用 Azure OpenAI: {options.Endpoint} 部署: {model}"); + _chatClient = new AzureOpenAIClient(new Uri(options.Endpoint), new ApiKeyCredential(options.ApiKey)) + .GetChatClient(model); + return; + } + + OpenAIClientOptions clientOptions = new(); + if (!string.IsNullOrWhiteSpace(options.Endpoint)) + { + clientOptions.Endpoint = ToOpenAICompatibleEndpoint(options.Endpoint); + } + + Console.Error.WriteLine($"使用 OpenAI 兼容接口: {clientOptions.Endpoint?.ToString() ?? "https://api.openai.com/v1"} 模型: {model}"); + _chatClient = new OpenAIClient(new ApiKeyCredential(options.ApiKey), clientOptions) + .GetChatClient(model); + } + + public AIAgent CreateAgent(string name, string instructions, IList? tools = null) + { + return _chatClient.AsAIAgent(instructions: instructions, name: name, tools: tools); + } + + public static LlmOptions Load(IConfiguration config) + { + LlmOptions options = config.GetSection("Llm").Get() ?? new LlmOptions(); + options.ApiKey = FirstNonEmpty(options.ApiKey, Env("OPENAI_API_KEY"), Env("AZURE_OPENAI_API_KEY")); + options.Endpoint = FirstNonEmpty(options.Endpoint, Env("OPENAI_ENDPOINT"), Env("OPENAI_BASE_URL"), Env("AZURE_OPENAI_ENDPOINT")); + options.Model = FirstNonEmpty(options.Model, Env("OPENAI_CHAT_MODEL"), Env("AZURE_OPENAI_DEPLOYMENT_NAME")); + return options; + } + + private static string DefaultModelFor(string? endpoint) + { + if (!string.IsNullOrWhiteSpace(endpoint) && endpoint.Contains("deepseek", StringComparison.OrdinalIgnoreCase)) + { + return "deepseek-chat"; + } + + return "gpt-4o-mini"; + } + + private static bool LooksLikeAzureOpenAI(string? endpoint) + { + if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri)) + { + return false; + } + + string host = uri.Host; + return host.Contains("openai.azure.com", StringComparison.OrdinalIgnoreCase) + || host.Contains("cognitiveservices.azure.com", StringComparison.OrdinalIgnoreCase) + || host.Contains("services.ai.azure.com", StringComparison.OrdinalIgnoreCase); + } + + private static Uri ToOpenAICompatibleEndpoint(string endpoint) + { + string trimmed = endpoint.TrimEnd('/'); + if (!trimmed.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + { + trimmed += "/v1"; + } + + return new Uri(trimmed); + } + + private static string? Env(string name) => Environment.GetEnvironmentVariable(name); + + private static string FirstNonEmpty(params string?[] values) + { + foreach (string? value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return ""; + } +} diff --git a/MAF1.Core/Utils/JsonText.cs b/MAF1.Core/Utils/JsonText.cs new file mode 100644 index 0000000..75a7e8d --- /dev/null +++ b/MAF1.Core/Utils/JsonText.cs @@ -0,0 +1,27 @@ +namespace MAF1.Utils; + +public static class JsonText +{ + public static string UnwrapObject(string text) + { + string trimmed = text.Trim(); + if (trimmed.StartsWith("```", StringComparison.Ordinal)) + { + int firstNewLine = trimmed.IndexOf('\n'); + int lastFence = trimmed.LastIndexOf("```", StringComparison.Ordinal); + if (firstNewLine >= 0 && lastFence > firstNewLine) + { + trimmed = trimmed[(firstNewLine + 1)..lastFence].Trim(); + } + } + + int objectStart = trimmed.IndexOf('{'); + int objectEnd = trimmed.LastIndexOf('}'); + if (objectStart >= 0 && objectEnd > objectStart) + { + return trimmed[objectStart..(objectEnd + 1)]; + } + + return trimmed; + } +} diff --git a/MAF1.Core/Utils/LlmOptions.cs b/MAF1.Core/Utils/LlmOptions.cs new file mode 100644 index 0000000..15e88a4 --- /dev/null +++ b/MAF1.Core/Utils/LlmOptions.cs @@ -0,0 +1,8 @@ +namespace MAF1.Utils; + +public sealed class LlmOptions +{ + public string ApiKey { get; set; } = ""; + public string Endpoint { get; set; } = ""; + public string Model { get; set; } = ""; +} diff --git a/MAF1.Core/Utils/WindowsConsole.cs b/MAF1.Core/Utils/WindowsConsole.cs new file mode 100644 index 0000000..cd679c6 --- /dev/null +++ b/MAF1.Core/Utils/WindowsConsole.cs @@ -0,0 +1,55 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace MAF1.Utils; + +public static class WindowsConsole +{ + private const uint Utf8CodePage = 65001; + private const int StdOutputHandle = -11; + private const uint EnableVirtualTerminalProcessing = 0x0004; + + public static void EnableUtf8() + { + if (!OperatingSystem.IsWindows()) + { + Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + Console.InputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + return; + } + + SetConsoleOutputCP(Utf8CodePage); + SetConsoleCP(Utf8CodePage); + + UTF8Encoding utf8 = new(encoderShouldEmitUTF8Identifier: false); + Console.OutputEncoding = utf8; + Console.InputEncoding = utf8; + + nint stdout = GetStdHandle(StdOutputHandle); + if (stdout != nint.Zero && GetConsoleMode(stdout, out uint mode)) + { + SetConsoleMode(stdout, mode | EnableVirtualTerminalProcessing); + } + + StreamWriter writer = new(Console.OpenStandardOutput(), utf8) + { + AutoFlush = true, + }; + Console.SetOut(writer); + } + + [DllImport("kernel32.dll")] + private static extern bool SetConsoleOutputCP(uint wCodePageID); + + [DllImport("kernel32.dll")] + private static extern bool SetConsoleCP(uint wCodePageID); + + [DllImport("kernel32.dll")] + private static extern nint GetStdHandle(int nStdHandle); + + [DllImport("kernel32.dll")] + private static extern bool GetConsoleMode(nint hConsoleHandle, out uint lpMode); + + [DllImport("kernel32.dll")] + private static extern bool SetConsoleMode(nint hConsoleHandle, uint dwMode); +} diff --git a/MAF1.csproj b/MAF1.csproj new file mode 100644 index 0000000..cd31d99 --- /dev/null +++ b/MAF1.csproj @@ -0,0 +1,63 @@ + + + + Exe + net10.0 + enable + enable + app.manifest + + + + + + + + + + + + + + + + + + + + + + + + + false + + + false + + + + + + PreserveNewest + + + PreserveNewest + + + + + + <_FileCityOut>$(MSBuildThisFileDirectory)plugins\file-city\bin\$(Configuration)\net10.0 + <_WeatherOut>$(MSBuildThisFileDirectory)plugins\weather\bin\$(Configuration)\net10.0 + + + <_FileCityFiles Include="$(_FileCityOut)\**\*" /> + <_WeatherFiles Include="$(_WeatherOut)\**\*" /> + + + + + + + diff --git a/MAF1.slnx b/MAF1.slnx new file mode 100644 index 0000000..57bcbb0 --- /dev/null +++ b/MAF1.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Orchestration/WorkflowOrchestration.cs b/Orchestration/WorkflowOrchestration.cs new file mode 100644 index 0000000..9ad3df4 --- /dev/null +++ b/Orchestration/WorkflowOrchestration.cs @@ -0,0 +1,50 @@ +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; + +namespace MAF1.Orchestration; + +public static class WorkflowOrchestration +{ + public static async Task RunAsync(Workflow workflow, string modeLabel, string filePath) + { + Console.WriteLine(modeLabel); + Console.WriteLine(); + + string prompt = $"请读取这个文件并提取有效城市名:{filePath}"; + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, prompt); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + + string? lastExecutorId = null; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + switch (evt) + { + case AgentResponseUpdateEvent update: + if (update.ExecutorId != lastExecutorId) + { + Console.WriteLine(); + Console.Write($"{update.ExecutorId}: "); + lastExecutorId = update.ExecutorId; + } + + if (!string.IsNullOrEmpty(update.Update.Text)) + { + Console.Write(update.Update.Text); + } + + break; + + case AgentResponseEvent: + break; + + case WorkflowOutputEvent output when output.As() is string text: + Console.WriteLine(); + Console.WriteLine(); + Console.WriteLine($"[工作流 / {output.ExecutorId}] {text}"); + break; + } + } + + Console.WriteLine(); + } +} diff --git a/PluginHost/CredentialStore.cs b/PluginHost/CredentialStore.cs new file mode 100644 index 0000000..4c593b4 --- /dev/null +++ b/PluginHost/CredentialStore.cs @@ -0,0 +1,115 @@ +using MAF1.PluginContract; +using MAF1.Utils; +using Microsoft.Extensions.Configuration; + +namespace MAF1.Plugins; + +public sealed class CredentialRecord +{ + public string Id { get; init; } = ""; + public string Name { get; init; } = ""; + public string Type { get; init; } = ""; + public string Endpoint { get; init; } = ""; + public string ApiKey { get; init; } = ""; + public string Model { get; init; } = ""; + public Dictionary Extra { get; init; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed class CredentialPublicView +{ + public string Id { get; init; } = ""; + public string Name { get; init; } = ""; + public string Type { get; init; } = ""; + public string Endpoint { get; init; } = ""; + public string Model { get; init; } = ""; + public bool HasApiKey { get; init; } +} + +public sealed class CredentialStore +{ + private readonly IConfiguration _config; + private readonly Dictionary _named = new(StringComparer.OrdinalIgnoreCase); + + public CredentialStore(IConfiguration config) + { + _config = config; + LlmOptions llm = AgentFactory.Load(config); + _named["llm-default"] = new CredentialRecord + { + Id = "llm-default", + Name = "系统默认模型", + Type = "openai-compatible", + Endpoint = llm.Endpoint, + ApiKey = llm.ApiKey, + Model = llm.Model, + }; + + IConfigurationSection section = config.GetSection("Credentials"); + foreach (IConfigurationSection child in section.GetChildren()) + { + string id = child.Key; + string type = child["type"] ?? "openai-compatible"; + bool useLlm = string.Equals(child["source"], "llm-section", StringComparison.OrdinalIgnoreCase) + || child.GetValue("useLlmSection", false); + CredentialRecord fallback = _named.GetValueOrDefault("llm-default")!; + _named[id] = new CredentialRecord + { + Id = id, + Name = child["name"] ?? id, + Type = type, + Endpoint = First(child["endpoint"], useLlm ? fallback.Endpoint : null), + ApiKey = First(child["apiKey"], useLlm ? fallback.ApiKey : null), + Model = First(child["model"], useLlm ? fallback.Model : null), + }; + } + } + + public IReadOnlyList ListPublic() + => _named.Values + .OrderBy(item => item.Id, StringComparer.OrdinalIgnoreCase) + .Select(ToPublic) + .ToList(); + + public CredentialRecord Resolve(string? id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return _named["llm-default"]; + } + + if (_named.TryGetValue(id, out CredentialRecord? record)) + { + return record; + } + + throw new InvalidOperationException($"找不到凭据 `{id}`。请在 appsettings.json 的 Credentials 中配置,或改用 llm-default。"); + } + + public PluginCredentialPayload ToPayload(CredentialRecord record) + => new() + { + Id = record.Id, + Type = record.Type, + Endpoint = record.Endpoint, + ApiKey = record.ApiKey, + Model = record.Model, + Extra = record.Extra, + }; + + public static CredentialPublicView ToPublic(CredentialRecord record) + => new() + { + Id = record.Id, + Name = record.Name, + Type = record.Type, + Endpoint = MaskEndpoint(record.Endpoint), + Model = record.Model, + HasApiKey = !string.IsNullOrWhiteSpace(record.ApiKey), + }; + + private static string MaskEndpoint(string endpoint) + => endpoint; + + private static string First(params string?[] values) + => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? ""; +} diff --git a/PluginHost/NodeCatalogService.cs b/PluginHost/NodeCatalogService.cs new file mode 100644 index 0000000..b1762d9 --- /dev/null +++ b/PluginHost/NodeCatalogService.cs @@ -0,0 +1,111 @@ +using MAF1.PluginContract; +using MAF1.Web; + +namespace MAF1.Plugins; + +public sealed class NodeCatalogSnapshot +{ + public string PluginsRoot { get; init; } = ""; + public List System { get; init; } = []; + public List Plugins { get; init; } = []; + public List Nodes { get; init; } = []; + public List Issues { get; init; } = []; + public IReadOnlyList LoadedPlugins { get; init; } = []; +} + +public sealed class NodeCatalogService(PluginScanner scanner) +{ + public NodeCatalogSnapshot Load() + { + PluginScanResult scan = scanner.Scan(); + List issues = [.. scan.Issues]; + List system = AgentCatalog.SystemNodes + .Select(Clone) + .ToList(); + foreach (AgentTypeInfo item in system) + { + item.Origin = "system"; + } + + Dictionary systemByType = system.ToDictionary(n => n.Type, StringComparer.OrdinalIgnoreCase); + List plugins = []; + foreach (LoadedPlugin plugin in scan.Plugins) + { + AgentTypeInfo info = ToTypeInfo(plugin); + if (systemByType.ContainsKey(info.Type)) + { + issues.Add(new CatalogIssue + { + Level = "info", + Source = info.Type, + Message = $"插件 `{info.Type}` 覆盖了同名系统节点,画布运行将走插件进程。", + }); + systemByType[info.Type].Overridden = true; + } + + plugins.Add(info); + } + + Dictionary merged = systemByType + .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase); + foreach (AgentTypeInfo plugin in plugins) + { + merged[plugin.Type] = plugin; + } + + return new NodeCatalogSnapshot + { + PluginsRoot = scan.Root, + System = system, + Plugins = plugins, + Nodes = merged.Values.OrderBy(n => n.Origin).ThenBy(n => n.Name).ToList(), + Issues = issues, + LoadedPlugins = scan.Plugins, + }; + } + + public LoadedPlugin? FindPlugin(string type) + => Load().LoadedPlugins.FirstOrDefault(p => p.Manifest.Id.Equals(type, StringComparison.OrdinalIgnoreCase)); + + private static AgentTypeInfo ToTypeInfo(LoadedPlugin plugin) + { + PluginManifest manifest = plugin.Manifest; + return new AgentTypeInfo + { + Type = manifest.Id, + Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name, + Description = manifest.Description, + Origin = "plugin", + Version = manifest.Version, + Folder = plugin.FolderName, + Inputs = manifest.Inputs.Select(ToPort).ToList(), + Outputs = manifest.Outputs.Select(ToPort).ToList(), + Credentials = manifest.Credentials, + }; + } + + private static PortInfo ToPort(PluginPort port) + => new() + { + Name = port.Name, + Type = port.Type, + Description = port.Description, + Required = port.Required, + }; + + private static AgentTypeInfo Clone(AgentTypeInfo source) + => new() + { + Type = source.Type, + Name = source.Name, + Description = source.Description, + Origin = source.Origin, + Version = source.Version, + Folder = source.Folder, + Overridden = source.Overridden, + Handler = source.Handler, + Inputs = source.Inputs, + Outputs = source.Outputs, + Credentials = source.Credentials, + }; +} diff --git a/PluginHost/PluginOptions.cs b/PluginHost/PluginOptions.cs new file mode 100644 index 0000000..9198598 --- /dev/null +++ b/PluginHost/PluginOptions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Configuration; + +namespace MAF1.Plugins; + +public sealed class PluginOptions +{ + public string Directory { get; set; } = "plugins"; + public int DefaultTimeoutSeconds { get; set; } = 180; + + public string ResolveRoot() + { + if (Path.IsPathRooted(Directory)) + { + return Directory; + } + + return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, Directory)); + } + + public static PluginOptions Load(IConfiguration config) + => config.GetSection("Plugins").Get() ?? new PluginOptions(); +} + +public sealed class LoadedPlugin +{ + public required string FolderName { get; init; } + public required string FolderPath { get; init; } + public required PluginContract.PluginManifest Manifest { get; init; } +} + +public sealed class CatalogIssue +{ + public string Level { get; init; } = "error"; + public string Source { get; init; } = ""; + public string Message { get; init; } = ""; +} diff --git a/PluginHost/PluginProcessRunner.cs b/PluginHost/PluginProcessRunner.cs new file mode 100644 index 0000000..6f9ad07 --- /dev/null +++ b/PluginHost/PluginProcessRunner.cs @@ -0,0 +1,249 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using MAF1.PluginContract; +using MAF1.Utils; + +namespace MAF1.Plugins; + +public sealed class PluginProcessRunner(PluginOptions options, CredentialStore credentials) +{ + public async Task> RunAsync( + LoadedPlugin plugin, + Dictionary inputs, + string? credentialId, + CancellationToken cancellationToken) + { + PluginManifest manifest = plugin.Manifest; + ValidateDeclaredInputs(manifest, inputs); + + Dictionary payload = ResolveCredentials(manifest, credentialId); + PluginRequest request = new() + { + Inputs = inputs, + Credentials = payload, + }; + + int timeoutSeconds = manifest.TimeoutSeconds > 0 ? manifest.TimeoutSeconds : options.DefaultTimeoutSeconds; + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + + ProcessStartInfo start = CreateStartInfo(plugin, payload); + using Process process = new() { StartInfo = start, EnableRaisingEvents = true }; + if (!process.Start()) + { + throw new InvalidOperationException($"无法启动插件 {manifest.Id}:{start.FileName}"); + } + + string json = JsonSerializer.Serialize(request, PluginJson.Options); + await process.StandardInput.WriteAsync(json.AsMemory(), timeout.Token); + await process.StandardInput.FlushAsync(timeout.Token); + process.StandardInput.Close(); + + Task stdoutTask = process.StandardOutput.ReadToEndAsync(timeout.Token); + Task stderrTask = process.StandardError.ReadToEndAsync(timeout.Token); + + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + TryKill(process); + throw new TimeoutException($"插件 {manifest.Id} 超过 {timeoutSeconds} 秒未结束,已终止。"); + } + + string stdout = await stdoutTask; + string stderr = await stderrTask; + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"插件 {manifest.Id} 退出码 {process.ExitCode}。{FormatStd(stderr, stdout)}"); + } + + Dictionary outputs = ParseOutputs(stdout); + ValidateDeclaredOutputs(manifest, outputs); + if (!string.IsNullOrWhiteSpace(stderr)) + { + outputs["_stderr"] = stderr.Trim(); + } + + return outputs; + } + + private Dictionary ResolveCredentials(PluginManifest manifest, string? credentialId) + { + Dictionary map = new(StringComparer.OrdinalIgnoreCase); + foreach (PluginCredentialNeed need in manifest.Credentials) + { + if (need.Type is "openai-compatible" or "llm" || need.Name.Equals("llm", StringComparison.OrdinalIgnoreCase)) + { + CredentialRecord record = credentials.Resolve(credentialId); + if (need.Required && string.IsNullOrWhiteSpace(record.ApiKey)) + { + throw new InvalidOperationException( + $"插件 {manifest.Id} 需要 LLM 凭据,但 `{record.Id}` 没有 API Key。请在环境变量 OPENAI_API_KEY 或 appsettings.json 中配置。"); + } + + map[need.Name] = credentials.ToPayload(record); + } + } + + if (manifest.Credentials.Count == 0) + { + map["llm"] = credentials.ToPayload(credentials.Resolve(credentialId)); + } + + return map; + } + + private ProcessStartInfo CreateStartInfo(LoadedPlugin plugin, Dictionary payload) + { + string command = plugin.Manifest.Launch.Command; + if (!Path.IsPathRooted(command)) + { + string local = Path.Combine(plugin.FolderPath, command); + if (File.Exists(local)) + { + command = local; + } + } + + ProcessStartInfo start = new() + { + FileName = command, + WorkingDirectory = plugin.FolderPath, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardInputEncoding = Encoding.UTF8, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + foreach (string arg in plugin.Manifest.Launch.Args) + { + start.ArgumentList.Add(arg); + } + + start.Environment["MAF1_CONTENT_ROOT"] = AppContext.BaseDirectory; + ApplyDotEnv(plugin.FolderPath, start); + foreach (KeyValuePair pair in plugin.Manifest.Env) + { + start.Environment[pair.Key] = pair.Value; + } + + if (payload.TryGetValue("llm", out PluginCredentialPayload? llm) || payload.Count > 0) + { + llm ??= payload.Values.First(); + if (!string.IsNullOrWhiteSpace(llm.ApiKey)) + { + start.Environment["OPENAI_API_KEY"] = llm.ApiKey; + } + + if (!string.IsNullOrWhiteSpace(llm.Endpoint)) + { + start.Environment["OPENAI_ENDPOINT"] = llm.Endpoint; + } + + if (!string.IsNullOrWhiteSpace(llm.Model)) + { + start.Environment["OPENAI_CHAT_MODEL"] = llm.Model; + } + } + + return start; + } + + private static void ApplyDotEnv(string pluginDir, ProcessStartInfo start) + { + string path = Path.Combine(pluginDir, ".env"); + if (!File.Exists(path)) + { + return; + } + + foreach (string raw in File.ReadAllLines(path)) + { + string line = raw.Trim(); + if (line.Length == 0 || line.StartsWith('#') || !line.Contains('=')) + { + continue; + } + + int split = line.IndexOf('='); + string key = line[..split].Trim(); + string value = line[(split + 1)..].Trim().Trim('"'); + if (!string.IsNullOrWhiteSpace(key)) + { + start.Environment[key] = value; + } + } + } + + private static void ValidateDeclaredInputs(PluginManifest manifest, Dictionary inputs) + { + foreach (PluginPort port in manifest.Inputs.Where(p => p.Required)) + { + if (!inputs.TryGetValue(port.Name, out object? value) || value is null || value.ToString() == "") + { + throw new InvalidOperationException( + $"插件 {manifest.Id} 的必填输入 `{port.Name}` 为空。请在节点配置里填写,或从上一节点连到这个端口。字段名必须和 plugin.json / 插件代码一致。"); + } + } + } + + private static void ValidateDeclaredOutputs(PluginManifest manifest, Dictionary outputs) + { + List missing = manifest.Outputs + .Select(p => p.Name) + .Where(name => !outputs.Keys.Any(k => k.Equals(name, StringComparison.OrdinalIgnoreCase))) + .ToList(); + if (missing.Count > 0) + { + throw new InvalidOperationException( + $"插件 {manifest.Id} 的输出字段与 plugin.json 不一致,缺少:{string.Join(", ", missing)}。请让插件 stdout 的 JSON 字段和清单 outputs 完全对应。"); + } + } + + private static Dictionary ParseOutputs(string stdout) + { + string json = JsonText.UnwrapObject(stdout); + using JsonDocument doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("插件 stdout 必须是一个 JSON 对象。"); + } + + Dictionary map = new(StringComparer.OrdinalIgnoreCase); + foreach (JsonProperty property in doc.RootElement.EnumerateObject()) + { + map[property.Name] = property.Value.Clone(); + } + + return map; + } + + private static string FormatStd(string stderr, string stdout) + { + string err = string.IsNullOrWhiteSpace(stderr) ? "" : $"stderr: {stderr.Trim()}"; + string outText = string.IsNullOrWhiteSpace(stdout) ? "" : $"stdout: {stdout.Trim()}"; + return string.Join(" ", new[] { err, outText }.Where(s => s.Length > 0)); + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + // 进程可能已经退出。 + } + } +} diff --git a/PluginHost/PluginScanner.cs b/PluginHost/PluginScanner.cs new file mode 100644 index 0000000..8fb331d --- /dev/null +++ b/PluginHost/PluginScanner.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using MAF1.PluginContract; + +namespace MAF1.Plugins; + +public sealed class PluginScanner(PluginOptions options) +{ + public PluginScanResult Scan() + { + string root = options.ResolveRoot(); + Directory.CreateDirectory(root); + + List plugins = []; + List issues = []; + HashSet ids = new(StringComparer.OrdinalIgnoreCase); + + foreach (string folder in Directory.GetDirectories(root)) + { + string folderName = Path.GetFileName(folder); + if (folderName.StartsWith('_') || folderName.StartsWith('.')) + { + continue; + } + + string manifestPath = Path.Combine(folder, "plugin.json"); + if (!File.Exists(manifestPath)) + { + issues.Add(new CatalogIssue + { + Level = "warning", + Source = folderName, + Message = "目录里没有 plugin.json,已跳过。", + }); + continue; + } + + PluginManifest? manifest; + try + { + string json = File.ReadAllText(manifestPath); + manifest = JsonSerializer.Deserialize(json, PluginJson.Options); + } + catch (Exception ex) + { + issues.Add(new CatalogIssue + { + Level = "error", + Source = folderName, + Message = $"plugin.json 无法解析:{ex.Message}", + }); + continue; + } + + if (manifest is null || string.IsNullOrWhiteSpace(manifest.Id)) + { + issues.Add(new CatalogIssue + { + Level = "error", + Source = folderName, + Message = "plugin.json 缺少 id。", + }); + continue; + } + + if (string.IsNullOrWhiteSpace(manifest.Launch.Command)) + { + issues.Add(new CatalogIssue + { + Level = "error", + Source = manifest.Id, + Message = "plugin.json 缺少 launch.command,宿主不会替插件拼命令。", + }); + continue; + } + + if (!ids.Add(manifest.Id)) + { + issues.Add(new CatalogIssue + { + Level = "error", + Source = manifest.Id, + Message = $"插件 id `{manifest.Id}` 重复,已忽略目录 {folderName}。", + }); + continue; + } + + if (DuplicatePortNames(manifest.Inputs)) + { + issues.Add(new CatalogIssue { Level = "error", Source = manifest.Id, Message = "inputs 字段名重复。" }); + continue; + } + + if (DuplicatePortNames(manifest.Outputs)) + { + issues.Add(new CatalogIssue { Level = "error", Source = manifest.Id, Message = "outputs 字段名重复。" }); + continue; + } + + plugins.Add(new LoadedPlugin + { + FolderName = folderName, + FolderPath = folder, + Manifest = manifest, + }); + } + + return new PluginScanResult(root, plugins, issues); + } + + private static bool DuplicatePortNames(List ports) + => ports.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1); +} + +public sealed record PluginScanResult( + string Root, + IReadOnlyList Plugins, + IReadOnlyList Issues); diff --git a/Plugins/.gitignore b/Plugins/.gitignore new file mode 100644 index 0000000..3e33fd4 --- /dev/null +++ b/Plugins/.gitignore @@ -0,0 +1,3 @@ +.env +**/bin/ +**/obj/ diff --git a/Plugins/README.md b/Plugins/README.md new file mode 100644 index 0000000..f05e5cc --- /dev/null +++ b/Plugins/README.md @@ -0,0 +1,20 @@ +# 插件目录 + +每个子文件夹是一个插件。软件启动后扫描**执行目录**下的 `plugins/`(即 `MAF1.exe` 旁边),不是源码目录。 + +## 必备文件 + +- `plugin.json`:id、启动命令、凭据声明、inputs、outputs +- `README.md`:给使用者看的说明 +- 可执行文件 / 脚本 / 源码:由 `launch.command` + `launch.args` 原样启动 + +## 凭据(不要写进 plugin.json) + +n8n / Dify 一类产品把 API Key 放在宿主凭据库,节点只声明「我需要哪种凭据」。本项目同样: + +- Key 和 endpoint 配在宿主的环境变量或 `appsettings.json` +- 节点可选 `credentialId`(默认 `llm-default`) +- 启动子进程时注入环境变量,并在 stdin JSON 的 `credentials` 里再传一份 +- 浏览器和流程图 JSON **不会**包含 apiKey + +第三方若要用自己的模型,可在插件目录放 `.env`(不要提交)。节点选中的宿主凭据会覆盖其中的同名变量。 diff --git a/Plugins/file-city/FileCityPlugin.csproj b/Plugins/file-city/FileCityPlugin.csproj new file mode 100644 index 0000000..f9d63d6 --- /dev/null +++ b/Plugins/file-city/FileCityPlugin.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/Plugins/file-city/Program.cs b/Plugins/file-city/Program.cs new file mode 100644 index 0000000..3fe660e --- /dev/null +++ b/Plugins/file-city/Program.cs @@ -0,0 +1,11 @@ +using MAF1.Agents.FileCity; +using MAF1.PluginContract; +using MAF1.Utils; + +PluginRequest request = await PluginStdio.ReadRequestAsync(); +PluginStdio.ApplyCredentialsToEnvironment(request.Credentials); + +AgentFactory factory = new(AgentFactory.Load(PluginStdio.LoadHostConfiguration())); +var result = await FileCityAgent.RunAsync(FileCityAgent.Create(factory), request.Inputs); +await PluginStdio.WriteOutputsAsync(result.Outputs); +return 0; diff --git a/Plugins/file-city/README.md b/Plugins/file-city/README.md new file mode 100644 index 0000000..42c4120 --- /dev/null +++ b/Plugins/file-city/README.md @@ -0,0 +1,34 @@ +# FileCity 插件 + +独立进程。宿主只读取本目录的 `plugin.json`,按 `launch` 原样启动,不会替你拼命令。 + +## 输入 / 输出 + +字段名必须和 `plugin.json` 以及 `Program.cs` 里读写的 JSON 键一致。 + +- 输入 `filePath` +- 输出 `hasValidCities`、`cities`、`reason`、`raw` + +## 凭据 + +不要把 API Key 写进 `plugin.json` 或流程图。本插件声明需要 `openai-compatible` 凭据。运行时宿主会: + +1. 把选中的凭据写入本进程环境变量 `OPENAI_ENDPOINT` / `OPENAI_API_KEY` / `OPENAI_CHAT_MODEL` +2. 通过 stdin JSON 的 `credentials.llm` 再传一份 + +可选:在本目录放 `.env` 作为插件自己的默认环境(适合第三方自带模型)。节点上选中的宿主凭据会覆盖 `.env` 里的同名变量。 + +## 协议 + +stdin: + +```json +{ + "inputs": { "filePath": "Data/cities.txt" }, + "credentials": { + "llm": { "id": "llm-default", "type": "openai-compatible", "endpoint": "...", "apiKey": "...", "model": "..." } + } +} +``` + +stdout:单个 JSON 对象,字段等于 outputs。日志请写 stderr。 diff --git a/Plugins/file-city/plugin.json b/Plugins/file-city/plugin.json new file mode 100644 index 0000000..41140e1 --- /dev/null +++ b/Plugins/file-city/plugin.json @@ -0,0 +1,33 @@ +{ + "id": "fileCity", + "name": "FileCityAgent", + "description": "读取指定文本文件,判断并抽出有效城市名。", + "version": "1.0.0", + "timeoutSeconds": 120, + "launch": { + "command": "dotnet", + "args": ["FileCityPlugin.dll"] + }, + "credentials": [ + { + "name": "llm", + "type": "openai-compatible", + "required": true, + "description": "由宿主注入 OpenAI 兼容的 endpoint / apiKey / model,不要写进本文件。" + } + ], + "inputs": [ + { + "name": "filePath", + "type": "string", + "required": true, + "description": "本地文件路径,例如 Data/cities.txt" + } + ], + "outputs": [ + { "name": "hasValidCities", "type": "bool", "description": "是否存在有效城市" }, + { "name": "cities", "type": "string[]", "description": "有效城市名列表" }, + { "name": "reason", "type": "string", "description": "判断说明" }, + { "name": "raw", "type": "string", "description": "Agent 原始文本" } + ] +} diff --git a/Plugins/weather/Program.cs b/Plugins/weather/Program.cs new file mode 100644 index 0000000..009c736 --- /dev/null +++ b/Plugins/weather/Program.cs @@ -0,0 +1,18 @@ +using MAF1.Agents.Weather; +using MAF1.PluginContract; +using MAF1.Tools; +using MAF1.Utils; +using Microsoft.Extensions.Configuration; + +PluginRequest request = await PluginStdio.ReadRequestAsync(); +PluginStdio.ApplyCredentialsToEnvironment(request.Credentials); + +IConfiguration config = PluginStdio.LoadHostConfiguration(); +AgentFactory factory = new(AgentFactory.Load(config)); +WeatherOptions weatherOptions = config.GetSection("Weather").Get() ?? new WeatherOptions(); +using HttpClient http = WeatherTools.CreateHttpClient(); +var result = await WeatherAgent.RunAsync( + WeatherAgent.Create(factory, new WeatherTools(weatherOptions, http)), + request.Inputs); +await PluginStdio.WriteOutputsAsync(result.Outputs); +return 0; diff --git a/Plugins/weather/README.md b/Plugins/weather/README.md new file mode 100644 index 0000000..2fae3c4 --- /dev/null +++ b/Plugins/weather/README.md @@ -0,0 +1,12 @@ +# Weather 插件 + +独立进程。启动命令只看 `plugin.json` 的 `launch`。 + +## 输入 / 输出 + +- 输入 `cities`(字符串数组,与代码、清单同名) +- 输出 `summary` + +## 凭据 + +LLM 的 endpoint / apiKey 由宿主注入,不出现在流程图端口上。天气 HTTP 配置走宿主 `appsettings.json`(通过环境变量 `MAF1_CONTENT_ROOT` 定位)。 diff --git a/Plugins/weather/WeatherPlugin.csproj b/Plugins/weather/WeatherPlugin.csproj new file mode 100644 index 0000000..f9d63d6 --- /dev/null +++ b/Plugins/weather/WeatherPlugin.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/Plugins/weather/plugin.json b/Plugins/weather/plugin.json new file mode 100644 index 0000000..c49990d --- /dev/null +++ b/Plugins/weather/plugin.json @@ -0,0 +1,30 @@ +{ + "id": "weather", + "name": "WeatherAgent", + "description": "按城市列表查询天气并汇总。", + "version": "1.0.0", + "timeoutSeconds": 180, + "launch": { + "command": "dotnet", + "args": ["WeatherPlugin.dll"] + }, + "credentials": [ + { + "name": "llm", + "type": "openai-compatible", + "required": true, + "description": "由宿主注入 OpenAI 兼容的 endpoint / apiKey / model,不要写进本文件。" + } + ], + "inputs": [ + { + "name": "cities", + "type": "string[]", + "required": true, + "description": "要查询的城市名列表" + } + ], + "outputs": [ + { "name": "summary", "type": "string", "description": "天气汇总文本" } + ] +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..c3366b6 --- /dev/null +++ b/Program.cs @@ -0,0 +1,47 @@ +using MAF1; +using MAF1.Utils; +using MAF1.Web; + +WindowsConsole.EnableUtf8(); + +if (args.Length > 0 && args[0] is "node" or "edge" or "--cli" or "-h" or "--help") +{ + await CliHost.RunAsync(args); + return; +} + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls("http://127.0.0.1:5288"); +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; +}); +builder.Services.AddSingleton(); + +WebApplication app = builder.Build(); +app.UseDefaultFiles(); +app.UseStaticFiles(); + +app.MapGet("/api/catalog", (AgentRuntime runtime) => +{ + var snapshot = runtime.Catalog.Load(); + return new + { + pluginsRoot = snapshot.PluginsRoot, + system = snapshot.System, + plugins = snapshot.Plugins, + nodes = snapshot.Nodes, + issues = snapshot.Issues, + }; +}); +app.MapGet("/api/credentials", (AgentRuntime runtime) => runtime.Credentials.ListPublic()); +app.MapGet("/api/status", (AgentRuntime runtime) => new +{ + weatherProvider = runtime.WeatherProvider, + pluginsRoot = runtime.PluginsRoot, +}); +app.MapPost("/api/run", (WorkflowGraph graph, AgentRuntime runtime, CancellationToken cancellationToken) + => runtime.Runner.RunAsync(graph, cancellationToken)); + +Console.WriteLine("可视化编排: http://127.0.0.1:5288"); +app.Run(); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..81fbea7 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,32 @@ +{ + "profiles": { + "designer": { + "commandName": "Project", + "applicationUrl": "http://127.0.0.1:5288", + "environmentVariables": { + "ASPNETCORE_URLS": "http://127.0.0.1:5288", + "OPENAI_ENDPOINT": "https://api.deepseek.com", + "OPENAI_API_KEY": "sk-b31330cf3a414d86ab614b9dfb3650c3", + "OPENAI_CHAT_MODEL": "deepseek-v4-flash" + } + }, + "node": { + "commandName": "Project", + "commandLineArgs": "node Data/cities.txt", + "environmentVariables": { + "OPENAI_ENDPOINT": "https://api.deepseek.com", + "OPENAI_API_KEY": "sk-b31330cf3a414d86ab614b9dfb3650c3", + "OPENAI_CHAT_MODEL": "deepseek-v4-flash" + } + }, + "edge": { + "commandName": "Project", + "commandLineArgs": "edge Data/cities.txt", + "environmentVariables": { + "OPENAI_ENDPOINT": "https://api.deepseek.com", + "OPENAI_API_KEY": "sk-b31330cf3a414d86ab614b9dfb3650c3", + "OPENAI_CHAT_MODEL": "deepseek-v4-flash" + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..c08b820 --- /dev/null +++ b/README.md @@ -0,0 +1,421 @@ +# MAF1 — 多智能体工作流编排 + +MAF1 是一个基于 **.NET 10** 和 **Microsoft Agents AI** 的多智能体(Multi-Agent)演示项目。它把「读文件抽城市 → 查天气」这条业务链路做成两种用法: + +1. **可视化编排器**:浏览器里拖节点、连线、配条件,然后一键运行。 +2. **命令行工作流**:用 Microsoft Agents AI Workflows 对比「条件写在节点里」和「条件写在边上」两种编排方式。 + +内置 Agent 在进程内执行;同一套能力也可以以 **独立进程插件** 的形式出现在画布上。LLM 的 endpoint / API Key / 模型由宿主注入,**不会**画成节点端口,也不会写进流程图 JSON。 + +默认打开可视化界面: + +--- + +## 功能概览 + +- **可视化工作流**:左侧节点面板(系统节点 + 扫描到的插件)、画布连线、右侧检查器、底部运行日志。 +- **系统节点**:`fileCity-system`(读文本抽城市)、`weather-system`(按城市查天气并汇总)。 +- **进程外插件**:扫描输出目录 `plugins/*/plugin.json`,按 `launch` 启动子进程,stdin / stdout 走 JSON。 +- **凭据隔离**:宿主配置 LLM;节点只声明需要哪种凭据(默认 `llm-default`)。浏览器拿不到原始 Key。 +- **边条件**:连线可设 `hasValidCities` / `!hasValidCities`,不满足则跳过下游节点。 +- **CLI 对照**:`node` 模式把判断放在 Gate 节点内;`edge` 模式把判断写在 Workflow 边上。 +- **天气数据源**:默认 [wttr.in](https://wttr.in),可改为 OpenWeatherMap。 + +--- + +## 技术栈 + +| 部分 | 说明 | +|------|------| +| 运行时 | .NET 10(`net10.0`) | +| 宿主 | ASP.NET Core Web SDK,Minimal API + 静态文件 | +| 编排库 | `Microsoft.Agents.AI.Workflows` 1.19.0 | +| LLM | `Azure.AI.OpenAI` + `Microsoft.Agents.AI.OpenAI`(兼容 OpenAI / Azure OpenAI / DeepSeek 等) | +| 前端 | `wwwroot` 下原生 HTML / CSS / JS,无 npm 依赖 | +| 插件 | 独立控制台进程,协议见 `MAF1.Core/PluginContract` | + +解决方案文件:`MAF1.slnx`(包含宿主、`MAF1.Core`、两个插件工程)。 + +--- + +## 仓库结构 + +``` +MAF1/ +├── MAF1.csproj # 宿主:Web UI + CLI +├── MAF1.slnx +├── Program.cs # 有 CLI 参数则走命令行,否则启动 Web +├── CliHost.cs +├── appsettings.json # LLM / 插件 / 天气配置 +├── app.manifest # Windows 控制台 UTF-8 +├── Properties/launchSettings.json +├── Data/ # 示例文本 +│ ├── cities.txt +│ └── not-cities.txt +├── Web/ # 目录、图执行、运行时组装 +├── PluginHost/ # 扫描插件、起进程、注入凭据 +├── Workflows/ # CLI 用的 Microsoft Agents AI Workflow +├── Orchestration/ # CLI 事件流式输出 +├── wwwroot/ # 可视化编排界面 +├── MAF1.Core/ # 共享:Agent、Tool、LLM、插件协议 +│ ├── Agents/FileCity/ +│ ├── Agents/Weather/ +│ ├── Tools/ +│ ├── Utils/AgentFactory.cs +│ └── PluginContract/ +└── plugins/ + ├── README.md # 插件目录约定 + ├── file-city/ # FileCity 独立进程 + └── weather/ # Weather 独立进程 +``` + +构建宿主时会编译两个插件,并把输出复制到宿主 `bin/.../plugins/file-city` 与 `plugins/weather`。**运行时扫描的是可执行文件旁边的 `plugins/`,不是源码树。** + +--- + +## 环境要求 + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- 一台 OpenAI 兼容的 Chat Completions 服务(默认配置指向 DeepSeek) +- 访问天气接口的网络(wttr.in 或 OpenWeatherMap) + +--- + +## 快速开始 + +### 1. 还原并编译 + +```bash +cd 仓库根目录 +dotnet restore +dotnet build +``` + +### 2. 配置模型 + +不要把 API Key 提交进 Git。任选一种方式: + +**方式 A:环境变量(推荐)** + +```powershell +$env:OPENAI_API_KEY = "你的密钥" +$env:OPENAI_ENDPOINT = "https://api.deepseek.com" +$env:OPENAI_CHAT_MODEL = "deepseek-v4-flash" +``` + +也识别: + +| 变量 | 含义 | +|------|------| +| `OPENAI_API_KEY` / `AZURE_OPENAI_API_KEY` | 密钥 | +| `OPENAI_ENDPOINT` / `OPENAI_BASE_URL` / `AZURE_OPENAI_ENDPOINT` | 服务地址 | +| `OPENAI_CHAT_MODEL` / `AZURE_OPENAI_DEPLOYMENT_NAME` | 模型名或 Azure 部署名 | + +配置优先级:环境变量会覆盖 `appsettings.json` 里空的或未填的对应项(见 `AgentFactory.Load`)。 + +**方式 B:`appsettings.json` 的 `Llm` 节点** + +```json +"Llm": { + "ApiKey": "", + "Endpoint": "https://api.deepseek.com", + "Model": "deepseek-v4-flash" +} +``` + +Azure OpenAI:把 Endpoint 设成 Azure 资源地址,模型字段填 **部署名**。程序会按 URL 判断是否走 Azure 客户端。 + +Visual Studio / `dotnet run --launch-profile designer` 会读 `Properties/launchSettings.json` 里的环境变量。该文件容易带上真实密钥,**请勿把含 Key 的版本推到远程**。 + +### 3. 启动可视化编排(默认) + +```bash +dotnet run +``` + +浏览器打开 。控制台会打印:`可视化编排: http://127.0.0.1:5288`。 + +界面操作建议: + +1. 点 **示例图**,生成「抽城市 → 查天气」的演示图(含边条件)。 +2. 选中节点,在右侧填 `filePath`(例如 `Data/cities.txt`),或检查端口映射。 +3. 点 **运行**,底部查看每步输入 / 输出。 +4. 改完 `plugins/` 后点 **刷新节点** 重新加载目录。 + +### 4. 命令行工作流 + +```bash +dotnet run -- node Data/cities.txt # 条件在 Gate 节点内部 +dotnet run -- edge Data/cities.txt # 条件在边上分流 +dotnet run -- --help +``` + +无有效城市时可用 `Data/not-cities.txt` 看跳过天气查询的路径。 + +启动配置(`launchSettings.json`): + +| Profile | 作用 | +|---------|------| +| `designer` | Web 编排器(默认) | +| `node` | CLI,节点内判断 | +| `edge` | CLI,边上判断 | + +--- + +## 配置说明 + +`appsettings.json` 会复制到输出目录。主要段落: + +### Llm + +宿主内置 Agent 与默认凭据 `llm-default` 使用这一段(可再被环境变量覆盖)。 + +### Plugins + +| 字段 | 含义 | +|------|------| +| `Directory` | 相对宿主内容根的插件目录,默认 `plugins` | +| `DefaultTimeoutSeconds` | 插件进程默认超时(秒),清单里可单独覆盖 | + +插件进程会收到 `MAF1_CONTENT_ROOT`,用于定位宿主的 `appsettings.json`(天气插件读天气配置)。 + +### Credentials + +节点只声明凭据类型。`llm-default` 的 `source: llm-section` 表示从 `Llm` 段生成,不在 JSON 里再写一份 Key。 + +### Weather + +| 字段 | 含义 | +|------|------| +| `Provider` | `Wttr`(默认)或 `OpenWeather` | +| `Language` | 语言,默认 `zh` | +| `Wttr.UrlTemplate` | `{location}`、`{lang}` 占位 | +| `OpenWeather.UrlTemplate` / `ApiKey` | OpenWeatherMap;需自行申请 Key | + +--- + +## 内置 Agent + +两条链路语义相同,只是执行位置不同。 + +### FileCity(抽城市) + +- 输入:`filePath`(本地文本路径,相对宿主工作目录即可) +- 输出:`hasValidCities`、`cities`、`reason`、`raw` +- 行为:LLM + 读文件工具,从文本里抽出有效城市名 + +系统节点类型:`fileCity-system` +插件 id:`fileCity`(`plugins/file-city`) + +### Weather(查天气) + +- 输入:`cities`(字符串数组) +- 输出:`summary` +- 行为:LLM + HTTP 天气工具,汇总各城市天气 + +系统节点类型:`weather-system` +插件 id:`weather`(`plugins/weather`) + +画布上可以混用:例如系统 FileCity 接到插件 Weather,只要端口名对得上。 + +--- + +## 可视化图模型 + +运行请求体大致为: + +```json +{ + "nodes": [ + { + "id": "n1", + "type": "fileCity-system", + "title": "抽城市", + "x": 80, + "y": 120, + "config": { "filePath": "Data/cities.txt" } + } + ], + "edges": [ + { + "id": "e1", + "from": "n1", + "to": "n2", + "fromPort": "cities", + "toPort": "cities", + "when": "hasValidCities" + } + ] +} +``` + +- **拓扑**:按依赖排序执行;未满足入边条件的节点会被标记为跳过。 +- **端口**:`fromPort` → `toPort` 把上游输出接到下游输入;未接线的必填项可写在 `config`。 +- **边条件 `when`**(当前实现): + - 空:始终通过 + - `hasValidCities`:上游输出该布尔为真 + - `!hasValidCities`:为假 + - 其它字符串:视为通过 + +--- + +## HTTP API + +端口固定:`http://127.0.0.1:5288`(`Program.cs`)。 + +| 方法 | 路径 | 说明 | +|------|------|------| +| `GET` | `/api/catalog` | 系统节点 + 插件节点、扫描问题、`pluginsRoot` | +| `GET` | `/api/credentials` | 对外凭据列表(不含原始 Key) | +| `GET` | `/api/status` | 当前天气 Provider、插件根路径 | +| `POST` | `/api/run` | Body 为工作流图 JSON,返回逐步 `steps` | + +JSON 使用 camelCase。静态站点来自 `wwwroot`。 + +--- + +## 插件机制 + +约定详见 [`plugins/README.md`](plugins/README.md)。每个子目录一份插件。 + +### 必备文件 + +- `plugin.json`:id、启动命令、凭据声明、inputs、outputs +- `README.md`:给人看的说明 +- 可执行文件:由 `launch.command` + `launch.args` **原样**启动,宿主不替你拼路径 + +示例(FileCity): + +```json +"launch": { + "command": "dotnet", + "args": ["FileCityPlugin.dll"] +} +``` + +工作目录是该插件文件夹;`dotnet FileCityPlugin.dll` 能在该目录下找到 dll。 + +### 进程协议 + +**stdin**(一次请求): + +```json +{ + "inputs": { "filePath": "Data/cities.txt" }, + "credentials": { + "llm": { + "id": "llm-default", + "type": "openai-compatible", + "endpoint": "...", + "apiKey": "...", + "model": "..." + } + } +} +``` + +**stdout**:单个 JSON 对象,字段名与 `outputs` 一致。 +**stderr**:日志。不要把日志打到 stdout,否则会破坏解析。 + +超时:清单 `timeoutSeconds`,否则用宿主 `Plugins:DefaultTimeoutSeconds`(默认 180)。 + +### 凭据注入 + +与 n8n / Dify 类似:Key 只在宿主。运行插件时会: + +1. 写入环境变量 `OPENAI_ENDPOINT` / `OPENAI_API_KEY` / `OPENAI_CHAT_MODEL` +2. 在 stdin JSON 的 `credentials` 里再传一份 + +插件目录可选 `.env` 作为第三方自带模型的默认环境;节点选中的宿主凭据会覆盖同名变量。**不要把 `.env` 提交进仓库。** + +更细的输入输出说明: + +- [`plugins/file-city/README.md`](plugins/file-city/README.md) +- [`plugins/weather/README.md`](plugins/weather/README.md) + +### 自己加插件 + +1. 新建 `plugins/你的插件/`,写 `plugin.json` 和启动程序。 +2. 若要随宿主一起编译,可仿照 `MAF1.csproj` 增加 `ProjectReference`(`ReferenceOutputAssembly=false`)和 `PublishPluginFolders` 复制规则。 +3. `id` 不要与系统节点类型冲突;若同名,目录会标记覆盖关系。 +4. 重启宿主或点「刷新节点」,确认 `/api/catalog` 的 `issues` 为空。 + +--- + +## 架构 + +``` +浏览器 wwwroot + │ REST + ▼ +ASP.NET Minimal API (Program.cs) + │ + ▼ +AgentRuntime + ├── NodeCatalogService 合并系统节点 + PluginScanner + ├── CredentialStore llm-default 等 + ├── ConfigurableWorkflowRunner 拓扑、端口、when、逐步日志 + ├── 系统 Handler FileCity / Weather(进程内) + └── PluginProcessRunner 子进程 stdin/stdout +``` + +CLI 不走画布,直接: + +`CliHost` → `CityWeatherWorkflow`(node / edge)→ `WorkflowOrchestration` 把 Workflow 事件打到控制台。 + +`MAF1.Core` 被宿主与插件共用,避免两套 Agent 逻辑分叉。 + +--- + +## 示例数据 + +`Data/cities.txt`: + +``` +成都 +阿姆斯特丹 +北京 +``` + +`Data/not-cities.txt`:不含有效城市,用于验证「无城市则不查天气」。 + +这些文件会随构建复制到输出目录。CLI 传入相对路径时,请在仓库根目录(或已复制 Data 的输出目录)下运行。 + +--- + +## 常见问题 + +**启动报「还没有配置模型」** +`Llm:ApiKey` 和环境变量都为空。按「快速开始」配置后重启。 + +**画布上看不到插件** +插件必须出现在 **exe 旁边** 的 `plugins/`。先 `dotnet build`,确认 `bin/Debug/net10.0/plugins/file-city/plugin.json` 存在。源码目录里的 `plugins/` 不会被运行时直接扫描。 + +**插件超时或卡住** +加大 `timeoutSeconds`;确认 LLM 与天气 HTTP 可访问;日志看 stderr。 + +**天气失败** +默认走 wttr.in。若被墙或限流,把 `Weather:Provider` 改为 `OpenWeather` 并填写 Key。 + +**Windows 控制台中文乱码** +项目带 `app.manifest` 并在入口启用 UTF-8。若仍乱码,把终端代码页设为 UTF-8。 + +**端口被占用** +当前 URL 写死为 `127.0.0.1:5288`。关掉占用进程,或临时改 `Program.cs` / `launchSettings.json`。 + +**没有自动化测试 / Docker** +仓库目前没有测试项目和容器文件。验证方式:UI 示例图 + CLI `node` / `edge`。 + +--- + +## 安全注意 + +- LLM Key、OpenWeather Key 只放在本机配置或环境变量,不要写进 `plugin.json`、流程图或前端。 +- `/api/credentials` 只返回公开元数据。 +- 若 Key 曾经出现在 `launchSettings.json` 并被提交或分享,请到服务商控制台轮换密钥。 + +--- + +## 许可与定位 + +本仓库是多智能体编排与进程外插件的**可运行演示**,便于对照系统节点与插件、对照 CLI 的 node / edge 两种条件写法。按你自己的许可证要求补充版权声明即可。 diff --git a/Web/AgentCatalog.cs b/Web/AgentCatalog.cs new file mode 100644 index 0000000..51cc007 --- /dev/null +++ b/Web/AgentCatalog.cs @@ -0,0 +1,103 @@ +using MAF1.PluginContract; + +namespace MAF1.Web; + +public sealed class PortInfo +{ + public string Name { get; init; } = ""; + public string Type { get; init; } = ""; + public string Description { get; init; } = ""; + public bool Required { get; init; } +} + +public sealed class AgentTypeInfo +{ + public string Type { get; init; } = ""; + public string Name { get; init; } = ""; + public string Description { get; init; } = ""; + public string Origin { get; set; } = "system"; + public string Version { get; init; } = ""; + public string Folder { get; init; } = ""; + public bool Overridden { get; set; } + + /// + /// 进程内实现。系统节点必填;插件节点为 None,走独立进程。 + /// + public SystemHandler Handler { get; init; } + + public IReadOnlyList Inputs { get; init; } = []; + public IReadOnlyList Outputs { get; init; } = []; + public IReadOnlyList Credentials { get; init; } = []; +} + +public enum SystemHandler +{ + None = 0, + FileCity, + Weather, +} + +public static class AgentCatalog +{ + public static AgentTypeInfo? FindSystem(string type) + => SystemNodes.FirstOrDefault(item => item.Type.Equals(type, StringComparison.OrdinalIgnoreCase)); + + public static IReadOnlyList SystemNodes { get; } = + [ + new() + { + Type = "fileCity-system", + Name = "FileCityAgent-System", + Description = "读取指定文本文件,判断并抽出有效城市名(系统内置实现)。", + Origin = "system", + Handler = SystemHandler.FileCity, + Inputs = + [ + new PortInfo { Name = "filePath", Type = "string", Description = "本地文件路径,例如 Data/cities.txt", Required = true }, + ], + Outputs = + [ + new PortInfo { Name = "hasValidCities", Type = "bool", Description = "是否存在有效城市" }, + new PortInfo { Name = "cities", Type = "string[]", Description = "有效城市名列表" }, + new PortInfo { Name = "reason", Type = "string", Description = "判断说明" }, + new PortInfo { Name = "raw", Type = "string", Description = "Agent 原始文本" }, + ], + Credentials = + [ + new PluginCredentialNeed + { + Name = "llm", + Type = "openai-compatible", + Required = true, + Description = "OpenAI 兼容接口:endpoint + apiKey + model", + }, + ], + }, + new() + { + Type = "weather-system", + Name = "WeatherAgent-System", + Description = "按城市列表查询天气并汇总(系统内置实现)。", + Origin = "system", + Handler = SystemHandler.Weather, + Inputs = + [ + new PortInfo { Name = "cities", Type = "string[]", Description = "要查询的城市名列表", Required = true }, + ], + Outputs = + [ + new PortInfo { Name = "summary", Type = "string", Description = "天气汇总文本" }, + ], + Credentials = + [ + new PluginCredentialNeed + { + Name = "llm", + Type = "openai-compatible", + Required = true, + Description = "OpenAI 兼容接口:endpoint + apiKey + model", + }, + ], + }, + ]; +} diff --git a/Web/AgentRuntime.cs b/Web/AgentRuntime.cs new file mode 100644 index 0000000..97d7ecf --- /dev/null +++ b/Web/AgentRuntime.cs @@ -0,0 +1,41 @@ +using MAF1.Agents.FileCity; +using MAF1.Agents.Weather; +using MAF1.Plugins; +using MAF1.Tools; +using MAF1.Utils; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Configuration; + +namespace MAF1.Web; + +public sealed class AgentRuntime +{ + public AgentRuntime(IConfiguration config) + { + AgentFactory factory = new(AgentFactory.Load(config)); + WeatherOptions weatherOptions = config.GetSection("Weather").Get() ?? new WeatherOptions(); + Http = WeatherTools.CreateHttpClient(); + WeatherTools weatherTools = new(weatherOptions, Http); + FileCity = FileCityAgent.Create(factory); + Weather = WeatherAgent.Create(factory, weatherTools); + PluginOptions pluginOptions = PluginOptions.Load(config); + Credentials = new CredentialStore(config); + Scanner = new PluginScanner(pluginOptions); + Catalog = new NodeCatalogService(Scanner); + PluginRunner = new PluginProcessRunner(pluginOptions, Credentials); + Runner = new ConfigurableWorkflowRunner(FileCity, Weather, Catalog, PluginRunner); + WeatherProvider = weatherOptions.Provider; + PluginsRoot = pluginOptions.ResolveRoot(); + } + + public AIAgent FileCity { get; } + public AIAgent Weather { get; } + public ConfigurableWorkflowRunner Runner { get; } + public NodeCatalogService Catalog { get; } + public PluginScanner Scanner { get; } + public PluginProcessRunner PluginRunner { get; } + public CredentialStore Credentials { get; } + public string WeatherProvider { get; } + public string PluginsRoot { get; } + public HttpClient Http { get; } +} diff --git a/Web/ConfigurableWorkflowRunner.cs b/Web/ConfigurableWorkflowRunner.cs new file mode 100644 index 0000000..48c0cf9 --- /dev/null +++ b/Web/ConfigurableWorkflowRunner.cs @@ -0,0 +1,287 @@ +using System.Text.Json; +using MAF1.Agents; +using MAF1.Agents.FileCity; +using MAF1.Agents.Weather; +using MAF1.PluginContract; +using MAF1.Plugins; +using Microsoft.Agents.AI; + +namespace MAF1.Web; + +public sealed class ConfigurableWorkflowRunner( + AIAgent fileCityAgent, + AIAgent weatherAgent, + NodeCatalogService catalog, + PluginProcessRunner plugins) +{ + private static readonly HashSet ReservedConfigKeys = new(StringComparer.OrdinalIgnoreCase) + { + "credentialId", + }; + + public async Task RunAsync(WorkflowGraph graph, CancellationToken cancellationToken) + { + try + { + Validate(graph); + NodeCatalogSnapshot snapshot = catalog.Load(); + List order = TopologicalOrder(graph); + Dictionary> outputs = []; + List steps = []; + + foreach (string nodeId in order) + { + WorkflowNode node = graph.Nodes.First(n => n.Id == nodeId); + Dictionary inputs = ResolveInputs(graph, node, outputs); + if (!PassEdgeConditions(graph, node, outputs, out string? skipReason)) + { + steps.Add(new NodeRunLog + { + NodeId = node.Id, + Type = node.Type, + Title = node.Title, + Skipped = true, + Message = skipReason, + Inputs = inputs, + }); + continue; + } + + NodeRunLog log = await RunNodeAsync(snapshot, node, inputs, cancellationToken); + steps.Add(log); + outputs[node.Id] = log.Outputs; + } + + return new WorkflowRunResult { Ok = true, Steps = steps }; + } + catch (Exception ex) + { + return new WorkflowRunResult { Ok = false, Error = ex.Message }; + } + } + + private async Task RunNodeAsync( + NodeCatalogSnapshot snapshot, + WorkflowNode node, + Dictionary inputs, + CancellationToken cancellationToken) + { + LoadedPlugin? plugin = snapshot.LoadedPlugins + .FirstOrDefault(p => p.Manifest.Id.Equals(node.Type, StringComparison.OrdinalIgnoreCase)); + if (plugin is not null) + { + return await RunPluginAsync(plugin, node, inputs, cancellationToken); + } + + AgentTypeInfo? system = AgentCatalog.FindSystem(node.Type) + ?? snapshot.System.FirstOrDefault(item => item.Type.Equals(node.Type, StringComparison.OrdinalIgnoreCase)); + if (system is not null) + { + AgentStepResult step = system.Handler switch + { + SystemHandler.FileCity => await FileCityAgent.RunAsync(fileCityAgent, inputs, cancellationToken), + SystemHandler.Weather => await WeatherAgent.RunAsync(weatherAgent, inputs, cancellationToken), + _ => throw new InvalidOperationException( + $"系统节点 `{system.Type}` 在 AgentCatalog 里没有绑定实现。请给它设置 Handler。"), + }; + return ToLog(node, step); + } + + throw new InvalidOperationException( + $"未找到节点类型 `{node.Type}`。系统节点来自 AgentCatalog,插件节点来自 plugins 目录,请点「刷新节点」。"); + } + + private async Task RunPluginAsync( + LoadedPlugin plugin, + WorkflowNode node, + Dictionary inputs, + CancellationToken cancellationToken) + { + Dictionary declared = FilterDeclaredInputs(plugin.Manifest, inputs); + node.Config.TryGetValue("credentialId", out string? credentialId); + Dictionary outputs = await plugins.RunAsync(plugin, declared, credentialId, cancellationToken); + string? stderr = null; + if (outputs.Remove("_stderr", out object? stderrValue)) + { + stderr = stderrValue?.ToString(); + } + + return new NodeRunLog + { + NodeId = node.Id, + Type = node.Type, + Title = node.Title, + Message = stderr, + Inputs = declared, + Outputs = outputs, + }; + } + + private static NodeRunLog ToLog(WorkflowNode node, AgentStepResult step) + => new() + { + NodeId = node.Id, + Type = node.Type, + Title = node.Title, + Message = step.Message, + Inputs = step.Inputs, + Outputs = step.Outputs, + }; + + private static Dictionary FilterDeclaredInputs(PluginManifest manifest, Dictionary inputs) + { + Dictionary declared = new(StringComparer.OrdinalIgnoreCase); + foreach (PluginPort port in manifest.Inputs) + { + if (inputs.TryGetValue(port.Name, out object? value)) + { + declared[port.Name] = value; + } + } + + return declared; + } + + private static void Validate(WorkflowGraph graph) + { + if (graph.Nodes.Count == 0) + { + throw new InvalidOperationException("画布上还没有节点。"); + } + + HashSet ids = graph.Nodes.Select(n => n.Id).ToHashSet(); + foreach (WorkflowEdge edge in graph.Edges) + { + if (!ids.Contains(edge.From) || !ids.Contains(edge.To)) + { + throw new InvalidOperationException("存在指向已删除节点的连线。"); + } + } + } + + private static List TopologicalOrder(WorkflowGraph graph) + { + Dictionary indegree = graph.Nodes.ToDictionary(n => n.Id, _ => 0); + Dictionary> outgoing = graph.Nodes.ToDictionary(n => n.Id, _ => new List()); + foreach (WorkflowEdge edge in graph.Edges) + { + indegree[edge.To]++; + outgoing[edge.From].Add(edge.To); + } + + Queue ready = new(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key)); + List order = []; + while (ready.Count > 0) + { + string id = ready.Dequeue(); + order.Add(id); + foreach (string next in outgoing[id].Distinct()) + { + indegree[next]--; + if (indegree[next] == 0) + { + ready.Enqueue(next); + } + } + } + + if (order.Count != graph.Nodes.Count) + { + throw new InvalidOperationException("工作流存在环,无法运行。"); + } + + return order; + } + + private static Dictionary ResolveInputs( + WorkflowGraph graph, + WorkflowNode node, + Dictionary> outputs) + { + Dictionary inputs = new(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair item in node.Config) + { + if (ReservedConfigKeys.Contains(item.Key) || string.IsNullOrWhiteSpace(item.Value)) + { + continue; + } + + inputs[item.Key] = item.Value; + } + + foreach (WorkflowEdge edge in graph.Edges.Where(e => e.To == node.Id)) + { + if (!outputs.TryGetValue(edge.From, out Dictionary? source)) + { + continue; + } + + if (source.TryGetValue(edge.FromPort, out object? value)) + { + inputs[edge.ToPort] = value; + } + } + + return inputs; + } + + private static bool PassEdgeConditions( + WorkflowGraph graph, + WorkflowNode node, + Dictionary> outputs, + out string? skipReason) + { + List incoming = graph.Edges.Where(e => e.To == node.Id).ToList(); + if (incoming.Count == 0) + { + skipReason = null; + return true; + } + + foreach (WorkflowEdge edge in incoming) + { + if (string.IsNullOrWhiteSpace(edge.When)) + { + continue; + } + + if (!outputs.TryGetValue(edge.From, out Dictionary? source)) + { + skipReason = $"上一节点 {edge.From} 尚未产出结果。"; + return false; + } + + bool hasCities = ReadBool(source, "hasValidCities"); + bool pass = edge.When switch + { + "hasValidCities" => hasCities, + "!hasValidCities" => !hasCities, + _ => true, + }; + if (!pass) + { + skipReason = $"连线条件 {edge.When} 不满足,跳过本节点。"; + return false; + } + } + + skipReason = null; + return true; + } + + private static bool ReadBool(Dictionary map, string key) + { + if (!map.TryGetValue(key, out object? value) || value is null) + { + return false; + } + + return value switch + { + bool b => b, + JsonElement el when el.ValueKind == JsonValueKind.True => true, + JsonElement el when el.ValueKind == JsonValueKind.False => false, + _ => bool.TryParse(value.ToString(), out bool parsed) && parsed, + }; + } +} diff --git a/Web/WorkflowModels.cs b/Web/WorkflowModels.cs new file mode 100644 index 0000000..362ff41 --- /dev/null +++ b/Web/WorkflowModels.cs @@ -0,0 +1,45 @@ +namespace MAF1.Web; + +public sealed class WorkflowGraph +{ + public List Nodes { get; set; } = []; + public List Edges { get; set; } = []; +} + +public sealed class WorkflowNode +{ + public string Id { get; set; } = ""; + public string Type { get; set; } = ""; + public string Title { get; set; } = ""; + public double X { get; set; } + public double Y { get; set; } + public Dictionary Config { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed class WorkflowEdge +{ + public string Id { get; set; } = ""; + public string From { get; set; } = ""; + public string To { get; set; } = ""; + public string FromPort { get; set; } = ""; + public string ToPort { get; set; } = ""; + public string? When { get; set; } +} + +public sealed class WorkflowRunResult +{ + public bool Ok { get; set; } + public string? Error { get; set; } + public List Steps { get; set; } = []; +} + +public sealed class NodeRunLog +{ + public string NodeId { get; set; } = ""; + public string Type { get; set; } = ""; + public string Title { get; set; } = ""; + public bool Skipped { get; set; } + public string? Message { get; set; } + public Dictionary Inputs { get; set; } = []; + public Dictionary Outputs { get; set; } = []; +} diff --git a/Workflows/CityGateExecutor.cs b/Workflows/CityGateExecutor.cs new file mode 100644 index 0000000..6bbcab4 --- /dev/null +++ b/Workflows/CityGateExecutor.cs @@ -0,0 +1,47 @@ +using MAF1.Agents.FileCity; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace MAF1.Workflows; + +internal sealed class CityGateExecutor() : ChatProtocolExecutor( + "CityGate", + new ChatProtocolExecutorOptions { AutoSendTurnToken = false }) +{ + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder builder) + { + return base.ConfigureProtocol(builder) + .SendsMessage() + .SendsMessage() + .YieldsOutput(); + } + + protected override async ValueTask TakeTurnAsync( + List messages, + IWorkflowContext context, + bool? emitEvents, + CancellationToken cancellationToken) + { + string text = string.Join( + Environment.NewLine, + messages + .Where(message => !string.IsNullOrWhiteSpace(message.Text)) + .Select(message => message.Text)); + + CityExtraction extraction = FileCityAgent.Parse(text); + if (!extraction.HasValidCities) + { + string reason = string.IsNullOrWhiteSpace(extraction.Reason) + ? "文件里没有可查询的城市。" + : extraction.Reason; + await context.YieldOutputAsync( + $"没有有效城市,工作流结束,不执行 WeatherAgent。{reason}", + cancellationToken); + return; + } + + string prompt = $"请查询这些城市的天气:{string.Join("、", extraction.Cities)}"; + await context.SendMessageAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken: cancellationToken); + await context.SendMessageAsync(new TurnToken(emitEvents), cancellationToken: cancellationToken); + } +} diff --git a/Workflows/CityParseExecutor.cs b/Workflows/CityParseExecutor.cs new file mode 100644 index 0000000..5005b57 --- /dev/null +++ b/Workflows/CityParseExecutor.cs @@ -0,0 +1,33 @@ +using MAF1.Agents.FileCity; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace MAF1.Workflows; + +/// +/// 边条件模式:这里只解析,不做 if。走哪条边由 AddEdge 的 condition 决定。 +/// +internal sealed class CityParseExecutor() : ChatProtocolExecutor( + "CityParse", + new ChatProtocolExecutorOptions { AutoSendTurnToken = false }) +{ + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder builder) + { + return base.ConfigureProtocol(builder).SendsMessage(); + } + + protected override async ValueTask TakeTurnAsync( + List messages, + IWorkflowContext context, + bool? emitEvents, + CancellationToken cancellationToken) + { + string text = string.Join( + Environment.NewLine, + messages + .Where(message => !string.IsNullOrWhiteSpace(message.Text)) + .Select(message => message.Text)); + + await context.SendMessageAsync(FileCityAgent.Parse(text), cancellationToken: cancellationToken); + } +} diff --git a/Workflows/CityWeatherWorkflow.cs b/Workflows/CityWeatherWorkflow.cs new file mode 100644 index 0000000..8ffdfec --- /dev/null +++ b/Workflows/CityWeatherWorkflow.cs @@ -0,0 +1,51 @@ +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using MAF1.Agents.FileCity; + +namespace MAF1.Workflows; + +public static class CityWeatherWorkflow +{ + public static Workflow BuildNodeCondition(AIAgent fileCityAgent, AIAgent weatherAgent) + { + (ExecutorBinding fileCity, ExecutorBinding weather) = BindAgents(fileCityAgent, weatherAgent); + CityGateExecutor gate = new(); + + return new WorkflowBuilder(fileCity) + .AddEdge(fileCity, gate) + .AddEdge(gate, weather) + .WithOutputFrom(gate, weather) + .WithName("CityWeather-Node") + .Build(); + } + + public static Workflow BuildEdgeCondition(AIAgent fileCityAgent, AIAgent weatherAgent) + { + (ExecutorBinding fileCity, ExecutorBinding weather) = BindAgents(fileCityAgent, weatherAgent); + CityParseExecutor parse = new(); + ToWeatherPromptExecutor toWeather = new(); + SkipWeatherExecutor skip = new(); + + return new WorkflowBuilder(fileCity) + .AddEdge(fileCity, parse) + .AddEdge(parse, toWeather, extraction => extraction is { HasValidCities: true }) + .AddEdge(parse, skip, extraction => extraction is not { HasValidCities: true }) + .AddEdge(toWeather, weather) + .WithOutputFrom(skip, weather) + .WithName("CityWeather-Edge") + .Build(); + } + + private static (ExecutorBinding FileCity, ExecutorBinding Weather) BindAgents(AIAgent fileCityAgent, AIAgent weatherAgent) + { + AIAgentHostOptions agentOptions = new() + { + ForwardIncomingMessages = false, + ReassignOtherAgentsAsUsers = true, + EmitAgentUpdateEvents = true, + EmitAgentResponseEvents = true, + }; + + return (fileCityAgent.BindAsExecutor(agentOptions), weatherAgent.BindAsExecutor(agentOptions)); + } +} diff --git a/Workflows/SkipWeatherExecutor.cs b/Workflows/SkipWeatherExecutor.cs new file mode 100644 index 0000000..7193e25 --- /dev/null +++ b/Workflows/SkipWeatherExecutor.cs @@ -0,0 +1,28 @@ +using MAF1.Agents.FileCity; +using Microsoft.Agents.AI.Workflows; + +namespace MAF1.Workflows; + +/// +/// 只有「没有城市」那条边会进到这里,所以这里不再判断。 +/// +internal sealed class SkipWeatherExecutor() : Executor("SkipWeather") +{ + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder builder) + { + return base.ConfigureProtocol(builder).YieldsOutput(); + } + + public override async ValueTask HandleAsync( + CityExtraction extraction, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + string reason = string.IsNullOrWhiteSpace(extraction.Reason) + ? "文件里没有可查询的城市。" + : extraction.Reason; + await context.YieldOutputAsync( + $"没有有效城市,工作流结束,不执行 WeatherAgent。{reason}", + cancellationToken); + } +} diff --git a/Workflows/ToWeatherPromptExecutor.cs b/Workflows/ToWeatherPromptExecutor.cs new file mode 100644 index 0000000..3dd852c --- /dev/null +++ b/Workflows/ToWeatherPromptExecutor.cs @@ -0,0 +1,28 @@ +using MAF1.Agents.FileCity; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace MAF1.Workflows; + +/// +/// 只有「有城市」那条边会进到这里,所以这里不再判断。 +/// +internal sealed class ToWeatherPromptExecutor() : Executor("ToWeatherPrompt") +{ + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder builder) + { + return base.ConfigureProtocol(builder) + .SendsMessage() + .SendsMessage(); + } + + public override async ValueTask HandleAsync( + CityExtraction extraction, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + string prompt = $"请查询这些城市的天气:{string.Join("、", extraction.Cities)}"; + await context.SendMessageAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken: cancellationToken); + await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken); + } +} diff --git a/app.manifest b/app.manifest new file mode 100644 index 0000000..46243c7 --- /dev/null +++ b/app.manifest @@ -0,0 +1,9 @@ + + + + + + UTF-8 + + + diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..9385819 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,29 @@ +{ + "Llm": { + "ApiKey": "", + "Endpoint": "https://api.deepseek.com", + "Model": "deepseek-v4-flash" + }, + "Plugins": { + "Directory": "plugins", + "DefaultTimeoutSeconds": 180 + }, + "Credentials": { + "llm-default": { + "type": "openai-compatible", + "name": "系统默认模型", + "source": "llm-section" + } + }, + "Weather": { + "Provider": "Wttr", + "Language": "zh", + "Wttr": { + "UrlTemplate": "https://wttr.in/{location}?lang={lang}&format=3" + }, + "OpenWeather": { + "UrlTemplate": "https://api.openweathermap.org/data/2.5/weather?q={location}&appid={apiKey}&units=metric&lang={lang}", + "ApiKey": "" + } + } +} diff --git a/wwwroot/css/app.css b/wwwroot/css/app.css new file mode 100644 index 0000000..9ff88e4 --- /dev/null +++ b/wwwroot/css/app.css @@ -0,0 +1,245 @@ +:root { + --bg: #0f1419; + --panel: #171e26; + --line: #2a3542; + --text: #e8eef4; + --muted: #8b9aab; + --accent: #4ea1ff; + --file: #3dd6c6; + --weather: #f0b429; +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + height: 100%; + font-family: "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} + +.top { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--line); +} + + .top h1 { + margin: 0 0 4px; + font-size: 18px; + } + + .top p { + margin: 0; + color: var(--muted); + font-size: 13px; + } + +.actions { + display: flex; + gap: 8px; +} + +button { + background: #243040; + color: var(--text); + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; +} + + button.primary { + background: var(--accent); + color: #061018; + border: 0; + font-weight: 600; + } + +main { + display: grid; + grid-template-columns: 220px 1fr 280px; + min-height: calc(100vh - 220px); +} + +.palette, .inspector { + background: var(--panel); + padding: 16px; + border-right: 1px solid var(--line); +} + +.inspector { + border-right: 0; + border-left: 1px solid var(--line); +} + +h2 { + margin: 0 0 10px; + font-size: 14px; +} + +.hint { + color: var(--muted); + font-size: 12px; +} + +.palette-item .tag { + display: inline-block; + margin-left: 6px; + font-size: 10px; + color: var(--accent); + border: 1px solid var(--line); + border-radius: 999px; + padding: 0 6px; +} + +.palette-group { + margin-top: 12px; + color: var(--muted); + font-size: 12px; +} + +.issue { + font-size: 11px; + color: #f0b429; + margin: 6px 0; +} + +.node.plugin { + border-top: 3px solid #9b8afb; +} + +.node.overridden { + opacity: 0.95; +} + + +.canvas-wrap { + position: relative; + overflow: hidden; + background: radial-gradient(#1c252f 1px, transparent 1px) 0 0 / 18px 18px; +} + +#canvas { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; +} + +#wires { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 1; + pointer-events: none; +} + +.node { + position: absolute; + width: 240px; + background: #1b2430; + border: 1px solid var(--line); + border-radius: 12px; + pointer-events: auto; +} + + .node.selected { + border-color: var(--accent); + } + + .node.fileCity { + border-top: 3px solid var(--file); + } + + .node.weather { + border-top: 3px solid var(--weather); + } + +.node-head { + padding: 10px 12px; + font-weight: 600; + cursor: move; + user-select: none; +} + +.ports { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + padding: 0 10px 12px; +} + +.port-col { + display: flex; + flex-direction: column; + gap: 6px; +} + +.port { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--muted); +} + + .port.out { + justify-content: flex-end; + } + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + flex: 0 0 10px; + cursor: pointer; +} + +.inspector label { + display: block; + font-size: 12px; + color: var(--muted); + margin: 10px 0 4px; +} + +.inspector input, .inspector select { + width: 100%; + background: #10161d; + color: var(--text); + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px; +} + +.log { + border-top: 1px solid var(--line); + padding: 12px 20px 20px; +} + + .log pre { + background: #10161d; + border-radius: 8px; + padding: 12px; + min-height: 80px; + white-space: pre-wrap; + font-size: 12px; + } + +.wire { + fill: none; + stroke: var(--accent); + stroke-width: 2; + pointer-events: stroke; +} + + .wire.selected { + stroke: var(--weather); + } diff --git a/wwwroot/index.html b/wwwroot/index.html new file mode 100644 index 0000000..ea62ffb --- /dev/null +++ b/wwwroot/index.html @@ -0,0 +1,42 @@ + + + + + + MAF 工作流编排 + + + +
+
+

工作流编排

+

系统节点和 plugins 目录里的插件会一起出现。LLM 的 endpoint / API Key 由宿主注入,不会画成输入端口。

+
+
+ + + +
+
+
+ +
+ +
+
+ +
+
+

运行结果

+
尚未运行。
+
+ + + diff --git a/wwwroot/js/app.js b/wwwroot/js/app.js new file mode 100644 index 0000000..5076cff --- /dev/null +++ b/wwwroot/js/app.js @@ -0,0 +1,338 @@ +const state = { + catalog: [], + system: [], + plugins: [], + issues: [], + credentials: [], + pluginsRoot: "", + nodes: [], + edges: [], + selected: null, + pending: null, +}; + +const canvas = document.getElementById("canvas"); +const wires = document.getElementById("wires"); +const inspector = document.getElementById("inspector"); +const logEl = document.getElementById("log"); + +function uid(prefix) { + return prefix + Math.random().toString(36).slice(2, 8); +} + +function typeInfo(type) { + return state.catalog.find((item) => item.type === type) + || state.system.find((item) => item.type === type) + || state.plugins.find((item) => item.type === type); +} + +function pickNode(list, inputName) { + return (list || []).find((item) => item.inputs?.some((p) => p.name === inputName)); +} + +function exampleGraph() { + const fileInfo = pickNode(state.system, "filePath") || pickNode(state.catalog, "filePath"); + const weatherInfo = pickNode(state.plugins, "cities") || pickNode(state.system, "cities") || pickNode(state.catalog, "cities"); + if (!fileInfo || !weatherInfo) { + logEl.textContent = "示例图需要目录里有「filePath 输入」和「cities 输入」的节点,请先刷新节点。"; + return { nodes: [], edges: [] }; + } + const fileConfig = { credentialId: "llm-default" }; + if (fileInfo.inputs?.some((p) => p.name === "filePath")) { + fileConfig.filePath = "Data/cities.txt"; + } + return { + nodes: [ + { id: "n1", type: fileInfo.type, title: fileInfo.name, x: 60, y: 80, config: fileConfig }, + { id: "n2", type: weatherInfo.type, title: weatherInfo.name, x: 420, y: 80, config: { credentialId: "llm-default" } }, + ], + edges: [ + { id: "e1", from: "n1", to: "n2", fromPort: "cities", toPort: "cities", when: "hasValidCities" }, + ], + }; +} + +function renderGroup(title, items) { + if (!items.length) { + return `
${title}

`; + } + return `
${title}
` + items.map((item) => ` +
+ ${item.name}${item.origin === "plugin" ? "插件" : "系统"} +
${item.description}
+
`).join(""); +} + +function renderPalette() { + const issues = (state.issues || []).map((item) => `
[${item.level}] ${item.source}: ${item.message}
`).join(""); + document.getElementById("palette").innerHTML = + `

${state.pluginsRoot || ""}

` + + renderGroup("系统节点", state.system) + + renderGroup("插件节点", state.plugins) + + (issues ? `
扫描说明
${issues}` : ""); + document.querySelectorAll(".palette-item").forEach((el) => { + el.onclick = () => addNode(el.dataset.type); + }); +} + +function addNode(type) { + const info = typeInfo(type); + if (!info) { + logEl.textContent = `没有类型 ${type},请先刷新节点。`; + return; + } + const config = { credentialId: "llm-default" }; + if (info.inputs?.some((p) => p.name === "filePath")) { + config.filePath = "Data/cities.txt"; + } + state.nodes.push({ + id: uid("n"), + type, + title: info.name, + x: 80 + state.nodes.length * 40, + y: 70 + state.nodes.length * 30, + config, + }); + render(); +} + +function render() { + canvas.innerHTML = ""; + for (const node of state.nodes) { + const info = typeInfo(node.type); + if (!info) { + continue; + } + const el = document.createElement("div"); + el.className = `node ${node.type} ${info.origin}` + (state.selected?.kind === "node" && state.selected.id === node.id ? " selected" : ""); + el.style.left = node.x + "px"; + el.style.top = node.y + "px"; + el.innerHTML = ` +
${node.title}
+
+
+ ${info.inputs.map((p) => `
${p.name}
`).join("")} +
+
+ ${info.outputs.map((p) => `
${p.name}
`).join("")} +
+
`; + el.querySelector(".node-head").onmousedown = (e) => startDrag(e, node); + el.onclick = (e) => { + if (e.target.classList.contains("dot")) return; + state.selected = { kind: "node", id: node.id }; + render(); + }; + canvas.appendChild(el); + } + canvas.querySelectorAll(".dot").forEach((dot) => { + dot.onclick = (e) => { + e.stopPropagation(); + onPort(dot.dataset.node, dot.dataset.port, dot.dataset.dir); + }; + }); + drawWires(); + renderInspector(); +} + +function startDrag(e, node) { + e.preventDefault(); + const wrap = document.querySelector(".canvas-wrap").getBoundingClientRect(); + const ox = e.clientX - wrap.left - node.x; + const oy = e.clientY - wrap.top - node.y; + const el = e.currentTarget.parentElement; + const move = (ev) => { + node.x = Math.max(0, ev.clientX - wrap.left - ox); + node.y = Math.max(0, ev.clientY - wrap.top - oy); + if (el) { + el.style.left = node.x + "px"; + el.style.top = node.y + "px"; + } + drawWires(); + }; + const up = () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); +} + +function onPort(nodeId, port, dir) { + if (dir === "out") { + state.pending = { nodeId, port }; + logEl.textContent = `已选输出 ${nodeTitle(nodeId)}.${port},请点击下一个节点的输入端口。`; + return; + } + if (!state.pending) { + logEl.textContent = "请先点击上一节点的输出端口,再点本节点输入。"; + return; + } + if (state.pending.nodeId === nodeId) return; + state.edges.push({ + id: uid("e"), + from: state.pending.nodeId, + to: nodeId, + fromPort: state.pending.port, + toPort: port, + when: state.pending.port === "cities" || port === "cities" ? "hasValidCities" : "", + }); + state.pending = null; + state.selected = { kind: "edge", id: state.edges.at(-1).id }; + render(); +} + +function nodeTitle(id) { + return state.nodes.find((n) => n.id === id)?.title ?? id; +} + +function drawWires() { + const wrap = document.querySelector(".canvas-wrap").getBoundingClientRect(); + wires.innerHTML = ""; + for (const edge of state.edges) { + const from = document.querySelector(`.dot[data-node="${edge.from}"][data-port="${edge.fromPort}"][data-dir="out"]`); + const to = document.querySelector(`.dot[data-node="${edge.to}"][data-port="${edge.toPort}"][data-dir="in"]`); + if (!from || !to) continue; + const a = from.getBoundingClientRect(); + const b = to.getBoundingClientRect(); + const x1 = a.left + a.width / 2 - wrap.left; + const y1 = a.top + a.height / 2 - wrap.top; + const x2 = b.left + b.width / 2 - wrap.left; + const y2 = b.top + b.height / 2 - wrap.top; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", `M ${x1} ${y1} C ${x1 + 80} ${y1}, ${x2 - 80} ${y2}, ${x2} ${y2}`); + path.setAttribute("class", "wire" + (state.selected?.kind === "edge" && state.selected.id === edge.id ? " selected" : "")); + path.style.pointerEvents = "stroke"; + path.onclick = () => { + state.selected = { kind: "edge", id: edge.id }; + render(); + }; + wires.appendChild(path); + } +} + +function renderInspector() { + if (!state.selected) { + inspector.innerHTML = `

选中节点可填固定输入;选中连线可改「上一节点输出 → 下一节点输入」和运行条件。

`; + return; + } + if (state.selected.kind === "node") { + const node = state.nodes.find((n) => n.id === state.selected.id); + const info = typeInfo(node.type); + inspector.innerHTML = ` +
${info.name} ${info.origin === "plugin" ? "插件" : "系统"}
+

${info.description}

+ + + + +

endpoint / API Key 由宿主注入进程,不会出现在输入端口或导出的流程图里。

+ ${info.inputs.map((p) => ` + + + `).join("")} +

若该输入已从上一节点连线,运行时以连线为准。

+ `; + inspector.querySelector("#title").oninput = (e) => { node.title = e.target.value; }; + inspector.querySelector("#credentialId")?.addEventListener("change", (e) => { node.config.credentialId = e.target.value; }); + inspector.querySelectorAll("[data-config]").forEach((input) => { + input.oninput = () => { node.config[input.dataset.config] = input.value; }; + }); + inspector.querySelector("#delNode").onclick = () => { + state.edges = state.edges.filter((e) => e.from !== node.id && e.to !== node.id); + state.nodes = state.nodes.filter((n) => n.id !== node.id); + state.selected = null; + render(); + }; + return; + } + const edge = state.edges.find((e) => e.id === state.selected.id); + const from = state.nodes.find((n) => n.id === edge.from); + const to = state.nodes.find((n) => n.id === edge.to); + const fromInfo = typeInfo(from.type); + const toInfo = typeInfo(to.type); + inspector.innerHTML = ` +
连线映射
+

上一节点输出 → 下一节点输入

+ +
${from.title}
+ + + +
${to.title}
+ + + + + `; + inspector.querySelector("#fromPort").onchange = (e) => { edge.fromPort = e.target.value; drawWires(); }; + inspector.querySelector("#toPort").onchange = (e) => { edge.toPort = e.target.value; }; + inspector.querySelector("#when").onchange = (e) => { edge.when = e.target.value; }; + inspector.querySelector("#delEdge").onclick = () => { + state.edges = state.edges.filter((item) => item.id !== edge.id); + state.selected = null; + render(); + }; +} + +async function runGraph() { + logEl.textContent = "运行中…"; + const res = await fetch("/api/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nodes: state.nodes, edges: state.edges }), + }); + const data = await res.json(); + if (!data.ok) { + logEl.textContent = data.error ?? "运行失败"; + return; + } + logEl.textContent = data.steps.map((step) => { + const head = `${step.title} (${step.type})${step.skipped ? " [跳过]" : ""}`; + const inputs = JSON.stringify(step.inputs, null, 2); + const outputs = JSON.stringify(step.outputs, null, 2); + return `${head}\n输入:\n${inputs}\n输出:\n${outputs}\n${step.message ?? ""}`; + }).join("\n\n-----\n\n"); +} + +async function loadCatalog() { + const [catalog, credentials] = await Promise.all([ + (await fetch("/api/catalog")).json(), + (await fetch("/api/credentials")).json(), + ]); + state.system = catalog.system ?? []; + state.plugins = catalog.plugins ?? []; + state.catalog = catalog.nodes ?? []; + state.issues = catalog.issues ?? []; + state.pluginsRoot = catalog.pluginsRoot ?? ""; + state.credentials = credentials ?? []; + renderPalette(); + if (state.nodes.length) { + render(); + } +} + +document.getElementById("btnExample").onclick = () => { + const graph = exampleGraph(); + state.nodes = graph.nodes; + state.edges = graph.edges; + state.selected = { kind: "edge", id: "e1" }; + render(); +}; +document.getElementById("btnRun").onclick = runGraph; +document.getElementById("btnRefresh").onclick = async () => { + logEl.textContent = "正在重新扫描系统节点和 plugins 目录…"; + await loadCatalog(); + logEl.textContent = `已刷新。系统 ${state.system.length} 个,插件 ${state.plugins.length} 个。`; +}; + +window.addEventListener("resize", drawWires); + +(async function init() { + await loadCatalog(); + document.getElementById("btnExample").click(); +})();