free-code-dotnet/docs/UI与扩展设计/UI与扩展设计-插件系统.md
应文浩wenhao.ying@xiaobao100.com e25ac591a7 init easy-code
2026-04-06 07:24:24 +08:00

8.9 KiB
Raw Blame History

插件系统

所属项目: free-code .NET 10 重写 所属模块: UI 与扩展设计 原始代码: ../../src/plugins/


概述

插件系统允许第三方代码在运行时扩展 free-code 的能力,包括注册新技能、新斜杠命令和新 MCP 服务器配置。原始实现通过 Node.js 的动态 import() 加载插件模块,利用模块缓存隔离各插件的命名空间。

.NET 重写用 AssemblyLoadContextALC替代动态导入实现更强的隔离性和确定性卸载。每个插件运行在独立的 PluginLoadContext 实例中,其依赖项从插件目录优先解析,不与宿主进程或其他插件共享程序集版本。PluginManager 管理所有插件的生命周期,支持安装、卸载、启用和禁用操作。


17.1 接口定义

/// <summary>
/// 插件管理器 — AssemblyLoadContext 隔离加载
/// 对应原始 plugin system
/// </summary>
public interface IPluginManager
{
    Task<IReadOnlyList<LoadedPlugin>> LoadAllPluginsAsync();
    Task InstallPluginAsync(string pluginId);
    Task UninstallPluginAsync(string pluginId);
    Task EnablePluginAsync(string pluginId);
    Task DisablePluginAsync(string pluginId);
    Task RefreshAsync();
}

17.2 LoadedPlugin 与 PluginManifest

/// <summary>
/// 已加载的插件实例描述
/// </summary>
public sealed record LoadedPlugin
{
    /// <summary>插件唯一标识符(目录名)</summary>
    public required string Id { get; init; }

    /// <summary>插件显示名称</summary>
    public required string Name { get; init; }

    /// <summary>版本字符串</summary>
    public required string Version { get; init; }

    /// <summary>来源marketplace | local | github</summary>
    public required string Source { get; init; }

    /// <summary>主程序集路径</summary>
    public required string AssemblyPath { get; init; }

    /// <summary>是否启用</summary>
    public bool IsEnabled { get; init; }

    /// <summary>插件清单</summary>
    public PluginManifest Manifest { get; init; } = new();
}

/// <summary>
/// 插件清单 — plugin.json 反序列化目标
/// 描述插件向宿主暴露的所有扩展点
/// </summary>
public sealed record PluginManifest
{
    /// <summary>插件提供的技能列表</summary>
    public IReadOnlyList<SkillDefinition> Skills { get; init; } = [];

    /// <summary>插件提供的斜杠命令列表</summary>
    public IReadOnlyList<ICommand> Commands { get; init; } = [];

    /// <summary>插件注册的 MCP 服务器配置(服务器名 → 配置)</summary>
    public IReadOnlyDictionary<string, ScopedMcpServerConfig> McpServers { get; init; }
        = new Dictionary<string, ScopedMcpServerConfig>();
}

设计意图

PluginManifest 直接对应 plugin.json 的顶层结构三个扩展点技能、命令、MCP 服务器)分别映射为 SkillLoaderCommandRegistryMcpClientManager 的动态注册入口。插件卸载时,这三个注册表各自移除该插件贡献的条目。


17.3 PluginLoadContext

/// <summary>
/// 可卸载的程序集加载上下文
/// 对应原始动态 import() 的隔离语义
/// </summary>
public sealed class PluginLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;

    public PluginLoadContext(string pluginPath) : base(isCollectible: true)
    {
        _resolver = new AssemblyDependencyResolver(pluginPath);
    }

    protected override Assembly? Load(AssemblyName assemblyName)
    {
        // 优先从插件目录解析,回退到默认加载上下文
        var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
        return assemblyPath != null
            ? LoadFromAssemblyPath(assemblyPath)
            : null; // null 触发回退到默认 ALC
    }

    protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
    {
        var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
        return libraryPath != null
            ? LoadUnmanagedDllFromPath(libraryPath)
            : IntPtr.Zero;
    }
}

设计意图

isCollectible: true 是关键参数。普通 AssemblyLoadContext 加载的程序集在整个进程生命周期内无法卸载。可回收 ALC 允许在所有对该上下文中类型的强引用归零后GC 完整回收其托管内存和元数据,这是 /plugin uninstall 命令实现热卸载的基础。

AssemblyDependencyResolver 读取插件主程序集旁边的 .deps.json 文件,自动解析所有传递依赖的路径。插件作者只需将所有依赖打包到插件目录,无需关心宿主进程中已有哪些程序集版本。


17.4 PluginManager 实现

public sealed class PluginManager : IPluginManager
{
    private readonly ConcurrentDictionary<string, PluginLoadContext> _contexts = new();
    private readonly ConcurrentDictionary<string, LoadedPlugin> _plugins = new();

    public async Task<IReadOnlyList<LoadedPlugin>> LoadAllPluginsAsync()
    {
        var pluginDir = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            ".free-code", "plugins");

        if (!Directory.Exists(pluginDir)) return [];

        foreach (var dir in Directory.GetDirectories(pluginDir))
        {
            var manifestPath = Path.Combine(dir, "plugin.json");
            if (!File.Exists(manifestPath)) continue;

            var manifest = JsonSerializer.Deserialize<PluginManifest>(
                await File.ReadAllTextAsync(manifestPath));

            var dllPath = Directory.GetFiles(dir, "*.dll").FirstOrDefault();
            if (dllPath == null) continue;

            var context  = new PluginLoadContext(dllPath);
            var assembly = context.LoadFromAssemblyPath(dllPath);

            _contexts[dir] = context;
            _plugins[dir]  = new LoadedPlugin
            {
                Id           = Path.GetFileName(dir),
                Name         = manifest?.Skills.FirstOrDefault()?.Name
                               ?? Path.GetFileName(dir),
                Version      = "1.0",
                Source       = "local",
                AssemblyPath = dllPath,
                IsEnabled    = true,
            };
        }

        return _plugins.Values.ToList();
    }

    public async Task UninstallPluginAsync(string pluginId)
    {
        if (_contexts.TryRemove(pluginId, out var context))
        {
            context.Unload(); // 触发 GC 可回收流程
        }
        _plugins.TryRemove(pluginId, out _);
    }

    public Task RefreshAsync()
    {
        // 重新扫描目录,加载新插件,卸载已删除的
        return LoadAllPluginsAsync();
    }

    public Task InstallPluginAsync(string pluginId)
    {
        // 从 marketplace / GitHub 下载到 ~/.free-code/plugins/<id>/
        // 然后调用 LoadAllPluginsAsync 触发加载
        throw new NotImplementedException("待实现marketplace 下载逻辑");
    }

    public Task EnablePluginAsync(string pluginId)
    {
        if (_plugins.TryGetValue(pluginId, out var plugin))
            _plugins[pluginId] = plugin with { IsEnabled = true };
        return Task.CompletedTask;
    }

    public Task DisablePluginAsync(string pluginId)
    {
        if (_plugins.TryGetValue(pluginId, out var plugin))
            _plugins[pluginId] = plugin with { IsEnabled = false };
        return Task.CompletedTask;
    }
}

插件目录结构

~/.free-code/plugins/
└── my-plugin/
    ├── plugin.json          # 插件清单
    ├── MyPlugin.dll         # 主程序集
    ├── MyPlugin.deps.json   # 依赖清单(供 AssemblyDependencyResolver 使用)
    └── SomeDependency.dll   # 插件私有依赖

plugin.json 示例

{
  "skills": [
    {
      "name": "my-skill",
      "description": "自定义技能示例",
      "content": "你是一个帮助用户完成特定任务的助手。"
    }
  ],
  "commands": [],
  "mcpServers": {
    "my-mcp": {
      "command": "npx",
      "args": ["-y", "my-mcp-server"],
      "scope": "local"
    }
  }
}

生命周期图

LoadAllPluginsAsync()
    │
    ├── 扫描 ~/.free-code/plugins/ 目录
    ├── 读取 plugin.json → 反序列化 PluginManifest
    ├── 创建 PluginLoadContext(dllPath)
    ├── LoadFromAssemblyPath(dllPath)
    └── 注册到 _plugins / _contexts

UninstallPluginAsync(id)
    │
    ├── context.Unload()      [ALC 标记为待回收]
    ├── 移除 _contexts[id]    [WeakReference 归零后 GC 回收]
    └── 移除 _plugins[id]     [插件对象消失]

参考资料