diff --git a/MAF1.Core/Agents/AgentStepResult.cs b/MAF1.Core/Agents/AgentStepResult.cs index 72a806a..ab11c7e 100644 --- a/MAF1.Core/Agents/AgentStepResult.cs +++ b/MAF1.Core/Agents/AgentStepResult.cs @@ -2,6 +2,10 @@ using System.Text.Json; namespace MAF1.Agents; +/// +/// 一次 Agent 执行的统一结果。网页编排器和进程外插件都用这套字段,方便画布把输出接到下一节点。 +/// Message 是给日志看的原文;Outputs 才是下游节点真正读取的端口数据。 +/// public sealed class AgentStepResult { public string Message { get; init; } = ""; @@ -9,8 +13,12 @@ public sealed class AgentStepResult public Dictionary Outputs { get; init; } = new(StringComparer.OrdinalIgnoreCase); } +/// +/// 从图 JSON / 插件 stdin 读入参。值可能是 string、数组,也可能是 System.Text.Json 反序列化后的 JsonElement。 +/// public static class AgentInputs { + /// 按端口名读一个字符串;没有该键则返回 null。 public static string? ReadString(IReadOnlyDictionary inputs, string key) { if (!inputs.TryGetValue(key, out object? value) || value is null) @@ -26,6 +34,7 @@ public static class AgentInputs return value.ToString(); } + /// 读城市列表:支持 JSON 数组、["a","b"] 风格,或用顿号/逗号分隔的一串文字。 public static List ReadStringList(IReadOnlyDictionary inputs, string key) { if (!inputs.TryGetValue(key, out object? value) || value is null) diff --git a/MAF1.Core/Agents/FileCity/CityExtraction.cs b/MAF1.Core/Agents/FileCity/CityExtraction.cs index 097f2d1..916d0e8 100644 --- a/MAF1.Core/Agents/FileCity/CityExtraction.cs +++ b/MAF1.Core/Agents/FileCity/CityExtraction.cs @@ -2,6 +2,9 @@ using System.Text.Json.Serialization; namespace MAF1.Agents.FileCity; +/// +/// FileCity Agent 约定输出的 JSON 形状。hasValidCities 会驱动边条件,也会触发「缺城市时等人确认」。 +/// public sealed class CityExtraction { [JsonPropertyName("hasValidCities")] diff --git a/MAF1.Core/Agents/FileCity/FileCityAgent.cs b/MAF1.Core/Agents/FileCity/FileCityAgent.cs index b610454..5eb60ed 100644 --- a/MAF1.Core/Agents/FileCity/FileCityAgent.cs +++ b/MAF1.Core/Agents/FileCity/FileCityAgent.cs @@ -6,8 +6,13 @@ using Microsoft.Extensions.AI; namespace MAF1.Agents.FileCity; +/// +/// 「读文件抽城市」Agent。学习路径:Create 注册工具和系统提示 → RunAsync 调模型 → Parse 把模型文本收成结构化结果。 +/// CLI、网页系统节点、file-city 插件三处都调用同一套逻辑,避免各写一份 prompt。 +/// public static class FileCityAgent { + /// 创建带 ReadTextFile 工具的聊天 Agent。模型必须先读文件,不能凭文件名猜内容。 public static AIAgent Create(AgentFactory factory) { return factory.CreateAgent( @@ -26,6 +31,7 @@ public static class FileCityAgent tools: [AIFunctionFactory.Create(FileTools.ReadTextFile)]); } + /// 跑一轮:读 filePath 输入,把解析后的 cities / hasValidCities 放进 Outputs。 public static async Task RunAsync( AIAgent agent, IReadOnlyDictionary inputs, @@ -50,6 +56,7 @@ public static class FileCityAgent }; } + /// 从模型原文里抠 JSON。模型偶尔会包 Markdown 代码块,所以先走 JsonText.UnwrapObject。 public static CityExtraction Parse(string text) { try diff --git a/MAF1.Core/Agents/Weather/WeatherAgent.cs b/MAF1.Core/Agents/Weather/WeatherAgent.cs index 489d464..522bc47 100644 --- a/MAF1.Core/Agents/Weather/WeatherAgent.cs +++ b/MAF1.Core/Agents/Weather/WeatherAgent.cs @@ -5,8 +5,12 @@ using Microsoft.Extensions.AI; namespace MAF1.Agents.Weather; +/// +/// 「按城市查天气」Agent。真正的 HTTP 查询在 WeatherTools.GetWeather,模型只负责决定调几次工具并写成中文摘要。 +/// public static class WeatherAgent { + /// 创建带 GetWeather 工具的 Agent。instructions 要求每个城市都调工具,禁止编造气温。 public static AIAgent Create(AgentFactory factory, WeatherTools weatherTools) { return factory.CreateAgent( @@ -19,6 +23,7 @@ public static class WeatherAgent tools: [AIFunctionFactory.Create(weatherTools.GetWeather)]); } + /// 需要上游把 cities 连到本节点。空列表直接抛错,避免无意义地打 LLM。 public static async Task RunAsync( AIAgent agent, IReadOnlyDictionary inputs, diff --git a/MAF1.Core/Decisions/DecisionModels.cs b/MAF1.Core/Decisions/DecisionModels.cs index 8bba520..9ef4f7b 100644 --- a/MAF1.Core/Decisions/DecisionModels.cs +++ b/MAF1.Core/Decisions/DecisionModels.cs @@ -2,6 +2,10 @@ using MAF1.Agents; namespace MAF1.Decisions; +/// +/// 工作流暂停时发给人或 UI 的确认请求。必须带 DefaultOptionId,超时就采用它。 +/// CLI 和 Web 共用这一套,避免两套文案分叉。 +/// public sealed class DecisionRequest { public string Id { get; set; } = ""; @@ -14,6 +18,7 @@ public sealed class DecisionRequest public DateTimeOffset Deadline { get; set; } } +/// 一个可选项。RequiresText=true 时前端会显示输入框(例如改填城市)。 public sealed class DecisionOption { public string Id { get; set; } = ""; @@ -24,6 +29,7 @@ public sealed class DecisionOption public bool IsDefault { get; set; } } +/// 人的选择或超时结果。TimedOut=true 时一律按默认方案处理,忽略 Text。 public sealed class DecisionAnswer { public string OptionId { get; set; } = ""; @@ -31,12 +37,14 @@ public sealed class DecisionAnswer public bool TimedOut { get; set; } } +/// 缺城市场景下的两个方案 id,前后端都硬编码这两个字符串。 public static class DecisionOptionIds { public const string Stop = "stop"; public const string QueryCities = "query-cities"; } +/// 工厂:生成「没有读到城市」这一类确认。网页 runner 和 CLI executor 都调用 Create。 public static class EmptyCityDecision { public static DecisionRequest Create(string nodeId, string? reason, int timeoutSeconds) @@ -72,6 +80,7 @@ public static class EmptyCityDecision }; } + /// 用户选「改查其它城市」且解析出至少一个城市名时返回 true。 public static bool TryContinueWithCities(DecisionAnswer answer, out List cities) { cities = []; diff --git a/MAF1.Core/PluginContract/PluginManifest.cs b/MAF1.Core/PluginContract/PluginManifest.cs index 2592323..17be9b6 100644 --- a/MAF1.Core/PluginContract/PluginManifest.cs +++ b/MAF1.Core/PluginContract/PluginManifest.cs @@ -2,6 +2,10 @@ using System.Text.Json.Serialization; namespace MAF1.PluginContract; +/// +/// 每个插件目录里的 plugin.json。网页宿主扫描这个文件来画节点端口,不会加载插件 DLL。 +/// Id 会出现在画布节点的 Type 上。 +/// public sealed class PluginManifest { public string Id { get; set; } = ""; @@ -16,12 +20,14 @@ public sealed class PluginManifest public List Outputs { get; set; } = []; } +/// 如何启动插件进程。Command 可以是相对插件目录的 exe,或 dotnet + dll。 public sealed class PluginLaunch { public string Command { get; set; } = ""; public List Args { get; set; } = []; } +/// 画布上的一个输入或输出端口。Name 必须和代码里读写的字段一致。 public sealed class PluginPort { public string Name { get; set; } = ""; @@ -30,6 +36,7 @@ public sealed class PluginPort public bool Required { get; set; } } +/// 插件声明「我需要哪种凭据」。宿主按 Type 注入,Key 不进流程图 JSON。 public sealed class PluginCredentialNeed { public string Name { get; set; } = ""; @@ -38,6 +45,7 @@ public sealed class PluginCredentialNeed public string Description { get; set; } = ""; } +/// 宿主写入插件 stdin 的整包请求:业务输入 + 凭据。 public sealed class PluginRequest { public Dictionary Inputs { get; set; } = new(StringComparer.OrdinalIgnoreCase); @@ -46,6 +54,7 @@ public sealed class PluginRequest public Dictionary Credentials { get; set; } = new(StringComparer.OrdinalIgnoreCase); } +/// 实际的 endpoint / apiKey / model。只在宿主→插件进程之间传递。 public sealed class PluginCredentialPayload { public string Id { get; set; } = ""; @@ -56,6 +65,7 @@ public sealed class PluginCredentialPayload public Dictionary Extra { get; set; } = new(StringComparer.OrdinalIgnoreCase); } +/// 插件 JSON 统一 camelCase,和网页前端字段名对齐。 public static class PluginJson { public static readonly System.Text.Json.JsonSerializerOptions Options = new() diff --git a/MAF1.Core/PluginContract/PluginStdio.cs b/MAF1.Core/PluginContract/PluginStdio.cs index f8890e6..47ba2de 100644 --- a/MAF1.Core/PluginContract/PluginStdio.cs +++ b/MAF1.Core/PluginContract/PluginStdio.cs @@ -4,8 +4,13 @@ using Microsoft.Extensions.Configuration; namespace MAF1.PluginContract; +/// +/// 进程外插件的 stdio 协议:宿主把 PluginRequest JSON 写入 stdin,插件把输出 JSON 写到 stdout。 +/// 学习时对照 PluginProcessRunner:那边启动进程、写 stdin、读 stdout。 +/// public static class PluginStdio { + /// 插件入口第一步:读完 stdin 再干活。宿主写完会关闭 stdin。 public static async Task ReadRequestAsync(CancellationToken cancellationToken = default) { using Stream stdin = Console.OpenStandardInput(); @@ -20,6 +25,7 @@ public static class PluginStdio return request ?? new PluginRequest(); } + /// 把 Outputs 写成一行 JSON。不要往 stdout 打日志,日志请走 stderr。 public static async Task WriteOutputsAsync(Dictionary outputs, CancellationToken cancellationToken = default) { string json = JsonSerializer.Serialize(outputs, PluginJson.Options); @@ -27,6 +33,7 @@ public static class PluginStdio await Console.Out.FlushAsync(cancellationToken); } + /// 把宿主注入的 LLM 凭据写进环境变量,这样 AgentFactory.Load 能读到 Key。 public static void ApplyCredentialsToEnvironment(IReadOnlyDictionary credentials) { foreach (KeyValuePair pair in credentials) @@ -49,6 +56,9 @@ public static class PluginStdio } } + /// + /// 插件自己读天气等配置时用。优先 MAF1_CONTENT_ROOT(宿主 exe 目录),否则向上找 appsettings.json。 + /// public static IConfiguration LoadHostConfiguration() { string contentRoot = Environment.GetEnvironmentVariable("MAF1_CONTENT_ROOT"); diff --git a/MAF1.Core/Tools/FileTools.cs b/MAF1.Core/Tools/FileTools.cs index f1e7403..ea57822 100644 --- a/MAF1.Core/Tools/FileTools.cs +++ b/MAF1.Core/Tools/FileTools.cs @@ -3,6 +3,10 @@ using System.Text; namespace MAF1.Tools; +/// +/// 给 LLM 调用的本地读文件工具。Description 属性会进工具 schema,模型据此决定何时调用。 +/// 路径解析会试当前目录、exe 目录、以及 MAF1_CONTENT_ROOT(插件进程由宿主注入)。 +/// public static class FileTools { [Description("Read the full UTF-8 text of a local file. Always call this before judging city names.")] @@ -28,6 +32,7 @@ public static class FileTools return File.ReadAllText(resolved, Encoding.UTF8); } + /// 相对路径时依次试 cwd、exe 目录、内容根、再往上两级父目录(适配 bin/Debug/netX)。 private static string? Resolve(string path) { if (Path.IsPathRooted(path) && File.Exists(path)) diff --git a/MAF1.Core/Tools/WeatherOptions.cs b/MAF1.Core/Tools/WeatherOptions.cs index 8fa4d37..5b18381 100644 --- a/MAF1.Core/Tools/WeatherOptions.cs +++ b/MAF1.Core/Tools/WeatherOptions.cs @@ -1,5 +1,6 @@ namespace MAF1.Tools; +/// 对应 appsettings.json 的 Weather 段。CLI 和 Web 各自有一份配置文件。 public sealed class WeatherOptions { public string Provider { get; set; } = "Wttr"; diff --git a/MAF1.Core/Tools/WeatherTools.cs b/MAF1.Core/Tools/WeatherTools.cs index 9775b68..042b62f 100644 --- a/MAF1.Core/Tools/WeatherTools.cs +++ b/MAF1.Core/Tools/WeatherTools.cs @@ -4,6 +4,10 @@ using System.Text.Json; namespace MAF1.Tools; +/// +/// 天气 HTTP 实现。Agent 通过 GetWeather 间接调用这里,而不是自己拼 URL。 +/// Provider=Wttr 免费无需 Key;OpenWeather 需要 ApiKey。 +/// public sealed class WeatherTools(WeatherOptions options, HttpClient http) { [Description("Look up live weather for a city or location. Always call this instead of guessing.")] @@ -82,6 +86,7 @@ public sealed class WeatherTools(WeatherOptions options, HttpClient http) : $"{name}:{description},气温 {temp:0.#}°C,湿度 {humidity}%"; } + /// 把 appsettings 里的 URL 模板换成真实地址。城市名要做 Uri.EscapeDataString。 private string Expand(string template, string location, string? apiKey) { return template @@ -90,6 +95,7 @@ public sealed class WeatherTools(WeatherOptions options, HttpClient http) .Replace("{apiKey}", apiKey ?? "", StringComparison.OrdinalIgnoreCase); } + /// 带超时和 User-Agent。部分天气站点会拒绝没有 UA 的请求。 public static HttpClient CreateHttpClient() { HttpClient http = new() { Timeout = TimeSpan.FromSeconds(20) }; diff --git a/MAF1.Core/Utils/AgentFactory.cs b/MAF1.Core/Utils/AgentFactory.cs index 446b5f0..bd948d1 100644 --- a/MAF1.Core/Utils/AgentFactory.cs +++ b/MAF1.Core/Utils/AgentFactory.cs @@ -8,6 +8,10 @@ using OpenAI.Chat; namespace MAF1.Utils; +/// +/// 创建聊天 Agent 的工厂。根据 Endpoint 判断走 Azure OpenAI 还是 OpenAI 兼容接口(DeepSeek 等)。 +/// 密钥优先级:appsettings Llm → 环境变量。浏览器永远拿不到 Key。 +/// public sealed class AgentFactory { private readonly ChatClient _chatClient; @@ -48,11 +52,13 @@ public sealed class AgentFactory .GetChatClient(model); } + /// 把 ChatClient 包成 Microsoft.Agents.AI 的 AIAgent,并挂上 tools。 public AIAgent CreateAgent(string name, string instructions, IList? tools = null) { return _chatClient.AsAIAgent(instructions: instructions, name: name, tools: tools); } + /// 合并配置文件和环境变量。插件进程里环境变量通常由宿主写入。 public static LlmOptions Load(IConfiguration config) { LlmOptions options = config.GetSection("Llm").Get() ?? new LlmOptions(); @@ -85,6 +91,7 @@ public sealed class AgentFactory || host.Contains("services.ai.azure.com", StringComparison.OrdinalIgnoreCase); } + /// OpenAI 兼容 API 要求 base URL 以 /v1 结尾,用户常只填到域名。 private static Uri ToOpenAICompatibleEndpoint(string endpoint) { string trimmed = endpoint.TrimEnd('/'); diff --git a/MAF1.Core/Utils/JsonText.cs b/MAF1.Core/Utils/JsonText.cs index 75a7e8d..6c66ee3 100644 --- a/MAF1.Core/Utils/JsonText.cs +++ b/MAF1.Core/Utils/JsonText.cs @@ -1,5 +1,8 @@ namespace MAF1.Utils; +/// +/// 从模型回复里抽出 JSON 对象。模型常包 ```json ... ```,或在前后加说明文字。 +/// public static class JsonText { public static string UnwrapObject(string text) diff --git a/MAF1.Core/Utils/LlmOptions.cs b/MAF1.Core/Utils/LlmOptions.cs index 15e88a4..ed1d46c 100644 --- a/MAF1.Core/Utils/LlmOptions.cs +++ b/MAF1.Core/Utils/LlmOptions.cs @@ -1,5 +1,6 @@ namespace MAF1.Utils; +/// LLM 连接信息。不要把真实 ApiKey 提交进 git。 public sealed class LlmOptions { public string ApiKey { get; set; } = ""; diff --git a/MAF1.Core/Utils/WindowsConsole.cs b/MAF1.Core/Utils/WindowsConsole.cs index cd679c6..82a4c00 100644 --- a/MAF1.Core/Utils/WindowsConsole.cs +++ b/MAF1.Core/Utils/WindowsConsole.cs @@ -3,6 +3,9 @@ using System.Text; namespace MAF1.Utils; +/// +/// Windows 控制台默认代码页容易把中文打成乱码。启动时切到 UTF-8,并打开 VT 序列(彩色输出用)。 +/// public static class WindowsConsole { private const uint Utf8CodePage = 65001; diff --git a/MAF1.Web/PluginHost/CredentialStore.cs b/MAF1.Web/PluginHost/CredentialStore.cs index 4c593b4..0fc3f88 100644 --- a/MAF1.Web/PluginHost/CredentialStore.cs +++ b/MAF1.Web/PluginHost/CredentialStore.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration; namespace MAF1.Plugins; +/// 内存中的完整凭据。只给插件进程用,不要序列化给浏览器。 public sealed class CredentialRecord { public string Id { get; init; } = ""; @@ -15,6 +16,7 @@ public sealed class CredentialRecord public Dictionary Extra { get; init; } = new(StringComparer.OrdinalIgnoreCase); } +/// GET /api/credentials 的形状:有没有 Key 用布尔值表示,不返回 Key 本身。 public sealed class CredentialPublicView { public string Id { get; init; } = ""; @@ -25,6 +27,9 @@ public sealed class CredentialPublicView public bool HasApiKey { get; init; } } +/// +/// 从 Llm 段和 Credentials 段组装凭据。节点 config.credentialId 默认 llm-default。 +/// public sealed class CredentialStore { private readonly IConfiguration _config; @@ -64,6 +69,7 @@ public sealed class CredentialStore } } + /// 给前端的列表。Endpoint 目前未打码(学习项目);ApiKey 绝不会出现在这里。 public IReadOnlyList ListPublic() => _named.Values .OrderBy(item => item.Id, StringComparer.OrdinalIgnoreCase) diff --git a/MAF1.Web/PluginHost/NodeCatalogService.cs b/MAF1.Web/PluginHost/NodeCatalogService.cs index b1762d9..e5099b5 100644 --- a/MAF1.Web/PluginHost/NodeCatalogService.cs +++ b/MAF1.Web/PluginHost/NodeCatalogService.cs @@ -3,6 +3,7 @@ using MAF1.Web; namespace MAF1.Plugins; +/// 一次目录快照:系统节点 + 插件节点合并后的 Nodes 列表给画布用。 public sealed class NodeCatalogSnapshot { public string PluginsRoot { get; init; } = ""; @@ -13,6 +14,7 @@ public sealed class NodeCatalogSnapshot public IReadOnlyList LoadedPlugins { get; init; } = []; } +/// 把扫描结果和 AgentCatalog 拼在一起。同名时插件覆盖系统实现。 public sealed class NodeCatalogService(PluginScanner scanner) { public NodeCatalogSnapshot Load() diff --git a/MAF1.Web/PluginHost/PluginOptions.cs b/MAF1.Web/PluginHost/PluginOptions.cs index 9198598..231636f 100644 --- a/MAF1.Web/PluginHost/PluginOptions.cs +++ b/MAF1.Web/PluginHost/PluginOptions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; namespace MAF1.Plugins; +/// Plugins:Directory 相对网页 exe,不是源码树里的 plugins/ 文件夹。 public sealed class PluginOptions { public string Directory { get; set; } = "plugins"; @@ -21,6 +22,7 @@ public sealed class PluginOptions => config.GetSection("Plugins").Get() ?? new PluginOptions(); } +/// 扫描成功后的一个插件:目录 + 已解析的 plugin.json。 public sealed class LoadedPlugin { public required string FolderName { get; init; } @@ -28,6 +30,7 @@ public sealed class LoadedPlugin public required PluginContract.PluginManifest Manifest { get; init; } } +/// 扫描问题,前端左侧「扫描说明」会显示。error 的插件不会进画布。 public sealed class CatalogIssue { public string Level { get; init; } = "error"; diff --git a/MAF1.Web/PluginHost/PluginProcessRunner.cs b/MAF1.Web/PluginHost/PluginProcessRunner.cs index 6f9ad07..aafcaef 100644 --- a/MAF1.Web/PluginHost/PluginProcessRunner.cs +++ b/MAF1.Web/PluginHost/PluginProcessRunner.cs @@ -6,6 +6,10 @@ using MAF1.Utils; namespace MAF1.Plugins; +/// +/// 启动子进程跑插件:stdin 写 PluginRequest,stdout 读输出 JSON,超时 Kill。 +/// 这是「进程外 Agent」的完整示例,对照 FileCityPlugin/Program.cs 一起看。 +/// public sealed class PluginProcessRunner(PluginOptions options, CredentialStore credentials) { public async Task> RunAsync( @@ -97,6 +101,7 @@ public sealed class PluginProcessRunner(PluginOptions options, CredentialStore c return map; } + /// 工作目录=插件文件夹;环境变量带上 OPENAI_* 和 MAF1_CONTENT_ROOT。 private ProcessStartInfo CreateStartInfo(LoadedPlugin plugin, Dictionary payload) { string command = plugin.Manifest.Launch.Command; diff --git a/MAF1.Web/PluginHost/PluginScanner.cs b/MAF1.Web/PluginHost/PluginScanner.cs index 8fb331d..7aee669 100644 --- a/MAF1.Web/PluginHost/PluginScanner.cs +++ b/MAF1.Web/PluginHost/PluginScanner.cs @@ -3,6 +3,9 @@ using MAF1.PluginContract; namespace MAF1.Plugins; +/// +/// 扫描 exe 旁边的 plugins/*/plugin.json。源码目录 Plugins/ 不会被运行时直接读到,要先 build 复制到输出目录。 +/// public sealed class PluginScanner(PluginOptions options) { public PluginScanResult Scan() diff --git a/MAF1.Web/Program.cs b/MAF1.Web/Program.cs index fcc4125..2ab16ce 100644 --- a/MAF1.Web/Program.cs +++ b/MAF1.Web/Program.cs @@ -1,3 +1,5 @@ +// 网页入口:Minimal API + wwwroot 静态文件。浏览器只调 REST,不接触 API Key。 +// 学习顺序建议:/api/catalog → 画布 → POST /api/run → 若 needsDecision 再 POST /api/run/{id}/decide。 using MAF1.Decisions; using MAF1.Utils; using MAF1.Web; @@ -16,6 +18,7 @@ WebApplication app = builder.Build(); app.UseDefaultFiles(); app.UseStaticFiles(); +// 左侧节点列表:系统内置 + 扫描到的插件。 app.MapGet("/api/catalog", (AgentRuntime runtime) => { var snapshot = runtime.Catalog.Load(); @@ -28,14 +31,17 @@ app.MapGet("/api/catalog", (AgentRuntime runtime) => issues = snapshot.Issues, }; }); +// 给下拉框用的凭据列表(不含原始 Key)。 app.MapGet("/api/credentials", (AgentRuntime runtime) => runtime.Credentials.ListPublic()); app.MapGet("/api/status", (AgentRuntime runtime) => new { weatherProvider = runtime.WeatherProvider, pluginsRoot = runtime.PluginsRoot, }); +// 提交画布 JSON。可能一次跑完,也可能返回 status=needsDecision。 app.MapPost("/api/run", (WorkflowGraph graph, AgentRuntime runtime, CancellationToken cancellationToken) => runtime.Runner.RunAsync(graph, cancellationToken)); +// 超时后前端轮询最终结果。 app.MapGet("/api/run/{runId}", (string runId, AgentRuntime runtime) => { WorkflowRunResult? result = runtime.Runner.Get(runId); @@ -43,6 +49,7 @@ app.MapGet("/api/run/{runId}", (string runId, AgentRuntime runtime) => ? Results.NotFound(new WorkflowRunResult { Ok = false, Status = WorkflowRunStatus.Failed, Error = "找不到这次运行。" }) : Results.Ok(result); }); +// 用户点确认:optionId=stop 或 query-cities(可带 text 城市名)。 app.MapPost("/api/run/{runId}/decide", (string runId, DecisionAnswer answer, AgentRuntime runtime, CancellationToken cancellationToken) => runtime.Runner.DecideAsync(runId, answer, cancellationToken)); diff --git a/MAF1.Web/Web/AgentCatalog.cs b/MAF1.Web/Web/AgentCatalog.cs index 51cc007..b3aa123 100644 --- a/MAF1.Web/Web/AgentCatalog.cs +++ b/MAF1.Web/Web/AgentCatalog.cs @@ -2,6 +2,7 @@ using MAF1.PluginContract; namespace MAF1.Web; +/// 画布上一个端口的元数据,前端用来渲染圆点和检查器文案。 public sealed class PortInfo { public string Name { get; init; } = ""; @@ -10,6 +11,9 @@ public sealed class PortInfo public bool Required { get; init; } } +/// +/// 一种可放到画布上的节点类型。Origin=system 走进程内 Handler;Origin=plugin 走独立进程。 +/// public sealed class AgentTypeInfo { public string Type { get; init; } = ""; @@ -30,6 +34,7 @@ public sealed class AgentTypeInfo public IReadOnlyList Credentials { get; init; } = []; } +/// 系统节点绑定到哪个 C# 实现。插件节点 Handler 保持 None。 public enum SystemHandler { None = 0, @@ -37,6 +42,9 @@ public enum SystemHandler Weather, } +/// +/// 写死在宿主里的两种系统节点。插件 id 若与 Type 冲突,运行时优先插件进程。 +/// public static class AgentCatalog { public static AgentTypeInfo? FindSystem(string type) diff --git a/MAF1.Web/Web/AgentRuntime.cs b/MAF1.Web/Web/AgentRuntime.cs index 2ab3d08..da7efd0 100644 --- a/MAF1.Web/Web/AgentRuntime.cs +++ b/MAF1.Web/Web/AgentRuntime.cs @@ -8,6 +8,10 @@ using Microsoft.Extensions.Configuration; namespace MAF1.Web; +/// +/// 网页进程的组合根:把 LLM、两个系统 Agent、插件扫描/启动子进程、图执行器绑在一起。 +/// ASP.NET 把它注册成 Singleton,一次请求里不要 new 第二份。 +/// public sealed class AgentRuntime { public AgentRuntime(IConfiguration config) diff --git a/MAF1.Web/Web/ConfigurableWorkflowRunner.cs b/MAF1.Web/Web/ConfigurableWorkflowRunner.cs index 2721ba3..ec38b6d 100644 --- a/MAF1.Web/Web/ConfigurableWorkflowRunner.cs +++ b/MAF1.Web/Web/ConfigurableWorkflowRunner.cs @@ -9,6 +9,11 @@ using Microsoft.Agents.AI; namespace MAF1.Web; +/// +/// 网页自己的图执行器(不是 Microsoft Agents AI Workflow)。 +/// 流程:拓扑排序 → 按边接线组装输入 → 跑系统节点或插件进程 → 若抽城市失败则暂停等人。 +/// 和 CLI 对照:这里用 DAG + when 条件,CLI 用 WorkflowBuilder 的 Executor 图。 +/// public sealed class ConfigurableWorkflowRunner( AIAgent fileCityAgent, AIAgent weatherAgent, @@ -17,6 +22,7 @@ public sealed class ConfigurableWorkflowRunner( WorkflowRunStore runs, int decisionTimeoutSeconds) { + /// 这些 config 键不是业务输入端口,接线时不要当 filePath 那样传给 Agent。 private static readonly HashSet ReservedConfigKeys = new(StringComparer.OrdinalIgnoreCase) { "credentialId", @@ -24,6 +30,7 @@ public sealed class ConfigurableWorkflowRunner( "decisionTimeoutSeconds", }; + /// 开始一次运行。后面若暂停,用同一 runId 继续 DecideAsync。 public async Task RunAsync(WorkflowGraph graph, CancellationToken cancellationToken) { try @@ -62,6 +69,7 @@ public sealed class ConfigurableWorkflowRunner( return await ResolveAsync(session, answer, cancellationToken, session.Pending?.Id); } + /// 从 session.NextIndex 接着跑。条件不满足的节点记 Skipped,不调用 LLM。 private async Task ContinueAsync(WorkflowSession session, CancellationToken cancellationToken) { try @@ -107,6 +115,7 @@ public sealed class ConfigurableWorkflowRunner( } } + /// 返回 needsDecision,并在后台倒计时到点后按默认方案 ResolveAsync。 private WorkflowRunResult PauseForEmptyCities(WorkflowSession session, WorkflowNode node, NodeRunLog log) { string? reason = ReadString(log.Outputs, "reason"); @@ -167,6 +176,7 @@ public sealed class ConfigurableWorkflowRunner( }); } + /// 把确认结果写回该节点的 outputs(例如改写 cities),然后继续后续节点。 private async Task ResolveAsync( WorkflowSession session, DecisionAnswer answer, @@ -319,6 +329,7 @@ public sealed class ConfigurableWorkflowRunner( Steps = steps ?? [], }; + /// 节点输出了 hasValidCities=false,且后面还有节点时,默认要弹确认。config 可关。 private static bool ShouldAskEmptyCities(WorkflowNode node, NodeRunLog log) { if (log.Skipped) @@ -340,6 +351,7 @@ public sealed class ConfigurableWorkflowRunner( return !ReadBool(log.Outputs, "hasValidCities"); } + /// 插件 id 优先于系统节点。系统节点按 Handler 调 FileCityAgent / WeatherAgent。 private async Task RunNodeAsync( NodeCatalogSnapshot snapshot, WorkflowNode node, @@ -439,6 +451,7 @@ public sealed class ConfigurableWorkflowRunner( } } + /// Kahn 算法拓扑排序。有环则无法确定执行顺序。 private static List TopologicalOrder(WorkflowGraph graph) { Dictionary indegree = graph.Nodes.ToDictionary(n => n.Id, _ => 0); @@ -473,6 +486,7 @@ public sealed class ConfigurableWorkflowRunner( return order; } + /// 先填节点 Config,再用入边把上游输出端口覆盖到下游输入端口(连线优先)。 private static Dictionary ResolveInputs( WorkflowGraph graph, WorkflowNode node, @@ -505,6 +519,7 @@ public sealed class ConfigurableWorkflowRunner( return inputs; } + /// 入边 When 不满足则跳过本节点。当前只实现 hasValidCities 这一类条件。 private static bool PassEdgeConditions( WorkflowGraph graph, WorkflowNode node, diff --git a/MAF1.Web/Web/WorkflowModels.cs b/MAF1.Web/Web/WorkflowModels.cs index 67eb418..377c986 100644 --- a/MAF1.Web/Web/WorkflowModels.cs +++ b/MAF1.Web/Web/WorkflowModels.cs @@ -2,12 +2,14 @@ using MAF1.Decisions; namespace MAF1.Web; +/// 浏览器 POST /api/run 的图:节点 + 连线。和前端 state.nodes / state.edges 一一对应。 public sealed class WorkflowGraph { public List Nodes { get; set; } = []; public List Edges { get; set; } = []; } +/// 画布节点。Config 里可写 filePath、credentialId、decisionTimeoutSeconds 等固定输入。 public sealed class WorkflowNode { public string Id { get; set; } = ""; @@ -18,6 +20,9 @@ public sealed class WorkflowNode public Dictionary Config { get; set; } = new(StringComparer.OrdinalIgnoreCase); } +/// +/// 一条边:FromPort → ToPort 传数据。When 是运行条件(hasValidCities / !hasValidCities / 空=始终)。 +/// public sealed class WorkflowEdge { public string Id { get; set; } = ""; @@ -28,6 +33,7 @@ public sealed class WorkflowEdge public string? When { get; set; } } +/// 一次运行的状态机:completed / needsDecision / failed。 public static class WorkflowRunStatus { public const string Completed = "completed"; @@ -35,6 +41,7 @@ public static class WorkflowRunStatus public const string Failed = "failed"; } +/// 返回给前端的运行快照。暂停时带 Decision,方便弹出确认框。 public sealed class WorkflowRunResult { public bool Ok { get; set; } @@ -46,6 +53,7 @@ public sealed class WorkflowRunResult public List Steps { get; set; } = []; } +/// 单个节点的执行日志,显示在页面底部「运行结果」。 public sealed class NodeRunLog { public string NodeId { get; set; } = ""; diff --git a/MAF1.Web/Web/WorkflowRunStore.cs b/MAF1.Web/Web/WorkflowRunStore.cs index 2b83ea6..c1326fe 100644 --- a/MAF1.Web/Web/WorkflowRunStore.cs +++ b/MAF1.Web/Web/WorkflowRunStore.cs @@ -4,6 +4,10 @@ using MAF1.Plugins; namespace MAF1.Web; +/// +/// 一次可暂停的运行。NextIndex 记住停在第几个节点;Pending 是当前确认请求。 +/// Mutex 防止「用户点击」和「超时自动确认」同时 Continue。 +/// internal sealed class WorkflowSession { public required string RunId { get; init; } @@ -22,6 +26,7 @@ internal sealed class WorkflowSession public object Sync { get; } = new(); } +/// 进程内运行字典。服务重启后 runId 会失效,学习项目不做持久化。 public sealed class WorkflowRunStore { private readonly ConcurrentDictionary _sessions = new(StringComparer.OrdinalIgnoreCase); diff --git a/MAF1.Web/wwwroot/css/app.css b/MAF1.Web/wwwroot/css/app.css index 14d0f62..6c622b3 100644 --- a/MAF1.Web/wwwroot/css/app.css +++ b/MAF1.Web/wwwroot/css/app.css @@ -1,3 +1,4 @@ +/* 编排器布局:顶栏、三栏(节点 / 画布 / 检查器)、底部日志、确认弹层。 */ :root { --bg: #0f1419; --panel: #171e26; diff --git a/MAF1.Web/wwwroot/index.html b/MAF1.Web/wwwroot/index.html index 704ac62..22cb191 100644 --- a/MAF1.Web/wwwroot/index.html +++ b/MAF1.Web/wwwroot/index.html @@ -1,4 +1,5 @@ + @@ -37,6 +38,7 @@

运行结果

尚未运行。
+