feat: 将插件 stdin 协议定为 v1,并按声明注入凭据

进程外插件改为只读环境变量和自身配置,避免读宿主 appsettings;宿主按 plugin.json 声明注入 LLM / OpenWeather 等凭据。
This commit is contained in:
2026-09-11 10:46:52 +08:00
parent d1b979f2db
commit b631acd42e
22 changed files with 377 additions and 95 deletions
+2 -1
View File
@@ -27,10 +27,11 @@ dotnet run --project MAF1.Web # 浏览器 http://127.0.0.1:528
## 关键陷阱(改代码前必读) ## 关键陷阱(改代码前必读)
1. **插件运行时读的是 bin 输出,不是源码树**`PluginScanner` 扫描 `MAF1.Web.exe` 旁的 `plugins/`。改完 `Plugins/` 源码必须重新 `dotnet build`(靠 `MAF1.Web.csproj``PublishPluginFolders` 目标复制)或点前端「刷新节点」。 1. **插件运行时读的是 bin 输出,不是源码树**`PluginScanner` 扫描 `MAF1.Web.exe` 旁的 `plugins/`。改完 `Plugins/` 源码必须重新 `dotnet build`(靠 `MAF1.Web.csproj``PublishPluginFolders` 目标复制)或点前端「刷新节点」。
2. **不要提交 API Key**Key 走环境变量或 `appsettings.json``Llm` 段(环境变量优先)。注意 `**/Properties/launchSettings.json` 里目前硬编码了一个真实 Key,新增/修改配置时切勿再写入真实密钥,也勿把含 Key 的版本提交。 2. **不要提交 API Key**Key 走环境变量或 `appsettings.json``Llm` / `Credentials` 段(环境变量优先)。注意 `**/Properties/launchSettings.json` 里目前硬编码了一个真实 Key,新增/修改配置时切勿再写入真实密钥,也勿把含 Key 的版本提交。
3. **插件 stdout 只能输出一行 JSON**:日志和提示走 stderr,否则宿主 `ParseOutputs` 会解析失败。 3. **插件 stdout 只能输出一行 JSON**:日志和提示走 stderr,否则宿主 `ParseOutputs` 会解析失败。
4. **天气查询必须用 `WeatherTools.CreateHttpClient()`**:带 20s 超时和正确 User-Agent,直接 `new HttpClient()` 可能被 wttr.in 拒绝。 4. **天气查询必须用 `WeatherTools.CreateHttpClient()`**:带 20s 超时和正确 User-Agent,直接 `new HttpClient()` 可能被 wttr.in 拒绝。
5. **LLM 输出解析前先过 `JsonText.UnwrapObject`**:剥离模型回复的 ```json ``` 围栏后再反序列化。 5. **LLM 输出解析前先过 `JsonText.UnwrapObject`**:剥离模型回复的 ```json ``` 围栏后再反序列化。
6. **进程外插件不要读宿主 appsettings**LLM 用 `AgentFactory.LoadFromEnvironment()`;其它配置用插件目录自己的 json。stdin 协议见 `docs/plugin-protocol-v1.schema.json``protocolVersion` 1)。
## 约定 ## 约定
@@ -12,6 +12,10 @@ public sealed class PluginManifest
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public string Description { get; set; } = ""; public string Description { get; set; } = "";
public string Version { get; set; } = "1.0.0"; public string Version { get; set; } = "1.0.0";
/// <summary>stdin/stdout 协议主版本。缺省或 0 视为 1。宿主拒绝大于 <see cref="PluginProtocol.Current"/> 的值。</summary>
public int ProtocolVersion { get; set; }
public PluginLaunch Launch { get; set; } = new(); public PluginLaunch Launch { get; set; } = new();
public int TimeoutSeconds { get; set; } public int TimeoutSeconds { get; set; }
public Dictionary<string, string> Env { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, string> Env { get; set; } = new(StringComparer.OrdinalIgnoreCase);
@@ -45,9 +49,51 @@ public sealed class PluginCredentialNeed
public string Description { get; set; } = ""; public string Description { get; set; } = "";
} }
/// <summary>stdin/stdout JSON 协议版本。缺省字段按 1 处理,便于旧 plugin.json 继续用。</summary>
public static class PluginProtocol
{
public const int Current = 1;
public static int Normalize(int version) => version <= 0 ? 1 : version;
public static bool IsSupported(int version)
{
int normalized = Normalize(version);
return normalized >= 1 && normalized <= Current;
}
}
/// <summary>凭据 type 约定。宿主按类型注入环境变量,未知类型只传 stdin Extra。</summary>
public static class PluginCredentialTypes
{
public const string OpenAiCompatible = "openai-compatible";
public const string Llm = "llm";
public const string OpenWeather = "openweather";
public static bool IsLlm(string? type)
=> type is OpenAiCompatible or Llm
|| string.Equals(type, "openai-compatible", StringComparison.OrdinalIgnoreCase)
|| string.Equals(type, "llm", StringComparison.OrdinalIgnoreCase);
public static bool IsOpenWeather(string? type)
=> string.Equals(type, OpenWeather, StringComparison.OrdinalIgnoreCase);
public static bool Matches(string? declaredType, string? recordType)
{
if (IsLlm(declaredType) && IsLlm(recordType))
{
return true;
}
return string.Equals(declaredType, recordType, StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>宿主写入插件 stdin 的整包请求:业务输入 + 凭据。</summary> /// <summary>宿主写入插件 stdin 的整包请求:业务输入 + 凭据。</summary>
public sealed class PluginRequest public sealed class PluginRequest
{ {
public int ProtocolVersion { get; set; } = PluginProtocol.Current;
public Dictionary<string, object?> Inputs { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, object?> Inputs { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[JsonPropertyName("credentials")] [JsonPropertyName("credentials")]
+39 -29
View File
@@ -33,53 +33,55 @@ public static class PluginStdio
await Console.Out.FlushAsync(cancellationToken); await Console.Out.FlushAsync(cancellationToken);
} }
/// <summary>把宿主注入的 LLM 凭据写进环境变量,这样 AgentFactory.Load 能读到 Key。</summary> /// <summary>
public static void ApplyCredentialsToEnvironment(IReadOnlyDictionary<string, PluginCredentialPayload> credentials) /// 把凭据写进环境变量。不传 target 时改当前进程(插件自己);宿主传入 ProcessStartInfo.Environment。
/// </summary>
public static void ApplyCredentialsToEnvironment(
IReadOnlyDictionary<string, PluginCredentialPayload> credentials,
IDictionary<string, string?>? target = null)
{ {
foreach (KeyValuePair<string, PluginCredentialPayload> pair in credentials) foreach (KeyValuePair<string, PluginCredentialPayload> pair in credentials)
{ {
PluginCredentialPayload cred = pair.Value; ApplyOne(pair.Key, pair.Value, target);
if (cred.Type is "openai-compatible" or "llm" || pair.Key.Equals("llm", StringComparison.OrdinalIgnoreCase)) }
}
private static void ApplyOne(string name, PluginCredentialPayload cred, IDictionary<string, string?>? target)
{ {
SetIfNotEmpty("OPENAI_ENDPOINT", cred.Endpoint); if (PluginCredentialTypes.IsLlm(cred.Type) || name.Equals("llm", StringComparison.OrdinalIgnoreCase))
SetIfNotEmpty("OPENAI_API_KEY", cred.ApiKey); {
SetIfNotEmpty("OPENAI_CHAT_MODEL", cred.Model); SetIfNotEmpty(target, "OPENAI_ENDPOINT", cred.Endpoint);
SetIfNotEmpty(target, "OPENAI_API_KEY", cred.ApiKey);
SetIfNotEmpty(target, "OPENAI_CHAT_MODEL", cred.Model);
}
if (PluginCredentialTypes.IsOpenWeather(cred.Type) || name.Equals("weather", StringComparison.OrdinalIgnoreCase))
{
SetIfNotEmpty(target, "OPENWEATHER_API_KEY", cred.ApiKey);
} }
foreach (KeyValuePair<string, string> extra in cred.Extra) foreach (KeyValuePair<string, string> extra in cred.Extra)
{ {
if (!string.IsNullOrWhiteSpace(extra.Value)) if (!string.IsNullOrWhiteSpace(extra.Value))
{ {
Environment.SetEnvironmentVariable(extra.Key, extra.Value); SetIfNotEmpty(target, extra.Key, extra.Value);
}
} }
} }
} }
/// <summary> /// <summary>
/// 插件自己读天气等配置时用。优先 MAF1_CONTENT_ROOT(宿主 exe 目录),否则向上找 appsettings.json /// 只读插件自己目录的 appsettings.json(工作目录或 dll 旁),再加上环境变量。不要去读宿主 exe 目录
/// </summary> /// </summary>
public static IConfiguration LoadHostConfiguration() public static IConfiguration LoadPluginConfiguration()
{ {
string? contentRoot = Environment.GetEnvironmentVariable("MAF1_CONTENT_ROOT"); string basePath = Directory.GetCurrentDirectory();
if (string.IsNullOrWhiteSpace(contentRoot) || !Directory.Exists(contentRoot)) if (!File.Exists(Path.Combine(basePath, "appsettings.json")))
{ {
contentRoot = AppContext.BaseDirectory; basePath = AppContext.BaseDirectory;
DirectoryInfo? parent = Directory.GetParent(contentRoot.TrimEnd(Path.DirectorySeparatorChar));
if (parent?.Name.Equals("plugins", StringComparison.OrdinalIgnoreCase) == false
&& parent?.Parent is not null
&& File.Exists(Path.Combine(parent.Parent.FullName, "appsettings.json")))
{
contentRoot = parent.Parent.FullName;
}
else if (parent is not null && File.Exists(Path.Combine(parent.FullName, "appsettings.json")))
{
contentRoot = parent.FullName;
}
} }
return new ConfigurationBuilder() return new ConfigurationBuilder()
.SetBasePath(contentRoot) .SetBasePath(basePath)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
.AddEnvironmentVariables() .AddEnvironmentVariables()
.Build(); .Build();
@@ -143,11 +145,19 @@ public static class PluginStdio
.ToList(); .ToList();
} }
private static void SetIfNotEmpty(string name, string? value) private static void SetIfNotEmpty(IDictionary<string, string?>? target, string name, string? value)
{ {
if (!string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
{ {
return;
}
if (target is not null)
{
target[name] = value;
return;
}
Environment.SetEnvironmentVariable(name, value); Environment.SetEnvironmentVariable(name, value);
} }
}
} }
+1 -1
View File
@@ -26,7 +26,7 @@ public sealed class WeatherTools(WeatherOptions options, HttpClient http)
{ {
if (string.IsNullOrWhiteSpace(options.OpenWeather.ApiKey)) if (string.IsNullOrWhiteSpace(options.OpenWeather.ApiKey))
{ {
return "OpenWeather 已启用,但 appsettings.json 里 Weather:OpenWeather:ApiKey 为空。请填入密钥,或把 Provider 改回 Wttr。"; return "OpenWeather 已启用,但 API Key 为空。请配置 OPENWEATHER_API_KEY(插件凭据或环境变量),或把 Provider 改回 Wttr。";
} }
return await QueryOpenWeatherAsync(location); return await QueryOpenWeatherAsync(location);
+4
View File
@@ -91,6 +91,10 @@ public sealed class AgentFactory
return options; return options;
} }
/// <summary>只读环境变量。进程外插件应优先用这个,不要去读宿主 appsettings.json。</summary>
public static LlmOptions LoadFromEnvironment()
=> Load(new ConfigurationBuilder().AddEnvironmentVariables().Build());
private static string DefaultModelFor(string? endpoint) private static string DefaultModelFor(string? endpoint)
{ {
if (!string.IsNullOrWhiteSpace(endpoint) && endpoint.Contains("deepseek", StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrWhiteSpace(endpoint) && endpoint.Contains("deepseek", StringComparison.OrdinalIgnoreCase))
+37 -7
View File
@@ -32,18 +32,16 @@ public sealed class CredentialPublicView
/// </summary> /// </summary>
public sealed class CredentialStore public sealed class CredentialStore
{ {
private readonly IConfiguration _config;
private readonly Dictionary<string, CredentialRecord> _named = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, CredentialRecord> _named = new(StringComparer.OrdinalIgnoreCase);
public CredentialStore(IConfiguration config) public CredentialStore(IConfiguration config)
{ {
_config = config;
LlmOptions llm = AgentFactory.Load(config); LlmOptions llm = AgentFactory.Load(config);
_named["llm-default"] = new CredentialRecord _named["llm-default"] = new CredentialRecord
{ {
Id = "llm-default", Id = "llm-default",
Name = "系统默认模型", Name = "系统默认模型",
Type = "openai-compatible", Type = PluginCredentialTypes.OpenAiCompatible,
Endpoint = llm.Endpoint, Endpoint = llm.Endpoint,
ApiKey = llm.ApiKey, ApiKey = llm.ApiKey,
Model = llm.Model, Model = llm.Model,
@@ -53,23 +51,38 @@ public sealed class CredentialStore
foreach (IConfigurationSection child in section.GetChildren()) foreach (IConfigurationSection child in section.GetChildren())
{ {
string id = child.Key; string id = child.Key;
string type = child["type"] ?? "openai-compatible"; string type = child["type"] ?? PluginCredentialTypes.OpenAiCompatible;
bool useLlm = string.Equals(child["source"], "llm-section", StringComparison.OrdinalIgnoreCase) bool useLlm = string.Equals(child["source"], "llm-section", StringComparison.OrdinalIgnoreCase)
|| child.GetValue("useLlmSection", false); || child.GetValue("useLlmSection", false);
CredentialRecord fallback = _named.GetValueOrDefault("llm-default")!; CredentialRecord fallback = _named["llm-default"];
Dictionary<string, string> extra = new(StringComparer.OrdinalIgnoreCase);
foreach (IConfigurationSection extraChild in child.GetSection("extra").GetChildren())
{
if (!string.IsNullOrWhiteSpace(extraChild.Value))
{
extra[extraChild.Key] = extraChild.Value;
}
}
string apiKey = First(
child["apiKey"],
useLlm ? fallback.ApiKey : null,
PluginCredentialTypes.IsOpenWeather(type) ? Environment.GetEnvironmentVariable("OPENWEATHER_API_KEY") : null);
_named[id] = new CredentialRecord _named[id] = new CredentialRecord
{ {
Id = id, Id = id,
Name = child["name"] ?? id, Name = child["name"] ?? id,
Type = type, Type = type,
Endpoint = First(child["endpoint"], useLlm ? fallback.Endpoint : null), Endpoint = First(child["endpoint"], useLlm ? fallback.Endpoint : null),
ApiKey = First(child["apiKey"], useLlm ? fallback.ApiKey : null), ApiKey = apiKey,
Model = First(child["model"], useLlm ? fallback.Model : null), Model = First(child["model"], useLlm ? fallback.Model : null),
Extra = extra,
}; };
} }
} }
/// <summary>给前端的列表。Endpoint 目前未打码(学习项目);ApiKey 绝不会出现在这里。</summary> /// <summary>给前端的列表。ApiKey 绝不会出现在这里。</summary>
public IReadOnlyList<CredentialPublicView> ListPublic() public IReadOnlyList<CredentialPublicView> ListPublic()
=> _named.Values => _named.Values
.OrderBy(item => item.Id, StringComparer.OrdinalIgnoreCase) .OrderBy(item => item.Id, StringComparer.OrdinalIgnoreCase)
@@ -91,6 +104,23 @@ public sealed class CredentialStore
throw new InvalidOperationException($"找不到凭据 `{id}`。请在 appsettings.json 的 Credentials 中配置,或改用 llm-default。"); throw new InvalidOperationException($"找不到凭据 `{id}`。请在 appsettings.json 的 Credentials 中配置,或改用 llm-default。");
} }
/// <summary>按插件声明的 type 取凭据。preferredId 类型不匹配时不会凑合用。</summary>
public CredentialRecord? TryResolveForType(string declaredType, string? preferredId)
{
if (!string.IsNullOrWhiteSpace(preferredId) && _named.TryGetValue(preferredId, out CredentialRecord? named))
{
if (!PluginCredentialTypes.Matches(declaredType, named.Type))
{
throw new InvalidOperationException(
$"凭据 `{preferredId}` 的类型是 `{named.Type}`,插件声明需要 `{declaredType}`。");
}
return named;
}
return _named.Values.FirstOrDefault(item => PluginCredentialTypes.Matches(declaredType, item.Type));
}
public PluginCredentialPayload ToPayload(CredentialRecord record) public PluginCredentialPayload ToPayload(CredentialRecord record)
=> new() => new()
{ {
+41 -33
View File
@@ -16,14 +16,16 @@ public sealed class PluginProcessRunner(PluginOptions options, CredentialStore c
LoadedPlugin plugin, LoadedPlugin plugin,
Dictionary<string, object?> inputs, Dictionary<string, object?> inputs,
string? credentialId, string? credentialId,
IReadOnlyDictionary<string, string>? credentialBindings,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
PluginManifest manifest = plugin.Manifest; PluginManifest manifest = plugin.Manifest;
ValidateDeclaredInputs(manifest, inputs); ValidateDeclaredInputs(manifest, inputs);
Dictionary<string, PluginCredentialPayload> payload = ResolveCredentials(manifest, credentialId); Dictionary<string, PluginCredentialPayload> payload = ResolveCredentials(manifest, credentialId, credentialBindings);
PluginRequest request = new() PluginRequest request = new()
{ {
ProtocolVersion = PluginProtocol.Normalize(manifest.ProtocolVersion),
Inputs = inputs, Inputs = inputs,
Credentials = payload, Credentials = payload,
}; };
@@ -75,33 +77,58 @@ public sealed class PluginProcessRunner(PluginOptions options, CredentialStore c
return outputs; return outputs;
} }
private Dictionary<string, PluginCredentialPayload> ResolveCredentials(PluginManifest manifest, string? credentialId) private Dictionary<string, PluginCredentialPayload> ResolveCredentials(
PluginManifest manifest,
string? credentialId,
IReadOnlyDictionary<string, string>? credentialBindings)
{ {
Dictionary<string, PluginCredentialPayload> map = new(StringComparer.OrdinalIgnoreCase); Dictionary<string, PluginCredentialPayload> map = new(StringComparer.OrdinalIgnoreCase);
foreach (PluginCredentialNeed need in manifest.Credentials) foreach (PluginCredentialNeed need in manifest.Credentials)
{ {
if (need.Type is "openai-compatible" or "llm" || need.Name.Equals("llm", StringComparison.OrdinalIgnoreCase)) if (string.IsNullOrWhiteSpace(need.Type) && string.IsNullOrWhiteSpace(need.Name))
{ {
CredentialRecord record = credentials.Resolve(credentialId); continue;
if (need.Required && string.IsNullOrWhiteSpace(record.ApiKey)) }
string declaredType = string.IsNullOrWhiteSpace(need.Type) ? need.Name : need.Type;
string? preferredId = null;
if (credentialBindings is not null
&& !string.IsNullOrWhiteSpace(need.Name)
&& credentialBindings.TryGetValue(need.Name, out string? bound)
&& !string.IsNullOrWhiteSpace(bound))
{
preferredId = bound;
}
else if (PluginCredentialTypes.IsLlm(declaredType) || need.Name.Equals("llm", StringComparison.OrdinalIgnoreCase))
{
preferredId = credentialId;
}
CredentialRecord? record = credentials.TryResolveForType(declaredType, preferredId);
if (record is null)
{
if (need.Required)
{
throw new InvalidOperationException(
$"插件 {manifest.Id} 需要类型 `{declaredType}` 的凭据 `{need.Name}`,但宿主 Credentials 里没有匹配项。");
}
continue;
}
if (need.Required && PluginCredentialTypes.IsLlm(declaredType) && string.IsNullOrWhiteSpace(record.ApiKey))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"插件 {manifest.Id} 需要 LLM 凭据,但 `{record.Id}` 没有 API Key。请在环境变量 OPENAI_API_KEY 或 appsettings.json 中配置。"); $"插件 {manifest.Id} 需要 LLM 凭据,但 `{record.Id}` 没有 API Key。请在环境变量 OPENAI_API_KEY 或 appsettings.json 中配置。");
} }
map[need.Name] = credentials.ToPayload(record); map[string.IsNullOrWhiteSpace(need.Name) ? declaredType : need.Name] = credentials.ToPayload(record);
}
}
if (manifest.Credentials.Count == 0)
{
map["llm"] = credentials.ToPayload(credentials.Resolve(credentialId));
} }
return map; return map;
} }
/// <summary>工作目录=插件文件夹;环境变量带上 OPENAI_* 和 MAF1_CONTENT_ROOT。</summary> /// <summary>工作目录=插件文件夹。凭据写入子进程环境变量,不读宿主 appsettings 路径。</summary>
private ProcessStartInfo CreateStartInfo(LoadedPlugin plugin, Dictionary<string, PluginCredentialPayload> payload) private ProcessStartInfo CreateStartInfo(LoadedPlugin plugin, Dictionary<string, PluginCredentialPayload> payload)
{ {
string command = plugin.Manifest.Launch.Command; string command = plugin.Manifest.Launch.Command;
@@ -132,32 +159,13 @@ public sealed class PluginProcessRunner(PluginOptions options, CredentialStore c
start.ArgumentList.Add(arg); start.ArgumentList.Add(arg);
} }
start.Environment["MAF1_CONTENT_ROOT"] = AppContext.BaseDirectory;
ApplyDotEnv(plugin.FolderPath, start); ApplyDotEnv(plugin.FolderPath, start);
foreach (KeyValuePair<string, string> pair in plugin.Manifest.Env) foreach (KeyValuePair<string, string> pair in plugin.Manifest.Env)
{ {
start.Environment[pair.Key] = pair.Value; start.Environment[pair.Key] = pair.Value;
} }
if (payload.TryGetValue("llm", out PluginCredentialPayload? llm) || payload.Count > 0) PluginStdio.ApplyCredentialsToEnvironment(payload, start.Environment);
{
llm ??= payload.Values.First();
if (!string.IsNullOrWhiteSpace(llm.ApiKey))
{
start.Environment["OPENAI_API_KEY"] = llm.ApiKey;
}
if (!string.IsNullOrWhiteSpace(llm.Endpoint))
{
start.Environment["OPENAI_ENDPOINT"] = llm.Endpoint;
}
if (!string.IsNullOrWhiteSpace(llm.Model))
{
start.Environment["OPENAI_CHAT_MODEL"] = llm.Model;
}
}
return start; return start;
} }
+14
View File
@@ -76,6 +76,20 @@ public sealed class PluginScanner(PluginOptions options)
continue; continue;
} }
int protocol = PluginProtocol.Normalize(manifest.ProtocolVersion);
if (!PluginProtocol.IsSupported(manifest.ProtocolVersion))
{
issues.Add(new CatalogIssue
{
Level = "error",
Source = manifest.Id,
Message = $"plugin.json 的 protocolVersion={protocol} 不受支持。当前宿主只接受 1(缺省视为 1)。",
});
continue;
}
manifest.ProtocolVersion = protocol;
if (!ids.Add(manifest.Id)) if (!ids.Add(manifest.Id))
{ {
issues.Add(new CatalogIssue issues.Add(new CatalogIssue
+22 -1
View File
@@ -391,7 +391,28 @@ public sealed class ConfigurableWorkflowRunner(
{ {
Dictionary<string, object?> declared = FilterDeclaredInputs(plugin.Manifest, inputs); Dictionary<string, object?> declared = FilterDeclaredInputs(plugin.Manifest, inputs);
node.Config.TryGetValue("credentialId", out string? credentialId); node.Config.TryGetValue("credentialId", out string? credentialId);
Dictionary<string, object?> outputs = await plugins.RunAsync(plugin, declared, credentialId, cancellationToken); Dictionary<string, string> bindings = new(StringComparer.OrdinalIgnoreCase);
foreach (KeyValuePair<string, string> pair in node.Config)
{
const string prefix = "credential:";
if (!pair.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
string name = pair.Key[prefix.Length..].Trim();
if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(pair.Value))
{
bindings[name] = pair.Value;
}
}
Dictionary<string, object?> outputs = await plugins.RunAsync(
plugin,
declared,
credentialId,
bindings,
cancellationToken);
string? stderr = null; string? stderr = null;
if (outputs.Remove("_stderr", out object? stderrValue)) if (outputs.Remove("_stderr", out object? stderrValue))
{ {
+5
View File
@@ -16,6 +16,11 @@
"type": "openai-compatible", "type": "openai-compatible",
"name": "系统默认模型", "name": "系统默认模型",
"source": "llm-section" "source": "llm-section"
},
"openweather-default": {
"type": "openweather",
"name": "OpenWeather",
"apiKey": ""
} }
}, },
"Weather": { "Weather": {
+49 -5
View File
@@ -7,7 +7,7 @@
* 3. runGraph POST /api/run;若 status=needsDecision 弹出确认框 * 3. runGraph POST /api/run;若 status=needsDecision 弹出确认框
* 4. 确认走 /api/run/{id}/decide;超时则 pollRun 等服务端自动采用默认方案 * 4. 确认走 /api/run/{id}/decide;超时则 pollRun 等服务端自动采用默认方案
* *
* selected / pending 只用于点击交互。发给后端的 JSON 不含 API Key,只有 credentialId。 * selected / pending 只用于点击交互。发给后端的 JSON 不含 API Key,只有 credentialId / credential:名称
*/ */
const state = { const state = {
catalog: [], catalog: [],
@@ -51,6 +51,50 @@ function typeInfo(type) {
|| state.plugins.find((item) => item.type === 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 `<p class="hint">此节点未声明凭据,宿主不会注入 API Key。</p>`;
}
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 `<label>凭据 ${label}</label><p class="hint">宿主没有类型 ${need.type} 的凭据。</p>`;
}
const opts = options.map((c) =>
`<option value="${c.id}" ${current === c.id ? "selected" : ""}>${c.name}${c.hasApiKey ? "已配置 Key" : "缺少 Key"}</option>`
).join("");
return `<label>凭据 ${label}</label><select data-credential-config="${cfgKey}">${opts}</select>`;
}).join("") + `<p class="hint">Key 由宿主注入子进程,不会出现在端口或导出的流程图里。</p>`;
}
function pickNode(list, inputName) { function pickNode(list, inputName) {
return (list || []).find((item) => item.inputs?.some((p) => p.name === inputName)); return (list || []).find((item) => item.inputs?.some((p) => p.name === inputName));
} }
@@ -252,9 +296,7 @@ function renderInspector() {
<p class="hint">${info.description}</p> <p class="hint">${info.description}</p>
<label>显示名</label> <label>显示名</label>
<input id="title" value="${node.title}" /> <input id="title" value="${node.title}" />
<label>LLM 凭据</label> ${renderCredentialFields(node, info)}
<select id="credentialId">${(state.credentials || []).map((c) => `<option value="${c.id}" ${ (node.config.credentialId || "llm-default") === c.id ? "selected" : ""}>${c.name}${c.hasApiKey ? "已配置 Key" : "缺少 Key"}</option>`).join("")}</select>
<p class="hint">endpoint / API Key 由宿主注入进程,不会出现在输入端口或导出的流程图里。</p>
${info.inputs.map((p) => ` ${info.inputs.map((p) => `
<label>固定输入 ${p.name}${p.type}${p.required ? " *" : ""}</label> <label>固定输入 ${p.name}${p.type}${p.required ? " *" : ""}</label>
<input data-config="${p.name}" value="${node.config[p.name] ?? ""}" placeholder="${p.description}" /> <input data-config="${p.name}" value="${node.config[p.name] ?? ""}" placeholder="${p.description}" />
@@ -262,7 +304,9 @@ function renderInspector() {
<p class="hint">若该输入已从上一节点连线,运行时以连线为准。</p> <p class="hint">若该输入已从上一节点连线,运行时以连线为准。</p>
<button type="button" id="delNode">删除节点</button>`; <button type="button" id="delNode">删除节点</button>`;
inspector.querySelector("#title").oninput = (e) => { node.title = e.target.value; }; 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) => { inspector.querySelectorAll("[data-config]").forEach((input) => {
input.oninput = () => { node.config[input.dataset.config] = input.value; }; input.oninput = () => { node.config[input.dataset.config] = input.value; };
}); });
+17 -4
View File
@@ -2,19 +2,32 @@
每个子文件夹是一个插件。软件启动后扫描**网页宿主执行目录**下的 `plugins/`(即 `MAF1.Web.exe` 旁边),不是源码目录。 每个子文件夹是一个插件。软件启动后扫描**网页宿主执行目录**下的 `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`:给使用者看的说明 - `README.md`:给使用者看的说明
- 可执行文件 / 脚本 / 源码:由 `launch.command` + `launch.args` 原样启动 - 可执行文件 / 脚本 / 源码:由 `launch.command` + `launch.args` 原样启动
非秘密配置(例如天气 URL 模板)放在**插件自己目录**的 `appsettings.json``plugin.json``env`。不要去读宿主 exe 旁的配置文件。
## 凭据(不要写进 plugin.json ## 凭据(不要写进 plugin.json
n8n / Dify 一类产品把 API Key 放在宿主凭据库,节点只声明「我需要哪种凭据」。本项目同样: n8n / Dify 一类产品把 API Key 放在宿主凭据库,节点只声明「我需要哪种凭据」。本项目同样:
- Key 和 endpoint 配在宿主的环境变量或 `appsettings.json` - Key 和 endpoint 配在宿主的环境变量或 `appsettings.json``Credentials` / `Llm`
- 节点可选 `credentialId`(默认 `llm-default` - 节点可选 `credentialId`LLM默认 `llm-default`;其它类型用 `credential:<name>` 或按 type 取默认
- 启动子进程时注入环境变量,并在 stdin JSON 的 `credentials` 里再传一份 - 只注入 **plugin.json 里声明过的**凭据。未声明 LLM 的插件拿不到 `OPENAI_API_KEY`
- 启动子进程时写入对应环境变量,并在 stdin JSON 的 `credentials` 里再传一份
- 浏览器和流程图 JSON **不会**包含 apiKey - 浏览器和流程图 JSON **不会**包含 apiKey
已知 `type` 与环境变量:
| type | 环境变量 |
|------|----------|
| `openai-compatible` / `llm` | `OPENAI_ENDPOINT` / `OPENAI_API_KEY` / `OPENAI_CHAT_MODEL` |
| `openweather` | `OPENWEATHER_API_KEY` |
| 其它 | `extra` 里的键原样写入环境变量 |
第三方若要用自己的模型,可在插件目录放 `.env`(不要提交)。节点选中的宿主凭据会覆盖其中的同名变量。 第三方若要用自己的模型,可在插件目录放 `.env`(不要提交)。节点选中的宿主凭据会覆盖其中的同名变量。
+1 -1
View File
@@ -7,7 +7,7 @@ using MAF1.Utils;
PluginRequest request = await PluginStdio.ReadRequestAsync(); PluginRequest request = await PluginStdio.ReadRequestAsync();
PluginStdio.ApplyCredentialsToEnvironment(request.Credentials); 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); var result = await FileCityAgent.RunAsync(FileCityAgent.Create(factory), request.Inputs);
await PluginStdio.WriteOutputsAsync(result.Outputs); await PluginStdio.WriteOutputsAsync(result.Outputs);
return 0; return 0;
+3
View File
@@ -2,6 +2,8 @@
独立进程。宿主只读取本目录的 `plugin.json`,按 `launch` 原样启动,不会替你拼命令。 独立进程。宿主只读取本目录的 `plugin.json`,按 `launch` 原样启动,不会替你拼命令。
`protocolVersion` 为 1。LLM 只从环境变量读取(宿主注入),不读宿主 `appsettings.json`
## 输入 / 输出 ## 输入 / 输出
字段名必须和 `plugin.json` 以及 `Program.cs` 里读写的 JSON 键一致。 字段名必须和 `plugin.json` 以及 `Program.cs` 里读写的 JSON 键一致。
@@ -24,6 +26,7 @@ stdin
```json ```json
{ {
"protocolVersion": 1,
"inputs": { "filePath": "Data/cities.txt" }, "inputs": { "filePath": "Data/cities.txt" },
"credentials": { "credentials": {
"llm": { "id": "llm-default", "type": "openai-compatible", "endpoint": "...", "apiKey": "...", "model": "..." } "llm": { "id": "llm-default", "type": "openai-compatible", "endpoint": "...", "apiKey": "...", "model": "..." }
+1
View File
@@ -3,6 +3,7 @@
"name": "FileCityAgent", "name": "FileCityAgent",
"description": "读取指定文本文件,判断并抽出有效城市名。", "description": "读取指定文本文件,判断并抽出有效城市名。",
"version": "1.0.0", "version": "1.0.0",
"protocolVersion": 1,
"timeoutSeconds": 120, "timeoutSeconds": 120,
"launch": { "launch": {
"command": "dotnet", "command": "dotnet",
+9 -2
View File
@@ -1,4 +1,5 @@
// 进程外 Weather 插件。同样只做 stdio 适配,天气实现仍在 MAF1.Core。 // 进程外 Weather 插件。stdio 适配 + 复用 MAF1.Core 的 WeatherAgent
// LLM / OpenWeather Key 只来自宿主注入的环境变量;天气站点配置读本插件目录的 appsettings.json。
using MAF1.Agents.Weather; using MAF1.Agents.Weather;
using MAF1.PluginContract; using MAF1.PluginContract;
using MAF1.Tools; using MAF1.Tools;
@@ -8,9 +9,15 @@ using Microsoft.Extensions.Configuration;
PluginRequest request = await PluginStdio.ReadRequestAsync(); PluginRequest request = await PluginStdio.ReadRequestAsync();
PluginStdio.ApplyCredentialsToEnvironment(request.Credentials); PluginStdio.ApplyCredentialsToEnvironment(request.Credentials);
IConfiguration config = PluginStdio.LoadHostConfiguration(); IConfiguration config = PluginStdio.LoadPluginConfiguration();
AgentFactory factory = new(AgentFactory.Load(config)); AgentFactory factory = new(AgentFactory.Load(config));
WeatherOptions weatherOptions = config.GetSection("Weather").Get<WeatherOptions>() ?? new WeatherOptions(); WeatherOptions weatherOptions = config.GetSection("Weather").Get<WeatherOptions>() ?? new WeatherOptions();
string? openWeatherKey = Environment.GetEnvironmentVariable("OPENWEATHER_API_KEY");
if (!string.IsNullOrWhiteSpace(openWeatherKey))
{
weatherOptions.OpenWeather.ApiKey = openWeatherKey;
}
using HttpClient http = WeatherTools.CreateHttpClient(); using HttpClient http = WeatherTools.CreateHttpClient();
var result = await WeatherAgent.RunAsync( var result = await WeatherAgent.RunAsync(
WeatherAgent.Create(factory, new WeatherTools(weatherOptions, http)), WeatherAgent.Create(factory, new WeatherTools(weatherOptions, http)),
+5 -3
View File
@@ -1,12 +1,14 @@
# Weather 插件 # Weather 插件
独立进程。启动命令只看 `plugin.json``launch` 独立进程。启动命令只看 `plugin.json``launch``protocolVersion` 为 1。
## 输入 / 输出 ## 输入 / 输出
- 输入 `cities`(字符串数组,与代码、清单同名) - 输入 `cities`(字符串数组,与代码、清单同名)
- 输出 `summary` - 输出 `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`,不读宿主配置
+3
View File
@@ -15,6 +15,9 @@
<None Update="plugin.json"> <None Update="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="README.md"> <None Update="README.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
+13
View File
@@ -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": ""
}
}
}
+7
View File
@@ -3,6 +3,7 @@
"name": "WeatherAgent", "name": "WeatherAgent",
"description": "按城市列表查询天气并汇总。", "description": "按城市列表查询天气并汇总。",
"version": "1.0.0", "version": "1.0.0",
"protocolVersion": 1,
"timeoutSeconds": 180, "timeoutSeconds": 180,
"launch": { "launch": {
"command": "dotnet", "command": "dotnet",
@@ -14,6 +15,12 @@
"type": "openai-compatible", "type": "openai-compatible",
"required": true, "required": true,
"description": "由宿主注入 OpenAI 兼容的 endpoint / apiKey / model,不要写进本文件。" "description": "由宿主注入 OpenAI 兼容的 endpoint / apiKey / model,不要写进本文件。"
},
{
"name": "weather",
"type": "openweather",
"required": false,
"description": "仅当本插件 appsettings.json 的 Weather:Provider 为 OpenWeather 时需要。Wttr 免费接口不用填。"
} }
], ],
"inputs": [ "inputs": [
+7 -4
View File
@@ -354,7 +354,7 @@ JSON 使用 camelCase。静态站点来自 `wwwroot`。
### 必备文件 ### 必备文件
- `plugin.json`:id、启动命令、凭据声明、inputs、outputs - `plugin.json`id、protocolVersion、启动命令、凭据声明、inputs、outputs
- `README.md`:给人看的说明 - `README.md`:给人看的说明
- 可执行文件:由 `launch.command` + `launch.args` **原样**启动,宿主不替你拼路径 - 可执行文件:由 `launch.command` + `launch.args` **原样**启动,宿主不替你拼路径
@@ -375,6 +375,7 @@ JSON 使用 camelCase。静态站点来自 `wwwroot`。
```json ```json
{ {
"protocolVersion": 1,
"inputs": { "filePath": "Data/cities.txt" }, "inputs": { "filePath": "Data/cities.txt" },
"credentials": { "credentials": {
"llm": { "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` 里再传一份 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 目录。
更细的输入输出说明: 更细的输入输出说明:
+47
View File
@@ -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" }
}
}
}
}
}