feat: 拆分 CLI 与网页宿主,并在无有效城市时暂停等人确认

将原单体 MAF1 拆成 MAF1(控制台工作流)和 MAF1.Web(可视化编排)。
抽城市失败时暂停:网页弹确认、CLI 控制台询问,超时采用默认结束查询;
也可改填城市后继续。共享决策模型放在 MAF1.Core。
This commit is contained in:
2026-08-28 10:43:57 +08:00
parent 5544788917
commit a3cca78592
42 changed files with 1427 additions and 457 deletions
+3
View File
@@ -0,0 +1,3 @@
成都
阿姆斯特丹
北京
+3
View File
@@ -0,0 +1,3 @@
香蕉
hello
不是城市
+43
View File
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>MAF1</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MAF1.Core\MAF1.Core.csproj" />
<ProjectReference Include="..\plugins\file-city\FileCityPlugin.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
<ProjectReference Include="..\plugins\weather\WeatherPlugin.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Data\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="PublishPluginFolders" AfterTargets="Build">
<PropertyGroup>
<_FileCityOut>$(MSBuildThisFileDirectory)..\plugins\file-city\bin\$(Configuration)\net10.0</_FileCityOut>
<_WeatherOut>$(MSBuildThisFileDirectory)..\plugins\weather\bin\$(Configuration)\net10.0</_WeatherOut>
</PropertyGroup>
<ItemGroup>
<_FileCityFiles Include="$(_FileCityOut)\**\*" />
<_WeatherFiles Include="$(_WeatherOut)\**\*" />
</ItemGroup>
<MakeDir Directories="$(OutputPath)plugins\file-city;$(OutputPath)plugins\weather" />
<Copy SourceFiles="@(_FileCityFiles)" DestinationFiles="@(_FileCityFiles->'$(OutputPath)plugins\file-city\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_WeatherFiles)" DestinationFiles="@(_WeatherFiles->'$(OutputPath)plugins\weather\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>
+115
View File
@@ -0,0 +1,115 @@
using MAF1.PluginContract;
using MAF1.Utils;
using Microsoft.Extensions.Configuration;
namespace MAF1.Plugins;
public sealed class CredentialRecord
{
public string Id { get; init; } = "";
public string Name { get; init; } = "";
public string Type { get; init; } = "";
public string Endpoint { get; init; } = "";
public string ApiKey { get; init; } = "";
public string Model { get; init; } = "";
public Dictionary<string, string> Extra { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}
public sealed class CredentialPublicView
{
public string Id { get; init; } = "";
public string Name { get; init; } = "";
public string Type { get; init; } = "";
public string Endpoint { get; init; } = "";
public string Model { get; init; } = "";
public bool HasApiKey { get; init; }
}
public sealed class CredentialStore
{
private readonly IConfiguration _config;
private readonly Dictionary<string, CredentialRecord> _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",
Endpoint = llm.Endpoint,
ApiKey = llm.ApiKey,
Model = llm.Model,
};
IConfigurationSection section = config.GetSection("Credentials");
foreach (IConfigurationSection child in section.GetChildren())
{
string id = child.Key;
string type = child["type"] ?? "openai-compatible";
bool useLlm = string.Equals(child["source"], "llm-section", StringComparison.OrdinalIgnoreCase)
|| child.GetValue("useLlmSection", false);
CredentialRecord fallback = _named.GetValueOrDefault("llm-default")!;
_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),
Model = First(child["model"], useLlm ? fallback.Model : null),
};
}
}
public IReadOnlyList<CredentialPublicView> ListPublic()
=> _named.Values
.OrderBy(item => item.Id, StringComparer.OrdinalIgnoreCase)
.Select(ToPublic)
.ToList();
public CredentialRecord Resolve(string? id)
{
if (string.IsNullOrWhiteSpace(id))
{
return _named["llm-default"];
}
if (_named.TryGetValue(id, out CredentialRecord? record))
{
return record;
}
throw new InvalidOperationException($"找不到凭据 `{id}`。请在 appsettings.json 的 Credentials 中配置,或改用 llm-default。");
}
public PluginCredentialPayload ToPayload(CredentialRecord record)
=> new()
{
Id = record.Id,
Type = record.Type,
Endpoint = record.Endpoint,
ApiKey = record.ApiKey,
Model = record.Model,
Extra = record.Extra,
};
public static CredentialPublicView ToPublic(CredentialRecord record)
=> new()
{
Id = record.Id,
Name = record.Name,
Type = record.Type,
Endpoint = MaskEndpoint(record.Endpoint),
Model = record.Model,
HasApiKey = !string.IsNullOrWhiteSpace(record.ApiKey),
};
private static string MaskEndpoint(string endpoint)
=> endpoint;
private static string First(params string?[] values)
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "";
}
+111
View File
@@ -0,0 +1,111 @@
using MAF1.PluginContract;
using MAF1.Web;
namespace MAF1.Plugins;
public sealed class NodeCatalogSnapshot
{
public string PluginsRoot { get; init; } = "";
public List<AgentTypeInfo> System { get; init; } = [];
public List<AgentTypeInfo> Plugins { get; init; } = [];
public List<AgentTypeInfo> Nodes { get; init; } = [];
public List<CatalogIssue> Issues { get; init; } = [];
public IReadOnlyList<LoadedPlugin> LoadedPlugins { get; init; } = [];
}
public sealed class NodeCatalogService(PluginScanner scanner)
{
public NodeCatalogSnapshot Load()
{
PluginScanResult scan = scanner.Scan();
List<CatalogIssue> issues = [.. scan.Issues];
List<AgentTypeInfo> system = AgentCatalog.SystemNodes
.Select(Clone)
.ToList();
foreach (AgentTypeInfo item in system)
{
item.Origin = "system";
}
Dictionary<string, AgentTypeInfo> systemByType = system.ToDictionary(n => n.Type, StringComparer.OrdinalIgnoreCase);
List<AgentTypeInfo> plugins = [];
foreach (LoadedPlugin plugin in scan.Plugins)
{
AgentTypeInfo info = ToTypeInfo(plugin);
if (systemByType.ContainsKey(info.Type))
{
issues.Add(new CatalogIssue
{
Level = "info",
Source = info.Type,
Message = $"插件 `{info.Type}` 覆盖了同名系统节点,画布运行将走插件进程。",
});
systemByType[info.Type].Overridden = true;
}
plugins.Add(info);
}
Dictionary<string, AgentTypeInfo> merged = systemByType
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
foreach (AgentTypeInfo plugin in plugins)
{
merged[plugin.Type] = plugin;
}
return new NodeCatalogSnapshot
{
PluginsRoot = scan.Root,
System = system,
Plugins = plugins,
Nodes = merged.Values.OrderBy(n => n.Origin).ThenBy(n => n.Name).ToList(),
Issues = issues,
LoadedPlugins = scan.Plugins,
};
}
public LoadedPlugin? FindPlugin(string type)
=> Load().LoadedPlugins.FirstOrDefault(p => p.Manifest.Id.Equals(type, StringComparison.OrdinalIgnoreCase));
private static AgentTypeInfo ToTypeInfo(LoadedPlugin plugin)
{
PluginManifest manifest = plugin.Manifest;
return new AgentTypeInfo
{
Type = manifest.Id,
Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name,
Description = manifest.Description,
Origin = "plugin",
Version = manifest.Version,
Folder = plugin.FolderName,
Inputs = manifest.Inputs.Select(ToPort).ToList(),
Outputs = manifest.Outputs.Select(ToPort).ToList(),
Credentials = manifest.Credentials,
};
}
private static PortInfo ToPort(PluginPort port)
=> new()
{
Name = port.Name,
Type = port.Type,
Description = port.Description,
Required = port.Required,
};
private static AgentTypeInfo Clone(AgentTypeInfo source)
=> new()
{
Type = source.Type,
Name = source.Name,
Description = source.Description,
Origin = source.Origin,
Version = source.Version,
Folder = source.Folder,
Overridden = source.Overridden,
Handler = source.Handler,
Inputs = source.Inputs,
Outputs = source.Outputs,
Credentials = source.Credentials,
};
}
+36
View File
@@ -0,0 +1,36 @@
using Microsoft.Extensions.Configuration;
namespace MAF1.Plugins;
public sealed class PluginOptions
{
public string Directory { get; set; } = "plugins";
public int DefaultTimeoutSeconds { get; set; } = 180;
public string ResolveRoot()
{
if (Path.IsPathRooted(Directory))
{
return Directory;
}
return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, Directory));
}
public static PluginOptions Load(IConfiguration config)
=> config.GetSection("Plugins").Get<PluginOptions>() ?? new PluginOptions();
}
public sealed class LoadedPlugin
{
public required string FolderName { get; init; }
public required string FolderPath { get; init; }
public required PluginContract.PluginManifest Manifest { get; init; }
}
public sealed class CatalogIssue
{
public string Level { get; init; } = "error";
public string Source { get; init; } = "";
public string Message { get; init; } = "";
}
+249
View File
@@ -0,0 +1,249 @@
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
{
// 进程可能已经退出。
}
}
}
+117
View File
@@ -0,0 +1,117 @@
using System.Text.Json;
using MAF1.PluginContract;
namespace MAF1.Plugins;
public sealed class PluginScanner(PluginOptions options)
{
public PluginScanResult Scan()
{
string root = options.ResolveRoot();
Directory.CreateDirectory(root);
List<LoadedPlugin> plugins = [];
List<CatalogIssue> issues = [];
HashSet<string> ids = new(StringComparer.OrdinalIgnoreCase);
foreach (string folder in Directory.GetDirectories(root))
{
string folderName = Path.GetFileName(folder);
if (folderName.StartsWith('_') || folderName.StartsWith('.'))
{
continue;
}
string manifestPath = Path.Combine(folder, "plugin.json");
if (!File.Exists(manifestPath))
{
issues.Add(new CatalogIssue
{
Level = "warning",
Source = folderName,
Message = "目录里没有 plugin.json,已跳过。",
});
continue;
}
PluginManifest? manifest;
try
{
string json = File.ReadAllText(manifestPath);
manifest = JsonSerializer.Deserialize<PluginManifest>(json, PluginJson.Options);
}
catch (Exception ex)
{
issues.Add(new CatalogIssue
{
Level = "error",
Source = folderName,
Message = $"plugin.json 无法解析:{ex.Message}",
});
continue;
}
if (manifest is null || string.IsNullOrWhiteSpace(manifest.Id))
{
issues.Add(new CatalogIssue
{
Level = "error",
Source = folderName,
Message = "plugin.json 缺少 id。",
});
continue;
}
if (string.IsNullOrWhiteSpace(manifest.Launch.Command))
{
issues.Add(new CatalogIssue
{
Level = "error",
Source = manifest.Id,
Message = "plugin.json 缺少 launch.command,宿主不会替插件拼命令。",
});
continue;
}
if (!ids.Add(manifest.Id))
{
issues.Add(new CatalogIssue
{
Level = "error",
Source = manifest.Id,
Message = $"插件 id `{manifest.Id}` 重复,已忽略目录 {folderName}。",
});
continue;
}
if (DuplicatePortNames(manifest.Inputs))
{
issues.Add(new CatalogIssue { Level = "error", Source = manifest.Id, Message = "inputs 字段名重复。" });
continue;
}
if (DuplicatePortNames(manifest.Outputs))
{
issues.Add(new CatalogIssue { Level = "error", Source = manifest.Id, Message = "outputs 字段名重复。" });
continue;
}
plugins.Add(new LoadedPlugin
{
FolderName = folderName,
FolderPath = folder,
Manifest = manifest,
});
}
return new PluginScanResult(root, plugins, issues);
}
private static bool DuplicatePortNames(List<PluginPort> ports)
=> ports.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1);
}
public sealed record PluginScanResult(
string Root,
IReadOnlyList<LoadedPlugin> Plugins,
IReadOnlyList<CatalogIssue> Issues);
+50
View File
@@ -0,0 +1,50 @@
using MAF1.Decisions;
using MAF1.Utils;
using MAF1.Web;
WindowsConsole.EnableUtf8();
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:5288");
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
});
builder.Services.AddSingleton<AgentRuntime>();
WebApplication app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapGet("/api/catalog", (AgentRuntime runtime) =>
{
var snapshot = runtime.Catalog.Load();
return new
{
pluginsRoot = snapshot.PluginsRoot,
system = snapshot.System,
plugins = snapshot.Plugins,
nodes = snapshot.Nodes,
issues = snapshot.Issues,
};
});
app.MapGet("/api/credentials", (AgentRuntime runtime) => runtime.Credentials.ListPublic());
app.MapGet("/api/status", (AgentRuntime runtime) => new
{
weatherProvider = runtime.WeatherProvider,
pluginsRoot = runtime.PluginsRoot,
});
app.MapPost("/api/run", (WorkflowGraph graph, AgentRuntime runtime, CancellationToken cancellationToken)
=> runtime.Runner.RunAsync(graph, cancellationToken));
app.MapGet("/api/run/{runId}", (string runId, AgentRuntime runtime) =>
{
WorkflowRunResult? result = runtime.Runner.Get(runId);
return result is null
? Results.NotFound(new WorkflowRunResult { Ok = false, Status = WorkflowRunStatus.Failed, Error = "找不到这次运行。" })
: Results.Ok(result);
});
app.MapPost("/api/run/{runId}/decide", (string runId, DecisionAnswer answer, AgentRuntime runtime, CancellationToken cancellationToken)
=> runtime.Runner.DecideAsync(runId, answer, cancellationToken));
Console.WriteLine("可视化编排: http://127.0.0.1:5288");
app.Run();
+14
View File
@@ -0,0 +1,14 @@
{
"profiles": {
"designer": {
"commandName": "Project",
"applicationUrl": "http://127.0.0.1:5288",
"environmentVariables": {
"ASPNETCORE_URLS": "http://127.0.0.1:5288",
"OPENAI_ENDPOINT": "https://api.deepseek.com",
"OPENAI_API_KEY": "sk-b31330cf3a414d86ab614b9dfb3650c3",
"OPENAI_CHAT_MODEL": "deepseek-v4-flash"
}
}
}
}
+103
View File
@@ -0,0 +1,103 @@
using MAF1.PluginContract;
namespace MAF1.Web;
public sealed class PortInfo
{
public string Name { get; init; } = "";
public string Type { get; init; } = "";
public string Description { get; init; } = "";
public bool Required { get; init; }
}
public sealed class AgentTypeInfo
{
public string Type { get; init; } = "";
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public string Origin { get; set; } = "system";
public string Version { get; init; } = "";
public string Folder { get; init; } = "";
public bool Overridden { get; set; }
/// <summary>
/// 进程内实现。系统节点必填;插件节点为 None,走独立进程。
/// </summary>
public SystemHandler Handler { get; init; }
public IReadOnlyList<PortInfo> Inputs { get; init; } = [];
public IReadOnlyList<PortInfo> Outputs { get; init; } = [];
public IReadOnlyList<PluginCredentialNeed> Credentials { get; init; } = [];
}
public enum SystemHandler
{
None = 0,
FileCity,
Weather,
}
public static class AgentCatalog
{
public static AgentTypeInfo? FindSystem(string type)
=> SystemNodes.FirstOrDefault(item => item.Type.Equals(type, StringComparison.OrdinalIgnoreCase));
public static IReadOnlyList<AgentTypeInfo> SystemNodes { get; } =
[
new()
{
Type = "fileCity-system",
Name = "FileCityAgent-System",
Description = "读取指定文本文件,判断并抽出有效城市名(系统内置实现)。",
Origin = "system",
Handler = SystemHandler.FileCity,
Inputs =
[
new PortInfo { Name = "filePath", Type = "string", Description = "本地文件路径,例如 Data/cities.txt", Required = true },
],
Outputs =
[
new PortInfo { Name = "hasValidCities", Type = "bool", Description = "是否存在有效城市" },
new PortInfo { Name = "cities", Type = "string[]", Description = "" },
new PortInfo { Name = "reason", Type = "string", Description = "判断说明" },
new PortInfo { Name = "raw", Type = "string", Description = "Agent 原始文本" },
],
Credentials =
[
new PluginCredentialNeed
{
Name = "llm",
Type = "openai-compatible",
Required = true,
Description = "OpenAI 兼容接口:endpoint + apiKey + model",
},
],
},
new()
{
Type = "weather-system",
Name = "WeatherAgent-System",
Description = "按城市列表查询天气并汇总(系统内置实现)。",
Origin = "system",
Handler = SystemHandler.Weather,
Inputs =
[
new PortInfo { Name = "cities", Type = "string[]", Description = "", Required = true },
],
Outputs =
[
new PortInfo { Name = "summary", Type = "string", Description = "天气汇总文本" },
],
Credentials =
[
new PluginCredentialNeed
{
Name = "llm",
Type = "openai-compatible",
Required = true,
Description = "OpenAI 兼容接口:endpoint + apiKey + model",
},
],
},
];
}
+45
View File
@@ -0,0 +1,45 @@
using MAF1.Agents.FileCity;
using MAF1.Agents.Weather;
using MAF1.Plugins;
using MAF1.Tools;
using MAF1.Utils;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Configuration;
namespace MAF1.Web;
public sealed class AgentRuntime
{
public AgentRuntime(IConfiguration config)
{
AgentFactory factory = new(AgentFactory.Load(config));
WeatherOptions weatherOptions = config.GetSection("Weather").Get<WeatherOptions>() ?? new WeatherOptions();
Http = WeatherTools.CreateHttpClient();
WeatherTools weatherTools = new(weatherOptions, Http);
FileCity = FileCityAgent.Create(factory);
Weather = WeatherAgent.Create(factory, weatherTools);
PluginOptions pluginOptions = PluginOptions.Load(config);
Credentials = new CredentialStore(config);
Scanner = new PluginScanner(pluginOptions);
Catalog = new NodeCatalogService(Scanner);
PluginRunner = new PluginProcessRunner(pluginOptions, Credentials);
RunStore = new WorkflowRunStore();
DecisionTimeoutSeconds = Math.Clamp(config.GetValue("Workflow:DecisionTimeoutSeconds", 30), 1, 600);
Runner = new ConfigurableWorkflowRunner(FileCity, Weather, Catalog, PluginRunner, RunStore, DecisionTimeoutSeconds);
WeatherProvider = weatherOptions.Provider;
PluginsRoot = pluginOptions.ResolveRoot();
}
public AIAgent FileCity { get; }
public AIAgent Weather { get; }
public ConfigurableWorkflowRunner Runner { get; }
public WorkflowRunStore RunStore { get; }
public int DecisionTimeoutSeconds { get; }
public NodeCatalogService Catalog { get; }
public PluginScanner Scanner { get; }
public PluginProcessRunner PluginRunner { get; }
public CredentialStore Credentials { get; }
public string WeatherProvider { get; }
public string PluginsRoot { get; }
public HttpClient Http { get; }
}
+582
View File
@@ -0,0 +1,582 @@
using System.Text.Json;
using MAF1.Agents;
using MAF1.Agents.FileCity;
using MAF1.Agents.Weather;
using MAF1.Decisions;
using MAF1.PluginContract;
using MAF1.Plugins;
using Microsoft.Agents.AI;
namespace MAF1.Web;
public sealed class ConfigurableWorkflowRunner(
AIAgent fileCityAgent,
AIAgent weatherAgent,
NodeCatalogService catalog,
PluginProcessRunner plugins,
WorkflowRunStore runs,
int decisionTimeoutSeconds)
{
private static readonly HashSet<string> ReservedConfigKeys = new(StringComparer.OrdinalIgnoreCase)
{
"credentialId",
"askOnEmptyCities",
"decisionTimeoutSeconds",
};
public async Task<WorkflowRunResult> RunAsync(WorkflowGraph graph, CancellationToken cancellationToken)
{
try
{
Validate(graph);
NodeCatalogSnapshot snapshot = catalog.Load();
List<string> order = TopologicalOrder(graph);
WorkflowSession session = new()
{
RunId = Guid.NewGuid().ToString("n")[..12],
Graph = graph,
Catalog = snapshot,
Order = order,
Outputs = new Dictionary<string, Dictionary<string, object?>>(StringComparer.OrdinalIgnoreCase),
Steps = [],
NextIndex = 0,
};
runs.TryAdd(session);
return await ContinueAsync(session, cancellationToken);
}
catch (Exception ex)
{
return Fail(ex.Message);
}
}
public WorkflowRunResult? Get(string runId) => runs.GetResult(runId);
public async Task<WorkflowRunResult> DecideAsync(string runId, DecisionAnswer answer, CancellationToken cancellationToken)
{
if (!runs.TryGet(runId, out WorkflowSession? session) || session is null)
{
return Fail("找不到这次运行,可能已经结束或服务已重启。");
}
return await ResolveAsync(session, answer, cancellationToken, session.Pending?.Id);
}
private async Task<WorkflowRunResult> ContinueAsync(WorkflowSession session, CancellationToken cancellationToken)
{
try
{
for (int i = session.NextIndex; i < session.Order.Count; i++)
{
string nodeId = session.Order[i];
WorkflowNode node = session.Graph.Nodes.First(n => n.Id == nodeId);
Dictionary<string, object?> inputs = ResolveInputs(session.Graph, node, session.Outputs);
if (!PassEdgeConditions(session.Graph, node, session.Outputs, out string? skipReason))
{
session.Steps.Add(new NodeRunLog
{
NodeId = node.Id,
Type = node.Type,
Title = node.Title,
Skipped = true,
Message = skipReason,
Inputs = inputs,
});
continue;
}
NodeRunLog log = await RunNodeAsync(session.Catalog, node, inputs, cancellationToken);
session.Steps.Add(log);
session.Outputs[node.Id] = log.Outputs;
session.NextIndex = i + 1;
if (ShouldAskEmptyCities(node, log) && i + 1 < session.Order.Count)
{
return PauseForEmptyCities(session, node, log);
}
}
return Complete(session);
}
catch (Exception ex)
{
WorkflowRunResult failed = Fail(ex.Message, session.RunId, session.Steps);
session.Completed = true;
session.LastResult = failed;
return failed;
}
}
private WorkflowRunResult PauseForEmptyCities(WorkflowSession session, WorkflowNode node, NodeRunLog log)
{
string? reason = ReadString(log.Outputs, "reason");
int timeout = decisionTimeoutSeconds;
if (node.Config.TryGetValue("decisionTimeoutSeconds", out string? configured)
&& int.TryParse(configured, out int parsed)
&& parsed > 0)
{
timeout = parsed;
}
DecisionRequest decision = EmptyCityDecision.Create(node.Id, reason, timeout);
session.Pending = decision;
session.TimeoutCts.Cancel();
session.TimeoutCts.Dispose();
session.TimeoutCts = new CancellationTokenSource();
session.LastResult = new WorkflowRunResult
{
Ok = true,
Status = WorkflowRunStatus.NeedsDecision,
RunId = session.RunId,
Decision = decision,
AppliedDecision = session.AppliedDecision,
Steps = session.Steps,
};
StartTimeout(session);
return session.LastResult;
}
private void StartTimeout(WorkflowSession session)
{
DecisionRequest? decision = session.Pending;
if (decision is null)
{
return;
}
CancellationToken token = session.TimeoutCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(decision.TimeoutSeconds), token);
await ResolveAsync(
session,
new DecisionAnswer { OptionId = decision.DefaultOptionId, TimedOut = true },
CancellationToken.None,
decision.Id);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
session.Completed = true;
session.LastResult = Fail($"默认方案自动确认失败:{ex.Message}", session.RunId, session.Steps);
}
});
}
private async Task<WorkflowRunResult> ResolveAsync(
WorkflowSession session,
DecisionAnswer answer,
CancellationToken cancellationToken,
string? expectedDecisionId)
{
await session.Mutex.WaitAsync(cancellationToken);
try
{
if (session.Completed)
{
return session.LastResult;
}
if (session.Pending is null
|| (!string.IsNullOrEmpty(expectedDecisionId)
&& !session.Pending.Id.Equals(expectedDecisionId, StringComparison.OrdinalIgnoreCase)))
{
return session.LastResult;
}
if (!answer.TimedOut)
{
WorkflowRunResult? invalid = ValidateAnswer(session, answer);
if (invalid is not null)
{
return invalid;
}
}
session.TimeoutCts.Cancel();
ApplyAnswer(session, answer);
session.Pending = null;
return await ContinueAsync(session, cancellationToken);
}
catch (Exception ex)
{
return Fail(ex.Message, session.RunId, session.Steps);
}
finally
{
session.Mutex.Release();
}
}
private static WorkflowRunResult? ValidateAnswer(WorkflowSession session, DecisionAnswer answer)
{
DecisionRequest decision = session.Pending!;
DecisionOption? option = decision.Options.FirstOrDefault(
item => item.Id.Equals(answer.OptionId, StringComparison.OrdinalIgnoreCase));
if (option is null)
{
return InvalidDecision(session, "请选择一个列出的方案。");
}
if (option.RequiresText && EmptyCityDecision.ParseCities(answer.Text).Count == 0)
{
return InvalidDecision(session, "这个方案需要输入城市名,例如:成都、北京。");
}
return null;
}
private static WorkflowRunResult InvalidDecision(WorkflowSession session, string error)
=> new()
{
Ok = false,
Status = WorkflowRunStatus.NeedsDecision,
RunId = session.RunId,
Error = error,
Decision = session.Pending,
AppliedDecision = session.AppliedDecision,
Steps = session.Steps,
};
private static void ApplyAnswer(WorkflowSession session, DecisionAnswer answer)
{
DecisionRequest decision = session.Pending
?? throw new InvalidOperationException("没有待确认的方案。");
string optionId = string.IsNullOrWhiteSpace(answer.OptionId)
? decision.DefaultOptionId
: answer.OptionId;
if (answer.TimedOut)
{
optionId = decision.DefaultOptionId;
}
DecisionOption option = decision.Options.First(
item => item.Id.Equals(optionId, StringComparison.OrdinalIgnoreCase));
DecisionAnswer applied = new()
{
OptionId = option.Id,
Text = answer.Text,
TimedOut = answer.TimedOut,
};
session.AppliedDecision = applied;
NodeRunLog? step = session.Steps.LastOrDefault(item => item.NodeId == decision.NodeId);
if (option.Id == DecisionOptionIds.QueryCities)
{
List<string> cities = EmptyCityDecision.ParseCities(answer.Text);
if (!session.Outputs.TryGetValue(decision.NodeId, out Dictionary<string, object?>? outputs))
{
outputs = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
session.Outputs[decision.NodeId] = outputs;
}
outputs["hasValidCities"] = true;
outputs["cities"] = cities;
outputs["reason"] = $"用户指定城市:{string.Join("", cities)}";
if (step is not null)
{
step.Outputs = outputs;
step.Message = Append(step.Message, $"用户确认:改查 {string.Join("", cities)}。");
}
}
else if (step is not null)
{
string how = answer.TimedOut ? "超时,自动采用默认方案" : "用户确认默认方案";
step.Message = Append(step.Message, $"{how}:结束后续查询。");
}
}
private static string Append(string? message, string extra)
=> string.IsNullOrWhiteSpace(message) ? extra : $"{message}\n{extra}";
private static WorkflowRunResult Complete(WorkflowSession session)
{
WorkflowRunResult result = new()
{
Ok = true,
Status = WorkflowRunStatus.Completed,
RunId = session.RunId,
AppliedDecision = session.AppliedDecision,
Steps = session.Steps,
};
session.Completed = true;
session.Pending = null;
session.LastResult = result;
return result;
}
private static WorkflowRunResult Fail(string error, string? runId = null, List<NodeRunLog>? steps = null)
=> new()
{
Ok = false,
Status = WorkflowRunStatus.Failed,
RunId = runId,
Error = error,
Steps = steps ?? [],
};
private static bool ShouldAskEmptyCities(WorkflowNode node, NodeRunLog log)
{
if (log.Skipped)
{
return false;
}
if (node.Config.TryGetValue("askOnEmptyCities", out string? flag)
&& flag.Equals("false", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!log.Outputs.ContainsKey("hasValidCities"))
{
return false;
}
return !ReadBool(log.Outputs, "hasValidCities");
}
private async Task<NodeRunLog> RunNodeAsync(
NodeCatalogSnapshot snapshot,
WorkflowNode node,
Dictionary<string, object?> inputs,
CancellationToken cancellationToken)
{
LoadedPlugin? plugin = snapshot.LoadedPlugins
.FirstOrDefault(p => p.Manifest.Id.Equals(node.Type, StringComparison.OrdinalIgnoreCase));
if (plugin is not null)
{
return await RunPluginAsync(plugin, node, inputs, cancellationToken);
}
AgentTypeInfo? system = AgentCatalog.FindSystem(node.Type)
?? snapshot.System.FirstOrDefault(item => item.Type.Equals(node.Type, StringComparison.OrdinalIgnoreCase));
if (system is not null)
{
AgentStepResult step = system.Handler switch
{
SystemHandler.FileCity => await FileCityAgent.RunAsync(fileCityAgent, inputs, cancellationToken),
SystemHandler.Weather => await WeatherAgent.RunAsync(weatherAgent, inputs, cancellationToken),
_ => throw new InvalidOperationException(
$"系统节点 `{system.Type}` 在 AgentCatalog 里没有绑定实现。请给它设置 Handler。"),
};
return ToLog(node, step);
}
throw new InvalidOperationException(
$"未找到节点类型 `{node.Type}`。系统节点来自 AgentCatalog,插件节点来自 plugins 目录,请点「刷新节点」。");
}
private async Task<NodeRunLog> RunPluginAsync(
LoadedPlugin plugin,
WorkflowNode node,
Dictionary<string, object?> inputs,
CancellationToken cancellationToken)
{
Dictionary<string, object?> declared = FilterDeclaredInputs(plugin.Manifest, inputs);
node.Config.TryGetValue("credentialId", out string? credentialId);
Dictionary<string, object?> outputs = await plugins.RunAsync(plugin, declared, credentialId, cancellationToken);
string? stderr = null;
if (outputs.Remove("_stderr", out object? stderrValue))
{
stderr = stderrValue?.ToString();
}
return new NodeRunLog
{
NodeId = node.Id,
Type = node.Type,
Title = node.Title,
Message = stderr,
Inputs = declared,
Outputs = outputs,
};
}
private static NodeRunLog ToLog(WorkflowNode node, AgentStepResult step)
=> new()
{
NodeId = node.Id,
Type = node.Type,
Title = node.Title,
Message = step.Message,
Inputs = step.Inputs,
Outputs = step.Outputs,
};
private static Dictionary<string, object?> FilterDeclaredInputs(PluginManifest manifest, Dictionary<string, object?> inputs)
{
Dictionary<string, object?> declared = new(StringComparer.OrdinalIgnoreCase);
foreach (PluginPort port in manifest.Inputs)
{
if (inputs.TryGetValue(port.Name, out object? value))
{
declared[port.Name] = value;
}
}
return declared;
}
private static void Validate(WorkflowGraph graph)
{
if (graph.Nodes.Count == 0)
{
throw new InvalidOperationException("画布上还没有节点。");
}
HashSet<string> ids = graph.Nodes.Select(n => n.Id).ToHashSet();
foreach (WorkflowEdge edge in graph.Edges)
{
if (!ids.Contains(edge.From) || !ids.Contains(edge.To))
{
throw new InvalidOperationException("存在指向已删除节点的连线。");
}
}
}
private static List<string> TopologicalOrder(WorkflowGraph graph)
{
Dictionary<string, int> indegree = graph.Nodes.ToDictionary(n => n.Id, _ => 0);
Dictionary<string, List<string>> outgoing = graph.Nodes.ToDictionary(n => n.Id, _ => new List<string>());
foreach (WorkflowEdge edge in graph.Edges)
{
indegree[edge.To]++;
outgoing[edge.From].Add(edge.To);
}
Queue<string> ready = new(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
List<string> order = [];
while (ready.Count > 0)
{
string id = ready.Dequeue();
order.Add(id);
foreach (string next in outgoing[id].Distinct())
{
indegree[next]--;
if (indegree[next] == 0)
{
ready.Enqueue(next);
}
}
}
if (order.Count != graph.Nodes.Count)
{
throw new InvalidOperationException("工作流存在环,无法运行。");
}
return order;
}
private static Dictionary<string, object?> ResolveInputs(
WorkflowGraph graph,
WorkflowNode node,
Dictionary<string, Dictionary<string, object?>> outputs)
{
Dictionary<string, object?> inputs = new(StringComparer.OrdinalIgnoreCase);
foreach (KeyValuePair<string, string> item in node.Config)
{
if (ReservedConfigKeys.Contains(item.Key) || string.IsNullOrWhiteSpace(item.Value))
{
continue;
}
inputs[item.Key] = item.Value;
}
foreach (WorkflowEdge edge in graph.Edges.Where(e => e.To == node.Id))
{
if (!outputs.TryGetValue(edge.From, out Dictionary<string, object?>? source))
{
continue;
}
if (source.TryGetValue(edge.FromPort, out object? value))
{
inputs[edge.ToPort] = value;
}
}
return inputs;
}
private static bool PassEdgeConditions(
WorkflowGraph graph,
WorkflowNode node,
Dictionary<string, Dictionary<string, object?>> outputs,
out string? skipReason)
{
List<WorkflowEdge> incoming = graph.Edges.Where(e => e.To == node.Id).ToList();
if (incoming.Count == 0)
{
skipReason = null;
return true;
}
foreach (WorkflowEdge edge in incoming)
{
if (string.IsNullOrWhiteSpace(edge.When))
{
continue;
}
if (!outputs.TryGetValue(edge.From, out Dictionary<string, object?>? source))
{
skipReason = $"上一节点 {edge.From} 尚未产出结果。";
return false;
}
bool hasCities = ReadBool(source, "hasValidCities");
bool pass = edge.When switch
{
"hasValidCities" => hasCities,
"!hasValidCities" => !hasCities,
_ => true,
};
if (!pass)
{
skipReason = $"连线条件 {edge.When} 不满足,跳过本节点。";
return false;
}
}
skipReason = null;
return true;
}
private static bool ReadBool(Dictionary<string, object?> map, string key)
{
if (!map.TryGetValue(key, out object? value) || value is null)
{
return false;
}
return value switch
{
bool b => b,
JsonElement el when el.ValueKind == JsonValueKind.True => true,
JsonElement el when el.ValueKind == JsonValueKind.False => false,
_ => bool.TryParse(value.ToString(), out bool parsed) && parsed,
};
}
private static string? ReadString(Dictionary<string, object?> map, string key)
{
if (!map.TryGetValue(key, out object? value) || value is null)
{
return null;
}
if (value is JsonElement el)
{
return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString();
}
return value.ToString();
}
}
+58
View File
@@ -0,0 +1,58 @@
using MAF1.Decisions;
namespace MAF1.Web;
public sealed class WorkflowGraph
{
public List<WorkflowNode> Nodes { get; set; } = [];
public List<WorkflowEdge> Edges { get; set; } = [];
}
public sealed class WorkflowNode
{
public string Id { get; set; } = "";
public string Type { get; set; } = "";
public string Title { get; set; } = "";
public double X { get; set; }
public double Y { get; set; }
public Dictionary<string, string> Config { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
public sealed class WorkflowEdge
{
public string Id { get; set; } = "";
public string From { get; set; } = "";
public string To { get; set; } = "";
public string FromPort { get; set; } = "";
public string ToPort { get; set; } = "";
public string? When { get; set; }
}
public static class WorkflowRunStatus
{
public const string Completed = "completed";
public const string NeedsDecision = "needsDecision";
public const string Failed = "failed";
}
public sealed class WorkflowRunResult
{
public bool Ok { get; set; }
public string Status { get; set; } = WorkflowRunStatus.Completed;
public string? RunId { get; set; }
public string? Error { get; set; }
public DecisionRequest? Decision { get; set; }
public DecisionAnswer? AppliedDecision { get; set; }
public List<NodeRunLog> Steps { get; set; } = [];
}
public sealed class NodeRunLog
{
public string NodeId { get; set; } = "";
public string Type { get; set; } = "";
public string Title { get; set; } = "";
public bool Skipped { get; set; }
public string? Message { get; set; }
public Dictionary<string, object?> Inputs { get; set; } = [];
public Dictionary<string, object?> Outputs { get; set; } = [];
}
+35
View File
@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using MAF1.Decisions;
using MAF1.Plugins;
namespace MAF1.Web;
internal sealed class WorkflowSession
{
public required string RunId { get; init; }
public required WorkflowGraph Graph { get; init; }
public required NodeCatalogSnapshot Catalog { get; init; }
public required List<string> Order { get; init; }
public required Dictionary<string, Dictionary<string, object?>> Outputs { get; init; }
public required List<NodeRunLog> Steps { get; init; }
public int NextIndex { get; set; }
public DecisionRequest? Pending { get; set; }
public bool Completed { get; set; }
public WorkflowRunResult LastResult { get; set; } = new();
public DecisionAnswer? AppliedDecision { get; set; }
public CancellationTokenSource TimeoutCts { get; set; } = new();
public SemaphoreSlim Mutex { get; } = new(1, 1);
public object Sync { get; } = new();
}
public sealed class WorkflowRunStore
{
private readonly ConcurrentDictionary<string, WorkflowSession> _sessions = new(StringComparer.OrdinalIgnoreCase);
internal bool TryAdd(WorkflowSession session) => _sessions.TryAdd(session.RunId, session);
internal bool TryGet(string runId, out WorkflowSession? session) => _sessions.TryGetValue(runId, out session);
public WorkflowRunResult? GetResult(string runId)
=> _sessions.TryGetValue(runId, out WorkflowSession? session) ? session.LastResult : null;
}
+32
View File
@@ -0,0 +1,32 @@
{
"Llm": {
"ApiKey": "",
"Endpoint": "https://api.deepseek.com",
"Model": "deepseek-v4-flash"
},
"Workflow": {
"DecisionTimeoutSeconds": 30
},
"Plugins": {
"Directory": "plugins",
"DefaultTimeoutSeconds": 180
},
"Credentials": {
"llm-default": {
"type": "openai-compatible",
"name": "系统默认模型",
"source": "llm-section"
}
},
"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": ""
}
}
}
+335
View File
@@ -0,0 +1,335 @@
:root {
--bg: #0f1419;
--panel: #171e26;
--line: #2a3542;
--text: #e8eef4;
--muted: #8b9aab;
--accent: #4ea1ff;
--file: #3dd6c6;
--weather: #f0b429;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
height: 100%;
font-family: "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
.top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--line);
}
.top h1 {
margin: 0 0 4px;
font-size: 18px;
}
.top p {
margin: 0;
color: var(--muted);
font-size: 13px;
}
.actions {
display: flex;
gap: 8px;
}
button {
background: #243040;
color: var(--text);
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 14px;
cursor: pointer;
}
button.primary {
background: var(--accent);
color: #061018;
border: 0;
font-weight: 600;
}
main {
display: grid;
grid-template-columns: 220px 1fr 280px;
min-height: calc(100vh - 220px);
}
.palette, .inspector {
background: var(--panel);
padding: 16px;
border-right: 1px solid var(--line);
}
.inspector {
border-right: 0;
border-left: 1px solid var(--line);
}
h2 {
margin: 0 0 10px;
font-size: 14px;
}
.hint {
color: var(--muted);
font-size: 12px;
}
.palette-item .tag {
display: inline-block;
margin-left: 6px;
font-size: 10px;
color: var(--accent);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0 6px;
}
.palette-group {
margin-top: 12px;
color: var(--muted);
font-size: 12px;
}
.issue {
font-size: 11px;
color: #f0b429;
margin: 6px 0;
}
.node.plugin {
border-top: 3px solid #9b8afb;
}
.node.overridden {
opacity: 0.95;
}
.canvas-wrap {
position: relative;
overflow: hidden;
background: radial-gradient(#1c252f 1px, transparent 1px) 0 0 / 18px 18px;
}
#canvas {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
#wires {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
}
.node {
position: absolute;
width: 240px;
background: #1b2430;
border: 1px solid var(--line);
border-radius: 12px;
pointer-events: auto;
}
.node.selected {
border-color: var(--accent);
}
.node.fileCity {
border-top: 3px solid var(--file);
}
.node.weather {
border-top: 3px solid var(--weather);
}
.node-head {
padding: 10px 12px;
font-weight: 600;
cursor: move;
user-select: none;
}
.ports {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 0 10px 12px;
}
.port-col {
display: flex;
flex-direction: column;
gap: 6px;
}
.port {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--muted);
}
.port.out {
justify-content: flex-end;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
flex: 0 0 10px;
cursor: pointer;
}
.inspector label {
display: block;
font-size: 12px;
color: var(--muted);
margin: 10px 0 4px;
}
.inspector input, .inspector select {
width: 100%;
background: #10161d;
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px;
}
.log {
border-top: 1px solid var(--line);
padding: 12px 20px 20px;
}
.log pre {
background: #10161d;
border-radius: 8px;
padding: 12px;
min-height: 80px;
white-space: pre-wrap;
font-size: 12px;
}
.wire {
fill: none;
stroke: var(--accent);
stroke-width: 2;
pointer-events: stroke;
}
.wire.selected {
stroke: var(--weather);
}
.modal {
position: fixed;
inset: 0;
background: rgba(6, 10, 16, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
padding: 20px;
}
.modal.hidden {
display: none;
}
.dialog {
width: min(480px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 18px 18px 16px;
}
.dialog h2 {
margin: 0 0 8px;
font-size: 16px;
}
.dialog p {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.5;
}
.decision-options {
display: flex;
flex-direction: column;
gap: 8px;
margin: 12px 0;
}
.decision-option {
display: block;
background: #10161d;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
}
.decision-option input {
margin-right: 8px;
}
.decision-option.selected {
border-color: var(--accent);
}
.decision-option strong {
display: block;
font-size: 13px;
}
.decision-option span {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
.dialog input {
width: 100%;
background: #10161d;
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.hidden {
display: none !important;
}
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MAF 工作流编排</title>
<link rel="stylesheet" href="/css/app.css" />
</head>
<body>
<header class="top">
<div>
<h1>工作流编排</h1>
<p>系统节点和 plugins 目录里的插件会一起出现。LLM 的 endpoint / API Key 由宿主注入,不会画成输入端口。</p>
</div>
<div class="actions">
<button type="button" id="btnRefresh">刷新节点</button>
<button type="button" id="btnExample">示例图</button>
<button type="button" id="btnRun" class="primary">运行</button>
</div>
</header>
<main>
<aside class="palette">
<h2>节点</h2>
<p class="hint">点击添加到画布</p>
<div id="palette"></div>
</aside>
<section class="canvas-wrap">
<svg id="wires"></svg>
<div id="canvas"></div>
</section>
<aside class="inspector">
<h2>节点配置</h2>
<div id="inspector"><p class="hint">选中节点或连线后在这里改输入 / 输出映射。</p></div>
</aside>
</main>
<section class="log">
<h2>运行结果</h2>
<pre id="log">尚未运行。</pre>
</section>
<div id="decisionModal" class="modal hidden" aria-hidden="true">
<div class="dialog">
<h2>需要你确认</h2>
<p id="decisionPrompt"></p>
<p id="decisionReason" class="hint"></p>
<p class="hint">未及时确认将使用默认方案。剩余 <strong id="decisionRemain">--</strong></p>
<div id="decisionOptions" class="decision-options"></div>
<label id="decisionTextLabel" class="hidden">其它城市</label>
<input id="decisionText" class="hidden" autocomplete="off" />
<p id="decisionError" class="issue hidden"></p>
<div class="dialog-actions">
<button type="button" id="decisionSubmit" class="primary">确认所选方案</button>
</div>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>
+486
View File
@@ -0,0 +1,486 @@
const state = {
catalog: [],
system: [],
plugins: [],
issues: [],
credentials: [],
pluginsRoot: "",
nodes: [],
edges: [],
selected: null,
pending: null,
};
const canvas = document.getElementById("canvas");
const wires = document.getElementById("wires");
const inspector = document.getElementById("inspector");
const logEl = document.getElementById("log");
const decisionModal = document.getElementById("decisionModal");
const decisionPrompt = document.getElementById("decisionPrompt");
const decisionReason = document.getElementById("decisionReason");
const decisionRemain = document.getElementById("decisionRemain");
const decisionOptions = document.getElementById("decisionOptions");
const decisionText = document.getElementById("decisionText");
const decisionTextLabel = document.getElementById("decisionTextLabel");
const decisionError = document.getElementById("decisionError");
const decisionSubmit = document.getElementById("decisionSubmit");
const btnRun = document.getElementById("btnRun");
let decisionTimer = null;
let pendingRunId = null;
let selectedOptionId = "";
function uid(prefix) {
return prefix + Math.random().toString(36).slice(2, 8);
}
function typeInfo(type) {
return state.catalog.find((item) => item.type === type)
|| state.system.find((item) => item.type === type)
|| state.plugins.find((item) => item.type === type);
}
function pickNode(list, inputName) {
return (list || []).find((item) => item.inputs?.some((p) => p.name === inputName));
}
function exampleGraph() {
const fileInfo = pickNode(state.system, "filePath") || pickNode(state.catalog, "filePath");
const weatherInfo = pickNode(state.plugins, "cities") || pickNode(state.system, "cities") || pickNode(state.catalog, "cities");
if (!fileInfo || !weatherInfo) {
logEl.textContent = "示例图需要目录里有「filePath 输入」和「cities 输入」的节点,请先刷新节点。";
return { nodes: [], edges: [] };
}
const fileConfig = { credentialId: "llm-default" };
if (fileInfo.inputs?.some((p) => p.name === "filePath")) {
fileConfig.filePath = "Data/cities.txt";
}
return {
nodes: [
{ id: "n1", type: fileInfo.type, title: fileInfo.name, x: 60, y: 80, config: fileConfig },
{ id: "n2", type: weatherInfo.type, title: weatherInfo.name, x: 420, y: 80, config: { credentialId: "llm-default" } },
],
edges: [
{ id: "e1", from: "n1", to: "n2", fromPort: "cities", toPort: "cities", when: "hasValidCities" },
],
};
}
function renderGroup(title, items) {
if (!items.length) {
return `<div class="palette-group">${title}</div><p class="hint">无</p>`;
}
return `<div class="palette-group">${title}</div>` + items.map((item) => `
<div class="palette-item" data-type="${item.type}">
<strong>${item.name}</strong><span class="tag">${item.origin === "plugin" ? "插件" : "系统"}</span>
<div class="hint">${item.description}</div>
</div>`).join("");
}
function renderPalette() {
const issues = (state.issues || []).map((item) => `<div class="issue">[${item.level}] ${item.source}: ${item.message}</div>`).join("");
document.getElementById("palette").innerHTML =
`<p class="hint">${state.pluginsRoot || ""}</p>` +
renderGroup("系统节点", state.system) +
renderGroup("插件节点", state.plugins) +
(issues ? `<div class="palette-group">扫描说明</div>${issues}` : "");
document.querySelectorAll(".palette-item").forEach((el) => {
el.onclick = () => addNode(el.dataset.type);
});
}
function addNode(type) {
const info = typeInfo(type);
if (!info) {
logEl.textContent = `没有类型 ${type},请先刷新节点。`;
return;
}
const config = { credentialId: "llm-default" };
if (info.inputs?.some((p) => p.name === "filePath")) {
config.filePath = "Data/cities.txt";
}
state.nodes.push({
id: uid("n"),
type,
title: info.name,
x: 80 + state.nodes.length * 40,
y: 70 + state.nodes.length * 30,
config,
});
render();
}
function render() {
canvas.innerHTML = "";
for (const node of state.nodes) {
const info = typeInfo(node.type);
if (!info) {
continue;
}
const el = document.createElement("div");
el.className = `node ${node.type} ${info.origin}` + (state.selected?.kind === "node" && state.selected.id === node.id ? " selected" : "");
el.style.left = node.x + "px";
el.style.top = node.y + "px";
el.innerHTML = `
<div class="node-head">${node.title}</div>
<div class="ports">
<div class="port-col">
${info.inputs.map((p) => `<div class="port in"><i class="dot" data-node="${node.id}" data-port="${p.name}" data-dir="in"></i>${p.name}</div>`).join("")}
</div>
<div class="port-col">
${info.outputs.map((p) => `<div class="port out">${p.name}<i class="dot" data-node="${node.id}" data-port="${p.name}" data-dir="out"></i></div>`).join("")}
</div>
</div>`;
el.querySelector(".node-head").onmousedown = (e) => startDrag(e, node);
el.onclick = (e) => {
if (e.target.classList.contains("dot")) return;
state.selected = { kind: "node", id: node.id };
render();
};
canvas.appendChild(el);
}
canvas.querySelectorAll(".dot").forEach((dot) => {
dot.onclick = (e) => {
e.stopPropagation();
onPort(dot.dataset.node, dot.dataset.port, dot.dataset.dir);
};
});
drawWires();
renderInspector();
}
function startDrag(e, node) {
e.preventDefault();
const wrap = document.querySelector(".canvas-wrap").getBoundingClientRect();
const ox = e.clientX - wrap.left - node.x;
const oy = e.clientY - wrap.top - node.y;
const el = e.currentTarget.parentElement;
const move = (ev) => {
node.x = Math.max(0, ev.clientX - wrap.left - ox);
node.y = Math.max(0, ev.clientY - wrap.top - oy);
if (el) {
el.style.left = node.x + "px";
el.style.top = node.y + "px";
}
drawWires();
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
function onPort(nodeId, port, dir) {
if (dir === "out") {
state.pending = { nodeId, port };
logEl.textContent = `已选输出 ${nodeTitle(nodeId)}.${port},请点击下一个节点的输入端口。`;
return;
}
if (!state.pending) {
logEl.textContent = "请先点击上一节点的输出端口,再点本节点输入。";
return;
}
if (state.pending.nodeId === nodeId) return;
state.edges.push({
id: uid("e"),
from: state.pending.nodeId,
to: nodeId,
fromPort: state.pending.port,
toPort: port,
when: state.pending.port === "cities" || port === "cities" ? "hasValidCities" : "",
});
state.pending = null;
state.selected = { kind: "edge", id: state.edges.at(-1).id };
render();
}
function nodeTitle(id) {
return state.nodes.find((n) => n.id === id)?.title ?? id;
}
function drawWires() {
const wrap = document.querySelector(".canvas-wrap").getBoundingClientRect();
wires.innerHTML = "";
for (const edge of state.edges) {
const from = document.querySelector(`.dot[data-node="${edge.from}"][data-port="${edge.fromPort}"][data-dir="out"]`);
const to = document.querySelector(`.dot[data-node="${edge.to}"][data-port="${edge.toPort}"][data-dir="in"]`);
if (!from || !to) continue;
const a = from.getBoundingClientRect();
const b = to.getBoundingClientRect();
const x1 = a.left + a.width / 2 - wrap.left;
const y1 = a.top + a.height / 2 - wrap.top;
const x2 = b.left + b.width / 2 - wrap.left;
const y2 = b.top + b.height / 2 - wrap.top;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", `M ${x1} ${y1} C ${x1 + 80} ${y1}, ${x2 - 80} ${y2}, ${x2} ${y2}`);
path.setAttribute("class", "wire" + (state.selected?.kind === "edge" && state.selected.id === edge.id ? " selected" : ""));
path.style.pointerEvents = "stroke";
path.onclick = () => {
state.selected = { kind: "edge", id: edge.id };
render();
};
wires.appendChild(path);
}
}
function renderInspector() {
if (!state.selected) {
inspector.innerHTML = `<p class="hint">选中节点可填固定输入;选中连线可改「上一节点输出 → 下一节点输入」和运行条件。</p>`;
return;
}
if (state.selected.kind === "node") {
const node = state.nodes.find((n) => n.id === state.selected.id);
const info = typeInfo(node.type);
inspector.innerHTML = `
<div><strong>${info.name}</strong> <span class="tag">${info.origin === "plugin" ? "插件" : "系统"}</span></div>
<p class="hint">${info.description}</p>
<label>显示名</label>
<input id="title" value="${node.title}" />
<label>LLM 凭据</label>
<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) => `
<label>固定输入 ${p.name}${p.type}${p.required ? " *" : ""}</label>
<input data-config="${p.name}" value="${node.config[p.name] ?? ""}" placeholder="${p.description}" />
`).join("")}
<p class="hint">若该输入已从上一节点连线,运行时以连线为准。</p>
<button type="button" id="delNode">删除节点</button>`;
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-config]").forEach((input) => {
input.oninput = () => { node.config[input.dataset.config] = input.value; };
});
inspector.querySelector("#delNode").onclick = () => {
state.edges = state.edges.filter((e) => e.from !== node.id && e.to !== node.id);
state.nodes = state.nodes.filter((n) => n.id !== node.id);
state.selected = null;
render();
};
return;
}
const edge = state.edges.find((e) => e.id === state.selected.id);
const from = state.nodes.find((n) => n.id === edge.from);
const to = state.nodes.find((n) => n.id === edge.to);
const fromInfo = typeInfo(from.type);
const toInfo = typeInfo(to.type);
inspector.innerHTML = `
<div><strong>连线映射</strong></div>
<p class="hint">上一节点输出 → 下一节点输入</p>
<label>来自</label>
<div>${from.title}</div>
<label>输出端口</label>
<select id="fromPort">${fromInfo.outputs.map((p) => `<option ${p.name === edge.fromPort ? "selected" : ""}>${p.name}</option>`).join("")}</select>
<label>到达</label>
<div>${to.title}</div>
<label>输入端口</label>
<select id="toPort">${toInfo.inputs.map((p) => `<option ${p.name === edge.toPort ? "selected" : ""}>${p.name}</option>`).join("")}</select>
<label>运行条件</label>
<select id="when">
<option value="">始终执行下一节点</option>
<option value="hasValidCities" ${edge.when === "hasValidCities" ? "selected" : ""}>仅当 hasValidCities 为 true</option>
<option value="!hasValidCities" ${edge.when === "!hasValidCities" ? "selected" : ""}>仅当 hasValidCities 为 false</option>
</select>
<button type="button" id="delEdge">删除连线</button>`;
inspector.querySelector("#fromPort").onchange = (e) => { edge.fromPort = e.target.value; drawWires(); };
inspector.querySelector("#toPort").onchange = (e) => { edge.toPort = e.target.value; };
inspector.querySelector("#when").onchange = (e) => { edge.when = e.target.value; };
inspector.querySelector("#delEdge").onclick = () => {
state.edges = state.edges.filter((item) => item.id !== edge.id);
state.selected = null;
render();
};
}
async function runGraph() {
closeDecision();
logEl.textContent = "运行中…";
btnRun.disabled = true;
try {
const res = await fetch("/api/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nodes: state.nodes, edges: state.edges }),
});
const data = await res.json();
handleRunResult(data);
} catch (err) {
logEl.textContent = err?.message ?? "运行失败";
btnRun.disabled = false;
}
}
function formatRunLog(data) {
const applied = data.appliedDecision
? `\n已选方案: ${data.appliedDecision.optionId}${data.appliedDecision.timedOut ? "(超时默认)" : ""}${data.appliedDecision.text ? " / " + data.appliedDecision.text : ""}\n`
: "";
const steps = (data.steps || []).map((step) => {
const head = `${step.title} (${step.type})${step.skipped ? " [跳过]" : ""}`;
const inputs = JSON.stringify(step.inputs, null, 2);
const outputs = JSON.stringify(step.outputs, null, 2);
return `${head}\n输入:\n${inputs}\n输出:\n${outputs}\n${step.message ?? ""}`;
}).join("\n\n-----\n\n");
return (applied + (steps || "没有步骤。")).trim();
}
function handleRunResult(data) {
if (data.status === "needsDecision" && data.decision) {
logEl.textContent = "等待确认…\n\n" + formatRunLog(data);
showDecision(data, data.error);
return;
}
closeDecision();
btnRun.disabled = false;
if (!data.ok) {
logEl.textContent = data.error ?? "运行失败";
return;
}
logEl.textContent = formatRunLog(data);
}
function showDecision(data, errorText) {
const decision = data.decision;
pendingRunId = data.runId;
selectedOptionId = decision.defaultOptionId;
decisionPrompt.textContent = decision.prompt || "请选择一个方案。";
decisionReason.textContent = decision.reason ? `原因:${decision.reason}` : "";
decisionError.textContent = errorText || "";
decisionError.classList.toggle("hidden", !errorText);
decisionOptions.innerHTML = (decision.options || []).map((option) => `
<label class="decision-option ${option.id === selectedOptionId ? "selected" : ""}" data-id="${option.id}" data-requires="${option.requiresText ? "1" : "0"}">
<input type="radio" name="decisionOption" value="${option.id}" ${option.id === selectedOptionId ? "checked" : ""} />
<strong>${option.label}</strong>
<span>${option.description || ""}${option.isDefault ? "(超时将自动选择)" : ""}</span>
</label>`).join("");
decisionOptions.querySelectorAll(".decision-option").forEach((el) => {
el.onchange = () => {
selectedOptionId = el.dataset.id;
decisionOptions.querySelectorAll(".decision-option").forEach((item) => {
item.classList.toggle("selected", item.dataset.id === selectedOptionId);
});
syncDecisionText();
};
});
decisionText.placeholder = (decision.options || []).find((o) => o.requiresText)?.textPlaceholder || "请输入";
syncDecisionText();
decisionModal.classList.remove("hidden");
decisionModal.setAttribute("aria-hidden", "false");
startDecisionTimer(decision.deadline);
}
function syncDecisionText() {
const option = decisionOptions.querySelector(`.decision-option[data-id="${selectedOptionId}"]`);
const needsText = option?.dataset.requires === "1";
decisionText.classList.toggle("hidden", !needsText);
decisionTextLabel.classList.toggle("hidden", !needsText);
if (needsText) {
decisionText.focus();
}
}
function startDecisionTimer(deadline) {
if (decisionTimer) {
clearInterval(decisionTimer);
}
const tick = async () => {
const remain = Math.max(0, Math.ceil((new Date(deadline).getTime() - Date.now()) / 1000));
decisionRemain.textContent = String(remain);
if (remain <= 0) {
clearInterval(decisionTimer);
decisionTimer = null;
if (pendingRunId) {
await pollRun(pendingRunId);
}
}
};
tick();
decisionTimer = setInterval(tick, 500);
}
async function pollRun(runId) {
for (let i = 0; i < 8; i++) {
try {
const res = await fetch(`/api/run/${runId}`);
const data = await res.json();
if (data.status !== "needsDecision") {
handleRunResult(data);
return;
}
} catch {
btnRun.disabled = false;
return;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
function closeDecision() {
if (decisionTimer) {
clearInterval(decisionTimer);
decisionTimer = null;
}
pendingRunId = null;
decisionModal.classList.add("hidden");
decisionModal.setAttribute("aria-hidden", "true");
}
decisionSubmit.onclick = async () => {
if (!pendingRunId || !selectedOptionId) {
return;
}
decisionSubmit.disabled = true;
decisionError.classList.add("hidden");
try {
const res = await fetch(`/api/run/${pendingRunId}/decide`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ optionId: selectedOptionId, text: decisionText.value }),
});
const data = await res.json();
handleRunResult(data);
} catch (err) {
decisionError.textContent = err?.message ?? "确认失败";
decisionError.classList.remove("hidden");
} finally {
decisionSubmit.disabled = false;
}
};
async function loadCatalog() {
const [catalog, credentials] = await Promise.all([
(await fetch("/api/catalog")).json(),
(await fetch("/api/credentials")).json(),
]);
state.system = catalog.system ?? [];
state.plugins = catalog.plugins ?? [];
state.catalog = catalog.nodes ?? [];
state.issues = catalog.issues ?? [];
state.pluginsRoot = catalog.pluginsRoot ?? "";
state.credentials = credentials ?? [];
renderPalette();
if (state.nodes.length) {
render();
}
}
document.getElementById("btnExample").onclick = () => {
const graph = exampleGraph();
state.nodes = graph.nodes;
state.edges = graph.edges;
state.selected = { kind: "edge", id: "e1" };
render();
};
btnRun.onclick = runGraph;
document.getElementById("btnRefresh").onclick = async () => {
logEl.textContent = "正在重新扫描系统节点和 plugins 目录…";
await loadCatalog();
logEl.textContent = `已刷新。系统 ${state.system.length} 个,插件 ${state.plugins.length} 个。`;
};
window.addEventListener("resize", drawWires);
(async function init() {
await loadCatalog();
document.getElementById("btnExample").click();
})();