# MAF1 — 多智能体工作流编排 MAF1 是一个基于 **.NET 10** 和 **Microsoft Agents AI** 的多智能体演示仓库。同一条「读文件抽城市 → 查天气」链路拆成两个可执行项目: 1. **MAF1**:命令行工作流。用 Microsoft Agents AI Workflows 对比「条件写在节点里」和「条件写在边上」。 2. **MAF1.Web**:可视化编排器。浏览器里拖节点、连线、配条件,然后一键运行。 内置 Agent 在 `MAF1.Core` 里;网页还可以把同一套能力以 **独立进程插件** 的形式画到画布上。LLM 的 endpoint / API Key / 模型由各宿主注入,**不会**画成节点端口,也不会写进流程图 JSON。 网页打开:(先启动 `MAF1.Web`) --- ## 功能概览 - **可视化工作流**:左侧节点面板(系统节点 + 扫描到的插件)、画布连线、右侧检查器、底部运行日志。 - **系统节点**:`fileCity-system`(读文本抽城市)、`weather-system`(按城市查天气并汇总)。 - **进程外插件**:扫描输出目录 `plugins/*/plugin.json`,按 `launch` 启动子进程,stdin / stdout 走 JSON。 - **凭据隔离**:宿主配置 LLM;节点只声明需要哪种凭据(默认 `llm-default`)。浏览器拿不到原始 Key。 - **边条件**:连线可设 `hasValidCities` / `!hasValidCities`,不满足则跳过下游节点。 - **人机决策**:抽城市失败时暂停等待确认;必须有默认方案(结束查询)。网页弹出确认框,CLI 在控制台询问。超时未确认则自动采用默认方案,也可以改填其它城市继续查天气。 - **CLI 对照**:`node` 模式把「有没有城市」写在 Gate 节点里;`edge` 模式把这条判断写在边上。两种模式缺城市时都会进入同一套确认。 - **天气数据源**:默认 [wttr.in](https://wttr.in),可改为 OpenWeatherMap。 --- ## 技术栈 | 部分 | 说明 | |------|------| | 运行时 | .NET 10(`net10.0`) | | 控制台 | `MAF1`:普通控制台,Microsoft Agents AI Workflows | | 网页 | `MAF1.Web`:ASP.NET Core Minimal API + `wwwroot` | | LLM | `Azure.AI.OpenAI` + `Microsoft.Agents.AI.OpenAI`(兼容 OpenAI / Azure OpenAI / DeepSeek 等) | | 前端 | `MAF1.Web/wwwroot` 下原生 HTML / CSS / JS,无 npm 依赖 | | 插件 | 独立控制台进程,协议见 `MAF1.Core/PluginContract`,由网页宿主扫描 | 解决方案文件:`MAF1.slnx`(`MAF1`、`MAF1.Web`、`MAF1.Core`、两个插件工程)。 --- ## 仓库结构 ``` 仓库根目录/ ├── MAF1.slnx ├── MAF1/ # 控制台项目 │ ├── MAF1.csproj │ ├── Program.cs / CliHost.cs │ ├── Workflows/ # Microsoft Agents AI Workflow │ ├── Orchestration/ # 控制台事件流 + 确认提示 │ ├── Data/ │ └── appsettings.json ├── MAF1.Web/ # 网页编排项目 │ ├── MAF1.Web.csproj │ ├── Program.cs # Minimal API │ ├── Web/ # 图执行、运行时组装 │ ├── PluginHost/ # 扫描插件、起进程、注入凭据 │ ├── wwwroot/ # 可视化界面 │ ├── Data/ │ └── appsettings.json ├── MAF1.Core/ # 共享:Agent、Tool、LLM、插件协议 └── plugins/ ├── file-city/ └── weather/ ``` 构建 **MAF1.Web** 时会编译两个插件,并把输出复制到网页宿主 `bin/.../plugins/`。**运行时扫描的是网页 exe 旁边的 `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 --project MAF1.Web --launch-profile designer` 会读 `MAF1.Web/Properties/launchSettings.json` 里的环境变量。该文件容易带上真实密钥,**请勿把含 Key 的版本推到远程**。 控制台 profile 在 `MAF1/Properties/launchSettings.json`(`node` / `edge`)。 ### 3. 启动可视化编排 ```bash dotnet run --project MAF1.Web ``` 浏览器打开 。控制台会打印:`可视化编排: http://127.0.0.1:5288`。 界面操作建议: 1. 点 **示例图**,生成「抽城市 → 查天气」的演示图(含边条件)。 2. 选中节点,在右侧填 `filePath`(例如 `Data/cities.txt`),或检查端口映射。 3. 点 **运行**,底部查看每步输入 / 输出。 4. 改完 `plugins/` 后点 **刷新节点** 重新加载目录。 ### 4. 命令行工作流 ```bash dotnet run --project MAF1 -- node Data/cities.txt # 条件在 Gate 节点内部 dotnet run --project MAF1 -- edge Data/cities.txt # 条件在边上分流 dotnet run --project MAF1 -- --help ``` 无参数时默认 `node Data/cities.txt`。无有效城市时可用 `Data/not-cities.txt`。工作流会在控制台询问如何继续(默认结束查询,也可输入其它城市);超时走默认方案。 ```bash dotnet run --project MAF1 -- node Data/not-cities.txt dotnet run --project MAF1 -- edge Data/not-cities.txt ``` 控制台会出现: ``` [需要确认] 没有读到有效城市。… 1) 结束查询(默认) ← 默认 2) 改查其它城市 输入序号,或直接回车采用默认: ``` 输入 `1` 或回车结束;输入 `2` 后再填城市名继续查天气;也可以直接输入 `成都`。等待秒数与网页相同,来自 `Workflow:DecisionTimeoutSeconds`。 启动配置: | 项目 | Profile | 作用 | |------|---------|------| | `MAF1.Web` | `designer` | 网页编排器 | | `MAF1` | `node` | CLI,节点内判断 | | `MAF1` | `edge` | CLI,边上判断 | --- ## 配置说明 `MAF1/appsettings.json` 与 `MAF1.Web/appsettings.json` 会分别复制到各自输出目录。网页项目额外包含 `Plugins` / `Credentials`。主要段落: ### 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 | ### Workflow | 字段 | 含义 | |------|------| | `DecisionTimeoutSeconds` | 需要用户确认时的等待秒数,默认 `30`。超时后采用默认方案。 | 节点 `config` 可覆盖:`decisionTimeoutSeconds`(秒)、`askOnEmptyCities`(`false` 时抽不到城市不询问,直接按边条件跳过)。 --- ## 内置 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" } ] } ``` - **拓扑**:按依赖排序执行;未满足入边条件的节点会被标记为跳过。 - **人机决策**:节点输出 `hasValidCities = false` 时,若后面还有节点,则返回 `status: needsDecision`,不立刻跑完。 - **端口**:`fromPort` → `toPort` 把上游输出接到下游输入;未接线的必填项可写在 `config`。 - **边条件 `when`**(当前实现): - 空:始终通过 - `hasValidCities`:上游输出该布尔为真 - `!hasValidCities`:为假 - 其它字符串:视为通过 --- ## HTTP API 端口固定:`http://127.0.0.1:5288`(`MAF1.Web/Program.cs`)。 | 方法 | 路径 | 说明 | |------|------|------| | `GET` | `/api/catalog` | 系统节点 + 插件节点、扫描问题、`pluginsRoot` | | `GET` | `/api/credentials` | 对外凭据列表(不含原始 Key) | | `GET` | `/api/status` | 当前天气 Provider、插件根路径 | | `POST` | `/api/run` | Body 为工作流图 JSON。可能直接跑完,也可能返回 `needsDecision` | | `GET` | `/api/run/{runId}` | 查询暂停中或已结束的一次运行 | | `POST` | `/api/run/{runId}/decide` | Body:`{ "optionId": "stop" }` 或 `{ "optionId": "query-cities", "text": "成都" }` | `POST /api/run` 在需要确认时返回: ```json { "ok": true, "status": "needsDecision", "runId": "…", "decision": { "prompt": "没有读到有效城市…", "defaultOptionId": "stop", "timeoutSeconds": 30, "deadline": "2026-08-27T09:22:00+00:00", "options": [ { "id": "stop", "label": "结束查询(默认)", "isDefault": true }, { "id": "query-cities", "label": "改查其它城市", "requiresText": true } ] }, "steps": [] } ``` 默认方案必须存在。服务端到期会自动按 `defaultOptionId` 继续;客户端倒计时结束后可再 `GET` 一次拿最终结果。 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.Web.csproj` 增加 `ProjectReference`(`ReferenceOutputAssembly=false`)和 `PublishPluginFolders` 复制规则。 3. `id` 不要与系统节点类型冲突;若同名,目录会标记覆盖关系。 4. 重启宿主或点「刷新节点」,确认 `/api/catalog` 的 `issues` 为空。 --- ## 架构 ``` 浏览器 MAF1.Web/wwwroot │ REST ▼ MAF1.Web Program.cs(Minimal API) │ ▼ AgentRuntime ├── NodeCatalogService 合并系统节点 + PluginScanner ├── CredentialStore llm-default 等 ├── ConfigurableWorkflowRunner 拓扑、端口、when、逐步日志 ├── 系统 Handler FileCity / Weather(进程内) └── PluginProcessRunner 子进程 stdin/stdout ``` 控制台项目不走画布: `MAF1` → `CliHost` → `CityWeatherWorkflow`(node / edge)→ `WorkflowOrchestration`。缺城市时走 `RequestPort`,与网页同一套默认方案 + 超时。 `MAF1.Core` 被控制台、网页与插件共用,避免两套 Agent 逻辑分叉。 --- ## 示例数据 `Data/cities.txt`: ``` 成都 阿姆斯特丹 北京 ``` `Data/not-cities.txt`:不含有效城市,用于验证「无城市则询问后默认不查天气」。 这些文件会随构建复制到各项目输出目录。CLI 传入相对路径时,请在仓库根目录用 `--project MAF1` 运行,或在已复制 Data 的输出目录下运行。 --- ## 常见问题 **启动报「还没有配置模型」** `Llm:ApiKey` 和环境变量都为空。按「快速开始」配置后重启。 **画布上看不到插件** 插件必须出现在 **MAF1.Web.exe 旁边** 的 `plugins/`。先 `dotnet build MAF1.Web/MAF1.Web.csproj`,确认 `MAF1.Web/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`。关掉占用进程,或临时改 `MAF1.Web/Program.cs` / `MAF1.Web/Properties/launchSettings.json`。 **没有自动化测试 / Docker** 仓库目前没有测试项目和容器文件。验证方式:UI 示例图 + CLI `node` / `edge`。 --- ## 安全注意 - LLM Key、OpenWeather Key 只放在本机配置或环境变量,不要写进 `plugin.json`、流程图或前端。 - `/api/credentials` 只返回公开元数据。 - 若 Key 曾经出现在 `launchSettings.json` 并被提交或分享,请到服务商控制台轮换密钥。 --- ## 许可与定位 本仓库是多智能体编排与进程外插件的**可运行演示**,便于对照系统节点与插件、对照 CLI 的 node / edge 两种条件写法。按你自己的许可证要求补充版权声明即可。