docs: 为 CLI、网页编排器和插件协议补充学习向注释
在核心类型、图执行器、stdio 插件和前端入口写清职责与对照路径,不改运行行为。
This commit is contained in:
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MAF1.Plugins;
|
||||
|
||||
/// <summary>内存中的完整凭据。只给插件进程用,不要序列化给浏览器。</summary>
|
||||
public sealed class CredentialRecord
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
@@ -15,6 +16,7 @@ public sealed class CredentialRecord
|
||||
public Dictionary<string, string> Extra { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>GET /api/credentials 的形状:有没有 Key 用布尔值表示,不返回 Key 本身。</summary>
|
||||
public sealed class CredentialPublicView
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
@@ -25,6 +27,9 @@ public sealed class CredentialPublicView
|
||||
public bool HasApiKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 Llm 段和 Credentials 段组装凭据。节点 config.credentialId 默认 llm-default。
|
||||
/// </summary>
|
||||
public sealed class CredentialStore
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
@@ -64,6 +69,7 @@ public sealed class CredentialStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>给前端的列表。Endpoint 目前未打码(学习项目);ApiKey 绝不会出现在这里。</summary>
|
||||
public IReadOnlyList<CredentialPublicView> ListPublic()
|
||||
=> _named.Values
|
||||
.OrderBy(item => item.Id, StringComparer.OrdinalIgnoreCase)
|
||||
|
||||
@@ -3,6 +3,7 @@ using MAF1.Web;
|
||||
|
||||
namespace MAF1.Plugins;
|
||||
|
||||
/// <summary>一次目录快照:系统节点 + 插件节点合并后的 Nodes 列表给画布用。</summary>
|
||||
public sealed class NodeCatalogSnapshot
|
||||
{
|
||||
public string PluginsRoot { get; init; } = "";
|
||||
@@ -13,6 +14,7 @@ public sealed class NodeCatalogSnapshot
|
||||
public IReadOnlyList<LoadedPlugin> LoadedPlugins { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>把扫描结果和 AgentCatalog 拼在一起。同名时插件覆盖系统实现。</summary>
|
||||
public sealed class NodeCatalogService(PluginScanner scanner)
|
||||
{
|
||||
public NodeCatalogSnapshot Load()
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MAF1.Plugins;
|
||||
|
||||
/// <summary>Plugins:Directory 相对网页 exe,不是源码树里的 plugins/ 文件夹。</summary>
|
||||
public sealed class PluginOptions
|
||||
{
|
||||
public string Directory { get; set; } = "plugins";
|
||||
@@ -21,6 +22,7 @@ public sealed class PluginOptions
|
||||
=> config.GetSection("Plugins").Get<PluginOptions>() ?? new PluginOptions();
|
||||
}
|
||||
|
||||
/// <summary>扫描成功后的一个插件:目录 + 已解析的 plugin.json。</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>扫描问题,前端左侧「扫描说明」会显示。error 的插件不会进画布。</summary>
|
||||
public sealed class CatalogIssue
|
||||
{
|
||||
public string Level { get; init; } = "error";
|
||||
|
||||
@@ -6,6 +6,10 @@ using MAF1.Utils;
|
||||
|
||||
namespace MAF1.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// 启动子进程跑插件:stdin 写 PluginRequest,stdout 读输出 JSON,超时 Kill。
|
||||
/// 这是「进程外 Agent」的完整示例,对照 FileCityPlugin/Program.cs 一起看。
|
||||
/// </summary>
|
||||
public sealed class PluginProcessRunner(PluginOptions options, CredentialStore credentials)
|
||||
{
|
||||
public async Task<Dictionary<string, object?>> RunAsync(
|
||||
@@ -97,6 +101,7 @@ public sealed class PluginProcessRunner(PluginOptions options, CredentialStore c
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <summary>工作目录=插件文件夹;环境变量带上 OPENAI_* 和 MAF1_CONTENT_ROOT。</summary>
|
||||
private ProcessStartInfo CreateStartInfo(LoadedPlugin plugin, Dictionary<string, PluginCredentialPayload> payload)
|
||||
{
|
||||
string command = plugin.Manifest.Launch.Command;
|
||||
|
||||
@@ -3,6 +3,9 @@ using MAF1.PluginContract;
|
||||
|
||||
namespace MAF1.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// 扫描 exe 旁边的 plugins/*/plugin.json。源码目录 Plugins/ 不会被运行时直接读到,要先 build 复制到输出目录。
|
||||
/// </summary>
|
||||
public sealed class PluginScanner(PluginOptions options)
|
||||
{
|
||||
public PluginScanResult Scan()
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using MAF1.PluginContract;
|
||||
|
||||
namespace MAF1.Web;
|
||||
|
||||
/// <summary>画布上一个端口的元数据,前端用来渲染圆点和检查器文案。</summary>
|
||||
public sealed class PortInfo
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
@@ -10,6 +11,9 @@ public sealed class PortInfo
|
||||
public bool Required { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一种可放到画布上的节点类型。Origin=system 走进程内 Handler;Origin=plugin 走独立进程。
|
||||
/// </summary>
|
||||
public sealed class AgentTypeInfo
|
||||
{
|
||||
public string Type { get; init; } = "";
|
||||
@@ -30,6 +34,7 @@ public sealed class AgentTypeInfo
|
||||
public IReadOnlyList<PluginCredentialNeed> Credentials { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>系统节点绑定到哪个 C# 实现。插件节点 Handler 保持 None。</summary>
|
||||
public enum SystemHandler
|
||||
{
|
||||
None = 0,
|
||||
@@ -37,6 +42,9 @@ public enum SystemHandler
|
||||
Weather,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写死在宿主里的两种系统节点。插件 id 若与 Type 冲突,运行时优先插件进程。
|
||||
/// </summary>
|
||||
public static class AgentCatalog
|
||||
{
|
||||
public static AgentTypeInfo? FindSystem(string type)
|
||||
|
||||
@@ -8,6 +8,10 @@ using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MAF1.Web;
|
||||
|
||||
/// <summary>
|
||||
/// 网页进程的组合根:把 LLM、两个系统 Agent、插件扫描/启动子进程、图执行器绑在一起。
|
||||
/// ASP.NET 把它注册成 Singleton,一次请求里不要 new 第二份。
|
||||
/// </summary>
|
||||
public sealed class AgentRuntime
|
||||
{
|
||||
public AgentRuntime(IConfiguration config)
|
||||
|
||||
@@ -9,6 +9,11 @@ using Microsoft.Agents.AI;
|
||||
|
||||
namespace MAF1.Web;
|
||||
|
||||
/// <summary>
|
||||
/// 网页自己的图执行器(不是 Microsoft Agents AI Workflow)。
|
||||
/// 流程:拓扑排序 → 按边接线组装输入 → 跑系统节点或插件进程 → 若抽城市失败则暂停等人。
|
||||
/// 和 CLI 对照:这里用 DAG + when 条件,CLI 用 WorkflowBuilder 的 Executor 图。
|
||||
/// </summary>
|
||||
public sealed class ConfigurableWorkflowRunner(
|
||||
AIAgent fileCityAgent,
|
||||
AIAgent weatherAgent,
|
||||
@@ -17,6 +22,7 @@ public sealed class ConfigurableWorkflowRunner(
|
||||
WorkflowRunStore runs,
|
||||
int decisionTimeoutSeconds)
|
||||
{
|
||||
/// <summary>这些 config 键不是业务输入端口,接线时不要当 filePath 那样传给 Agent。</summary>
|
||||
private static readonly HashSet<string> ReservedConfigKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"credentialId",
|
||||
@@ -24,6 +30,7 @@ public sealed class ConfigurableWorkflowRunner(
|
||||
"decisionTimeoutSeconds",
|
||||
};
|
||||
|
||||
/// <summary>开始一次运行。后面若暂停,用同一 runId 继续 DecideAsync。</summary>
|
||||
public async Task<WorkflowRunResult> RunAsync(WorkflowGraph graph, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -62,6 +69,7 @@ public sealed class ConfigurableWorkflowRunner(
|
||||
return await ResolveAsync(session, answer, cancellationToken, session.Pending?.Id);
|
||||
}
|
||||
|
||||
/// <summary>从 session.NextIndex 接着跑。条件不满足的节点记 Skipped,不调用 LLM。</summary>
|
||||
private async Task<WorkflowRunResult> ContinueAsync(WorkflowSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -107,6 +115,7 @@ public sealed class ConfigurableWorkflowRunner(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>返回 needsDecision,并在后台倒计时到点后按默认方案 ResolveAsync。</summary>
|
||||
private WorkflowRunResult PauseForEmptyCities(WorkflowSession session, WorkflowNode node, NodeRunLog log)
|
||||
{
|
||||
string? reason = ReadString(log.Outputs, "reason");
|
||||
@@ -167,6 +176,7 @@ public sealed class ConfigurableWorkflowRunner(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>把确认结果写回该节点的 outputs(例如改写 cities),然后继续后续节点。</summary>
|
||||
private async Task<WorkflowRunResult> ResolveAsync(
|
||||
WorkflowSession session,
|
||||
DecisionAnswer answer,
|
||||
@@ -319,6 +329,7 @@ public sealed class ConfigurableWorkflowRunner(
|
||||
Steps = steps ?? [],
|
||||
};
|
||||
|
||||
/// <summary>节点输出了 hasValidCities=false,且后面还有节点时,默认要弹确认。config 可关。</summary>
|
||||
private static bool ShouldAskEmptyCities(WorkflowNode node, NodeRunLog log)
|
||||
{
|
||||
if (log.Skipped)
|
||||
@@ -340,6 +351,7 @@ public sealed class ConfigurableWorkflowRunner(
|
||||
return !ReadBool(log.Outputs, "hasValidCities");
|
||||
}
|
||||
|
||||
/// <summary>插件 id 优先于系统节点。系统节点按 Handler 调 FileCityAgent / WeatherAgent。</summary>
|
||||
private async Task<NodeRunLog> RunNodeAsync(
|
||||
NodeCatalogSnapshot snapshot,
|
||||
WorkflowNode node,
|
||||
@@ -439,6 +451,7 @@ public sealed class ConfigurableWorkflowRunner(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kahn 算法拓扑排序。有环则无法确定执行顺序。</summary>
|
||||
private static List<string> TopologicalOrder(WorkflowGraph graph)
|
||||
{
|
||||
Dictionary<string, int> indegree = graph.Nodes.ToDictionary(n => n.Id, _ => 0);
|
||||
@@ -473,6 +486,7 @@ public sealed class ConfigurableWorkflowRunner(
|
||||
return order;
|
||||
}
|
||||
|
||||
/// <summary>先填节点 Config,再用入边把上游输出端口覆盖到下游输入端口(连线优先)。</summary>
|
||||
private static Dictionary<string, object?> ResolveInputs(
|
||||
WorkflowGraph graph,
|
||||
WorkflowNode node,
|
||||
@@ -505,6 +519,7 @@ public sealed class ConfigurableWorkflowRunner(
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/// <summary>入边 When 不满足则跳过本节点。当前只实现 hasValidCities 这一类条件。</summary>
|
||||
private static bool PassEdgeConditions(
|
||||
WorkflowGraph graph,
|
||||
WorkflowNode node,
|
||||
|
||||
@@ -2,12 +2,14 @@ using MAF1.Decisions;
|
||||
|
||||
namespace MAF1.Web;
|
||||
|
||||
/// <summary>浏览器 POST /api/run 的图:节点 + 连线。和前端 state.nodes / state.edges 一一对应。</summary>
|
||||
public sealed class WorkflowGraph
|
||||
{
|
||||
public List<WorkflowNode> Nodes { get; set; } = [];
|
||||
public List<WorkflowEdge> Edges { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>画布节点。Config 里可写 filePath、credentialId、decisionTimeoutSeconds 等固定输入。</summary>
|
||||
public sealed class WorkflowNode
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
@@ -18,6 +20,9 @@ public sealed class WorkflowNode
|
||||
public Dictionary<string, string> Config { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一条边:FromPort → ToPort 传数据。When 是运行条件(hasValidCities / !hasValidCities / 空=始终)。
|
||||
/// </summary>
|
||||
public sealed class WorkflowEdge
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
@@ -28,6 +33,7 @@ public sealed class WorkflowEdge
|
||||
public string? When { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>一次运行的状态机:completed / needsDecision / failed。</summary>
|
||||
public static class WorkflowRunStatus
|
||||
{
|
||||
public const string Completed = "completed";
|
||||
@@ -35,6 +41,7 @@ public static class WorkflowRunStatus
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
|
||||
/// <summary>返回给前端的运行快照。暂停时带 Decision,方便弹出确认框。</summary>
|
||||
public sealed class WorkflowRunResult
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
@@ -46,6 +53,7 @@ public sealed class WorkflowRunResult
|
||||
public List<NodeRunLog> Steps { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>单个节点的执行日志,显示在页面底部「运行结果」。</summary>
|
||||
public sealed class NodeRunLog
|
||||
{
|
||||
public string NodeId { get; set; } = "";
|
||||
|
||||
@@ -4,6 +4,10 @@ using MAF1.Plugins;
|
||||
|
||||
namespace MAF1.Web;
|
||||
|
||||
/// <summary>
|
||||
/// 一次可暂停的运行。NextIndex 记住停在第几个节点;Pending 是当前确认请求。
|
||||
/// Mutex 防止「用户点击」和「超时自动确认」同时 Continue。
|
||||
/// </summary>
|
||||
internal sealed class WorkflowSession
|
||||
{
|
||||
public required string RunId { get; init; }
|
||||
@@ -22,6 +26,7 @@ internal sealed class WorkflowSession
|
||||
public object Sync { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>进程内运行字典。服务重启后 runId 会失效,学习项目不做持久化。</summary>
|
||||
public sealed class WorkflowRunStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, WorkflowSession> _sessions = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* 编排器布局:顶栏、三栏(节点 / 画布 / 检查器)、底部日志、确认弹层。 */
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--panel: #171e26;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- 单页编排器:左节点列表、中画布、右检查器、下日志。逻辑全在 /js/app.js。 -->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -37,6 +38,7 @@
|
||||
<h2>运行结果</h2>
|
||||
<pre id="log">尚未运行。</pre>
|
||||
</section>
|
||||
<!-- 缺城市时的确认层,对应后端 status=needsDecision -->
|
||||
<div id="decisionModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="dialog">
|
||||
<h2>需要你确认</h2>
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* 可视化编排器前端(无框架)。
|
||||
*
|
||||
* 学习路径:
|
||||
* 1. init → loadCatalog:拉系统节点和插件节点
|
||||
* 2. 示例图 / 拖节点、点端口连线;图数据在 state.nodes / state.edges
|
||||
* 3. runGraph POST /api/run;若 status=needsDecision 弹出确认框
|
||||
* 4. 确认走 /api/run/{id}/decide;超时则 pollRun 等服务端自动采用默认方案
|
||||
*
|
||||
* selected / pending 只用于点击交互。发给后端的 JSON 不含 API Key,只有 credentialId。
|
||||
*/
|
||||
const state = {
|
||||
catalog: [],
|
||||
system: [],
|
||||
@@ -44,6 +55,7 @@ function pickNode(list, inputName) {
|
||||
return (list || []).find((item) => item.inputs?.some((p) => p.name === inputName));
|
||||
}
|
||||
|
||||
/** 生成「抽城市 → 有城市才查天气」的示例图。连线 when=hasValidCities。 */
|
||||
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");
|
||||
@@ -110,6 +122,7 @@ function addNode(type) {
|
||||
render();
|
||||
}
|
||||
|
||||
/** 根据 state 重画节点 DOM;连线是 SVG,在 drawWires。 */
|
||||
function render() {
|
||||
canvas.innerHTML = "";
|
||||
for (const node of state.nodes) {
|
||||
@@ -172,6 +185,7 @@ function startDrag(e, node) {
|
||||
window.addEventListener("mouseup", up);
|
||||
}
|
||||
|
||||
/** 先点输出端口记下 pending,再点输入端口生成一条边。 */
|
||||
function onPort(nodeId, port, dir) {
|
||||
if (dir === "out") {
|
||||
state.pending = { nodeId, port };
|
||||
@@ -293,6 +307,7 @@ function renderInspector() {
|
||||
};
|
||||
}
|
||||
|
||||
/** 把画布图交给后端。可能直接完成,也可能弹出 decisionModal。 */
|
||||
async function runGraph() {
|
||||
closeDecision();
|
||||
logEl.textContent = "运行中…";
|
||||
@@ -379,6 +394,7 @@ function syncDecisionText() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 倒计时到 0 后去 GET 运行结果,因为服务端会自己按默认方案继续。 */
|
||||
function startDecisionTimer(deadline) {
|
||||
if (decisionTimer) {
|
||||
clearInterval(decisionTimer);
|
||||
@@ -447,6 +463,7 @@ decisionSubmit.onclick = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 拉节点目录和凭据列表,刷新左侧面板。 */
|
||||
async function loadCatalog() {
|
||||
const [catalog, credentials] = await Promise.all([
|
||||
(await fetch("/api/catalog")).json(),
|
||||
|
||||
Reference in New Issue
Block a user