Files
MAF1/MAF1.Web/PluginHost/PluginProcessRunner.cs
T
admin777 a3cca78592 feat: 拆分 CLI 与网页宿主,并在无有效城市时暂停等人确认
将原单体 MAF1 拆成 MAF1(控制台工作流)和 MAF1.Web(可视化编排)。
抽城市失败时暂停:网页弹确认、CLI 控制台询问,超时采用默认结束查询;
也可改填城市后继续。共享决策模型放在 MAF1.Core。
2026-08-28 10:43:57 +08:00

250 lines
8.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using MAF1.PluginContract;
using MAF1.Utils;
namespace MAF1.Plugins;
public sealed class PluginProcessRunner(PluginOptions options, CredentialStore credentials)
{
public async Task<Dictionary<string, object?>> RunAsync(
LoadedPlugin plugin,
Dictionary<string, object?> inputs,
string? credentialId,
CancellationToken cancellationToken)
{
PluginManifest manifest = plugin.Manifest;
ValidateDeclaredInputs(manifest, inputs);
Dictionary<string, PluginCredentialPayload> payload = ResolveCredentials(manifest, credentialId);
PluginRequest request = new()
{
Inputs = inputs,
Credentials = payload,
};
int timeoutSeconds = manifest.TimeoutSeconds > 0 ? manifest.TimeoutSeconds : options.DefaultTimeoutSeconds;
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
ProcessStartInfo start = CreateStartInfo(plugin, payload);
using Process process = new() { StartInfo = start, EnableRaisingEvents = true };
if (!process.Start())
{
throw new InvalidOperationException($"无法启动插件 {manifest.Id}{start.FileName}");
}
string json = JsonSerializer.Serialize(request, PluginJson.Options);
await process.StandardInput.WriteAsync(json.AsMemory(), timeout.Token);
await process.StandardInput.FlushAsync(timeout.Token);
process.StandardInput.Close();
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync(timeout.Token);
Task<string> stderrTask = process.StandardError.ReadToEndAsync(timeout.Token);
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
TryKill(process);
throw new TimeoutException($"插件 {manifest.Id} 超过 {timeoutSeconds} 秒未结束,已终止。");
}
string stdout = await stdoutTask;
string stderr = await stderrTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"插件 {manifest.Id} 退出码 {process.ExitCode}。{FormatStd(stderr, stdout)}");
}
Dictionary<string, object?> outputs = ParseOutputs(stdout);
ValidateDeclaredOutputs(manifest, outputs);
if (!string.IsNullOrWhiteSpace(stderr))
{
outputs["_stderr"] = stderr.Trim();
}
return outputs;
}
private Dictionary<string, PluginCredentialPayload> ResolveCredentials(PluginManifest manifest, string? credentialId)
{
Dictionary<string, PluginCredentialPayload> map = new(StringComparer.OrdinalIgnoreCase);
foreach (PluginCredentialNeed need in manifest.Credentials)
{
if (need.Type is "openai-compatible" or "llm" || need.Name.Equals("llm", StringComparison.OrdinalIgnoreCase))
{
CredentialRecord record = credentials.Resolve(credentialId);
if (need.Required && string.IsNullOrWhiteSpace(record.ApiKey))
{
throw new InvalidOperationException(
$"插件 {manifest.Id} 需要 LLM 凭据,但 `{record.Id}` 没有 API Key。请在环境变量 OPENAI_API_KEY 或 appsettings.json 中配置。");
}
map[need.Name] = credentials.ToPayload(record);
}
}
if (manifest.Credentials.Count == 0)
{
map["llm"] = credentials.ToPayload(credentials.Resolve(credentialId));
}
return map;
}
private ProcessStartInfo CreateStartInfo(LoadedPlugin plugin, Dictionary<string, PluginCredentialPayload> payload)
{
string command = plugin.Manifest.Launch.Command;
if (!Path.IsPathRooted(command))
{
string local = Path.Combine(plugin.FolderPath, command);
if (File.Exists(local))
{
command = local;
}
}
ProcessStartInfo start = new()
{
FileName = command,
WorkingDirectory = plugin.FolderPath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardInputEncoding = Encoding.UTF8,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (string arg in plugin.Manifest.Launch.Args)
{
start.ArgumentList.Add(arg);
}
start.Environment["MAF1_CONTENT_ROOT"] = AppContext.BaseDirectory;
ApplyDotEnv(plugin.FolderPath, start);
foreach (KeyValuePair<string, string> 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;
}
}
return start;
}
private static void ApplyDotEnv(string pluginDir, ProcessStartInfo start)
{
string path = Path.Combine(pluginDir, ".env");
if (!File.Exists(path))
{
return;
}
foreach (string raw in File.ReadAllLines(path))
{
string line = raw.Trim();
if (line.Length == 0 || line.StartsWith('#') || !line.Contains('='))
{
continue;
}
int split = line.IndexOf('=');
string key = line[..split].Trim();
string value = line[(split + 1)..].Trim().Trim('"');
if (!string.IsNullOrWhiteSpace(key))
{
start.Environment[key] = value;
}
}
}
private static void ValidateDeclaredInputs(PluginManifest manifest, Dictionary<string, object?> inputs)
{
foreach (PluginPort port in manifest.Inputs.Where(p => p.Required))
{
if (!inputs.TryGetValue(port.Name, out object? value) || value is null || value.ToString() == "")
{
throw new InvalidOperationException(
$"插件 {manifest.Id} 的必填输入 `{port.Name}` 为空。请在节点配置里填写,或从上一节点连到这个端口。字段名必须和 plugin.json / 插件代码一致。");
}
}
}
private static void ValidateDeclaredOutputs(PluginManifest manifest, Dictionary<string, object?> outputs)
{
List<string> missing = manifest.Outputs
.Select(p => p.Name)
.Where(name => !outputs.Keys.Any(k => k.Equals(name, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (missing.Count > 0)
{
throw new InvalidOperationException(
$"插件 {manifest.Id} 的输出字段与 plugin.json 不一致,缺少:{string.Join(", ", missing)}。请让插件 stdout 的 JSON 字段和清单 outputs 完全对应。");
}
}
private static Dictionary<string, object?> ParseOutputs(string stdout)
{
string json = JsonText.UnwrapObject(stdout);
using JsonDocument doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException("插件 stdout 必须是一个 JSON 对象。");
}
Dictionary<string, object?> map = new(StringComparer.OrdinalIgnoreCase);
foreach (JsonProperty property in doc.RootElement.EnumerateObject())
{
map[property.Name] = property.Value.Clone();
}
return map;
}
private static string FormatStd(string stderr, string stdout)
{
string err = string.IsNullOrWhiteSpace(stderr) ? "" : $"stderr: {stderr.Trim()}";
string outText = string.IsNullOrWhiteSpace(stdout) ? "" : $"stdout: {stdout.Trim()}";
return string.Join(" ", new[] { err, outText }.Where(s => s.Length > 0));
}
private static void TryKill(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch
{
// 进程可能已经退出。
}
}
}