docs: 为 CLI、网页编排器和插件协议补充学习向注释
在核心类型、图执行器、stdio 插件和前端入口写清职责与对照路径,不改运行行为。
This commit is contained in:
@@ -2,6 +2,10 @@ using System.Text.Json;
|
||||
|
||||
namespace MAF1.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// 一次 Agent 执行的统一结果。网页编排器和进程外插件都用这套字段,方便画布把输出接到下一节点。
|
||||
/// Message 是给日志看的原文;Outputs 才是下游节点真正读取的端口数据。
|
||||
/// </summary>
|
||||
public sealed class AgentStepResult
|
||||
{
|
||||
public string Message { get; init; } = "";
|
||||
@@ -9,8 +13,12 @@ public sealed class AgentStepResult
|
||||
public Dictionary<string, object?> Outputs { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从图 JSON / 插件 stdin 读入参。值可能是 string、数组,也可能是 System.Text.Json 反序列化后的 JsonElement。
|
||||
/// </summary>
|
||||
public static class AgentInputs
|
||||
{
|
||||
/// <summary>按端口名读一个字符串;没有该键则返回 null。</summary>
|
||||
public static string? ReadString(IReadOnlyDictionary<string, object?> inputs, string key)
|
||||
{
|
||||
if (!inputs.TryGetValue(key, out object? value) || value is null)
|
||||
@@ -26,6 +34,7 @@ public static class AgentInputs
|
||||
return value.ToString();
|
||||
}
|
||||
|
||||
/// <summary>读城市列表:支持 JSON 数组、["a","b"] 风格,或用顿号/逗号分隔的一串文字。</summary>
|
||||
public static List<string> ReadStringList(IReadOnlyDictionary<string, object?> inputs, string key)
|
||||
{
|
||||
if (!inputs.TryGetValue(key, out object? value) || value is null)
|
||||
|
||||
@@ -2,6 +2,9 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace MAF1.Agents.FileCity;
|
||||
|
||||
/// <summary>
|
||||
/// FileCity Agent 约定输出的 JSON 形状。hasValidCities 会驱动边条件,也会触发「缺城市时等人确认」。
|
||||
/// </summary>
|
||||
public sealed class CityExtraction
|
||||
{
|
||||
[JsonPropertyName("hasValidCities")]
|
||||
|
||||
@@ -6,8 +6,13 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
namespace MAF1.Agents.FileCity;
|
||||
|
||||
/// <summary>
|
||||
/// 「读文件抽城市」Agent。学习路径:Create 注册工具和系统提示 → RunAsync 调模型 → Parse 把模型文本收成结构化结果。
|
||||
/// CLI、网页系统节点、file-city 插件三处都调用同一套逻辑,避免各写一份 prompt。
|
||||
/// </summary>
|
||||
public static class FileCityAgent
|
||||
{
|
||||
/// <summary>创建带 ReadTextFile 工具的聊天 Agent。模型必须先读文件,不能凭文件名猜内容。</summary>
|
||||
public static AIAgent Create(AgentFactory factory)
|
||||
{
|
||||
return factory.CreateAgent(
|
||||
@@ -26,6 +31,7 @@ public static class FileCityAgent
|
||||
tools: [AIFunctionFactory.Create(FileTools.ReadTextFile)]);
|
||||
}
|
||||
|
||||
/// <summary>跑一轮:读 filePath 输入,把解析后的 cities / hasValidCities 放进 Outputs。</summary>
|
||||
public static async Task<AgentStepResult> RunAsync(
|
||||
AIAgent agent,
|
||||
IReadOnlyDictionary<string, object?> inputs,
|
||||
@@ -50,6 +56,7 @@ public static class FileCityAgent
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>从模型原文里抠 JSON。模型偶尔会包 Markdown 代码块,所以先走 JsonText.UnwrapObject。</summary>
|
||||
public static CityExtraction Parse(string text)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -5,8 +5,12 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
namespace MAF1.Agents.Weather;
|
||||
|
||||
/// <summary>
|
||||
/// 「按城市查天气」Agent。真正的 HTTP 查询在 WeatherTools.GetWeather,模型只负责决定调几次工具并写成中文摘要。
|
||||
/// </summary>
|
||||
public static class WeatherAgent
|
||||
{
|
||||
/// <summary>创建带 GetWeather 工具的 Agent。instructions 要求每个城市都调工具,禁止编造气温。</summary>
|
||||
public static AIAgent Create(AgentFactory factory, WeatherTools weatherTools)
|
||||
{
|
||||
return factory.CreateAgent(
|
||||
@@ -19,6 +23,7 @@ public static class WeatherAgent
|
||||
tools: [AIFunctionFactory.Create(weatherTools.GetWeather)]);
|
||||
}
|
||||
|
||||
/// <summary>需要上游把 cities 连到本节点。空列表直接抛错,避免无意义地打 LLM。</summary>
|
||||
public static async Task<AgentStepResult> RunAsync(
|
||||
AIAgent agent,
|
||||
IReadOnlyDictionary<string, object?> inputs,
|
||||
|
||||
@@ -2,6 +2,10 @@ using MAF1.Agents;
|
||||
|
||||
namespace MAF1.Decisions;
|
||||
|
||||
/// <summary>
|
||||
/// 工作流暂停时发给人或 UI 的确认请求。必须带 DefaultOptionId,超时就采用它。
|
||||
/// CLI 和 Web 共用这一套,避免两套文案分叉。
|
||||
/// </summary>
|
||||
public sealed class DecisionRequest
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
@@ -14,6 +18,7 @@ public sealed class DecisionRequest
|
||||
public DateTimeOffset Deadline { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>一个可选项。RequiresText=true 时前端会显示输入框(例如改填城市)。</summary>
|
||||
public sealed class DecisionOption
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
@@ -24,6 +29,7 @@ public sealed class DecisionOption
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>人的选择或超时结果。TimedOut=true 时一律按默认方案处理,忽略 Text。</summary>
|
||||
public sealed class DecisionAnswer
|
||||
{
|
||||
public string OptionId { get; set; } = "";
|
||||
@@ -31,12 +37,14 @@ public sealed class DecisionAnswer
|
||||
public bool TimedOut { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>缺城市场景下的两个方案 id,前后端都硬编码这两个字符串。</summary>
|
||||
public static class DecisionOptionIds
|
||||
{
|
||||
public const string Stop = "stop";
|
||||
public const string QueryCities = "query-cities";
|
||||
}
|
||||
|
||||
/// <summary>工厂:生成「没有读到城市」这一类确认。网页 runner 和 CLI executor 都调用 Create。</summary>
|
||||
public static class EmptyCityDecision
|
||||
{
|
||||
public static DecisionRequest Create(string nodeId, string? reason, int timeoutSeconds)
|
||||
@@ -72,6 +80,7 @@ public static class EmptyCityDecision
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>用户选「改查其它城市」且解析出至少一个城市名时返回 true。</summary>
|
||||
public static bool TryContinueWithCities(DecisionAnswer answer, out List<string> cities)
|
||||
{
|
||||
cities = [];
|
||||
|
||||
@@ -2,6 +2,10 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace MAF1.PluginContract;
|
||||
|
||||
/// <summary>
|
||||
/// 每个插件目录里的 plugin.json。网页宿主扫描这个文件来画节点端口,不会加载插件 DLL。
|
||||
/// Id 会出现在画布节点的 Type 上。
|
||||
/// </summary>
|
||||
public sealed class PluginManifest
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
@@ -16,12 +20,14 @@ public sealed class PluginManifest
|
||||
public List<PluginPort> Outputs { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>如何启动插件进程。Command 可以是相对插件目录的 exe,或 dotnet + dll。</summary>
|
||||
public sealed class PluginLaunch
|
||||
{
|
||||
public string Command { get; set; } = "";
|
||||
public List<string> Args { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>画布上的一个输入或输出端口。Name 必须和代码里读写的字段一致。</summary>
|
||||
public sealed class PluginPort
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
@@ -30,6 +36,7 @@ public sealed class PluginPort
|
||||
public bool Required { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>插件声明「我需要哪种凭据」。宿主按 Type 注入,Key 不进流程图 JSON。</summary>
|
||||
public sealed class PluginCredentialNeed
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
@@ -38,6 +45,7 @@ public sealed class PluginCredentialNeed
|
||||
public string Description { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>宿主写入插件 stdin 的整包请求:业务输入 + 凭据。</summary>
|
||||
public sealed class PluginRequest
|
||||
{
|
||||
public Dictionary<string, object?> Inputs { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -46,6 +54,7 @@ public sealed class PluginRequest
|
||||
public Dictionary<string, PluginCredentialPayload> Credentials { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>实际的 endpoint / apiKey / model。只在宿主→插件进程之间传递。</summary>
|
||||
public sealed class PluginCredentialPayload
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
@@ -56,6 +65,7 @@ public sealed class PluginCredentialPayload
|
||||
public Dictionary<string, string> Extra { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>插件 JSON 统一 camelCase,和网页前端字段名对齐。</summary>
|
||||
public static class PluginJson
|
||||
{
|
||||
public static readonly System.Text.Json.JsonSerializerOptions Options = new()
|
||||
|
||||
@@ -4,8 +4,13 @@ using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MAF1.PluginContract;
|
||||
|
||||
/// <summary>
|
||||
/// 进程外插件的 stdio 协议:宿主把 PluginRequest JSON 写入 stdin,插件把输出 JSON 写到 stdout。
|
||||
/// 学习时对照 PluginProcessRunner:那边启动进程、写 stdin、读 stdout。
|
||||
/// </summary>
|
||||
public static class PluginStdio
|
||||
{
|
||||
/// <summary>插件入口第一步:读完 stdin 再干活。宿主写完会关闭 stdin。</summary>
|
||||
public static async Task<PluginRequest> ReadRequestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using Stream stdin = Console.OpenStandardInput();
|
||||
@@ -20,6 +25,7 @@ public static class PluginStdio
|
||||
return request ?? new PluginRequest();
|
||||
}
|
||||
|
||||
/// <summary>把 Outputs 写成一行 JSON。不要往 stdout 打日志,日志请走 stderr。</summary>
|
||||
public static async Task WriteOutputsAsync(Dictionary<string, object?> outputs, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(outputs, PluginJson.Options);
|
||||
@@ -27,6 +33,7 @@ public static class PluginStdio
|
||||
await Console.Out.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>把宿主注入的 LLM 凭据写进环境变量,这样 AgentFactory.Load 能读到 Key。</summary>
|
||||
public static void ApplyCredentialsToEnvironment(IReadOnlyDictionary<string, PluginCredentialPayload> credentials)
|
||||
{
|
||||
foreach (KeyValuePair<string, PluginCredentialPayload> pair in credentials)
|
||||
@@ -49,6 +56,9 @@ public static class PluginStdio
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 插件自己读天气等配置时用。优先 MAF1_CONTENT_ROOT(宿主 exe 目录),否则向上找 appsettings.json。
|
||||
/// </summary>
|
||||
public static IConfiguration LoadHostConfiguration()
|
||||
{
|
||||
string contentRoot = Environment.GetEnvironmentVariable("MAF1_CONTENT_ROOT");
|
||||
|
||||
@@ -3,6 +3,10 @@ using System.Text;
|
||||
|
||||
namespace MAF1.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// 给 LLM 调用的本地读文件工具。Description 属性会进工具 schema,模型据此决定何时调用。
|
||||
/// 路径解析会试当前目录、exe 目录、以及 MAF1_CONTENT_ROOT(插件进程由宿主注入)。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>相对路径时依次试 cwd、exe 目录、内容根、再往上两级父目录(适配 bin/Debug/netX)。</summary>
|
||||
private static string? Resolve(string path)
|
||||
{
|
||||
if (Path.IsPathRooted(path) && File.Exists(path))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace MAF1.Tools;
|
||||
|
||||
/// <summary>对应 appsettings.json 的 Weather 段。CLI 和 Web 各自有一份配置文件。</summary>
|
||||
public sealed class WeatherOptions
|
||||
{
|
||||
public string Provider { get; set; } = "Wttr";
|
||||
|
||||
@@ -4,6 +4,10 @@ using System.Text.Json;
|
||||
|
||||
namespace MAF1.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// 天气 HTTP 实现。Agent 通过 GetWeather 间接调用这里,而不是自己拼 URL。
|
||||
/// Provider=Wttr 免费无需 Key;OpenWeather 需要 ApiKey。
|
||||
/// </summary>
|
||||
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}%";
|
||||
}
|
||||
|
||||
/// <summary>把 appsettings 里的 URL 模板换成真实地址。城市名要做 Uri.EscapeDataString。</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>带超时和 User-Agent。部分天气站点会拒绝没有 UA 的请求。</summary>
|
||||
public static HttpClient CreateHttpClient()
|
||||
{
|
||||
HttpClient http = new() { Timeout = TimeSpan.FromSeconds(20) };
|
||||
|
||||
@@ -8,6 +8,10 @@ using OpenAI.Chat;
|
||||
|
||||
namespace MAF1.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// 创建聊天 Agent 的工厂。根据 Endpoint 判断走 Azure OpenAI 还是 OpenAI 兼容接口(DeepSeek 等)。
|
||||
/// 密钥优先级:appsettings Llm → 环境变量。浏览器永远拿不到 Key。
|
||||
/// </summary>
|
||||
public sealed class AgentFactory
|
||||
{
|
||||
private readonly ChatClient _chatClient;
|
||||
@@ -48,11 +52,13 @@ public sealed class AgentFactory
|
||||
.GetChatClient(model);
|
||||
}
|
||||
|
||||
/// <summary>把 ChatClient 包成 Microsoft.Agents.AI 的 AIAgent,并挂上 tools。</summary>
|
||||
public AIAgent CreateAgent(string name, string instructions, IList<AITool>? tools = null)
|
||||
{
|
||||
return _chatClient.AsAIAgent(instructions: instructions, name: name, tools: tools);
|
||||
}
|
||||
|
||||
/// <summary>合并配置文件和环境变量。插件进程里环境变量通常由宿主写入。</summary>
|
||||
public static LlmOptions Load(IConfiguration config)
|
||||
{
|
||||
LlmOptions options = config.GetSection("Llm").Get<LlmOptions>() ?? new LlmOptions();
|
||||
@@ -85,6 +91,7 @@ public sealed class AgentFactory
|
||||
|| host.Contains("services.ai.azure.com", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>OpenAI 兼容 API 要求 base URL 以 /v1 结尾,用户常只填到域名。</summary>
|
||||
private static Uri ToOpenAICompatibleEndpoint(string endpoint)
|
||||
{
|
||||
string trimmed = endpoint.TrimEnd('/');
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
namespace MAF1.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// 从模型回复里抽出 JSON 对象。模型常包 ```json ... ```,或在前后加说明文字。
|
||||
/// </summary>
|
||||
public static class JsonText
|
||||
{
|
||||
public static string UnwrapObject(string text)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace MAF1.Utils;
|
||||
|
||||
/// <summary>LLM 连接信息。不要把真实 ApiKey 提交进 git。</summary>
|
||||
public sealed class LlmOptions
|
||||
{
|
||||
public string ApiKey { get; set; } = "";
|
||||
|
||||
@@ -3,6 +3,9 @@ using System.Text;
|
||||
|
||||
namespace MAF1.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 控制台默认代码页容易把中文打成乱码。启动时切到 UTF-8,并打开 VT 序列(彩色输出用)。
|
||||
/// </summary>
|
||||
public static class WindowsConsole
|
||||
{
|
||||
private const uint Utf8CodePage = 65001;
|
||||
|
||||
Reference in New Issue
Block a user