Spiga

Net企业级AI项目6:接入外部世界

2026-02-14 21:46:24

一、MCP概述

1. MCP协议

我们示例到目前所有的功能都是在项目内部开发完成的,如果一个 AI 应用只能使用内部功能,那它还达不到企业级 AI 的要求。因为企业内部会有各种各样的应用软件,虽然前面我们介绍了通过使用 AI 访问数据库来读取业务数据,但那只能满足简单的读取数据库的需求。

真实业务场景会复杂得多,比如希望在数据库中查找最新的销售报告,并将其通过电子邮件发送给指定的角色用户。这里发送邮件是企业内部邮箱服务的功能,而指定角色是身份服务的功能。这些功能在企业内部对应的应用中已经实现了,我们不可能在 AI 系统中再实现一次,而是需要让 AI 拥有调用其他系统的能力。

类似于 Web 系统中的 API 接口一样,AI 也有专门外部系统接入的协议,它就是 MCP 协议。

关于 MCP 的内容,我已经在前面的文章中详细介绍过了,请跳转到 MCP大模型外挂商店 阅读。

2. MCP 交互流程

我们用前面说的“在数据库中查找最新的销售报告,并将其通过电子邮件发送给指定的角色用户”来举例:

  • 初始化:MCP 客户端连接到 MCP 服务器
  • LLM推理:查询数据库 + 查询角色 + 发送邮件
  • 工具调用:MCP 客户端负责接受指令,发送给MCP服务器
  • 执行:MCP 服务器负责执行,返给结果给客户端,返回给LLM
  • 生成最终答复

3. MCP 服务生态

  • 官方代码库 :由 MCP 维护的 GitHub 代码库是首选的起点 。这里包含了许多常用服务的参考实现,例如本地文件系统、GitHub、Git、PostgreSQL 和用于浏览器自动化的 Puppeteer。这些官方服务器是学习和理解 MCP 最佳实践的绝佳范例。

  • 社区精选列表 :这是一个由社区驱动和维护的 GitHub 列表,它展示了 MCP 生态系统的广度和创造力 。在这里,可以找到从浏览器自动化、数据库交互到智能家居控制和特定应用(如 Bilibili 内容搜索)的各种服务器 。

  • MCP市场:一个第三方的 MCP 服务器和客户端市场,聚合了大量的服务器和客户端,并提供了分类和搜索功能 。

4. 安装 MCP 文件系统服务

这里我们从 github 中下载 modelcontextprotocol 提供的 filesystem 来做示例。

安装这个文件系统服务提供了很多种方式,细节可以查看项目说明。我们这里采用 npx 来安装

npm install -g @modelcontextprotocol/server-filesystem

接下来我们构建一个 MCP 主机,来使用这个文件系统服务。项目文档里面也有介绍

npx -y @modelcontextprotocol/server-filesystem C:/Test
  • C:/Test 是最小配置原则,必需至少配置一个可以访问的目录,文件系统服务操作设置的目录。多个目录用空格区分

  • 图中我们看到已经启动了一个MCP服务,这个服务使用的是stdio通信方式,这意味着我们没有任何方法来使用这个MCP服务,因为没有提供外部的通信方式。

    stdio包括: 标准输入 stdin,标准输出 stdout,标准错误 stderr。它的通信方式是嵌入到其他进程,已子进程的方式来执行。

    我们可以用这个命令来看一下服务是否正常(检查正常后可以关闭)。

二、MCP 插件架构设计

我们已经有了一个 MCP 服务了,那怎么把这个服务接入到我们 AI 应用中去呢?

我们知道 AI 应用有三大能力:资源、提示词、工具。很显然,MCP 它应该属于工具范畴。

我们之前已经提供了一套插件式的工具框架,那些工具都是应用内部实现好的,我们称做静态工具。

现在我们需要在这个基础上扩展一个 MCP 的插件架构,我们把这种工具称做动态工具。

接下来我们看看如何实现这个架构。

1. 配置的动态性

首先我们需要创建一个专门的 MCP 服务的管理应用,我们使用数据库来管理 AI 能使用 MCP 服务。

  • 我们在 Core 层创建名为 Qjy.AICopilot.Core.McpServer 的类库项目
//Qjy.AICopilot.Core.McpServer/Aggregates/McpServerInfo/McpServerInfo.cs
public class McpServerInfo : IAggregateRoot
{
    protected McpServerInfo()
    {
    }

    public McpServerInfo(
        string name, 
        string description, 
        McpTransportType transportType, 
        string? command, 
        string arguments)
    {
        Id = Guid.NewGuid();
        Name = name;
        Description = description;
        TransportType = transportType;
        Command = command;
        Arguments = arguments;
        IsEnabled = true;
    }
    
    public Guid Id { get; set; }

    // 服务名称,作为插件的唯一标识,例如 "github-server"
    public string Name { get; private set; } = null!;
    
    // 服务描述
    public string Description { get; private set; } = null!;

    // 传输类型:Stdio 或 Sse
    public McpTransportType TransportType { get; private set; }
    
    // 针对 Stdio 的配置:可执行文件路径 (如 "node", "python")
    public string? Command { get; private set; }
    
    // 针对 Stdio 的配置:启动参数 (如 "build/index.js")
    // 针对 SSE 的配置:目标 URL
    public string Arguments { get; private set; } = null!;

    // 是否启用
    public bool IsEnabled { get; private set; }
}

public enum McpTransportType
{
    Stdio = 1,
    Sse = 2
}
  • 配置数据库映射
public class McpServerConfiguration : IEntityTypeConfiguration<McpServerInfo>
{
    public void Configure(EntityTypeBuilder<McpServerInfo> builder)
    {
        builder.ToTable("mcp_server_info");

        builder.HasKey(b => b.Id);
        builder.Property(b => b.Id).HasColumnName("id");

        builder.Property(b => b.Name)
            .IsRequired()
            .HasMaxLength(100)
            .HasColumnName("name");
        
        // 保证名称唯一
        builder.HasIndex(b => b.Name).IsUnique();

        builder.Property(b => b.Description)
            .IsRequired()
            .HasMaxLength(500)
            .HasColumnName("description");

        builder.Property(b => b.Command)
            .HasMaxLength(200)
            .HasColumnName("command");
        
        builder.Property(b => b.Arguments)
            .IsRequired()
            .HasMaxLength(1000)
            .HasColumnName("arguments");

        builder.Property(b => b.TransportType)
            .IsRequired()
            .HasConversion<string>() // 存储枚举字符串,增强可读性
            .HasMaxLength(50)
            .HasColumnName("transport_type");

        builder.Property(b => b.IsEnabled)
            .IsRequired()
            .HasColumnName("is_enabled");
    }
}
  • 添加种子数据
//Qjy.AICopilot.MigrationWorkApp/SeedData/McpServerInfoData.cs
public static class McpServerInfoData
{
    public static IEnumerable<McpServerInfo> GetMcpServerInfos()
    {
        // 添加文件系统
        var fileSystemAgentPlugin = new McpServerInfo(
            "FileSystem",
            "提供本地文件系统访问能力,它允许在限定的目录范围内执行文件和目录相关操作,包括读取文件内容、创建和写入文件、列出目录结构、移动或重命名文件等。",
            McpTransportType.Stdio,
            "npx",
            @"-y @modelcontextprotocol/server-filesystem C:\Test"
        );

        return [fileSystemAgentPlugin];
    }
}

//Qjy.AICopilot.MigrationWorkApp/Worker.cs
private static async Task SeedDataAsync(
    AiCopilotDbContext dbContext,
    RoleManager<IdentityRole> roleManager,
    UserManager<IdentityUser> userManager,
    CancellationToken cancellationToken)
{
    // 其他代码    

    // 创建默认MCPServer
    if (!await dbContext.McpServerInfos.AnyAsync(cancellationToken: cancellationToken))
    {
        await dbContext.McpServerInfos.AddRangeAsync(McpServerInfoData.GetMcpServerInfos(), cancellationToken);
    }
}

2. 插件的统一性

MCP 工具本质上它也是一种工具,能通过配置 ChatClient 的 AITools 属性来实现工具调用,因此我们需要统一管理系统工具和 MCP 工具。

要将两种类型不相同的对象,让它们能一起使用,设计模式中有很多中实现方式。比如适配器模式、桥接模式等。

我们这里使用桥接模式来实现,我们可以直接在原来的插件框架上,将 MCP 工具转化成 AITools 即可。

  • 在插件框架中定义一个桥接插件,实现 IAgentPlugin 接口,用于将外部 MCP 服务适配为内部的原生插件
//Qjy.AICopilot.AgentPlugin/GenericBridgePlugin.cs
/// <summary>
/// MCP 通用桥接插件。
/// 该类实现了 IAgentPlugin 接口,用于将外部 MCP 服务适配为内部的原生插件。
/// 它不包含具体的业务逻辑,而是作为 MCP 工具集的容器。
/// </summary>
public class GenericBridgePlugin : IAgentPlugin
{
    /// <summary>
    /// 插件名称。
    /// 映射自 McpServerInfo.Name。
    /// 使用 required 关键字强制在初始化时赋值,确保插件标识的完整性。
    /// </summary>
    public required string Name { get; init; }

    /// <summary>
    /// 插件描述。
    /// 映射自 McpServerInfo.Description。
    /// 这段描述将被注入到 LLM 的 System Prompt 中,用于指导模型何时使用该插件。
    /// </summary>
    public required string Description { get; init; }

    /// <summary>
    /// 动态注入的工具列表。
    /// 这些工具对象由 MCP SDK 在握手阶段解析生成,包含了工具名称、参数 Schema 以及执行回调。
    /// </summary>
    public IEnumerable<AITool>? AITools { get; init; }

    /// <summary>
    /// 实现接口方法,向 Agent 暴露该插件所拥有的所有能力。
    /// </summary>
    /// <returns>工具定义集合</returns>
    public IEnumerable<AITool>? GetAITools()
    {
        return AITools;
    }
}
  • 扩展 AgentPluginLoader 类,提供一个既支持注册原生插件,也支持注册 MCP 桥接插件的方法
//Qjy.AICopilot.AgentPlugin/AgentPluginLoader.cs
/// <summary>
/// 动态注册一个 Agent 插件。
/// 该方法既支持注册原生插件,也支持注册 MCP 桥接插件。
/// </summary>
/// <param name="plugin">插件实例</param>
public void RegisterAgentPlugin(IAgentPlugin plugin)
{
    // 1. 存储插件实例
    _plugins[plugin.Name] = plugin;

    // 2. 提取并缓存工具列表
    // 这一步是为了优化性能,避免每次 Agent 询问工具时都去遍历插件
    var tools = plugin.GetAITools()?.ToArray() ?? [];
    _aiTools[plugin.Name] = tools;
}

3. 生命周期管理

每一个 MCP 服务都有启动和停止过程,我们不可能为要求每个接入的 MCP 服务去单独实现服务的启动和停止,因此我们需要提供统一的生命周期管理实现。

  • 首先我们创建一个 Qjy.AICopilot.McpService 类库项目,添加 ModelContextProtocol 包的引用

  • 我们封装一个 IMcpServerBootstrap 接口,并提供默认 MCP 启动实现,支持 Stdio 和 Sse 两种交互方式。在启动过程中,同时将 MCP 工具使用前面提供的 RegisterAgentPlugin 方法,将 MCP 工具加载到工具列表中。

//Qjy.AICopilot.McpService/IMcpServerBootstrap.cs
public interface IMcpServerBootstrap
{
    IAsyncEnumerable<McpClient> StartAsync(CancellationToken cancellationToken);
}

//Qjy.AICopilot.McpService/McpServerBootstrap.cs
public class McpServerBootstrap(
    IDataQueryService dataQueryService, 
    AgentPluginLoader agentPluginLoader, 
    ILogger<McpServerBootstrap> logger) : IMcpServerBootstrap
{
    public async IAsyncEnumerable<McpClient> StartAsync([EnumeratorCancellation] CancellationToken ct)
    {
        var query = dataQueryService.McpServerInfos
            .Where(m => m.IsEnabled);

        var mcpServerInfos = await dataQueryService.ToListAsync(query);

        foreach (var mcpServerInfo in mcpServerInfos)
        {
            McpClient mcpClient = null!;
            switch (mcpServerInfo.TransportType)
            {
                case McpTransportType.Stdio:
                    mcpClient = await CreateStdioClientAsync(mcpServerInfo, ct);
                    break;
                case McpTransportType.Sse:
                    mcpClient = await CreateSseClientAsync(mcpServerInfo, ct);
                    break;
            }

            logger.LogInformation(
                "已连接到 MCP 服务器 - {Name}",
                mcpServerInfo.Name);

            var tools = await mcpClient.ListToolsAsync(
                cancellationToken: ct);

            logger.LogInformation(
                "已发现 {ToolsCount} 个工具",
                tools.Count);
    
            // 构建并注册适配器插件
            // 这一步将 MCP 的数据模型转换为 Agent 的插件模型
            RegisterMcpPlugin(mcpServerInfo, tools);

            logger.LogInformation(
                "已注册 MCP 插件 - {Name}",
                mcpServerInfo.Name);
            
            yield return mcpClient;
        }
    }

    private async Task<McpClient> CreateStdioClientAsync(McpServerInfo mcpServerInfo, CancellationToken ct)
    {
        var transportOptions = new StdioClientTransportOptions
        {
            Command = "npx",
            Arguments = mcpServerInfo.Arguments.Split(' ')
        };

        var transport = new StdioClientTransport(transportOptions);
        return await McpClient.CreateAsync(
            transport,
            cancellationToken: ct);
    }
    
    private async Task<McpClient> CreateSseClientAsync(McpServerInfo mcpServerInfo, CancellationToken ct)
    {
        var transportOptions = new HttpClientTransportOptions
        {
            Endpoint = new Uri(mcpServerInfo.Arguments)
        };

        var transport = new HttpClientTransport(transportOptions);
        return await McpClient.CreateAsync(
            transport,
            cancellationToken: ct);
    }

    /// <summary>
    /// 将 MCP 服务元数据和工具列表封装为通用桥接插件,并注册到系统。
    /// </summary>
    /// <param name="info">数据库中的服务配置信息</param>
    /// <param name="mcpTools">从 MCP Client 获取的实时工具列表</param>
    private void RegisterMcpPlugin(McpServerInfo info, IEnumerable<AITool> mcpTools)
    {
        var mcpPlugin = new GenericBridgePlugin
        {
            // 名称作为命名空间,至关重要
            Name = info.Name,

            // 描述用于语义路由
            Description = info.Description,

            // 直接传递工具集合
            AITools = mcpTools
        };

        // 注册到全局插件系统
        agentPluginLoader.RegisterAgentPlugin(mcpPlugin);
    }
}
  • 实现 MCP 管理逻辑,即实现 MCP 的启动和停止逻辑
//Qjy.AICopilot.McpService/McpServerManager.cs
public class McpServerManager(IServiceScopeFactory scopeFactory, ILogger<McpServerManager> logger) : IHostedService
{
    // 用于存储所有活跃的客户端实例
    private readonly IList<McpClient> _mcpClients = [];
    
    /// <summary>
    /// 应用启动时触发
    /// </summary>
    public async Task StartAsync(CancellationToken ct)
    {
        logger.LogInformation("=== MCP Server Manager 启动中 ===");

        // 1. 显式创建作用域,以解析 Scoped 服务
        using var scope = scopeFactory.CreateScope();
        
        // 2. 从作用域中获取启动器
        var bootstrap = scope.ServiceProvider.GetRequiredService<IMcpServerBootstrap>();

        // 3. 消费异步流
        // 这里的 await foreach 使得只要有一个服务连接成功,就可以立即处理,
        // 而不必等待所有服务都连接完成。
        await foreach (var mcpClient in bootstrap.StartAsync(ct))
        {
            // 将客户端实例加入内存列表进行托管
            _mcpClients.Add(mcpClient);
        }

        logger.LogInformation("=== MCP Server Manager 启动完成,共托管 {Count} 个服务 ===", _mcpClients.Count);
    }

    /// <summary>
    /// 应用停止时触发
    /// </summary>
    public async Task StopAsync(CancellationToken ct)
    {
        logger.LogInformation("正在关闭 MCP 服务连接...");

        // 优雅关闭:并行释放所有客户端资源
        // 我们不希望一个客户端的关闭卡死阻碍其他客户端的关闭
        var closeTasks = _mcpClients.Select(async client => 
        {
            try
            {
                // DisposeAsync 会发送关闭信号,对于 Stdio 传输,这会 Kill 掉子进程
                await client.DisposeAsync();
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "关闭 MCP 客户端时发生错误");
            }
        });

        await Task.WhenAll(closeTasks);
        
        _mcpClients.Clear();
        logger.LogInformation("所有 MCP 服务资源已释放");
    }
}
  • 服务注册
//Qjy.AICopilot.McpService/DependencyInjection.cs
public static class DependencyInjection
{
    public static void AddMcpService(this IHostApplicationBuilder builder)
    {
        // 注册启动器为 Scoped,因为它依赖于 Scoped 的 DbContext
        builder.Services.AddScoped<IMcpServerBootstrap, McpServerBootstrap>();
        
        // 注册管理器为 HostedService
        // 这是一个 Singleton 单例,会在应用生命周期内一直存在
        builder.Services.AddHostedService<McpServerManager>();
    }
}

//Qjy.AICopilot.HttpApi/DependencyInjection.cs
public void AddApplicationService()
{
    // 其他代码
    
    builder.AddMcpService();
}

三、人机协助

1. 人机协作理论基础

目前我们已经实现了系统工具和 MCP 工具,但现在还存在一个问题。传统软件在执行工具调用时,具有确定性。而 AI Agent 存在概率性,可能发生错误的工具选择,或者生成错误的参数。因此对于一些具有风险的操作,需要进行人工审批。

信任边界模型,对操作分类:

  • 只读安全区,无需干预
  • 可逆操作区,事后审计、弱审批
  • 关键不可逆操作区,强制审批

Agent 自主性分级体系:

  • L0:手动工具
  • L1:辅助驾驶
  • L2:监督式代理
  • L3:条件自治
  • L4:完全自治

状态流程:

  1. 思考中:生成并生成工具调用
  2. 挂起/等待批准:保存当前上下文、堆栈信息、待执行的参数。持久化到缓存/数据库,释放计算资源,生成一个审批 ID 返回给用户
  3. 恢复/唤醒:用户提交带有审批 ID 和结构(Y/N)的请求,系统根据 ID 加载之前的上下文,将用户的审批和结果注入到 Agent 的消息历史
  4. 执行中:Agent获得授权,实际调用工具

2. Agent 框架审批机制

审批请求包括内容:

  1. 意图摘要
  2. 工具详情
  3. 风险提示

用户响应内容:

  1. 批准或拒绝
  2. 携带审批 ID

Agent 框架审批机制:

  • 审批目标:函数调用请求
  • 标准工具调用:
    1. 推理
    2. 生成
    3. 拦截
      • 生成审批请求,函数审批请求对象,Agent 等待下一轮对话
      • 用户批准,构造函数审批响应对象(标记为批准/拒绝),决定是否执行工具,拒绝时给LLM抛出特定错误
    4. 解析/执行
    5. 回调

Agent 框架使用装饰器模式,提供了专门需要人工审批机制 Function

public class ApprovalRequiredAIFunction : AIFunction
{
    private readonly AIFunction _innerFunction;
    public ApprovalRequiredAIFunction(AIFunction inner)
    {
		_innerFunction = inner;
        //继承元数据,但添加“需要审批”的标记
    }
    
    //在调用逻辑中,框架会识别这个标记
/

3. 统一工具权限拦截

并不是所有工具的调用都需要审批,所以我们需要提前知道哪些工具是高风险的。

我们这里使用黑白名单的方式来实现,就跟 Agent 框架使用的装饰器模式一样,给具有高风险的工具加上一个标记,Agent 框架会自动识别这个标记。

对于系统工具,由于这部分工具是在项目内开发实现了。要给这些工具添加标记,只需要添加一个专门的数据即可。

对于 MCP 工具,由于没办法去修改 MCP 服务的实现,我们只好采用动态配置的方式给工具配置风险等级。我们项目中 MCP 服务是配置在数据库中的,因此我们可以在配置 MCP 服务前,根据 MCP 服务的文档,提前识别好风险工具,然后把风险工具配置在数据库中,来实现动态配置。

现在我们来分别实现这两类工具的标识:

  • 扩展插件接口和抽象基类
    • 在接口中,添加 HighRiskTools 属性。
    • 在抽象基类的 GetAITools 方法中,将 HighRiskTools 中配置的工具包装成 ApprovalRequiredAIFunction,框架自动实现人工审批。
//Qjy.AICopilot.AgentPlugin/IAgentPlugin.cs
public interface IAgentPlugin
{
    /// <summary>
    /// 获取该插件中被标记为“高风险”或“敏感”的工具名称列表。
    /// 位于此列表中的工具,在被 Agent 调用时,会触发人机回环拦截机制,
    /// 要求用户显式批准后方可执行。
    /// </summary>
    IEnumerable<string>? HighRiskTools { get; }
}

//Qjy.AICopilot.AgentPlugin/AgentPluginBase.cs
public abstract class AgentPluginBase : IAgentPlugin
{
    /// <summary>
    /// 利用 Microsoft.Extensions.AI 库,将 C# 方法自动转换为 AITool。
    /// 并根据 HighRiskTools 配置,自动为敏感工具添加审批拦截器。
    /// </summary>
    public IEnumerable<AITool>? GetAITools()
    {
        // 1. 获取所有标记了 [Description] 的方法
        // 这些是原始的业务逻辑方法
        var rawMethods = GetToolMethods();

        // 2. 转换为 AITool 并注入拦截逻辑
        var tools = rawMethods.Select(method =>
        {
            // 步骤 A: 创建基础 AI 函数
            // AIFunctionFactory.Create 是微软提供的工厂方法,
            // 它会读取方法签名、参数类型和 Description 特性,生成 JSON Schema。
            // 'this' 参数确保了当工具被调用时,是在当前插件实例上执行的。
            var function = AIFunctionFactory.Create(method, this);

            // 步骤 B: 检查该方法是否在高风险列表中
            if (HighRiskTools == null || !HighRiskTools.Contains(method.Name))
            {
                // 如果是普通工具,直接返回
                return function;
            }

            // 步骤 C: 注入审批拦截器
            // 如果是高风险工具,我们不直接返回原始 function,
            // 而是将其包装在 ApprovalRequiredAIFunction 中。
            // 当 LLM 尝试调用此工具时,框架会识别这个包装器,并挂起执行,等待人工审批。
            #pragma warning disable MEAI001
            var approvalFunction = new ApprovalRequiredAIFunction(function);

            return approvalFunction;
        });

        return tools;
    }

    public virtual IEnumerable<string>? HighRiskTools { get; init; }
}

//给 MCP 通用桥接插件也实现 IAgentPlugin 接口
//Qjy.AICopilot.AgentPlugin/GenericBridgePlugin.cs
public class GenericBridgePlugin : IAgentPlugin
{
    public IEnumerable<string>? HighRiskTools { get; init; }
}
  • 系统工具的拦截:这里我们删除之前实现的系统时间工具,重新创建一个系统工具。提供“获取系统时间”和“重启服务器(假实现)”,2个系统工具。
//Qjy.AICopilot.AiGatewayService/Plugins/SystemOpsPlugin.cs
public class SystemOpsPlugin : AgentPluginBase
{
    public override string Description => "提供系统级别的运维操作能力,如时间查询、服务重启等。";
    
    // 在此处静态定义:RestartServer 是高风险工具
    // 使用 nameof 关键字可以避免硬编码字符串带来的拼写错误风险,并支持重构
    public override IEnumerable<string> HighRiskTools => [nameof(RestartServer)];
    
    [Description("获取当前系统时间")]
    public string GetSystemTime() => DateTime.Now.ToString("O");

    [Description("执行服务器重启操作")]
    public string RestartServer()
    {
        // 实际场景中,这里可能会调用 Process.Start("shutdown", "/r /t 0");
        return "Server restart command issued successfully.";
    }
}
  • MCP 工具的拦截,修改 MCP 实体模型,并通过种子数据配置风险工具,最后修改 MCP 启动方法,使用 ApprovalRequiredAIFunction 包装风险工具。
//步骤1:修改模型
//Qjy.AICopilot.Core.McpServer/Aggregates/McpServerInfo/McpServerInfo.cs
public class McpServerInfo : IAggregateRoot
{
    public McpServerInfo(
        string name, 
        string description, 
        McpTransportType transportType, 
        string? command, 
        string arguments,
        List<string>? sensitiveTools = null)
    {
        // 其他代码
        SensitiveTools = sensitiveTools;
    }

    // 敏感工具列表
    public List<string>? SensitiveTools { get; private set; }
}

//步骤2:配置数据库映射
//Qjy.AICopilot.EntityFrameworkCore/Configuration/McpServer/McpServerConfiguration.cs
public void Configure(EntityTypeBuilder<McpServerInfo> builder)
{
	// 其它代码
    
    builder.Property(b => b.SensitiveTools)
        .HasColumnName("sensitive_tools");
}

//步骤3:在种子数据中配置风险工具
//Qjy.AICopilot.MigrationWorkApp/SeedData/McpServerInfoData.cs
public static class McpServerInfoData
{
    public static IEnumerable<McpServerInfo> GetMcpServerInfos()
    {
        // 1. 定义文件系统的高风险操作列表
        // 这些字符串必须与 MCP Server 提供的工具名称严格匹配
        var fileSystemRisks = new List<string>
        {
            "write_file",
            "edit_file",
            "move_file"
        };

        // 添加文件系统
        var fileSystemAgentPlugin = new McpServerInfo(
            "FileSystem",
            "提供本地文件系统访问能力,它允许在限定的目录范围内执行文件和目录相关操作,包括读取文件内容、创建和写入文件、列出目录结构、移动或重命名文件等。",
            McpTransportType.Stdio,
            "npx",
            @"-y @modelcontextprotocol/server-filesystem E:\Test",
            fileSystemRisks
        );

        return [fileSystemAgentPlugin];
    }
}

//步骤4:修改
//Qjy.AICopilot.McpService/McpServerBootstrap.cs
/// <summary>
/// 将 MCP 服务元数据和工具列表封装为通用桥接插件,并注册到系统。
/// 在此过程中,根据配置动态注入审批拦截器。
/// </summary>
/// <param name="mcpServerInfo">包含敏感工具配置的数据库实体</param>
/// <param name="mcpTools">从 MCP Client 实时获取的原始工具列表</param>
private void RegisterMcpPlugin(McpServerInfo mcpServerInfo, IList<McpClientTool> mcpTools)
{
    // 1. 动态转换与封装工具
    var tools = mcpTools
        .Select<McpClientTool, AIFunction>(tool =>
        {
            // 步骤 A: 检查当前工具是否在数据库配置的敏感列表中
            var isSensitive = mcpServerInfo.SensitiveTools != null &&
                              mcpServerInfo.SensitiveTools.Contains(tool.Name);

            if (!isSensitive)
            {
                // 如果不是敏感工具,直接返回原始的 McpClientTool
                // McpClientTool 本身实现了 AIFunction,可以直接使用
                return tool;
            }

            // 步骤 B: 注入审批拦截器
            // 对于 MCP 工具,原理与原生插件完全一致。
            // 我们使用 ApprovalRequiredAIFunction 将原始的 MCP 工具包裹起来。
            // 当 Agent 调用此工具时,会先触发宿主的审批流程,
            // 审批通过后,ApprovalRequiredAIFunction 内部会调用 tool.InvokeAsync,
            // 进而通过 JSON-RPC 发送给远程的 Node.js 进程。
            #pragma warning disable MEAI001
            var approvalFunction = new ApprovalRequiredAIFunction(tool);
            return approvalFunction;
        });

    // 2. 创建通用桥接插件
    // 将处理过的工具列表(包含普通工具和包装后的审批工具)赋值给插件
    var mcpPlugin = new GenericBridgePlugin
    {
        Name = mcpServerInfo.Name,
        Description = mcpServerInfo.Description,
        // 这里传入的是已经混合了 Wrapper 的 AIFunction 集合
        AITools = tools,

        // 同时,我们将原始的敏感列表赋值给 HighRiskTools 属性
        // 这样做是为了让 UI 层或元数据层能够知道哪些工具是高风险的
        // (即使执行层的拦截已经由 AITools 内部的对象处理了)
        HighRiskTools = mcpServerInfo.SensitiveTools
    };

    // 3. 注册到全局插件加载器
    agentPluginLoader.RegisterAgentPlugin(mcpPlugin);
}

4. 重构审批工作流

扩展人机协助之后,由于人工审批可能是几分钟之后,也可能是第二天,程序不可能一直等待。此时,AI 应用需要流程挂起,等待用户决策。用户回复后,又需要进行系统恢复,继续下一步工作流。

这时候,我们的工作流模式将变成两种情况:

  • 对话流程:通过意图识别、RAG搜索、数据分析、工具挂载等,进行 Agent 构建,然后 Agent 运行。
  • 审批流程:直接 Agent 运行(复用之前的 Agent 上下文)

接下来,我们重构一下目前的工作流实现。

  • 步骤1:创建 Agent 上下文对象
//Qjy.AICopilot.AiGatewayService/Workflows/FinalAgentContext.cs
public class FinalAgentContext
{
    // 核心 Agent 实例
    public required AIAgent Agent { get; init; }

    // 当前对话的线程/历史记录
    public required AgentThread Thread { get; init; }

    // 用户输入的文本(或是经过 RAG 增强后的 Prompt)
    public required string InputText { get; set; }

    // 运行选项,包含了动态挂载的工具列表、温度设置等
    public required ChatClientAgentRunOptions RunOptions { get; init; }

    // 会话 ID
    public Guid SessionId { get; init; }

    // --- 审批相关状态 ---

    // 待处理的审批请求内容集合
    // 当 Agent 发起审批时,我们将请求对象暂存在这里
    #pragma warning disable MEAI001
    public List<FunctionApprovalRequestContent> FunctionApprovalRequestContents { get; } = [];

    // 用户本次批准的 CallId 列表
    // 当用户提交批准时,前端会传回这些 ID
    public List<string> FunctionApprovalCallIds { get; } = [];
}
  • 步骤 2:删除旧的最终处理执行器(FinalProcessExecutor),拆分为 Agent 构建执行器和 Agent 运行执行器
    • Agent 构建执行器:负责冷启动,输出一个初始化完毕的 Agent 上下文对象。用户发起新一轮对话执行
    • Agent运行执行器:负责热运行,接受一个准备好的 Agent 上下文,进入流式循环,实现可重入
//删除 FinalProcessExecutor.cs
//Qjy.AICopilot.AiGatewayService/Workflows/FinalProcessExecutor.cs
  • 步骤 3:实现最终 Agent 构建执行器,就是将旧的 FinalProcessExecutor 中执行部分的代码抽出去,然后构建 FinalAgentContext 并传递给下一个节点。
//Qjy.AICopilot.AiGatewayService/Workflows/FinalAgentBuildExecutor.cs
/// <summary>
/// 最终 Agent 构建执行器
/// 职责:利用聚合后的上下文构建 Agent,注入 RAG 提示词。
/// </summary>
public class FinalAgentBuildExecutor(
    ChatAgentFactory agentFactory,
    IDataQueryService dataQuery,
    ILogger<FinalAgentBuildExecutor> logger) :
    Executor<GenerationContext>("FinalAgentBuildExecutor")
{
    public override async ValueTask HandleAsync(
        GenerationContext genContext,
        IWorkflowContext context,
        CancellationToken ct = default)
    {
        try
        {
            var request = genContext.Request;
            logger.LogInformation("开始最终生成,SessionId: {SessionId}", request.SessionId);

            // 1. 获取会话关联的模板配置
            // 我们需要知道当前会话使用的是哪个 Agent 模板(例如"通用助手"或"HR助手")
            var session = await dataQuery.FirstOrDefaultAsync(dataQuery.Sessions.Where(s => s.Id == request.SessionId));

            if (session == null) throw new InvalidOperationException("会话不存在");

            // 2. 创建基础 Agent 实例
            // 此时 Agent 拥有的是数据库中定义的静态 System Prompt
            var agent = await agentFactory.CreateAgentAsync(session.TemplateId, isSaveChatMessage: false);

            // 3. 构建 Prompt (RAG 与 数据分析上下文注入)
            string finalUserPrompt;
            var hasKnowledge = !string.IsNullOrWhiteSpace(genContext.KnowledgeContext);
            var hasDataAnalysis = !string.IsNullOrWhiteSpace(genContext.DataAnalysisContext);
            var hasContext = hasKnowledge || hasDataAnalysis;

            if (hasContext)
            {
                // 构建混合上下文内容
                var contextBuilder = new StringBuilder();

                if (hasDataAnalysis)
                {

                    contextBuilder.AppendLine("数据库查询结果:");
                    contextBuilder.AppendLine(genContext.DataAnalysisContext);
                    contextBuilder.AppendLine();
                }

                if (hasKnowledge)
                {
                    contextBuilder.AppendLine("知识库检索参考信息:");
                    contextBuilder.AppendLine(genContext.KnowledgeContext);
                    contextBuilder.AppendLine();
                }

                finalUserPrompt = $"""
                                   请基于以下参考信息(包含数据库查询结果或检索文档)回答我的问题:

                                   <context>
                                   {contextBuilder}
                                   </context>

                                   回答要求:
                                   1. 引用参考信息时,请标注来源 ID(例如 [^1])。
                                   2. 针对数据分析结果,请结合用户问题进行自然语言解释。
                                   3. 在回答结尾,如果引用了知识库文档,请生成“参考资料”列表。
                                   4. 如果参考信息不足以回答问题,请直接说明,严禁编造。
                                   5. 保持回答专业、简洁。

                                   我的问题:
                                   {request.Message}
                                   """;

                logger.LogDebug("增强模式激活:注入知识({KSize}),注入数据({DSize})。",
                    genContext.KnowledgeContext?.Length ?? 0,
                    genContext.DataAnalysisContext?.Length ?? 0);
            }
            else
            {
                // 无上下文模式:直接透传用户问题
                finalUserPrompt = request.Message;
                logger.LogDebug("增强模式未激活:仅使用用户原始输入。");
            }

            // 4. 准备执行参数 (ChatOptions)
            // 将动态加载的工具集挂载到本次执行的选项中
            var runOptions = new ChatClientAgentRunOptions
            {
                ChatOptions = new ChatOptions
                {
                    Tools = genContext.Tools // <-- 动态挂载工具
                }
            };

            // 如果有注入任何上下文(知识或数据),都降低温度以保证事实性
            if (hasContext)
            {
                runOptions.ChatOptions.Temperature = 0.3f;
            }

            // 5. 构建 FinalAgentContext 并传递给下一个节点
            // 注意:我们这里不执行 RunStreamingAsync,而是创建好环境就交棒。
            var agentThread = agent.GetNewThread();
            var finalAgentContext = new FinalAgentContext
            {
                Agent = agent,
                Thread = agentThread,
                InputText = finalUserPrompt,
                RunOptions = runOptions,
                SessionId = request.SessionId
            };

            // 将构建好的 Context 发送给工作流的下一个节点 (即 FinalAgentRunExecutor)
            await context.SendMessageAsync(finalAgentContext, ct);
        }
        catch (Exception e)
        {
            logger.LogError(e, "最终 Agent 构建阶段发生错误");
            await context.AddEventAsync(new ExecutorFailedEvent(Id, e), ct);
            throw;
        }
    }
}

注意:上面代码中 新的 agentThread 没有持久化,未来可以扩展一下。

  • 步骤 4:实现 Agent 流式运行执行器
//Qjy.AICopilot.AiGatewayService/Workflows/FinalAgentRunExecutor.cs
/// <summary>
/// Agent 流式运行执行器
/// 职责:执行对话循环,处理审批请求拦截与响应恢复。
/// </summary>
public class FinalAgentRunExecutor(
    ILogger<FinalAgentRunExecutor> logger) :
    Executor<FinalAgentContext, FinalAgentContext>("FinalAgentRunExecutor")
{
    public override async ValueTask<FinalAgentContext> HandleAsync(
        FinalAgentContext agentContext,
        IWorkflowContext context,
        CancellationToken cancellationToken = new())
    {
        try
        {
            // 1. 构建本次发送给 Agent 的消息列表
            List<ChatMessage> message = [];

            // 检查是否存在待处理的“批准 CallId”
            // 如果存在,说明这是审批后的恢复流程,而不是新的一轮对话
            var isApprovalResumption = agentContext.FunctionApprovalRequestContents.Count != 0
                                        && agentContext.FunctionApprovalCallIds.Count != 0;

            if (isApprovalResumption)
            {
                // --- 审批恢复逻辑 ---
                logger.LogInformation("检测到审批响应,正在恢复 Agent 执行...");

                foreach (var callId in agentContext.FunctionApprovalCallIds)
                {
                    // 在暂存的请求列表中查找对应的 RequestContent
                    var requestContent = agentContext.FunctionApprovalRequestContents
                        .FirstOrDefault(rc => rc.FunctionCall.CallId == callId);
                    if (requestContent == null)
                    {
                        logger.LogWarning("未找到 CallId: {CallId} 的审批请求上下文,跳过。", callId);
                        continue;
                    }

                    // 核心逻辑:模拟生成审批结果消息
                    // CreateResponse 是框架提供的方法,它会为特定的审批请求生成一个审批响应对象:
                    // True 表示通过审批,False 表示未通过审批
                    var isApproved = agentContext.InputText == "批准";
                    var response = requestContent.CreateResponse(isApproved);

                    // 将这个响应包装为 User 消息发送给 Agent
                    // Agent 收到后,内部机制会解除挂起状态,真正执行工具调用
                    message.Add(new ChatMessage(ChatRole.User, [response]));

                    // 清理已处理的请求
                    agentContext.FunctionApprovalRequestContents.Remove(requestContent);
                }

                // 清空本次处理的 ID 列表
                agentContext.FunctionApprovalCallIds.Clear();
            }
            else
            {
                // --- 正常对话逻辑 ---
                // 直接发送用户的 Prompt
                message.Add(new ChatMessage(ChatRole.User, agentContext.InputText));
            }

            // 2. 进入流式运行循环
            // 无论是初次运行还是恢复运行,都复用同一个 agentContext.Thread 和 RunOptions
            await foreach (var update in agentContext.Agent.RunStreamingAsync(
                               message,
                               agentContext.Thread,
                               agentContext.RunOptions,
                               cancellationToken))
            {
                // 3. 实时捕获流中的内容
                foreach (var content in update.Contents)
                {
                    // 关键点:拦截审批请求
                    // 如果 Agent 想要执行敏感操作,它不会直接执行,而是产生 FunctionApprovalRequestContent
                    #pragma warning disable MEAI001
                    if (content is FunctionApprovalRequestContent requestContent)
                    {
                        logger.LogInformation("Agent 发起审批请求: {Name}", requestContent.FunctionCall.Name);
                        // 我们必须将这个请求暂存到 Context 中,以便后续恢复时使用
                        agentContext.FunctionApprovalRequestContents.Add(requestContent);
                    }
                }

                // 4. 将更新事件转发给工作流,最终推送给前端
                await context.AddEventAsync(new AgentRunUpdateEvent(Id, update), cancellationToken);
            }

            // 返回更新后的 Context,以便状态保持
            return agentContext;
        }
        catch (Exception e)
        {
            logger.LogError(e, "最终Agent运行阶段发生错误");
            await context.AddEventAsync(new ExecutorFailedEvent(Id, e), cancellationToken);
            throw;
        }
    }
}
  • 步骤 5:构建工作流,将原来的意图工作流,改成两个工作流。我们创建一个新的 WorkflowFactory 工厂方法
//Qjy.AICopilot.AiGatewayService/Workflows/WorkflowFactory.cs
public class WorkflowFactory(
    IntentRoutingExecutor intentRouting,
    ToolsPackExecutor toolsPack,
    KnowledgeRetrievalExecutor knowledgeRetrieval,
    DataAnalysisExecutor dataAnalysis,
    ContextAggregatorExecutor contextAggregator,
    FinalAgentBuildExecutor agentBuild,
    FinalAgentRunExecutor agentRun)
{
    public Workflow CreateIntentWorkflow()
    {
        var workflowBuilder = new WorkflowBuilder(intentRouting)
            // 1. 扇出 (Fan-out): 意图识别 -> [工具打包, 知识检索]
            // IntentRoutingExecutor 输出的 List<IntentResult> 会被广播给 targets 列表中的每一个节点
            .AddFanOutEdge(intentRouting, [toolsPack, knowledgeRetrieval, dataAnalysis])
            // 2. 扇入 (Fan-in): [工具打包, 知识检索] -> 聚合器
            // 聚合器接收来自 sources 列表的所有输出
            .AddFanInEdge([toolsPack, knowledgeRetrieval, dataAnalysis], contextAggregator)
            // 3. 线性连接: 聚合器 -> 最终处理
            .AddEdge(contextAggregator, agentBuild)
            .AddEdge(agentBuild, agentRun)
            .WithOutputFrom(agentRun);

        return workflowBuilder.Build();
    }

    public Workflow CreateFinalAgentRunWorkflow()
    {
        var workflowBuilder = new WorkflowBuilder(agentRun);
        return workflowBuilder.Build();
    }
}
  • 步骤 6:删除旧的意图工作流,并实现注册
//Qjy.AICopilot.AiGatewayService/Workflows/WorkflowFactory.cs
  • 步骤 7:扩展返回类型,现在工作流多了一个需要审批的返回数据,我们这里添加一下返回类型
//Qjy.AICopilot.AiGatewayService/Agents/ChunkType.cs
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ChunkType
{
    Error,
    Text,
    Intent,
    FunctionCall,
    FunctionResult,
    Widget,
    ApprovalRequest
}
  • 步骤 8:修改 ChatStreamRequest,添加 CallIds 参数,如果 CallIds 不为空,表示这是一次针对特定工具调用的审批请求
//Qjy.AICopilot.AiGatewayService/Agents/ChatStreamRequest.cs
[AuthorizeRequirement("AiGateway.Chat")]
// CallId 列表:如果不为空,表示这是一次针对特定工具调用的审批响应
public record ChatStreamRequest(Guid SessionId, string Message, List<string>? CallIds) : IStreamRequest<ChatChunk>;

public class ChatStreamHandler(
    IDataQueryService queryService, 
    WorkflowFactory workflowFactory) 
    : IStreamRequestHandler<ChatStreamRequest, ChatChunk>
{
    // 内存状态存储:SessionId -> 挂起的 AgentContext
    private static readonly Dictionary<Guid, FinalAgentContext> AgentContexts = new();

    public async IAsyncEnumerable<ChatChunk> Handle(ChatStreamRequest request, [EnumeratorCancellation] CancellationToken ct)
    {
        // 1. 基础校验
        if (!queryService.Sessions.Any(session => session.Id == request.SessionId))
        {
            throw new Exception("未找到会话");
        }
        
        // 2. 路由判断:是审批响应还是新对话?
        if (request.CallIds != null && request.CallIds.Count != 0)
        {
            // --- 分支 A:处理审批响应 ---            
            // 尝试从内存中取出之前挂起的 Context
            AgentContexts.TryGetValue(request.SessionId, out var agentContext);
            if (agentContext == null)
            {
                throw new Exception("会话已过期或上下文丢失,无法完成审批流程。");
            }

            // 更新 Context 状态
            agentContext.InputText = request.Message; // "批准" 或 "拒绝"
            agentContext.FunctionApprovalCallIds.AddRange(request.CallIds); // 用户批准的 ID 列表
            
            // 创建仅包含 AgentRun 阶段的短工作流
            // 我们不需要重新执行 Build,直接复用现有的 AgentContext
            var workflow = workflowFactory.CreateFinalAgentRunWorkflow();
            
            // 启动工作流(传入AgentContext)
            await using var workflowRun = await InProcessExecution.StreamAsync(workflow, agentContext, cancellationToken: ct);
            
            // 监听并转发事件
            await foreach (var chatChunk in RunWorkflow(workflowRun, request.SessionId, ct))
            {
                yield return chatChunk;
            }

            // 流程结束后,如果所有审批请求都处理完了,就可以移除缓存
            if (agentContext.FunctionApprovalRequestContents.Count == 0)
            {
                AgentContexts.Remove(request.SessionId);
            }
        }
        else
        {
            // --- 分支 B:处理新对话 ---            
            // 创建完整的意图识别工作流 (Intent -> ... -> Build -> Run)
            var workflow = workflowFactory.CreateIntentWorkflow();
            
            // 启动工作流(传入用户请求)
            await using var workflowRun = await InProcessExecution.StreamAsync(workflow, request, cancellationToken: ct);
            
            // 监听并转发事件
            await foreach (var chatChunk in RunWorkflow(workflowRun, request.SessionId, ct))
            {
                yield return chatChunk;
            };
        }
    }

    // 事件转换逻辑:将工作流事件转换为前端可消费的 ChatChunk
    // [增加] 监听函数审批请求对象
    private async IAsyncEnumerable<ChatChunk> RunWorkflow(StreamingRun workflowRun, Guid sessionId, CancellationToken ct)
    {
        await foreach (var workflowEvent in workflowRun.WatchStreamAsync(ct))
        {
            Console.WriteLine(workflowEvent);
            switch (workflowEvent)
            {
                case WorkflowOutputEvent evt:
                    if (evt.Data is FinalAgentContext agentContext && agentContext.FunctionApprovalRequestContents.Count != 0)
                    {
                        AgentContexts.TryAdd(sessionId, agentContext);
                    }
                    break;
                case ExecutorFailedEvent evt:
                    yield return new ChatChunk(evt.ExecutorId, ChunkType.Error, evt.Data?.Message ?? string.Empty);
                    break;
                case AgentRunResponseEvent evt:
                    switch (evt.ExecutorId)
                    {
                        case "IntentRoutingExecutor":
                            yield return new ChatChunk(evt.ExecutorId, ChunkType.Intent, evt.Response.Text);
                            break;
                        case "DataAnalysisExecutor":
                            yield return new ChatChunk(evt.ExecutorId, ChunkType.Widget, evt.Response.Text);
                            break;
                    }
                    break;
                case AgentRunUpdateEvent evt:
                    foreach (var evtContent in evt.Update.Contents)
                    {
                        switch (evtContent)
                        {
                            case TextContent content:
                                yield return new ChatChunk(evt.ExecutorId, ChunkType.Text, content.Text);
                                break;
                            case FunctionCallContent content:
                                var fun = new
                                {
                                    id = content.CallId,
                                    name = content.Name, 
                                    args = content.Arguments
                                };
                                yield return new ChatChunk(evt.ExecutorId, ChunkType.FunctionCall, fun.ToJson());
                                break;
                            case FunctionResultContent content:
                                var result = new
                                {
                                    id = content.CallId,
                                    result = content.Result
                                };
                                yield return new ChatChunk(evt.ExecutorId, ChunkType.FunctionResult,
                                    result.ToJson());
                                break;                            
                            #pragma warning disable MEAI001
                            case FunctionApprovalRequestContent content:
                                // 监听函数审批请求对象
                                var approval = new
                                {
                                    callId = content.FunctionCall.CallId,
                                    name = content.FunctionCall.Name,
                                    args = content.FunctionCall.Arguments
                                };
                                yield return new ChatChunk(evt.ExecutorId, ChunkType.ApprovalRequest,
                                    approval.ToJson());
                                break;
                        }
                    }
                    break;
            }
        }
    }
}
  • 步骤 9:代码整理。这一步非必要,到目前 Workflows 文件夹下面的类比较多了,可以创建一个 Executors 文件夹,将所有的执行器移到该文件夹里面。

    另外可以将所有的执行器,把继承 : ReflectingExecutor("xxx"), IMessageHandler<List, BranchResult>,改成继承 Executor("xxx")。下面用 DataAnalysisExecutor 举例

public class DataAnalysisExecutor() : ReflectingExecutor<DataAnalysisExecutor>("DataAnalysisExecutor"),
      IMessageHandler<List<IntentResult>, BranchResult>
    public async ValueTask<BranchResult> HandleAsync()
    {
        return BranchResult.FromDataAnalysis(string.Empty);
    }
}
//改成
public class DataAnalysisExecutor() : Executor<List<IntentResult>>("DataAnalysisExecutor")
{
    public override async ValueTask HandleAsync()
    {
        // 将旧的 return,改成调用 SendMessageAsync,
        // 再 return
        await context.SendMessageAsync(BranchResult.FromDataAnalysis(string.Empty), cancellationToken);        
        return;
    }
}

四、审批请求前端适配

1. 新增审批数据模型

  • 新增 ChunkType 枚举项
//Qjy.AICopilot.VueUI/src/types/protocols.ts
export enum ChunkType {
  Error = 'Error',
  Text = 'Text',
  Intent = 'Intent',
  Widget = 'Widget',
  FunctionResult = 'FunctionResult',
  FunctionCall = 'FunctionCall',
  ApprovalRequest = 'ApprovalRequest'   // 新增
}
  • 添加审批请求对象
//Qjy.AICopilot.VueUI/src/types/protocols.ts
/**
 * 函数审批请求
 */
export interface FunctionApprovalRequest {
  callId: string;
  name: string;
  args: string;
}
  • 扩展审批请求消息片段块
//Qjy.AICopilot.VueUI/src/types/models.ts
/**
 * 扩展消息块-审批请求片段
 * 用于在消息列表中渲染审批卡片
 */
export interface ApprovalChunk extends ChatChunk {
  // 复用传输层的载体数据
  request: FunctionApprovalRequest;

  // 审批单的当前状态
  // pending: 等待用户操作
  // approved: 用户已批准
  // rejected: 用户已拒绝
  status: 'pending' | 'approved' | 'rejected';
}

2. API 接口扩展

//Qjy.AICopilot.VueUI/src/services/chatService.ts
/**
 * 发送消息并接收流式响应
 * @param sessionId 会话ID
 * @param message 用户输入的内容
 * @param callbacks 回调函数集合
 */
async sendMessageStream(sessionId: string, message: string, callbacks: StreamCallbacks, callIds?: string[]) {
  const ctrl = new AbortController();

  try {
    // 使用微软的库发起 SSE 请求
    await fetchEventSource(`${baseUrl}/aigateway/chat`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`
      },
      body: JSON.stringify({
        sessionId: sessionId,
        message: message,
        callIds: callIds
      }),
      signal: ctrl.signal,

      // 1. 处理连接打开
      async onopen(response) {
        if (response.ok) {
          return; // 连接成功
        } else {
          throw new Error(`连接失败: ${response.status}`);
        }
      },

      // 2. 处理消息接收
      onmessage(msg) {
        try {
          // 解析后端发来的 ChatChunk JSON
          const chunk: ChatChunk = JSON.parse(msg.data);
          console.log(chunk);
          callbacks.onChunkReceived(chunk);
        } catch (err) {
          console.error('无法解析区消息块:', err);
        }
      },

      // 3. 处理连接关闭
      onclose() {
        callbacks.onComplete();
      },

      // 保持连接,即使页面进入后台
      openWhenHidden: true
    });
  } catch (err) {
    callbacks.onError(err);
  }
}

3. 审批状态数据处理

  • 添加正在处理的属性
//Qjy.AICopilot.VueUI/src/stores/chatStore.ts
// 是否正在等待用户审批
// 当此值为 true 时,聊天输入框应当被禁用或锁定
const isWaitingForApproval = ref(false);
  • 创建和切换新会话时重置数据状态
//Qjy.AICopilot.VueUI/src/stores/chatStore.ts
async function createNewSession() {
    const newSession = await chatService.createSession();
    sessions.value.unshift(newSession);
    currentSessionId.value = newSession.id;
    messagesMap.value[newSession.id] = [];
    isStreaming.value = false;
    isWaitingForApproval.value = false;
}
  • 审批处理函数
//Qjy.AICopilot.VueUI/src/stores/chatStore.ts
/**
  * 提交审批
  * @param callId 审批单 ID
  * @param chunk 审批数据块
  */
async function submitApproval(callId: string, chunk: ApprovalChunk) {
    if (!currentSessionId.value) return;

    const sessionId = currentSessionId.value;

    try {
        // 1. 准备接收新的流
        isStreaming.value = true;

        // 找到要追加的目标消息(即包含审批请求的那条 AI 消息)
        let targetMsg = getLastAssistantMessage(sessionId);
        // 如果找不到(极少见),则创建一条新的
        if (!targetMsg) {
            targetMsg = addMessage(sessionId, {
                sessionId,
                role: MessageRole.Assistant,
                chunks: [],
                isStreaming: true,
                timestamp: Date.now()
            });
        }

        // 2. 调用服务
        const messageText = chunk.status === 'approved' ? "批准" : "拒绝";
        await chatService.sendMessageStream(
            sessionId,
            messageText,
            {
                onChunkReceived: (chunk: ChatChunk) => {
                    // 回调逻辑复用了之前的 chunk 处理函数
                    // 无论是初始对话还是恢复对话,只要是 ChatChunk,处理方式都是一样的
                    processChunk(targetMsg!, chunk);
                },
                onComplete: () => {
                    isStreaming.value = false;
                    if (targetMsg) targetMsg.isStreaming = false;

                    // 流结束意味着本次人机交互闭环完成
                    // 解除全局挂起锁,允许用户发送新消息
                    isWaitingForApproval.value = false;
                },
                onError: (err) => {
                    console.error('审批响应流中断:', err);
                    isStreaming.value = false;
                    isWaitingForApproval.value = false;
                }
            },
            [callId]
        );

    } catch (error) {
        console.error('提交审批失败:', error);
        isStreaming.value = false;
    }
}

// ================= 辅助函数 (Internal) =================
/**
 * 处理审批请求数据块
 */
function addApprovalRequestChunk(msg: ChatMessage, chunk: ChatChunk) {
    try {
        // 1. 反序列化后端传递的 Payload
        // 注意:content 字段是 FunctionApprovalRequestContent 的 JSON 字符串
        const requestPayload = JSON.parse(chunk.content) as FunctionApprovalRequest;

        // 2. 构造前端使用的 ViewModel
        const approvalChunk: ApprovalChunk = {
            ...chunk,              // 保留 source, type 等基础元数据
            request: requestPayload,
            status: 'pending'      // 初始状态默认为“待处理”
        };

        // 3. 将块追加到当前消息的消息体中
        // 这样 UI 层的 v-for 循环就能渲染出对应的 ApprovalCard 组件
        msg.chunks.push(approvalChunk);

        // 4. 触发全局锁定
        // 这是一个关键的副作用:告知整个应用现在进入“人机协作模式”
        // 输入框组件监听到此状态后,应变为 Disabled 状态
        isWaitingForApproval.value = true;

        console.log(`收到审批请求: [${requestPayload.name}] ID: ${requestPayload.callId}`);

    } catch (error) {
        console.error('解析审批请求失败:', error, chunk.content);
        // 在生产环境中,这里可能需要生成一个 ErrorChunk 来提示用户
    }
}

/**
   * 获取当前正在生成的 AI 消息
   * 用于审批恢复后,将后续内容追加到同一条消息气泡中
   */
function getLastAssistantMessage(sid: string): ChatMessage | null {
    const list = messagesMap.value[sid];
    if (!list || list.length === 0) return null;

    const lastMsg = list[list.length - 1]!;
    if (lastMsg.role === MessageRole.Assistant) {
        return lastMsg;
    }
    return null;
}

/**
   * 将 processChunk 提取为独立函数
   * 原本在 sendMessage 中的 switch case 逻辑,现在被两个 Action 复用
   */
function processChunk(msg: ChatMessage, chunk: ChatChunk) {
    switch (chunk.type) {
        case ChunkType.Text:
            addTextChunk(msg, chunk);
            break;
        case ChunkType.Intent:
            addIntentChunk(msg, chunk);
            break;
        case ChunkType.FunctionCall:
            addFunctionCallChunk(msg, chunk);
            break;
        case ChunkType.FunctionResult:
            addFunctionResultChunk(msg, chunk);
            break;
        case ChunkType.Widget:
            addWidgetChunk(msg, chunk);
            break;
        case ChunkType.ApprovalRequest:
            addApprovalRequestChunk(msg, chunk);
            break;
    }
}

// 导出
return {
    sessions,
    currentSessionId,
    currentSession,
    currentMessages,
    isStreaming,
    isWaitingForApproval,
    init,
    createNewSession,
    selectSession,
    sendMessage,
    submitApproval
};

4. 构建审批卡片组件

  • 实现审批卡片组件
//Qjy.AICopilot.VueUI/src/components/chat/ApprovalCart.vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { ApprovalChunk } from '@/types/models';
import ArgumentViewer from './ArgumentViewer.vue';

// 定义 Props
interface Props {
  chunk: ApprovalChunk;
}

const props = defineProps<Props>();

// 定义 Events
// 组件只负责展示和交互,具体的 API 调用逻辑交由父组件或 Store 处理
const emit = defineEmits<{
  (e: 'approve', callId: string): void;
  (e: 'reject', callId: string): void;
}>();

// 本地 loading 状态,防止重复点击
const isProcessing = ref(false);

// 提取核心数据
const request = computed(() => props.chunk.request);
const status = computed(() => props.chunk.status);

// 判断当前是否处于可交互状态
const isPending = computed(() => status.value === 'pending');

const handleApprove = () => {
  if (isProcessing.value) return;
  isProcessing.value = true;
  // 抛出事件,携带 CallId
  emit('approve', request.value.callId);
};

const handleReject = () => {
  if (isProcessing.value) return;
  isProcessing.value = true;
  emit('reject', request.value.callId);
};
</script>

<template>
  <div class="approval-card" :class="status">
    <div class="card-header">
      <div class="header-icon">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
        </svg>
      </div>
      <div class="header-content">
        <h3 class="title">请求执行敏感操作</h3>
        <p class="subtitle">AI 正在请求权限以执行外部工具调用</p>
      </div>
      <div v-if="!isPending" class="status-badge" :class="status">
        {{ status === 'approved' ? '已批准' : '已拒绝' }}
      </div>
    </div>

    <div class="card-body">
      <div class="function-info">
        <span class="label">目标工具:</span>
        <code class="function-name">{{ request.name }}</code>
      </div>

      <div class="arguments-section">
        <span class="label">参数详情:</span>
        <ArgumentViewer :args="request.args" />
      </div>
    </div>

    <div class="card-footer">
      <template v-if="isPending">
        <button
          class="btn btn-reject"
          @click="handleReject"
          :disabled="isProcessing"
        >
          拒绝执行
        </button>
        <button
          class="btn btn-approve"
          @click="handleApprove"
          :disabled="isProcessing"
        >
          <span v-if="isProcessing">处理中...</span>
          <span v-else>批准执行</span>
        </button>
      </template>

      <div v-else class="result-message">
        <span v-if="status === 'approved'" class="text-success">
          操作已授权。
        </span>
        <span v-else class="text-danger">
          操作已被用户拦截。
        </span>
      </div>
    </div>
  </div>
</template>

<style scoped>
/* 卡片容器:默认样式 */
.approval-card {
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  background: white;
  margin: 12px 0;
  box-shadow: 0 2px 4px rgba(0,0,0,0.05);
  overflow: hidden;
  transition: all 0.3s ease;
  max-width: 600px;
}

/* 状态修饰符:已拒绝 */
.approval-card.rejected {
  opacity: 0.7;
  border-color: #cbd5e1;
  background: #f8fafc;
}

/* 状态修饰符:已批准 */
.approval-card.approved {
  border-color: #bbf7d0;
  background: #f0fdf4;
}

/* --- Header 区域 --- */
.card-header {
  display: flex;
  align-items: center;
  padding: 12px 16px;
  background: #fff7ed; /* 浅橙色背景警告 */
  border-bottom: 1px solid #ffedd5;
}

.approval-card.approved .card-header {
  background: #dcfce7; /* 浅绿色 */
  border-bottom-color: #bbf7d0;
}

.approval-card.rejected .card-header {
  background: #f1f5f9; /* 浅灰色 */
  border-bottom-color: #e2e8f0;
}

.header-icon {
  width: 24px;
  height: 24px;
  margin-right: 12px;
  color: #ea580c; /* 深橙色图标 */
}

.header-content {
  flex: 1;
}

.title {
  margin: 0;
  font-size: 1rem;
  font-weight: 600;
  color: #1e293b;
}

.subtitle {
  margin: 0;
  font-size: 0.8rem;
  color: #64748b;
}

/* --- Body 区域 --- */
.card-body {
  padding: 16px;
}

.function-info {
  display: flex;
  align-items: center;
  margin-bottom: 12px;
}

.label {
  font-size: 0.85rem;
  font-weight: 600;
  color: #64748b;
  margin-right: 8px;
  width: 70px; /* 固定宽度对齐 */
}

.function-name {
  background: #e0e7ff;
  color: #4338ca;
  padding: 2px 6px;
  border-radius: 4px;
  font-family: monospace;
  font-weight: bold;
}

.risk-alert {
  margin-top: 12px;
  padding: 8px;
  background: #fef2f2;
  border: 1px solid #fecaca;
  color: #991b1b;
  font-size: 0.85rem;
  border-radius: 4px;
}

/* --- Footer 区域 --- */
.card-footer {
  padding: 12px 16px;
  background: #f8fafc;
  border-top: 1px solid #e2e8f0;
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}

/* 按钮样式 */
.btn {
  padding: 8px 16px;
  border-radius: 6px;
  font-size: 0.9rem;
  font-weight: 500;
  cursor: pointer;
  border: 1px solid transparent;
  transition: all 0.2s;
}

.btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.btn-reject {
  background: white;
  border-color: #cbd5e1;
  color: #475569;
}

.btn-reject:hover:not(:disabled) {
  background: #f1f5f9;
  color: #ef4444; /* 悬停变红 */
  border-color: #ef4444;
}

.btn-approve {
  background: #2563eb; /* 品牌蓝 */
  color: white;
}

.btn-approve:hover:not(:disabled) {
  background: #1d4ed8;
  box-shadow: 0 2px 4px rgba(37, 99, 235, 0.3);
}

.status-badge {
  font-size: 0.8rem;
  padding: 2px 8px;
  border-radius: 12px;
  font-weight: 600;
}

.status-badge.approved {
  background: #166534;
  color: white;
}

.status-badge.rejected {
  background: #64748b;
  color: white;
}

.result-message {
  font-size: 0.9rem;
  font-weight: 500;
}

.text-success { color: #166534; }
.text-danger { color: #991b1b; }
</style>
  • 实现审批卡片参数组件,该组件被审批卡片使用,审批卡片会有 0或多个参数,使用一个专门的组件来显示参数信息
//Qjy.AICopilot.VueUI/src/components/chat/ArgumentViewer.vue
<script setup lang="ts">
import { computed } from 'vue';

interface Props {
  // 接收联合类型:可以是对象,也可以是字符串
  args: string | Record<string, any>;
}

const props = defineProps<Props>();

// 计算属性:统一转换为 { key, value } 数组
const parsedArgs = computed(() => {
  // 1. 空值处理
  if (!props.args) return [];

  let content = props.args;

  // 2. 如果是字符串,尝试解析为对象
  if (typeof content === 'string') {
    try {
      const parsed = JSON.parse(content);
      // 只有解析结果是“非数组的对象”时,才视为字典处理
      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
        content = parsed;
      } else {
        // 虽然解析成功但不是字典(如数组、数字),或者解析失败,都视为原始字符串展示
        return [{ key: 'Raw', value: content, type: 'string' }];
      }
    } catch (e) {
      // JSON 解析异常,直接展示原始字符串
      return [{ key: 'Raw', value: content, type: 'string' }];
    }
  }

  // 3. 此时 content 应该是一个对象,进行最后的校验并遍历
  if (content && typeof content === 'object' && !Array.isArray(content)) {
    return Object.keys(content).map(key => ({
      key,
      value: (content as Record<string, any>)[key],
      type: typeof (content as Record<string, any>)[key]
    }));
  }

  // 4. 兜底:既不是字符串也不是合法对象,强转字符串展示
  return [{ key: 'Raw', value: String(props.args), type: 'string' }];
});

// 辅助函数:格式化特定的值
const formatValue = (val: any) => {
  if (val === null) return 'null';
  if (typeof val === 'boolean') return val ? 'True' : 'False';
  if (typeof val === 'object') return JSON.stringify(val);
  return String(val);
};
</script>

<template>
  <div class="arg-viewer">
    <div v-if="parsedArgs.length === 0" class="empty-args">
      无参数
    </div>

    <div v-else class="arg-list">
      <div
        v-for="item in parsedArgs"
        :key="item.key"
        class="arg-item"
      >
        <span class="arg-key">{{ item.key }}:</span>

        <code v-if="item.type === 'string' && (item.value as string).length > 50" class="arg-value long-text">
          {{ item.value }}
        </code>

        <span v-else :class="['arg-value', item.type]">
          {{ formatValue(item.value) }}
        </span>
      </div>
    </div>
  </div>
</template>

<style scoped>
.arg-viewer {
  background-color: #f8f9fa;
  border-radius: 6px;
  padding: 8px 12px;
  font-family: 'Consolas', 'Monaco', monospace;
  font-size: 0.9em;
  border: 1px solid #e9ecef;
}

.empty-args {
  color: #adb5bd;
  font-style: italic;
}

.arg-item {
  display: flex;
  align-items: baseline;
  margin-bottom: 4px;
  line-height: 1.5;
}

.arg-item:last-child {
  margin-bottom: 0;
}

.arg-key {
  color: #495057;
  font-weight: 600;
  margin-right: 8px;
  flex-shrink: 0; /* 防止 Key 被压缩 */
}

.arg-value {
  color: #212529;
  word-break: break-all; /* 允许在任意字符间换行 */
}

.arg-value.boolean {
  color: #d63384; /* 布尔值用洋红色 */
}

.arg-value.number {
  color: #0d6efd; /* 数字用蓝色 */
}

.arg-value.long-text {
  display: block;
  background-color: #fff;
  border: 1px solid #dee2e6;
  padding: 4px;
  border-radius: 4px;
  margin-top: 4px;
  white-space: pre-wrap; /* 保留换行符 */
  color: #d9534f; /* 字符串用红色 */
  width: 100%;
}
</style>
  • 修改消息组件,因为后台没有 FinalProcessExecutor 执行器了,修改成 FinalAgentRunExecutor
//Qjy.AICopilot.VueUI/src/components/chat/ArgumentViewer.vue
<script setup lang="ts">
const finalChunks = computed(() =>
  props.message.chunks.filter(chunk => chunk.source === 'FinalAgentRunExecutor' || chunk.source === 'User') || []
);
</script>
  • 修改最终处理组件
    • 添加处理用户批准和拒绝操作
    • 引用审批组件
<script setup lang="ts">
  import { renderMarkdown } from '@/utils/markdown';
  import FunctionCallItem from './FunctionCallItem.vue';
  import ApprovalCard from './ApprovalCard.vue';
  import { type ChatChunk, ChunkType } from "@/types/protocols.ts";
  import type { ApprovalChunk, FunctionCallChunk } from "@/types/models.ts";
  import { useChatStore } from '@/stores/chatStore';

  // 连接 Store
  const store = useChatStore();

  const props = defineProps<{
    chunks: ChatChunk[]
    isUser: boolean;
    isStreaming: boolean;
  }>();

  const getFunctionCall = (chunk: ChatChunk): FunctionCallChunk =>
    chunk as FunctionCallChunk;

  /**
   * 处理用户批准操作
   * @param callId 审批单 ID
   * @param chunk 审批数据块
   */
  const onApprove = async (callId: string, chunk: ApprovalChunk) => {
    chunk.status = 'approved';
    await store.submitApproval(callId, chunk);
  };

  /**
   * 处理用户拒绝操作
   * @param callId 审批单 ID
   * @param chunk 审批数据块
   */
  const onReject = async (callId: string, chunk: ApprovalChunk) => {
    chunk.status = 'rejected';
    await store.submitApproval(callId, chunk);
  };
</script>

<template>
  <div class="block-final message-bubble" :class="isUser ? 'bubble-user' : 'bubble-ai'">
    <template v-for="chunk in chunks">
      <div v-if="chunk.type === ChunkType.Text"
           class="markdown-body inline-block-container"
           v-html="renderMarkdown(chunk.content)"></div>

      <div v-else-if="chunk.type === ChunkType.FunctionCall"
           class="my-1 inline-block">
        <FunctionCallItem :call="getFunctionCall(chunk).functionCall"
                          :mini="true" />
      </div>

      <ApprovalCard v-else-if="chunk.type === ChunkType.ApprovalRequest"
                    :chunk="chunk as ApprovalChunk"
                    @approve="(id) => onApprove(id, chunk as ApprovalChunk)"
                    @reject="(id) => onReject(id, chunk as ApprovalChunk)" />

      <span v-if="isStreaming" class="cursor-blink">|</span>
    </template>
  </div>
</template>
  • 修改消息窗体页面
    • 当需要审批时,为了不污染 AI 上下文,不允许用户继续发送消息
    • 添加一个禁用的样式
<script setup lang="ts">
// 计算属性:是否允许输入
// 只有在既没有流式传输,也没有等待审批时,才允许输入
const isInputDisabled = computed(() => {
  return store.isStreaming || store.isWaitingForApproval
});

// 计算 Placeholder 提示文案
const inputPlaceholder = computed(() => {
  if (store.isWaitingForApproval) return "请先处理上方的审批请求...";
  if (store.isStreaming) return "AI 正在思考中...";
  return "输入您的问题 (Enter 发送, Shift+Enter 换行)..."; // 默认文案
});
</script>
<template>
  <div class="chat-layout">
    
    <div class="main-wrapper">

      <footer class="chat-input-area">
        <div class="input-container">
          <el-input
            v-model="inputValue"
            type="textarea"
            :autosize="{ minRows: 1, maxRows: 4 }"
            :placeholder=inputPlaceholder
            @keydown.enter.prevent="(e:KeyboardEvent) => { if(!e.shiftKey) handleSend() }"
            :disabled="isInputDisabled"
          />
          <el-button
            type="primary"
            class="send-btn"
            :disabled="isInputDisabled || !inputValue.trim()"
            @click="handleSend"
          >
            <el-icon><Promotion /></el-icon>
          </el-button>
        </div>
        <div class="footer-tip">
          AI 生成的内容可能不准确,请核实重要信息。
        </div>
      </footer>
    </div>
  </div>
</template>

<style scoped>
/* 给禁用状态的输入框加一些样式,增强视觉反馈 */
textarea:disabled {
  background-color: #f3f4f6;
  cursor: not-allowed;
  color: #9ca3af;
}
</style>

5. 测试

至此,审批功能就完成了,我们来看看效果。