outputs = await plugins.RunAsync(
+ plugin,
+ declared,
+ credentialId,
+ bindings,
+ cancellationToken);
string? stderr = null;
if (outputs.Remove("_stderr", out object? stderrValue))
{
diff --git a/MAF1.Web/appsettings.json b/MAF1.Web/appsettings.json
index 875d277..e184fa2 100644
--- a/MAF1.Web/appsettings.json
+++ b/MAF1.Web/appsettings.json
@@ -16,6 +16,11 @@
"type": "openai-compatible",
"name": "系统默认模型",
"source": "llm-section"
+ },
+ "openweather-default": {
+ "type": "openweather",
+ "name": "OpenWeather",
+ "apiKey": ""
}
},
"Weather": {
diff --git a/MAF1.Web/wwwroot/js/app.js b/MAF1.Web/wwwroot/js/app.js
index eaf9b36..769eea3 100644
--- a/MAF1.Web/wwwroot/js/app.js
+++ b/MAF1.Web/wwwroot/js/app.js
@@ -7,7 +7,7 @@
* 3. runGraph POST /api/run;若 status=needsDecision 弹出确认框
* 4. 确认走 /api/run/{id}/decide;超时则 pollRun 等服务端自动采用默认方案
*
- * selected / pending 只用于点击交互。发给后端的 JSON 不含 API Key,只有 credentialId。
+ * selected / pending 只用于点击交互。发给后端的 JSON 不含 API Key,只有 credentialId / credential:名称。
*/
const state = {
catalog: [],
@@ -51,6 +51,50 @@ function typeInfo(type) {
|| state.plugins.find((item) => item.type === type);
}
+function isLlmCredentialType(type) {
+ const t = (type || "").toLowerCase();
+ return t === "openai-compatible" || t === "llm";
+}
+
+function credentialConfigKey(need) {
+ if (isLlmCredentialType(need.type) || (need.name || "").toLowerCase() === "llm") {
+ return "credentialId";
+ }
+ return "credential:" + need.name;
+}
+
+function credentialsMatching(type) {
+ return (state.credentials || []).filter((c) => {
+ if (isLlmCredentialType(type) && isLlmCredentialType(c.type)) {
+ return true;
+ }
+ return (c.type || "").toLowerCase() === (type || "").toLowerCase();
+ });
+}
+
+function renderCredentialFields(node, info) {
+ const needs = info.credentials || [];
+ if (needs.length === 0) {
+ return `此节点未声明凭据,宿主不会注入 API Key。
`;
+ }
+ return needs.map((need) => {
+ const cfgKey = credentialConfigKey(need);
+ const options = credentialsMatching(need.type);
+ const current = node.config[cfgKey] || (options[0] ? options[0].id : "");
+ if (current && !node.config[cfgKey]) {
+ node.config[cfgKey] = current;
+ }
+ const label = need.name + (need.required ? " *" : "(可选)");
+ if (options.length === 0) {
+ return `宿主没有类型 ${need.type} 的凭据。
`;
+ }
+ const opts = options.map((c) =>
+ ``
+ ).join("");
+ return ``;
+ }).join("") + `Key 由宿主注入子进程,不会出现在端口或导出的流程图里。
`;
+}
+
function pickNode(list, inputName) {
return (list || []).find((item) => item.inputs?.some((p) => p.name === inputName));
}
@@ -252,9 +296,7 @@ function renderInspector() {
${info.description}
-
-
- endpoint / API Key 由宿主注入进程,不会出现在输入端口或导出的流程图里。
+ ${renderCredentialFields(node, info)}
${info.inputs.map((p) => `
@@ -262,7 +304,9 @@ function renderInspector() {
若该输入已从上一节点连线,运行时以连线为准。
`;
inspector.querySelector("#title").oninput = (e) => { node.title = e.target.value; };
- inspector.querySelector("#credentialId")?.addEventListener("change", (e) => { node.config.credentialId = e.target.value; });
+ inspector.querySelectorAll("[data-credential-config]").forEach((select) => {
+ select.addEventListener("change", () => { node.config[select.dataset.credentialConfig] = select.value; });
+ });
inspector.querySelectorAll("[data-config]").forEach((input) => {
input.oninput = () => { node.config[input.dataset.config] = input.value; };
});
diff --git a/Plugins/README.md b/Plugins/README.md
index aa583db..3d83987 100644
--- a/Plugins/README.md
+++ b/Plugins/README.md
@@ -2,19 +2,32 @@
每个子文件夹是一个插件。软件启动后扫描**网页宿主执行目录**下的 `plugins/`(即 `MAF1.Web.exe` 旁边),不是源码目录。
+协议:`plugin.json` 的 `protocolVersion` 为 **1**(缺省也当 1)。stdin 形状见 [docs/plugin-protocol-v1.schema.json](../docs/plugin-protocol-v1.schema.json)。
+
## 必备文件
-- `plugin.json`:id、启动命令、凭据声明、inputs、outputs
+- `plugin.json`:id、protocolVersion、启动命令、凭据声明、inputs、outputs
- `README.md`:给使用者看的说明
- 可执行文件 / 脚本 / 源码:由 `launch.command` + `launch.args` 原样启动
+非秘密配置(例如天气 URL 模板)放在**插件自己目录**的 `appsettings.json` 或 `plugin.json` 的 `env`。不要去读宿主 exe 旁的配置文件。
+
## 凭据(不要写进 plugin.json)
n8n / Dify 一类产品把 API Key 放在宿主凭据库,节点只声明「我需要哪种凭据」。本项目同样:
-- Key 和 endpoint 配在宿主的环境变量或 `appsettings.json`
-- 节点可选 `credentialId`(默认 `llm-default`)
-- 启动子进程时注入环境变量,并在 stdin JSON 的 `credentials` 里再传一份
+- Key 和 endpoint 配在宿主的环境变量或 `appsettings.json` 的 `Credentials` / `Llm`
+- 节点可选 `credentialId`(LLM,默认 `llm-default`);其它类型用 `credential:` 或按 type 取默认
+- 只注入 **plugin.json 里声明过的**凭据。未声明 LLM 的插件拿不到 `OPENAI_API_KEY`
+- 启动子进程时写入对应环境变量,并在 stdin JSON 的 `credentials` 里再传一份
- 浏览器和流程图 JSON **不会**包含 apiKey
+已知 `type` 与环境变量:
+
+| type | 环境变量 |
+|------|----------|
+| `openai-compatible` / `llm` | `OPENAI_ENDPOINT` / `OPENAI_API_KEY` / `OPENAI_CHAT_MODEL` |
+| `openweather` | `OPENWEATHER_API_KEY` |
+| 其它 | `extra` 里的键原样写入环境变量 |
+
第三方若要用自己的模型,可在插件目录放 `.env`(不要提交)。节点选中的宿主凭据会覆盖其中的同名变量。
diff --git a/Plugins/file-city/Program.cs b/Plugins/file-city/Program.cs
index bd933bc..253cbb4 100644
--- a/Plugins/file-city/Program.cs
+++ b/Plugins/file-city/Program.cs
@@ -7,7 +7,7 @@ using MAF1.Utils;
PluginRequest request = await PluginStdio.ReadRequestAsync();
PluginStdio.ApplyCredentialsToEnvironment(request.Credentials);
-AgentFactory factory = new(AgentFactory.Load(PluginStdio.LoadHostConfiguration()));
+AgentFactory factory = new(AgentFactory.LoadFromEnvironment());
var result = await FileCityAgent.RunAsync(FileCityAgent.Create(factory), request.Inputs);
await PluginStdio.WriteOutputsAsync(result.Outputs);
return 0;
diff --git a/Plugins/file-city/README.md b/Plugins/file-city/README.md
index 42c4120..965ed06 100644
--- a/Plugins/file-city/README.md
+++ b/Plugins/file-city/README.md
@@ -2,6 +2,8 @@
独立进程。宿主只读取本目录的 `plugin.json`,按 `launch` 原样启动,不会替你拼命令。
+`protocolVersion` 为 1。LLM 只从环境变量读取(宿主注入),不读宿主 `appsettings.json`。
+
## 输入 / 输出
字段名必须和 `plugin.json` 以及 `Program.cs` 里读写的 JSON 键一致。
@@ -24,6 +26,7 @@ stdin:
```json
{
+ "protocolVersion": 1,
"inputs": { "filePath": "Data/cities.txt" },
"credentials": {
"llm": { "id": "llm-default", "type": "openai-compatible", "endpoint": "...", "apiKey": "...", "model": "..." }
diff --git a/Plugins/file-city/plugin.json b/Plugins/file-city/plugin.json
index 41140e1..c43416a 100644
--- a/Plugins/file-city/plugin.json
+++ b/Plugins/file-city/plugin.json
@@ -3,6 +3,7 @@
"name": "FileCityAgent",
"description": "读取指定文本文件,判断并抽出有效城市名。",
"version": "1.0.0",
+ "protocolVersion": 1,
"timeoutSeconds": 120,
"launch": {
"command": "dotnet",
diff --git a/Plugins/weather/Program.cs b/Plugins/weather/Program.cs
index bb3c97d..f1a76f9 100644
--- a/Plugins/weather/Program.cs
+++ b/Plugins/weather/Program.cs
@@ -1,4 +1,5 @@
-// 进程外 Weather 插件。同样只做 stdio 适配,天气实现仍在 MAF1.Core。
+// 进程外 Weather 插件。stdio 适配 + 复用 MAF1.Core 的 WeatherAgent。
+// LLM / OpenWeather Key 只来自宿主注入的环境变量;天气站点配置读本插件目录的 appsettings.json。
using MAF1.Agents.Weather;
using MAF1.PluginContract;
using MAF1.Tools;
@@ -8,9 +9,15 @@ using Microsoft.Extensions.Configuration;
PluginRequest request = await PluginStdio.ReadRequestAsync();
PluginStdio.ApplyCredentialsToEnvironment(request.Credentials);
-IConfiguration config = PluginStdio.LoadHostConfiguration();
+IConfiguration config = PluginStdio.LoadPluginConfiguration();
AgentFactory factory = new(AgentFactory.Load(config));
WeatherOptions weatherOptions = config.GetSection("Weather").Get() ?? new WeatherOptions();
+string? openWeatherKey = Environment.GetEnvironmentVariable("OPENWEATHER_API_KEY");
+if (!string.IsNullOrWhiteSpace(openWeatherKey))
+{
+ weatherOptions.OpenWeather.ApiKey = openWeatherKey;
+}
+
using HttpClient http = WeatherTools.CreateHttpClient();
var result = await WeatherAgent.RunAsync(
WeatherAgent.Create(factory, new WeatherTools(weatherOptions, http)),
diff --git a/Plugins/weather/README.md b/Plugins/weather/README.md
index 2fae3c4..4f3bef6 100644
--- a/Plugins/weather/README.md
+++ b/Plugins/weather/README.md
@@ -1,12 +1,14 @@
# Weather 插件
-独立进程。启动命令只看 `plugin.json` 的 `launch`。
+独立进程。启动命令只看 `plugin.json` 的 `launch`。`protocolVersion` 为 1。
## 输入 / 输出
- 输入 `cities`(字符串数组,与代码、清单同名)
- 输出 `summary`
-## 凭据
+## 凭据与配置
-LLM 的 endpoint / apiKey 由宿主注入,不出现在流程图端口上。天气 HTTP 配置走宿主 `appsettings.json`(通过环境变量 `MAF1_CONTENT_ROOT` 定位)。
+- LLM:宿主注入 `OPENAI_*`,以及 stdin `credentials.llm`
+- OpenWeather(可选):宿主凭据类型 `openweather`,注入 `OPENWEATHER_API_KEY`。默认 Provider 是免费的 Wttr,不需要这个 Key
+- 天气 URL / 语言:读**本插件目录**的 `appsettings.json`,不读宿主配置
diff --git a/Plugins/weather/WeatherPlugin.csproj b/Plugins/weather/WeatherPlugin.csproj
index f9d63d6..791aa42 100644
--- a/Plugins/weather/WeatherPlugin.csproj
+++ b/Plugins/weather/WeatherPlugin.csproj
@@ -15,6 +15,9 @@
PreserveNewest
+
+ PreserveNewest
+
PreserveNewest
diff --git a/Plugins/weather/appsettings.json b/Plugins/weather/appsettings.json
new file mode 100644
index 0000000..74793d9
--- /dev/null
+++ b/Plugins/weather/appsettings.json
@@ -0,0 +1,13 @@
+{
+ "Weather": {
+ "Provider": "Wttr",
+ "Language": "zh",
+ "Wttr": {
+ "UrlTemplate": "https://wttr.in/{location}?lang={lang}&format=3"
+ },
+ "OpenWeather": {
+ "UrlTemplate": "https://api.openweathermap.org/data/2.5/weather?q={location}&appid={apiKey}&units=metric&lang={lang}",
+ "ApiKey": ""
+ }
+ }
+}
diff --git a/Plugins/weather/plugin.json b/Plugins/weather/plugin.json
index c49990d..6412d4a 100644
--- a/Plugins/weather/plugin.json
+++ b/Plugins/weather/plugin.json
@@ -3,6 +3,7 @@
"name": "WeatherAgent",
"description": "按城市列表查询天气并汇总。",
"version": "1.0.0",
+ "protocolVersion": 1,
"timeoutSeconds": 180,
"launch": {
"command": "dotnet",
@@ -14,6 +15,12 @@
"type": "openai-compatible",
"required": true,
"description": "由宿主注入 OpenAI 兼容的 endpoint / apiKey / model,不要写进本文件。"
+ },
+ {
+ "name": "weather",
+ "type": "openweather",
+ "required": false,
+ "description": "仅当本插件 appsettings.json 的 Weather:Provider 为 OpenWeather 时需要。Wttr 免费接口不用填。"
}
],
"inputs": [
diff --git a/README.md b/README.md
index ae01097..a3e480d 100644
--- a/README.md
+++ b/README.md
@@ -354,7 +354,7 @@ JSON 使用 camelCase。静态站点来自 `wwwroot`。
### 必备文件
-- `plugin.json`:id、启动命令、凭据声明、inputs、outputs
+- `plugin.json`:id、protocolVersion、启动命令、凭据声明、inputs、outputs
- `README.md`:给人看的说明
- 可执行文件:由 `launch.command` + `launch.args` **原样**启动,宿主不替你拼路径
@@ -375,6 +375,7 @@ JSON 使用 camelCase。静态站点来自 `wwwroot`。
```json
{
+ "protocolVersion": 1,
"inputs": { "filePath": "Data/cities.txt" },
"credentials": {
"llm": {
@@ -395,12 +396,14 @@ JSON 使用 camelCase。静态站点来自 `wwwroot`。
### 凭据注入
-与 n8n / Dify 类似:Key 只在宿主。运行插件时会:
+与 n8n / Dify 类似:Key 只在宿主。只注入 `plugin.json` 声明过的凭据。运行插件时会:
-1. 写入环境变量 `OPENAI_ENDPOINT` / `OPENAI_API_KEY` / `OPENAI_CHAT_MODEL`
+1. 按 type 写入环境变量(LLM 为 `OPENAI_*`,OpenWeather 为 `OPENWEATHER_API_KEY`)
2. 在 stdin JSON 的 `credentials` 里再传一份
-插件目录可选 `.env` 作为第三方自带模型的默认环境;节点选中的宿主凭据会覆盖同名变量。**不要把 `.env` 提交进仓库。**
+JSON Schema:[docs/plugin-protocol-v1.schema.json](docs/plugin-protocol-v1.schema.json)。`plugin.json` 的 `protocolVersion` 缺省视为 1;大于 1 的清单会被扫描拒绝。
+
+插件目录可选 `.env` 作为第三方自带模型的默认环境;节点选中的宿主凭据会覆盖同名变量。**不要把 `.env` 提交进仓库。** 非秘密配置放在插件自己的 `appsettings.json`,不要读宿主 exe 目录。
更细的输入输出说明:
diff --git a/docs/plugin-protocol-v1.schema.json b/docs/plugin-protocol-v1.schema.json
new file mode 100644
index 0000000..5099b4f
--- /dev/null
+++ b/docs/plugin-protocol-v1.schema.json
@@ -0,0 +1,47 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://maf1.local/plugin-protocol-v1.schema.json",
+ "title": "MAF1 plugin protocol v1",
+ "description": "宿主写入插件 stdin 的 PluginRequest。stdout 必须是与 plugin.json outputs 字段对应的单个 JSON 对象。",
+ "type": "object",
+ "required": ["protocolVersion", "inputs"],
+ "additionalProperties": false,
+ "properties": {
+ "protocolVersion": {
+ "type": "integer",
+ "const": 1
+ },
+ "inputs": {
+ "type": "object",
+ "description": "键名与 plugin.json inputs[].name 一致。",
+ "additionalProperties": true
+ },
+ "credentials": {
+ "type": "object",
+ "description": "键名与 plugin.json credentials[].name 一致。未声明的凭据不会出现。",
+ "additionalProperties": {
+ "$ref": "#/$defs/credentialPayload"
+ }
+ }
+ },
+ "$defs": {
+ "credentialPayload": {
+ "type": "object",
+ "required": ["id", "type"],
+ "properties": {
+ "id": { "type": "string" },
+ "type": {
+ "type": "string",
+ "description": "openai-compatible | llm | openweather | 其它自定义类型"
+ },
+ "endpoint": { "type": ["string", "null"] },
+ "apiKey": { "type": ["string", "null"] },
+ "model": { "type": ["string", "null"] },
+ "extra": {
+ "type": "object",
+ "additionalProperties": { "type": "string" }
+ }
+ }
+ }
+ }
+}