初始提交
This commit is contained in:
@@ -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; } = [];
|
||||
}
|
||||
Reference in New Issue
Block a user