feat: 将插件 stdin 协议定为 v1,并按声明注入凭据
进程外插件改为只读环境变量和自身配置,避免读宿主 appsettings;宿主按 plugin.json 声明注入 LLM / OpenWeather 等凭据。
This commit is contained in:
@@ -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")]
|
||||||
|
|||||||
@@ -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))
|
}
|
||||||
{
|
}
|
||||||
SetIfNotEmpty("OPENAI_ENDPOINT", cred.Endpoint);
|
|
||||||
SetIfNotEmpty("OPENAI_API_KEY", cred.ApiKey);
|
|
||||||
SetIfNotEmpty("OPENAI_CHAT_MODEL", cred.Model);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (KeyValuePair<string, string> extra in cred.Extra)
|
private static void ApplyOne(string name, PluginCredentialPayload cred, IDictionary<string, string?>? 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<string, string> extra in cred.Extra)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(extra.Value))
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(extra.Value))
|
SetIfNotEmpty(target, extra.Key, extra.Value);
|
||||||
{
|
|
||||||
Environment.SetEnvironmentVariable(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))
|
||||||
{
|
{
|
||||||
Environment.SetEnvironmentVariable(name, value);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (target is not null)
|
||||||
|
{
|
||||||
|
target[name] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Environment.SetEnvironmentVariable(name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(
|
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)
|
if (need.Required && PluginCredentialTypes.IsLlm(declaredType) && string.IsNullOrWhiteSpace(record.ApiKey))
|
||||||
{
|
{
|
||||||
map["llm"] = credentials.ToPayload(credentials.Resolve(credentialId));
|
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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`(不要提交)。节点选中的宿主凭据会覆盖其中的同名变量。
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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": "..." }
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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`,不读宿主配置
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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 目录。
|
||||||
|
|
||||||
更细的输入输出说明:
|
更细的输入输出说明:
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user