From b631acd42e94272abf92769c789ba7fab22f96df Mon Sep 17 00:00:00 2001 From: luoqiang <2769838458@qq.com> Date: Fri, 11 Sep 2026 10:46:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=86=E6=8F=92=E4=BB=B6=20stdin=20?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E5=AE=9A=E4=B8=BA=20v1=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=8C=89=E5=A3=B0=E6=98=8E=E6=B3=A8=E5=85=A5=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 进程外插件改为只读环境变量和自身配置,避免读宿主 appsettings;宿主按 plugin.json 声明注入 LLM / OpenWeather 等凭据。 --- AGENTS.md | 3 +- MAF1.Core/PluginContract/PluginManifest.cs | 46 +++++++++++++ MAF1.Core/PluginContract/PluginStdio.cs | 78 ++++++++++++---------- MAF1.Core/Tools/WeatherTools.cs | 2 +- MAF1.Core/Utils/AgentFactory.cs | 4 ++ MAF1.Web/PluginHost/CredentialStore.cs | 44 ++++++++++-- MAF1.Web/PluginHost/PluginProcessRunner.cs | 72 +++++++++++--------- MAF1.Web/PluginHost/PluginScanner.cs | 14 ++++ MAF1.Web/Web/ConfigurableWorkflowRunner.cs | 23 ++++++- MAF1.Web/appsettings.json | 5 ++ MAF1.Web/wwwroot/js/app.js | 54 +++++++++++++-- Plugins/README.md | 21 ++++-- Plugins/file-city/Program.cs | 2 +- Plugins/file-city/README.md | 3 + Plugins/file-city/plugin.json | 1 + Plugins/weather/Program.cs | 11 ++- Plugins/weather/README.md | 8 ++- Plugins/weather/WeatherPlugin.csproj | 3 + Plugins/weather/appsettings.json | 13 ++++ Plugins/weather/plugin.json | 7 ++ README.md | 11 +-- docs/plugin-protocol-v1.schema.json | 47 +++++++++++++ 22 files changed, 377 insertions(+), 95 deletions(-) create mode 100644 Plugins/weather/appsettings.json create mode 100644 docs/plugin-protocol-v1.schema.json diff --git a/AGENTS.md b/AGENTS.md index 9c4546b..5dae1c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 目标复制)或点前端「刷新节点」。 -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` 会解析失败。 4. **天气查询必须用 `WeatherTools.CreateHttpClient()`**:带 20s 超时和正确 User-Agent,直接 `new HttpClient()` 可能被 wttr.in 拒绝。 5. **LLM 输出解析前先过 `JsonText.UnwrapObject`**:剥离模型回复的 ```json ``` 围栏后再反序列化。 +6. **进程外插件不要读宿主 appsettings**:LLM 用 `AgentFactory.LoadFromEnvironment()`;其它配置用插件目录自己的 json。stdin 协议见 `docs/plugin-protocol-v1.schema.json`(`protocolVersion` 1)。 ## 约定 diff --git a/MAF1.Core/PluginContract/PluginManifest.cs b/MAF1.Core/PluginContract/PluginManifest.cs index 17be9b6..1117257 100644 --- a/MAF1.Core/PluginContract/PluginManifest.cs +++ b/MAF1.Core/PluginContract/PluginManifest.cs @@ -12,6 +12,10 @@ public sealed class PluginManifest public string Name { get; set; } = ""; public string Description { get; set; } = ""; public string Version { get; set; } = "1.0.0"; + + /// stdin/stdout 协议主版本。缺省或 0 视为 1。宿主拒绝大于 的值。 + public int ProtocolVersion { get; set; } + public PluginLaunch Launch { get; set; } = new(); public int TimeoutSeconds { get; set; } public Dictionary Env { get; set; } = new(StringComparer.OrdinalIgnoreCase); @@ -45,9 +49,51 @@ public sealed class PluginCredentialNeed public string Description { get; set; } = ""; } +/// stdin/stdout JSON 协议版本。缺省字段按 1 处理,便于旧 plugin.json 继续用。 +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; + } +} + +/// 凭据 type 约定。宿主按类型注入环境变量,未知类型只传 stdin Extra。 +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); + } +} + /// 宿主写入插件 stdin 的整包请求:业务输入 + 凭据。 public sealed class PluginRequest { + public int ProtocolVersion { get; set; } = PluginProtocol.Current; + public Dictionary Inputs { get; set; } = new(StringComparer.OrdinalIgnoreCase); [JsonPropertyName("credentials")] diff --git a/MAF1.Core/PluginContract/PluginStdio.cs b/MAF1.Core/PluginContract/PluginStdio.cs index 0313295..e52a1ee 100644 --- a/MAF1.Core/PluginContract/PluginStdio.cs +++ b/MAF1.Core/PluginContract/PluginStdio.cs @@ -33,53 +33,55 @@ public static class PluginStdio await Console.Out.FlushAsync(cancellationToken); } - /// 把宿主注入的 LLM 凭据写进环境变量,这样 AgentFactory.Load 能读到 Key。 - public static void ApplyCredentialsToEnvironment(IReadOnlyDictionary credentials) + /// + /// 把凭据写进环境变量。不传 target 时改当前进程(插件自己);宿主传入 ProcessStartInfo.Environment。 + /// + public static void ApplyCredentialsToEnvironment( + IReadOnlyDictionary credentials, + IDictionary? target = null) { foreach (KeyValuePair pair in credentials) { - PluginCredentialPayload cred = pair.Value; - if (cred.Type is "openai-compatible" or "llm" || pair.Key.Equals("llm", StringComparison.OrdinalIgnoreCase)) - { - SetIfNotEmpty("OPENAI_ENDPOINT", cred.Endpoint); - SetIfNotEmpty("OPENAI_API_KEY", cred.ApiKey); - SetIfNotEmpty("OPENAI_CHAT_MODEL", cred.Model); - } + ApplyOne(pair.Key, pair.Value, target); + } + } - foreach (KeyValuePair extra in cred.Extra) + private static void ApplyOne(string name, PluginCredentialPayload cred, IDictionary? target) + { + if (PluginCredentialTypes.IsLlm(cred.Type) || name.Equals("llm", StringComparison.OrdinalIgnoreCase)) + { + 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 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); } } } /// - /// 插件自己读天气等配置时用。优先 MAF1_CONTENT_ROOT(宿主 exe 目录),否则向上找 appsettings.json。 + /// 只读插件自己目录的 appsettings.json(工作目录或 dll 旁),再加上环境变量。不要去读宿主 exe 目录。 /// - public static IConfiguration LoadHostConfiguration() + public static IConfiguration LoadPluginConfiguration() { - string? contentRoot = Environment.GetEnvironmentVariable("MAF1_CONTENT_ROOT"); - if (string.IsNullOrWhiteSpace(contentRoot) || !Directory.Exists(contentRoot)) + string basePath = Directory.GetCurrentDirectory(); + if (!File.Exists(Path.Combine(basePath, "appsettings.json"))) { - contentRoot = 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; - } + basePath = AppContext.BaseDirectory; } return new ConfigurationBuilder() - .SetBasePath(contentRoot) + .SetBasePath(basePath) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) .AddEnvironmentVariables() .Build(); @@ -143,11 +145,19 @@ public static class PluginStdio .ToList(); } - private static void SetIfNotEmpty(string name, string? value) + private static void SetIfNotEmpty(IDictionary? target, string name, string? value) { - if (!string.IsNullOrWhiteSpace(value)) + if (string.IsNullOrWhiteSpace(value)) { - Environment.SetEnvironmentVariable(name, value); + return; } + + if (target is not null) + { + target[name] = value; + return; + } + + Environment.SetEnvironmentVariable(name, value); } } diff --git a/MAF1.Core/Tools/WeatherTools.cs b/MAF1.Core/Tools/WeatherTools.cs index 042b62f..907cd37 100644 --- a/MAF1.Core/Tools/WeatherTools.cs +++ b/MAF1.Core/Tools/WeatherTools.cs @@ -26,7 +26,7 @@ public sealed class WeatherTools(WeatherOptions options, HttpClient http) { 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); diff --git a/MAF1.Core/Utils/AgentFactory.cs b/MAF1.Core/Utils/AgentFactory.cs index 6817763..3d1aa15 100644 --- a/MAF1.Core/Utils/AgentFactory.cs +++ b/MAF1.Core/Utils/AgentFactory.cs @@ -91,6 +91,10 @@ public sealed class AgentFactory return options; } + /// 只读环境变量。进程外插件应优先用这个,不要去读宿主 appsettings.json。 + public static LlmOptions LoadFromEnvironment() + => Load(new ConfigurationBuilder().AddEnvironmentVariables().Build()); + private static string DefaultModelFor(string? endpoint) { if (!string.IsNullOrWhiteSpace(endpoint) && endpoint.Contains("deepseek", StringComparison.OrdinalIgnoreCase)) diff --git a/MAF1.Web/PluginHost/CredentialStore.cs b/MAF1.Web/PluginHost/CredentialStore.cs index 0fc3f88..5b33ba7 100644 --- a/MAF1.Web/PluginHost/CredentialStore.cs +++ b/MAF1.Web/PluginHost/CredentialStore.cs @@ -32,18 +32,16 @@ public sealed class CredentialPublicView /// public sealed class CredentialStore { - private readonly IConfiguration _config; private readonly Dictionary _named = new(StringComparer.OrdinalIgnoreCase); public CredentialStore(IConfiguration config) { - _config = config; LlmOptions llm = AgentFactory.Load(config); _named["llm-default"] = new CredentialRecord { Id = "llm-default", Name = "系统默认模型", - Type = "openai-compatible", + Type = PluginCredentialTypes.OpenAiCompatible, Endpoint = llm.Endpoint, ApiKey = llm.ApiKey, Model = llm.Model, @@ -53,23 +51,38 @@ public sealed class CredentialStore foreach (IConfigurationSection child in section.GetChildren()) { 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) || child.GetValue("useLlmSection", false); - CredentialRecord fallback = _named.GetValueOrDefault("llm-default")!; + CredentialRecord fallback = _named["llm-default"]; + Dictionary 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 { Id = id, Name = child["name"] ?? id, Type = type, 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), + Extra = extra, }; } } - /// 给前端的列表。Endpoint 目前未打码(学习项目);ApiKey 绝不会出现在这里。 + /// 给前端的列表。ApiKey 绝不会出现在这里。 public IReadOnlyList ListPublic() => _named.Values .OrderBy(item => item.Id, StringComparer.OrdinalIgnoreCase) @@ -91,6 +104,23 @@ public sealed class CredentialStore throw new InvalidOperationException($"找不到凭据 `{id}`。请在 appsettings.json 的 Credentials 中配置,或改用 llm-default。"); } + /// 按插件声明的 type 取凭据。preferredId 类型不匹配时不会凑合用。 + 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) => new() { diff --git a/MAF1.Web/PluginHost/PluginProcessRunner.cs b/MAF1.Web/PluginHost/PluginProcessRunner.cs index aafcaef..ba066f2 100644 --- a/MAF1.Web/PluginHost/PluginProcessRunner.cs +++ b/MAF1.Web/PluginHost/PluginProcessRunner.cs @@ -16,14 +16,16 @@ public sealed class PluginProcessRunner(PluginOptions options, CredentialStore c LoadedPlugin plugin, Dictionary inputs, string? credentialId, + IReadOnlyDictionary? credentialBindings, CancellationToken cancellationToken) { PluginManifest manifest = plugin.Manifest; ValidateDeclaredInputs(manifest, inputs); - Dictionary payload = ResolveCredentials(manifest, credentialId); + Dictionary payload = ResolveCredentials(manifest, credentialId, credentialBindings); PluginRequest request = new() { + ProtocolVersion = PluginProtocol.Normalize(manifest.ProtocolVersion), Inputs = inputs, Credentials = payload, }; @@ -75,33 +77,58 @@ public sealed class PluginProcessRunner(PluginOptions options, CredentialStore c return outputs; } - private Dictionary ResolveCredentials(PluginManifest manifest, string? credentialId) + private Dictionary ResolveCredentials( + PluginManifest manifest, + string? credentialId, + IReadOnlyDictionary? credentialBindings) { Dictionary map = new(StringComparer.OrdinalIgnoreCase); 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); - if (need.Required && string.IsNullOrWhiteSpace(record.ApiKey)) + continue; + } + + 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} 需要 LLM 凭据,但 `{record.Id}` 没有 API Key。请在环境变量 OPENAI_API_KEY 或 appsettings.json 中配置。"); + $"插件 {manifest.Id} 需要类型 `{declaredType}` 的凭据 `{need.Name}`,但宿主 Credentials 里没有匹配项。"); } - map[need.Name] = credentials.ToPayload(record); + continue; } - } - if (manifest.Credentials.Count == 0) - { - map["llm"] = credentials.ToPayload(credentials.Resolve(credentialId)); + if (need.Required && PluginCredentialTypes.IsLlm(declaredType) && string.IsNullOrWhiteSpace(record.ApiKey)) + { + throw new InvalidOperationException( + $"插件 {manifest.Id} 需要 LLM 凭据,但 `{record.Id}` 没有 API Key。请在环境变量 OPENAI_API_KEY 或 appsettings.json 中配置。"); + } + + map[string.IsNullOrWhiteSpace(need.Name) ? declaredType : need.Name] = credentials.ToPayload(record); } return map; } - /// 工作目录=插件文件夹;环境变量带上 OPENAI_* 和 MAF1_CONTENT_ROOT。 + /// 工作目录=插件文件夹。凭据写入子进程环境变量,不读宿主 appsettings 路径。 private ProcessStartInfo CreateStartInfo(LoadedPlugin plugin, Dictionary payload) { string command = plugin.Manifest.Launch.Command; @@ -132,32 +159,13 @@ public sealed class PluginProcessRunner(PluginOptions options, CredentialStore c start.ArgumentList.Add(arg); } - start.Environment["MAF1_CONTENT_ROOT"] = AppContext.BaseDirectory; ApplyDotEnv(plugin.FolderPath, start); foreach (KeyValuePair pair in plugin.Manifest.Env) { start.Environment[pair.Key] = pair.Value; } - if (payload.TryGetValue("llm", out PluginCredentialPayload? llm) || payload.Count > 0) - { - 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; - } - } - + PluginStdio.ApplyCredentialsToEnvironment(payload, start.Environment); return start; } diff --git a/MAF1.Web/PluginHost/PluginScanner.cs b/MAF1.Web/PluginHost/PluginScanner.cs index 7aee669..5c681a9 100644 --- a/MAF1.Web/PluginHost/PluginScanner.cs +++ b/MAF1.Web/PluginHost/PluginScanner.cs @@ -76,6 +76,20 @@ public sealed class PluginScanner(PluginOptions options) 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)) { issues.Add(new CatalogIssue diff --git a/MAF1.Web/Web/ConfigurableWorkflowRunner.cs b/MAF1.Web/Web/ConfigurableWorkflowRunner.cs index ce0110b..fb8c2a6 100644 --- a/MAF1.Web/Web/ConfigurableWorkflowRunner.cs +++ b/MAF1.Web/Web/ConfigurableWorkflowRunner.cs @@ -391,7 +391,28 @@ public sealed class ConfigurableWorkflowRunner( { Dictionary declared = FilterDeclaredInputs(plugin.Manifest, inputs); node.Config.TryGetValue("credentialId", out string? credentialId); - Dictionary outputs = await plugins.RunAsync(plugin, declared, credentialId, cancellationToken); + Dictionary bindings = new(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair 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 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" } + } + } + } + } +}