初始提交
This commit is contained in:
+369
@@ -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
|
||||
+93
@@ -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<WeatherOptions>() ?? 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<string> 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
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
成都
|
||||
阿姆斯特丹
|
||||
北京
|
||||
@@ -0,0 +1,3 @@
|
||||
香蕉
|
||||
hello
|
||||
不是城市
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MAF1.Agents;
|
||||
|
||||
public sealed class AgentStepResult
|
||||
{
|
||||
public string Message { get; init; } = "";
|
||||
public Dictionary<string, object?> Inputs { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, object?> Outputs { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal static class AgentInputs
|
||||
{
|
||||
public static string? ReadString(IReadOnlyDictionary<string, object?> 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<string> ReadStringList(IReadOnlyDictionary<string, object?> 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<string> typed)
|
||||
{
|
||||
return typed.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.Trim()).ToList();
|
||||
}
|
||||
|
||||
if (value is IEnumerable<object> objects)
|
||||
{
|
||||
return objects.Select(o => o?.ToString())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s!.Trim())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return Split(value.ToString());
|
||||
}
|
||||
|
||||
private static List<string> Split(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return text.Split(['、', ',', ';', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -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<string> Cities { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = "";
|
||||
}
|
||||
@@ -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<AgentStepResult> RunAsync(
|
||||
AIAgent agent,
|
||||
IReadOnlyDictionary<string, object?> 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<string, object?> { ["filePath"] = filePath },
|
||||
Outputs = new Dictionary<string, object?>
|
||||
{
|
||||
["hasValidCities"] = extraction.HasValidCities,
|
||||
["cities"] = extraction.Cities,
|
||||
["reason"] = extraction.Reason,
|
||||
["raw"] = response.Text,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static CityExtraction Parse(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
CityExtraction? result = JsonSerializer.Deserialize<CityExtraction>(
|
||||
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 };
|
||||
}
|
||||
@@ -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<AgentStepResult> RunAsync(
|
||||
AIAgent agent,
|
||||
IReadOnlyDictionary<string, object?> inputs,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<string> 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<string, object?> { ["cities"] = cities },
|
||||
Outputs = new Dictionary<string, object?> { ["summary"] = response.Text },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>MAF1</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.1.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.19.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<string, string> Env { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<PluginCredentialNeed> Credentials { get; set; } = [];
|
||||
public List<PluginPort> Inputs { get; set; } = [];
|
||||
public List<PluginPort> Outputs { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class PluginLaunch
|
||||
{
|
||||
public string Command { get; set; } = "";
|
||||
public List<string> 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<string, object?> Inputs { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[JsonPropertyName("credentials")]
|
||||
public Dictionary<string, PluginCredentialPayload> 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<string, string> 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,
|
||||
};
|
||||
}
|
||||
@@ -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<PluginRequest> 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<PluginRequest>(json, PluginJson.Options);
|
||||
return request ?? new PluginRequest();
|
||||
}
|
||||
|
||||
public static async Task WriteOutputsAsync(Dictionary<string, object?> 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<string, PluginCredentialPayload> credentials)
|
||||
{
|
||||
foreach (KeyValuePair<string, PluginCredentialPayload> 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<string, string> 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<string, object?> 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<string> ReadStringList(Dictionary<string, object?> 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<string> typed)
|
||||
{
|
||||
return typed.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.Trim()).ToList();
|
||||
}
|
||||
|
||||
return Split(value.ToString());
|
||||
}
|
||||
|
||||
private static List<string> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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<string> 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<string> 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<string> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<AITool>? tools = null)
|
||||
{
|
||||
return _chatClient.AsAIAgent(instructions: instructions, name: name, tools: tools);
|
||||
}
|
||||
|
||||
public static LlmOptions Load(IConfiguration config)
|
||||
{
|
||||
LlmOptions options = config.GetSection("Llm").Get<LlmOptions>() ?? 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 "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="plugins\file-city\**" />
|
||||
<Compile Remove="plugins\weather\**" />
|
||||
<Compile Remove="MAF1.Core\**" />
|
||||
<Content Remove="plugins\file-city\**" />
|
||||
<Content Remove="plugins\weather\**" />
|
||||
<Content Remove="MAF1.Core\**" />
|
||||
<EmbeddedResource Remove="plugins\file-city\**" />
|
||||
<EmbeddedResource Remove="plugins\weather\**" />
|
||||
<EmbeddedResource Remove="MAF1.Core\**" />
|
||||
<None Remove="plugins\file-city\**" />
|
||||
<None Remove="plugins\weather\**" />
|
||||
<None Remove="MAF1.Core\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.19.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="MAF1.Core\MAF1.Core.csproj" />
|
||||
<ProjectReference Include="plugins\file-city\FileCityPlugin.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="plugins\weather\WeatherPlugin.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Data\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PublishPluginFolders" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<_FileCityOut>$(MSBuildThisFileDirectory)plugins\file-city\bin\$(Configuration)\net10.0</_FileCityOut>
|
||||
<_WeatherOut>$(MSBuildThisFileDirectory)plugins\weather\bin\$(Configuration)\net10.0</_WeatherOut>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<_FileCityFiles Include="$(_FileCityOut)\**\*" />
|
||||
<_WeatherFiles Include="$(_WeatherOut)\**\*" />
|
||||
</ItemGroup>
|
||||
<MakeDir Directories="$(OutputPath)plugins\file-city;$(OutputPath)plugins\weather" />
|
||||
<Copy SourceFiles="@(_FileCityFiles)" DestinationFiles="@(_FileCityFiles->'$(OutputPath)plugins\file-city\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
<Copy SourceFiles="@(_WeatherFiles)" DestinationFiles="@(_WeatherFiles->'$(OutputPath)plugins\weather\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
<Solution>
|
||||
<Folder Name="/plugins/" />
|
||||
<Folder Name="/plugins/file-city/">
|
||||
<Project Path="plugins/file-city/FileCityPlugin.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/plugins/weather/">
|
||||
<Project Path="plugins/weather/WeatherPlugin.csproj" />
|
||||
</Folder>
|
||||
<Project Path="MAF1.Core/MAF1.Core.csproj" />
|
||||
<Project Path="MAF1.csproj" />
|
||||
</Solution>
|
||||
@@ -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<string>() is string text:
|
||||
Console.WriteLine();
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"[工作流 / {output.ExecutorId}] {text}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> 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<string, CredentialRecord> _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<CredentialPublicView> 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)) ?? "";
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using MAF1.PluginContract;
|
||||
using MAF1.Web;
|
||||
|
||||
namespace MAF1.Plugins;
|
||||
|
||||
public sealed class NodeCatalogSnapshot
|
||||
{
|
||||
public string PluginsRoot { get; init; } = "";
|
||||
public List<AgentTypeInfo> System { get; init; } = [];
|
||||
public List<AgentTypeInfo> Plugins { get; init; } = [];
|
||||
public List<AgentTypeInfo> Nodes { get; init; } = [];
|
||||
public List<CatalogIssue> Issues { get; init; } = [];
|
||||
public IReadOnlyList<LoadedPlugin> LoadedPlugins { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class NodeCatalogService(PluginScanner scanner)
|
||||
{
|
||||
public NodeCatalogSnapshot Load()
|
||||
{
|
||||
PluginScanResult scan = scanner.Scan();
|
||||
List<CatalogIssue> issues = [.. scan.Issues];
|
||||
List<AgentTypeInfo> system = AgentCatalog.SystemNodes
|
||||
.Select(Clone)
|
||||
.ToList();
|
||||
foreach (AgentTypeInfo item in system)
|
||||
{
|
||||
item.Origin = "system";
|
||||
}
|
||||
|
||||
Dictionary<string, AgentTypeInfo> systemByType = system.ToDictionary(n => n.Type, StringComparer.OrdinalIgnoreCase);
|
||||
List<AgentTypeInfo> 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<string, AgentTypeInfo> 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,
|
||||
};
|
||||
}
|
||||
@@ -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<PluginOptions>() ?? 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; } = "";
|
||||
}
|
||||
@@ -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<Dictionary<string, object?>> RunAsync(
|
||||
LoadedPlugin plugin,
|
||||
Dictionary<string, object?> inputs,
|
||||
string? credentialId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PluginManifest manifest = plugin.Manifest;
|
||||
ValidateDeclaredInputs(manifest, inputs);
|
||||
|
||||
Dictionary<string, PluginCredentialPayload> 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<string> stdoutTask = process.StandardOutput.ReadToEndAsync(timeout.Token);
|
||||
Task<string> 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<string, object?> outputs = ParseOutputs(stdout);
|
||||
ValidateDeclaredOutputs(manifest, outputs);
|
||||
if (!string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
outputs["_stderr"] = stderr.Trim();
|
||||
}
|
||||
|
||||
return outputs;
|
||||
}
|
||||
|
||||
private Dictionary<string, PluginCredentialPayload> ResolveCredentials(PluginManifest manifest, string? credentialId)
|
||||
{
|
||||
Dictionary<string, PluginCredentialPayload> 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<string, PluginCredentialPayload> 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<string, string> 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<string, object?> 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<string, object?> outputs)
|
||||
{
|
||||
List<string> 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<string, object?> 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<string, object?> 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
|
||||
{
|
||||
// 进程可能已经退出。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<LoadedPlugin> plugins = [];
|
||||
List<CatalogIssue> issues = [];
|
||||
HashSet<string> 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<PluginManifest>(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<PluginPort> ports)
|
||||
=> ports.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1);
|
||||
}
|
||||
|
||||
public sealed record PluginScanResult(
|
||||
string Root,
|
||||
IReadOnlyList<LoadedPlugin> Plugins,
|
||||
IReadOnlyList<CatalogIssue> Issues);
|
||||
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
**/bin/
|
||||
**/obj/
|
||||
@@ -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`(不要提交)。节点选中的宿主凭据会覆盖其中的同名变量。
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\MAF1.Core\MAF1.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="plugin.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="README.md">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
@@ -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。
|
||||
@@ -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 原始文本" }
|
||||
]
|
||||
}
|
||||
@@ -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<WeatherOptions>() ?? 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;
|
||||
@@ -0,0 +1,12 @@
|
||||
# Weather 插件
|
||||
|
||||
独立进程。启动命令只看 `plugin.json` 的 `launch`。
|
||||
|
||||
## 输入 / 输出
|
||||
|
||||
- 输入 `cities`(字符串数组,与代码、清单同名)
|
||||
- 输出 `summary`
|
||||
|
||||
## 凭据
|
||||
|
||||
LLM 的 endpoint / apiKey 由宿主注入,不出现在流程图端口上。天气 HTTP 配置走宿主 `appsettings.json`(通过环境变量 `MAF1_CONTENT_ROOT` 定位)。
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\MAF1.Core\MAF1.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="plugin.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="README.md">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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": "天气汇总文本" }
|
||||
]
|
||||
}
|
||||
+47
@@ -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<AgentRuntime>();
|
||||
|
||||
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();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
默认打开可视化界面:<http://127.0.0.1:5288>
|
||||
|
||||
---
|
||||
|
||||
## 功能概览
|
||||
|
||||
- **可视化工作流**:左侧节点面板(系统节点 + 扫描到的插件)、画布连线、右侧检查器、底部运行日志。
|
||||
- **系统节点**:`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>。控制台会打印:`可视化编排: 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 两种条件写法。按你自己的许可证要求补充版权声明即可。
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// 进程内实现。系统节点必填;插件节点为 None,走独立进程。
|
||||
/// </summary>
|
||||
public SystemHandler Handler { get; init; }
|
||||
|
||||
public IReadOnlyList<PortInfo> Inputs { get; init; } = [];
|
||||
public IReadOnlyList<PortInfo> Outputs { get; init; } = [];
|
||||
public IReadOnlyList<PluginCredentialNeed> 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<AgentTypeInfo> 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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<WeatherOptions>() ?? 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; }
|
||||
}
|
||||
@@ -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<string> ReservedConfigKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"credentialId",
|
||||
};
|
||||
|
||||
public async Task<WorkflowRunResult> RunAsync(WorkflowGraph graph, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Validate(graph);
|
||||
NodeCatalogSnapshot snapshot = catalog.Load();
|
||||
List<string> order = TopologicalOrder(graph);
|
||||
Dictionary<string, Dictionary<string, object?>> outputs = [];
|
||||
List<NodeRunLog> steps = [];
|
||||
|
||||
foreach (string nodeId in order)
|
||||
{
|
||||
WorkflowNode node = graph.Nodes.First(n => n.Id == nodeId);
|
||||
Dictionary<string, object?> 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<NodeRunLog> RunNodeAsync(
|
||||
NodeCatalogSnapshot snapshot,
|
||||
WorkflowNode node,
|
||||
Dictionary<string, object?> 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<NodeRunLog> RunPluginAsync(
|
||||
LoadedPlugin plugin,
|
||||
WorkflowNode node,
|
||||
Dictionary<string, object?> inputs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Dictionary<string, object?> declared = FilterDeclaredInputs(plugin.Manifest, inputs);
|
||||
node.Config.TryGetValue("credentialId", out string? credentialId);
|
||||
Dictionary<string, object?> 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<string, object?> FilterDeclaredInputs(PluginManifest manifest, Dictionary<string, object?> inputs)
|
||||
{
|
||||
Dictionary<string, object?> 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<string> 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<string> TopologicalOrder(WorkflowGraph graph)
|
||||
{
|
||||
Dictionary<string, int> indegree = graph.Nodes.ToDictionary(n => n.Id, _ => 0);
|
||||
Dictionary<string, List<string>> outgoing = graph.Nodes.ToDictionary(n => n.Id, _ => new List<string>());
|
||||
foreach (WorkflowEdge edge in graph.Edges)
|
||||
{
|
||||
indegree[edge.To]++;
|
||||
outgoing[edge.From].Add(edge.To);
|
||||
}
|
||||
|
||||
Queue<string> ready = new(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
|
||||
List<string> 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<string, object?> ResolveInputs(
|
||||
WorkflowGraph graph,
|
||||
WorkflowNode node,
|
||||
Dictionary<string, Dictionary<string, object?>> outputs)
|
||||
{
|
||||
Dictionary<string, object?> inputs = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, string> 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<string, object?>? 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<string, Dictionary<string, object?>> outputs,
|
||||
out string? skipReason)
|
||||
{
|
||||
List<WorkflowEdge> 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<string, object?>? 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<string, object?> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace MAF1.Web;
|
||||
|
||||
public sealed class WorkflowGraph
|
||||
{
|
||||
public List<WorkflowNode> Nodes { get; set; } = [];
|
||||
public List<WorkflowEdge> 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<string, string> 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<NodeRunLog> 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<string, object?> Inputs { get; set; } = [];
|
||||
public Dictionary<string, object?> Outputs { get; set; } = [];
|
||||
}
|
||||
@@ -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<ChatMessage>()
|
||||
.SendsMessage<TurnToken>()
|
||||
.YieldsOutput<string>();
|
||||
}
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using MAF1.Agents.FileCity;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace MAF1.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// 边条件模式:这里只解析,不做 if。走哪条边由 AddEdge 的 condition 决定。
|
||||
/// </summary>
|
||||
internal sealed class CityParseExecutor() : ChatProtocolExecutor(
|
||||
"CityParse",
|
||||
new ChatProtocolExecutorOptions { AutoSendTurnToken = false })
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder builder)
|
||||
{
|
||||
return base.ConfigureProtocol(builder).SendsMessage<CityExtraction>();
|
||||
}
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<CityExtraction>(parse, toWeather, extraction => extraction is { HasValidCities: true })
|
||||
.AddEdge<CityExtraction>(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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MAF1.Agents.FileCity;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace MAF1.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// 只有「没有城市」那条边会进到这里,所以这里不再判断。
|
||||
/// </summary>
|
||||
internal sealed class SkipWeatherExecutor() : Executor<CityExtraction>("SkipWeather")
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder builder)
|
||||
{
|
||||
return base.ConfigureProtocol(builder).YieldsOutput<string>();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MAF1.Agents.FileCity;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace MAF1.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// 只有「有城市」那条边会进到这里,所以这里不再判断。
|
||||
/// </summary>
|
||||
internal sealed class ToWeatherPromptExecutor() : Executor<CityExtraction>("ToWeatherPrompt")
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder builder)
|
||||
{
|
||||
return base.ConfigureProtocol(builder)
|
||||
.SendsMessage<ChatMessage>()
|
||||
.SendsMessage<TurnToken>();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="MAF1"/>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MAF 工作流编排</title>
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<div>
|
||||
<h1>工作流编排</h1>
|
||||
<p>系统节点和 plugins 目录里的插件会一起出现。LLM 的 endpoint / API Key 由宿主注入,不会画成输入端口。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="btnRefresh">刷新节点</button>
|
||||
<button type="button" id="btnExample">示例图</button>
|
||||
<button type="button" id="btnRun" class="primary">运行</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<aside class="palette">
|
||||
<h2>节点</h2>
|
||||
<p class="hint">点击添加到画布</p>
|
||||
<div id="palette"></div>
|
||||
</aside>
|
||||
<section class="canvas-wrap">
|
||||
<svg id="wires"></svg>
|
||||
<div id="canvas"></div>
|
||||
</section>
|
||||
<aside class="inspector">
|
||||
<h2>节点配置</h2>
|
||||
<div id="inspector"><p class="hint">选中节点或连线后在这里改输入 / 输出映射。</p></div>
|
||||
</aside>
|
||||
</main>
|
||||
<section class="log">
|
||||
<h2>运行结果</h2>
|
||||
<pre id="log">尚未运行。</pre>
|
||||
</section>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 `<div class="palette-group">${title}</div><p class="hint">无</p>`;
|
||||
}
|
||||
return `<div class="palette-group">${title}</div>` + items.map((item) => `
|
||||
<div class="palette-item" data-type="${item.type}">
|
||||
<strong>${item.name}</strong><span class="tag">${item.origin === "plugin" ? "插件" : "系统"}</span>
|
||||
<div class="hint">${item.description}</div>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
function renderPalette() {
|
||||
const issues = (state.issues || []).map((item) => `<div class="issue">[${item.level}] ${item.source}: ${item.message}</div>`).join("");
|
||||
document.getElementById("palette").innerHTML =
|
||||
`<p class="hint">${state.pluginsRoot || ""}</p>` +
|
||||
renderGroup("系统节点", state.system) +
|
||||
renderGroup("插件节点", state.plugins) +
|
||||
(issues ? `<div class="palette-group">扫描说明</div>${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 = `
|
||||
<div class="node-head">${node.title}</div>
|
||||
<div class="ports">
|
||||
<div class="port-col">
|
||||
${info.inputs.map((p) => `<div class="port in"><i class="dot" data-node="${node.id}" data-port="${p.name}" data-dir="in"></i>${p.name}</div>`).join("")}
|
||||
</div>
|
||||
<div class="port-col">
|
||||
${info.outputs.map((p) => `<div class="port out">${p.name}<i class="dot" data-node="${node.id}" data-port="${p.name}" data-dir="out"></i></div>`).join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
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 = `<p class="hint">选中节点可填固定输入;选中连线可改「上一节点输出 → 下一节点输入」和运行条件。</p>`;
|
||||
return;
|
||||
}
|
||||
if (state.selected.kind === "node") {
|
||||
const node = state.nodes.find((n) => n.id === state.selected.id);
|
||||
const info = typeInfo(node.type);
|
||||
inspector.innerHTML = `
|
||||
<div><strong>${info.name}</strong> <span class="tag">${info.origin === "plugin" ? "插件" : "系统"}</span></div>
|
||||
<p class="hint">${info.description}</p>
|
||||
<label>显示名</label>
|
||||
<input id="title" value="${node.title}" />
|
||||
<label>LLM 凭据</label>
|
||||
<select id="credentialId">${(state.credentials || []).map((c) => `<option value="${c.id}" ${ (node.config.credentialId || "llm-default") === c.id ? "selected" : ""}>${c.name}(${c.hasApiKey ? "已配置 Key" : "缺少 Key"})</option>`).join("")}</select>
|
||||
<p class="hint">endpoint / API Key 由宿主注入进程,不会出现在输入端口或导出的流程图里。</p>
|
||||
${info.inputs.map((p) => `
|
||||
<label>固定输入 ${p.name}(${p.type})${p.required ? " *" : ""}</label>
|
||||
<input data-config="${p.name}" value="${node.config[p.name] ?? ""}" placeholder="${p.description}" />
|
||||
`).join("")}
|
||||
<p class="hint">若该输入已从上一节点连线,运行时以连线为准。</p>
|
||||
<button type="button" id="delNode">删除节点</button>`;
|
||||
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 = `
|
||||
<div><strong>连线映射</strong></div>
|
||||
<p class="hint">上一节点输出 → 下一节点输入</p>
|
||||
<label>来自</label>
|
||||
<div>${from.title}</div>
|
||||
<label>输出端口</label>
|
||||
<select id="fromPort">${fromInfo.outputs.map((p) => `<option ${p.name === edge.fromPort ? "selected" : ""}>${p.name}</option>`).join("")}</select>
|
||||
<label>到达</label>
|
||||
<div>${to.title}</div>
|
||||
<label>输入端口</label>
|
||||
<select id="toPort">${toInfo.inputs.map((p) => `<option ${p.name === edge.toPort ? "selected" : ""}>${p.name}</option>`).join("")}</select>
|
||||
<label>运行条件</label>
|
||||
<select id="when">
|
||||
<option value="">始终执行下一节点</option>
|
||||
<option value="hasValidCities" ${edge.when === "hasValidCities" ? "selected" : ""}>仅当 hasValidCities 为 true</option>
|
||||
<option value="!hasValidCities" ${edge.when === "!hasValidCities" ? "selected" : ""}>仅当 hasValidCities 为 false</option>
|
||||
</select>
|
||||
<button type="button" id="delEdge">删除连线</button>`;
|
||||
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();
|
||||
})();
|
||||
Reference in New Issue
Block a user