feat: 新增轻量 MCP 工具与 AI Agent,并限制 token 滥用
提供查价格/对客说明/查轨迹工具与管理接口;扩展 LLM function calling;按用户 Redis 限流并压缩上下文与工具结果。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
53c986c746
commit
7c410b1625
32
sql/20260729_mcp_tool.sql
Normal file
32
sql/20260729_mcp_tool.sql
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
-- MCP 工具配置(启用/描述覆盖),与代码内置目录合并
|
||||||
|
-- 对齐 t_system_other_config / t_order_file 等配置表结构习惯
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_mcp_tool` (
|
||||||
|
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
|
||||||
|
`ToolKey` VARCHAR(64) NOT NULL COMMENT '工具编码,如 oms.price_check',
|
||||||
|
`DisplayName` VARCHAR(128) NOT NULL COMMENT '显示名称',
|
||||||
|
`Description` VARCHAR(1000) NOT NULL COMMENT '给 AI 的工具描述',
|
||||||
|
`Enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用:0-否,1-是',
|
||||||
|
`SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前',
|
||||||
|
`Remark` VARCHAR(512) NULL COMMENT '备注',
|
||||||
|
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
||||||
|
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
|
||||||
|
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
|
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`UpdateUserId` INT NULL COMMENT '更新人',
|
||||||
|
`UpdateTime` DATETIME NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`Id`),
|
||||||
|
UNIQUE KEY `uk_mcp_tool_key` (`ToolKey`),
|
||||||
|
KEY `idx_mcp_tool_enabled` (`Enabled`, `SortOrder`, `Id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP 工具配置表';
|
||||||
|
|
||||||
|
INSERT INTO `t_mcp_tool` (`ToolKey`, `DisplayName`, `Description`, `Enabled`, `SortOrder`, `Remark`)
|
||||||
|
VALUES
|
||||||
|
('oms.price_check', '查价格', '根据目的国、重量等查询运费报价,返回可用渠道与价格摘要。', 1, 10, '内置:封装 /order/price-check'),
|
||||||
|
('oms.channel_guide', '查渠道对客说明', '按销售产品代码/名称查询对客说明(禁运、可寄品名等),判断客户品名是否可承运。', 1, 20, '内置:读销售产品 CustomerGuideMarkdown'),
|
||||||
|
('oms.track_query', '查轨迹', '根据运单号/客户单号查询物流轨迹时间轴摘要。', 1, 30, '内置:轨迹中心 GetDetailTimeline')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`DisplayName` = VALUES(`DisplayName`),
|
||||||
|
`Description` = VALUES(`Description`),
|
||||||
|
`SortOrder` = VALUES(`SortOrder`),
|
||||||
|
`Remark` = VALUES(`Remark`);
|
||||||
38
sql/20260729_mcp_tool_alter_align.sql
Normal file
38
sql/20260729_mcp_tool_alter_align.sql
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
-- 若已按旧版建过 t_mcp_tool,执行本脚本补齐字段(幂等)
|
||||||
|
|
||||||
|
ALTER TABLE `t_mcp_tool`
|
||||||
|
ADD COLUMN IF NOT EXISTS `Remark` VARCHAR(512) NULL COMMENT '备注' AFTER `SortOrder`;
|
||||||
|
|
||||||
|
-- MySQL 8.0.12 前无 IF NOT EXISTS 时,可改用下面手工判断;8.0.29+ 支持 ADD COLUMN IF NOT EXISTS
|
||||||
|
-- 并发戳:已存在则跳过
|
||||||
|
SET @col_exists := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 't_mcp_tool'
|
||||||
|
AND COLUMN_NAME = 'ConcurrencyStamp'
|
||||||
|
);
|
||||||
|
SET @sql := IF(
|
||||||
|
@col_exists = 0,
|
||||||
|
'ALTER TABLE `t_mcp_tool` ADD COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'' AFTER `IsDeleted`',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @idx_exists := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 't_mcp_tool'
|
||||||
|
AND INDEX_NAME = 'idx_mcp_tool_enabled'
|
||||||
|
);
|
||||||
|
SET @sql := IF(
|
||||||
|
@idx_exists = 0,
|
||||||
|
'ALTER TABLE `t_mcp_tool` ADD KEY `idx_mcp_tool_enabled` (`Enabled`, `SortOrder`, `Id`)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
ALTER TABLE `t_mcp_tool` COMMENT = 'MCP 工具配置表';
|
||||||
85
src/Oms.Application/Dtos/Mcp/McpDtos.cs
Normal file
85
src/Oms.Application/Dtos/Mcp/McpDtos.cs
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Oms.Application.Dtos.Mcp;
|
||||||
|
|
||||||
|
public class McpToolOutput
|
||||||
|
{
|
||||||
|
public int? Id { get; set; }
|
||||||
|
public string ToolKey { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
/// <summary>内置参数 JSON Schema(只读)</summary>
|
||||||
|
public string InputSchemaJson { get; set; } = string.Empty;
|
||||||
|
public bool Builtin { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class McpToolSaveInput
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string ToolKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class McpToolCallInput
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string ToolKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>工具参数 JSON 对象字符串或字典序列化</summary>
|
||||||
|
public string ArgumentsJson { get; set; } = "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class McpToolCallOutput
|
||||||
|
{
|
||||||
|
public string ToolKey { get; set; } = string.Empty;
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public long ElapsedMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AiAgentChatInput
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string ProviderKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Model { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public List<AiAgentChatMessageInput> Messages { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>为空则使用全部已启用工具</summary>
|
||||||
|
public List<string>? ToolKeys { get; set; }
|
||||||
|
|
||||||
|
public int MaxToolRounds { get; set; } = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AiAgentChatMessageInput
|
||||||
|
{
|
||||||
|
public string Role { get; set; } = "user";
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AiAgentChatOutput
|
||||||
|
{
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public List<AiAgentToolTraceOutput> ToolTraces { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AiAgentToolTraceOutput
|
||||||
|
{
|
||||||
|
public string ToolKey { get; set; } = string.Empty;
|
||||||
|
public string ArgumentsJson { get; set; } = string.Empty;
|
||||||
|
public string ResultPreview { get; set; } = string.Empty;
|
||||||
|
public bool Success { get; set; }
|
||||||
|
}
|
||||||
116
src/Oms.Application/Mcp/AiAgentAbuseGuard.cs
Normal file
116
src/Oms.Application/Mcp/AiAgentAbuseGuard.cs
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
using Fake.Application;
|
||||||
|
using Oms.Application.Helpers;
|
||||||
|
|
||||||
|
namespace Oms.Application.Mcp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Agent 防滥用(Redis 计数,不落库日志):按用户限制每分钟/每天调用次数与 LLM 轮次预算。
|
||||||
|
/// </summary>
|
||||||
|
public static class AiAgentAbuseGuard
|
||||||
|
{
|
||||||
|
/// <summary>每用户每分钟最多 Agent 请求次数</summary>
|
||||||
|
public const int MaxRequestsPerMinute = 6;
|
||||||
|
|
||||||
|
/// <summary>每用户每天最多 Agent 请求次数</summary>
|
||||||
|
public const int MaxRequestsPerDay = 60;
|
||||||
|
|
||||||
|
/// <summary>每用户每天最多 LLM 调用次数(含工具轮次)</summary>
|
||||||
|
public const int MaxLlmCallsPerDay = 180;
|
||||||
|
|
||||||
|
/// <summary>单次请求最多工具轮次(含最终总结)</summary>
|
||||||
|
public const int MaxToolRoundsHardCap = 2;
|
||||||
|
|
||||||
|
/// <summary>单次请求最多执行工具次数</summary>
|
||||||
|
public const int MaxToolInvocationsPerRequest = 3;
|
||||||
|
|
||||||
|
/// <summary>单轮最多并行 tool_calls</summary>
|
||||||
|
public const int MaxToolCallsPerRound = 2;
|
||||||
|
|
||||||
|
/// <summary>送入模型的对话条数上限(不含 system)</summary>
|
||||||
|
public const int MaxHistoryMessages = 8;
|
||||||
|
|
||||||
|
/// <summary>单条消息字符上限</summary>
|
||||||
|
public const int MaxMessageChars = 1200;
|
||||||
|
|
||||||
|
/// <summary>工具结果写入上下文的字符上限</summary>
|
||||||
|
public const int MaxToolResultChars = 1000;
|
||||||
|
|
||||||
|
public static void EnsureAllowed(string principalKey)
|
||||||
|
{
|
||||||
|
var key = Normalize(principalKey);
|
||||||
|
TryIncr($"oms:ai:agent:rpm:{key}:{DateTime.UtcNow:yyyyMMddHHmm}", MaxRequestsPerMinute, 70,
|
||||||
|
$"提问过于频繁,请稍后再试(每分钟最多 {MaxRequestsPerMinute} 次)");
|
||||||
|
TryIncr($"oms:ai:agent:rpd:{key}:{DateTime.UtcNow:yyyyMMdd}", MaxRequestsPerDay, 86400,
|
||||||
|
$"今日提问次数已达上限({MaxRequestsPerDay} 次),请明天再试或联系客服");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>预扣 / 累计一次 LLM 调用;超限抛错。</summary>
|
||||||
|
public static void ConsumeLlmCall(string principalKey, int count = 1)
|
||||||
|
{
|
||||||
|
if (count <= 0) return;
|
||||||
|
var key = Normalize(principalKey);
|
||||||
|
var redisKey = $"oms:ai:agent:llm:{key}:{DateTime.UtcNow:yyyyMMdd}";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = RedisHelper.Client;
|
||||||
|
var next = client.IncrBy(redisKey, count);
|
||||||
|
if (next == count)
|
||||||
|
{
|
||||||
|
client.Expire(redisKey, 86400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next > MaxLlmCallsPerDay)
|
||||||
|
{
|
||||||
|
throw new BusinessException(
|
||||||
|
$"今日 AI 调用额度已用尽(约 {MaxLlmCallsPerDay} 次模型请求),请明天再试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (BusinessException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Redis 异常时不阻断,避免整站挂死;仍依赖轮次硬顶
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryIncr(string redisKey, int max, int ttlSeconds, string denyMessage)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = RedisHelper.Client;
|
||||||
|
var next = client.Incr(redisKey);
|
||||||
|
if (next == 1)
|
||||||
|
{
|
||||||
|
client.Expire(redisKey, ttlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next > max)
|
||||||
|
{
|
||||||
|
throw new BusinessException(denyMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (BusinessException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Redis 不可用时放行,靠硬顶控消耗
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string principalKey)
|
||||||
|
{
|
||||||
|
var raw = string.IsNullOrWhiteSpace(principalKey) ? "anon" : principalKey.Trim();
|
||||||
|
return raw.Length > 64 ? raw[..64] : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Truncate(string? text, int maxChars)
|
||||||
|
{
|
||||||
|
var s = text ?? string.Empty;
|
||||||
|
if (s.Length <= maxChars) return s;
|
||||||
|
return s[..maxChars] + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/Oms.Application/Mcp/McpToolCatalog.cs
Normal file
78
src/Oms.Application/Mcp/McpToolCatalog.cs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
namespace Oms.Application.Mcp;
|
||||||
|
|
||||||
|
public record McpToolDefinition(
|
||||||
|
string ToolKey,
|
||||||
|
string DisplayName,
|
||||||
|
string Description,
|
||||||
|
string InputSchemaJson,
|
||||||
|
int DefaultSortOrder);
|
||||||
|
|
||||||
|
/// <summary>内置 MCP 工具目录(代码权威;库表可覆盖名称/描述/启用)。</summary>
|
||||||
|
public static class McpToolCatalog
|
||||||
|
{
|
||||||
|
public const string PriceCheck = "oms.price_check";
|
||||||
|
public const string ChannelGuide = "oms.channel_guide";
|
||||||
|
public const string TrackQuery = "oms.track_query";
|
||||||
|
|
||||||
|
public static IReadOnlyList<McpToolDefinition> All { get; } =
|
||||||
|
[
|
||||||
|
new(
|
||||||
|
PriceCheck,
|
||||||
|
"查价格",
|
||||||
|
"根据目的国、重量等查询运费报价,返回可用渠道与价格摘要。",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"destinationCountry": { "type": "string", "description": "目的国家二字码,如 US、GB" },
|
||||||
|
"cargoWeight": { "type": "number", "description": "货物重量 kg" },
|
||||||
|
"length": { "type": "number", "description": "长 cm,可选" },
|
||||||
|
"width": { "type": "number", "description": "宽 cm,可选" },
|
||||||
|
"height": { "type": "number", "description": "高 cm,可选" },
|
||||||
|
"postalCode": { "type": "string", "description": "邮编,可选" },
|
||||||
|
"shippingMethod": { "type": "string", "description": "运输方式/产品代码关键词,可选" },
|
||||||
|
"cargoType": { "type": "string", "description": "货物属性编码,如 general/sensitive,可选" }
|
||||||
|
},
|
||||||
|
"required": ["destinationCountry", "cargoWeight"]
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
10),
|
||||||
|
new(
|
||||||
|
ChannelGuide,
|
||||||
|
"查渠道对客说明",
|
||||||
|
"按销售产品代码或名称查询对客说明,判断客户品名是否可承运(禁运/可寄)。",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"productCode": { "type": "string", "description": "销售产品代码,优先使用" },
|
||||||
|
"productName": { "type": "string", "description": "销售产品名称关键词(无代码时用)" },
|
||||||
|
"cargoName": { "type": "string", "description": "客户品名,用于对照说明判断可否承运" }
|
||||||
|
},
|
||||||
|
"required": ["cargoName"],
|
||||||
|
"anyOf": [
|
||||||
|
{ "required": ["productCode"] },
|
||||||
|
{ "required": ["productName"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
20),
|
||||||
|
new(
|
||||||
|
TrackQuery,
|
||||||
|
"查轨迹",
|
||||||
|
"根据运单号或客户单号查询物流轨迹时间轴摘要。",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"orderNumber": { "type": "string", "description": "运单号/跟踪号/客户单号" }
|
||||||
|
},
|
||||||
|
"required": ["orderNumber"]
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
30)
|
||||||
|
];
|
||||||
|
|
||||||
|
public static McpToolDefinition? Find(string toolKey) =>
|
||||||
|
All.FirstOrDefault(x => string.Equals(x.ToolKey, toolKey, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
315
src/Oms.Application/Mcp/McpToolExecutor.cs
Normal file
315
src/Oms.Application/Mcp/McpToolExecutor.cs
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Fake.Application;
|
||||||
|
using Fake.DependencyInjection;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Oms.Application.Dtos.Orders;
|
||||||
|
using Oms.Application.Integrations.SmallBagTrack;
|
||||||
|
using Oms.Application.Services.Orders;
|
||||||
|
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||||
|
using Oms.Domain.Shared.Enums;
|
||||||
|
|
||||||
|
namespace Oms.Application.Mcp;
|
||||||
|
|
||||||
|
public interface IMcpToolExecutor
|
||||||
|
{
|
||||||
|
Task<(bool Success, string Content)> InvokeAsync(
|
||||||
|
string toolKey,
|
||||||
|
string argumentsJson,
|
||||||
|
int? customerId = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class McpToolExecutor(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ISalesProductRepository salesProductRepository,
|
||||||
|
ISmallBagTrackConfigProvider trackConfigProvider,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
ILogger<McpToolExecutor> logger) : IMcpToolExecutor, IScopedDependency
|
||||||
|
{
|
||||||
|
public const string TrackHttpClientName = "McpTrackTimeline";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = false
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<(bool Success, string Content)> InvokeAsync(
|
||||||
|
string toolKey,
|
||||||
|
string argumentsJson,
|
||||||
|
int? customerId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var key = toolKey.Trim();
|
||||||
|
var result = key switch
|
||||||
|
{
|
||||||
|
McpToolCatalog.PriceCheck => await PriceCheckAsync(argumentsJson, customerId, cancellationToken),
|
||||||
|
McpToolCatalog.ChannelGuide => await ChannelGuideAsync(argumentsJson, cancellationToken),
|
||||||
|
McpToolCatalog.TrackQuery => await TrackQueryAsync(argumentsJson, cancellationToken),
|
||||||
|
_ => (false, $"未知工具:{toolKey}")
|
||||||
|
};
|
||||||
|
logger.LogInformation(
|
||||||
|
"MCP tool {Tool} finished in {Ms}ms success={Ok}",
|
||||||
|
key,
|
||||||
|
sw.ElapsedMilliseconds,
|
||||||
|
result.Item1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "MCP tool {Tool} failed", toolKey);
|
||||||
|
return (false, $"工具执行失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool, string)> PriceCheckAsync(
|
||||||
|
string argumentsJson,
|
||||||
|
int? customerId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var country = GetString(root, "destinationCountry", "DestinationCountry");
|
||||||
|
if (string.IsNullOrWhiteSpace(country))
|
||||||
|
{
|
||||||
|
return (false, "缺少 destinationCountry(目的国家二字码)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var weight = GetDecimal(root, "cargoWeight", "CargoWeight");
|
||||||
|
if (weight is null or <= 0)
|
||||||
|
{
|
||||||
|
return (false, "缺少有效的 cargoWeight(kg)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var input = new OrderPriceCheckInput
|
||||||
|
{
|
||||||
|
DestinationCountry = country.Trim().ToUpperInvariant(),
|
||||||
|
CargoWeight = weight,
|
||||||
|
Length = GetDecimal(root, "length", "Length"),
|
||||||
|
Width = GetDecimal(root, "width", "Width"),
|
||||||
|
Height = GetDecimal(root, "height", "Height"),
|
||||||
|
PostalCode = GetString(root, "postalCode", "PostalCode"),
|
||||||
|
ShippingMethod = GetString(root, "shippingMethod", "ShippingMethod"),
|
||||||
|
CargoType = GetString(root, "cargoType", "CargoType"),
|
||||||
|
CustomerId = customerId
|
||||||
|
};
|
||||||
|
|
||||||
|
var orderService = serviceProvider.GetService(typeof(OrderService)) as OrderService
|
||||||
|
?? throw new BusinessException("OrderService 未注册");
|
||||||
|
var result = await orderService.PriceCheckAsync(input);
|
||||||
|
|
||||||
|
var top = (result.Items ?? [])
|
||||||
|
.OrderBy(x => x.TotalCostNum > 0 ? x.TotalCostNum : x.TotalCost)
|
||||||
|
.Take(5)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
productCode = x.ProductCode,
|
||||||
|
shippingMethod = x.ShippingMethod,
|
||||||
|
warehouse = x.Warehouse,
|
||||||
|
cargoType = x.CargoTypeName ?? x.CargoType,
|
||||||
|
totalCost = x.TotalCostNum > 0 ? x.TotalCostNum : x.TotalCost,
|
||||||
|
deliveryTime = x.DeliveryTime,
|
||||||
|
chargeableWeight = x.ChargeableWeight,
|
||||||
|
quoteLevel = x.QuoteLevel
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var failures = (result.Failures ?? [])
|
||||||
|
.Take(5)
|
||||||
|
.Select(x => new { productCode = x.ProductCode, reason = x.Reason })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
count = top.Count,
|
||||||
|
items = top,
|
||||||
|
failures,
|
||||||
|
queryHint = result.QueryHint == null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
result.QueryHint.QuoteLevel,
|
||||||
|
result.QueryHint.MissingFieldCodes,
|
||||||
|
result.QueryHint.Disclaimers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (true, JsonSerializer.Serialize(payload, JsonOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool, string)> ChannelGuideAsync(string argumentsJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var productCode = GetString(root, "productCode", "ProductCode");
|
||||||
|
var productName = GetString(root, "productName", "ProductName");
|
||||||
|
var cargoName = GetString(root, "cargoName", "CargoName");
|
||||||
|
|
||||||
|
var products = new List<Oms.Domain.ChannelsAggregate.Entities.SalesProduct>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(productCode))
|
||||||
|
{
|
||||||
|
var one = await salesProductRepository.GetByProductCodeAsync(productCode.Trim(), cancellationToken);
|
||||||
|
if (one != null) products.Add(one);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (products.Count == 0 && !string.IsNullOrWhiteSpace(productName))
|
||||||
|
{
|
||||||
|
var keyword = productName.Trim();
|
||||||
|
var list = await salesProductRepository.GetListAsync(
|
||||||
|
x => x.IsActive == EnableStatus.Enabled
|
||||||
|
&& (x.ProductName.Contains(keyword) || x.ProductCode.Contains(keyword)));
|
||||||
|
products.AddRange(list.Take(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (products.Count == 0)
|
||||||
|
{
|
||||||
|
return (false, "未找到销售产品,请提供 productCode 或 productName");
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = products.Select(p =>
|
||||||
|
{
|
||||||
|
var guide = p.CustomerGuideMarkdown?.Trim() ?? string.Empty;
|
||||||
|
if (guide.Length > 1500) guide = guide[..1500] + "…";
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
productCode = p.ProductCode,
|
||||||
|
productName = p.ProductName,
|
||||||
|
hasGuide = !string.IsNullOrWhiteSpace(guide),
|
||||||
|
customerGuideMarkdown = string.IsNullOrWhiteSpace(guide) ? null : guide,
|
||||||
|
cargoNameToCheck = string.IsNullOrWhiteSpace(cargoName) ? null : cargoName.Trim(),
|
||||||
|
hint = string.IsNullOrWhiteSpace(guide)
|
||||||
|
? "该产品尚未配置对客说明,无法据此判断品名可否承运。"
|
||||||
|
: "请根据对客说明判断 cargoName 是否可寄;说明未提及的品名不要擅自判定可寄。"
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return (true, JsonSerializer.Serialize(new { items }, JsonOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool, string)> TrackQueryAsync(string argumentsJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
|
||||||
|
var orderNumber = GetString(doc.RootElement, "orderNumber", "OrderNumber", "serviceNo", "trackNo");
|
||||||
|
if (string.IsNullOrWhiteSpace(orderNumber))
|
||||||
|
{
|
||||||
|
return (false, "缺少 orderNumber(运单号)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = await trackConfigProvider.GetAsync(cancellationToken);
|
||||||
|
if (!settings.Enabled || string.IsNullOrWhiteSpace(settings.BaseUrl))
|
||||||
|
{
|
||||||
|
return (false, "轨迹服务未启用或未配置 BaseUrl(系统其他配置 TrackService)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseUrl = settings.BaseUrl.Trim().TrimEnd('/');
|
||||||
|
var url = $"{baseUrl}/Track/Order/GetDetailTimeline";
|
||||||
|
var client = httpClientFactory.CreateClient(TrackHttpClientName);
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||||
|
request.Content = JsonContent.Create(new { orderNumber = orderNumber.Trim() });
|
||||||
|
|
||||||
|
var auth = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(auth))
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("Authorization", auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await client.SendAsync(request, cancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return (false, $"轨迹查询失败 ({(int)response.StatusCode}): {Trim(body, 400)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timelineDoc = JsonDocument.Parse(body);
|
||||||
|
var root = timelineDoc.RootElement;
|
||||||
|
// 兼容直接数组 / { data: [] } / { Data: [] }
|
||||||
|
JsonElement arr = root;
|
||||||
|
if (root.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty("data", out var d) || root.TryGetProperty("Data", out d))
|
||||||
|
arr = d;
|
||||||
|
else if (root.TryGetProperty("items", out var items) || root.TryGetProperty("Items", out items))
|
||||||
|
arr = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
var events = new List<object>();
|
||||||
|
if (arr.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var el in arr.EnumerateArray().Take(12))
|
||||||
|
{
|
||||||
|
events.Add(new
|
||||||
|
{
|
||||||
|
time = Pick(el, "trackTime", "TrackTime", "time", "Time", "createTime", "CreateTime"),
|
||||||
|
content = Pick(el, "trackContent", "TrackContent", "content", "Content", "description", "Description"),
|
||||||
|
location = Pick(el, "location", "Location", "city", "City"),
|
||||||
|
status = Pick(el, "trackCode", "TrackCode", "status", "Status")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
orderNumber = orderNumber.Trim(),
|
||||||
|
count = events.Count,
|
||||||
|
events
|
||||||
|
}, JsonOptions));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return (true, Trim(body, 4000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement root, params string[] names)
|
||||||
|
{
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var s = el.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(s)) return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? GetDecimal(JsonElement root, params string[] names)
|
||||||
|
{
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty(name, out var el)) continue;
|
||||||
|
if (el.ValueKind == JsonValueKind.Number && el.TryGetDecimal(out var d)) return d;
|
||||||
|
if (el.ValueKind == JsonValueKind.String && decimal.TryParse(el.GetString(), out var parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Pick(JsonElement el, params string[] names)
|
||||||
|
{
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (!el.TryGetProperty(name, out var p)) continue;
|
||||||
|
var s = p.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => p.GetString(),
|
||||||
|
JsonValueKind.Number => p.ToString(),
|
||||||
|
_ => p.ToString()
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(s)) return s;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Trim(string text, int max) =>
|
||||||
|
string.IsNullOrEmpty(text) ? string.Empty : text.Length <= max ? text : text[..max];
|
||||||
|
}
|
||||||
317
src/Oms.Application/Services/Mcp/McpToolService.cs
Normal file
317
src/Oms.Application/Services/Mcp/McpToolService.cs
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Fake.Application;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Oms.Application.Dtos.Mcp;
|
||||||
|
using Oms.Application.Dtos.Shared;
|
||||||
|
using Oms.Application.Mcp;
|
||||||
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Domain.CustomersAggregate.Repositories;
|
||||||
|
using Oms.Domain.McpAggregate.Entities;
|
||||||
|
using Oms.Domain.McpAggregate.Repositories;
|
||||||
|
using Oms.Domain.XhsAggregate.Repositories;
|
||||||
|
|
||||||
|
namespace Oms.Application.Services.Mcp;
|
||||||
|
|
||||||
|
[ApiExplorerSettings(GroupName = "OMS-Mcp")]
|
||||||
|
public class McpToolService : ApplicationService
|
||||||
|
{
|
||||||
|
private IMcpToolRepository McpToolRepository => LazyServiceProvider.GetRequiredService<IMcpToolRepository>();
|
||||||
|
private IMcpToolExecutor ToolExecutor => LazyServiceProvider.GetRequiredService<IMcpToolExecutor>();
|
||||||
|
private IXhsAiProviderRepository AiProviderRepository => LazyServiceProvider.GetRequiredService<IXhsAiProviderRepository>();
|
||||||
|
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||||||
|
private XhsLlmClient LlmClient => LazyServiceProvider.GetRequiredService<XhsLlmClient>();
|
||||||
|
|
||||||
|
/// <summary>列出 MCP 工具(内置目录 + 库表覆盖)</summary>
|
||||||
|
[HttpPost("/mcp/tool/list")]
|
||||||
|
public async Task<OmsItemsResult<McpToolOutput>> ListAsync()
|
||||||
|
{
|
||||||
|
var stored = await McpToolRepository.GetListAsync();
|
||||||
|
var byKey = stored.ToDictionary(x => x.ToolKey, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var items = McpToolCatalog.All.Select(def =>
|
||||||
|
{
|
||||||
|
byKey.TryGetValue(def.ToolKey, out var row);
|
||||||
|
return new McpToolOutput
|
||||||
|
{
|
||||||
|
Id = row?.Id,
|
||||||
|
ToolKey = def.ToolKey,
|
||||||
|
DisplayName = string.IsNullOrWhiteSpace(row?.DisplayName) ? def.DisplayName : row!.DisplayName,
|
||||||
|
Description = string.IsNullOrWhiteSpace(row?.Description) ? def.Description : row!.Description,
|
||||||
|
Enabled = row?.Enabled ?? true,
|
||||||
|
SortOrder = row?.SortOrder ?? def.DefaultSortOrder,
|
||||||
|
InputSchemaJson = def.InputSchemaJson.Trim(),
|
||||||
|
Builtin = true
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.ToolKey)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new OmsItemsResult<McpToolOutput> { Items = items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>保存工具启用状态与描述</summary>
|
||||||
|
[HttpPost("/mcp/tool/save")]
|
||||||
|
public async Task<McpToolOutput> SaveAsync(McpToolSaveInput input)
|
||||||
|
{
|
||||||
|
var def = McpToolCatalog.Find(input.ToolKey)
|
||||||
|
?? throw new BusinessException($"未知工具:{input.ToolKey}");
|
||||||
|
|
||||||
|
var entity = await McpToolRepository.GetByToolKeyAsync(def.ToolKey);
|
||||||
|
if (entity == null)
|
||||||
|
{
|
||||||
|
entity = new McpTool { ToolKey = def.ToolKey };
|
||||||
|
await McpToolRepository.InsertAsync(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.DisplayName = string.IsNullOrWhiteSpace(input.DisplayName) ? def.DisplayName : input.DisplayName.Trim();
|
||||||
|
entity.Description = string.IsNullOrWhiteSpace(input.Description) ? def.Description : input.Description.Trim();
|
||||||
|
entity.Enabled = input.Enabled;
|
||||||
|
entity.SortOrder = input.SortOrder;
|
||||||
|
await McpToolRepository.UpdateAsync(entity);
|
||||||
|
|
||||||
|
return new McpToolOutput
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
ToolKey = entity.ToolKey,
|
||||||
|
DisplayName = entity.DisplayName,
|
||||||
|
Description = entity.Description,
|
||||||
|
Enabled = entity.Enabled,
|
||||||
|
SortOrder = entity.SortOrder,
|
||||||
|
InputSchemaJson = def.InputSchemaJson.Trim(),
|
||||||
|
Builtin = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>手动试跑工具</summary>
|
||||||
|
[HttpPost("/mcp/tool/call")]
|
||||||
|
public async Task<McpToolCallOutput> CallAsync(McpToolCallInput input)
|
||||||
|
{
|
||||||
|
await EnsureToolEnabledAsync(input.ToolKey);
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var (ok, content) = await ToolExecutor.InvokeAsync(input.ToolKey, input.ArgumentsJson ?? "{}");
|
||||||
|
return new McpToolCallOutput
|
||||||
|
{
|
||||||
|
ToolKey = input.ToolKey,
|
||||||
|
Success = ok,
|
||||||
|
Content = content,
|
||||||
|
ElapsedMs = sw.ElapsedMilliseconds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AI Agent 对话(可自动调用已启用 MCP 工具;带防滥用限流)</summary>
|
||||||
|
[HttpPost("/ai/agent/chat")]
|
||||||
|
public async Task<AiAgentChatOutput> AgentChatAsync(AiAgentChatInput input)
|
||||||
|
{
|
||||||
|
var customerId = await OmsSessionHelper.ResolveXhsCustomerIdAsync(CustomerRepository, CurrentUser);
|
||||||
|
var mapId = OmsSessionHelper.TryGetCustomerMapId(CurrentUser);
|
||||||
|
var principal = mapId is > 0
|
||||||
|
? $"u{mapId.Value}"
|
||||||
|
: (customerId > 0 ? $"c{customerId}" : $"a{CurrentUser.GetId<int>()}");
|
||||||
|
|
||||||
|
AiAgentAbuseGuard.EnsureAllowed(principal);
|
||||||
|
|
||||||
|
var provider = await ResolveProviderAsync(customerId, input.ProviderKey.Trim());
|
||||||
|
|
||||||
|
var enabledTools = await ResolveEnabledToolsAsync(input.ToolKeys);
|
||||||
|
var llmTools = enabledTools.Select(t =>
|
||||||
|
{
|
||||||
|
JsonNode parameters;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
parameters = JsonNode.Parse(t.InputSchemaJson) ?? new JsonObject();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
parameters = new JsonObject { ["type"] = "object" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LlmToolDefinition
|
||||||
|
{
|
||||||
|
Name = t.ToolKey,
|
||||||
|
Description = $"{t.DisplayName}:{t.Description}",
|
||||||
|
Parameters = parameters
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var messages = new List<LlmChatMessage>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Role = "system",
|
||||||
|
Content =
|
||||||
|
"你是 OMS 客户侧物流助手。可用工具:" +
|
||||||
|
"oms.price_check(查价格)、oms.channel_guide(查对客说明/品名可否承运)、oms.track_query(查轨迹)。" +
|
||||||
|
"\n【强制槽位规则】信息不齐时禁止猜测调用工具,先用一两句中文追问缺项:" +
|
||||||
|
"\n- 查价必填:目的国家、实重(kg);选填:邮编、长宽高(cm)、货物属性、运输方式。" +
|
||||||
|
"国家可用中文,你在调工具时转成二字码(美国→US、英国→GB)。" +
|
||||||
|
"\n- 对客说明/可承运必填:销售产品代码或名称 + 客户品名;缺一不可。" +
|
||||||
|
"\n- 查轨迹必填:运单号/跟踪号/客户单号;若上下文已有运单号可直接查。" +
|
||||||
|
"\n【省 token】回复尽量短;一次只调必需工具;不要复述大段工具原文或 JSON。"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var m in TrimHistory(input.Messages))
|
||||||
|
{
|
||||||
|
messages.Add(new LlmChatMessage
|
||||||
|
{
|
||||||
|
Role = string.IsNullOrWhiteSpace(m.Role) ? "user" : m.Role.Trim(),
|
||||||
|
Content = AiAgentAbuseGuard.Truncate(m.Content, AiAgentAbuseGuard.MaxMessageChars)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var traces = new List<AiAgentToolTraceOutput>();
|
||||||
|
var maxRounds = Math.Clamp(
|
||||||
|
input.MaxToolRounds <= 0 ? AiAgentAbuseGuard.MaxToolRoundsHardCap : input.MaxToolRounds,
|
||||||
|
1,
|
||||||
|
AiAgentAbuseGuard.MaxToolRoundsHardCap);
|
||||||
|
var toolInvocations = 0;
|
||||||
|
|
||||||
|
for (var round = 0; round < maxRounds; round++)
|
||||||
|
{
|
||||||
|
AiAgentAbuseGuard.ConsumeLlmCall(principal);
|
||||||
|
var result = await LlmClient.ChatCompletionDetailedAsync(
|
||||||
|
provider,
|
||||||
|
messages,
|
||||||
|
llmTools.Count > 0 ? llmTools : null,
|
||||||
|
input.Model);
|
||||||
|
|
||||||
|
if (result.ToolCalls.Count == 0)
|
||||||
|
{
|
||||||
|
return new AiAgentChatOutput
|
||||||
|
{
|
||||||
|
Content = result.Content?.Trim() ?? string.Empty,
|
||||||
|
ToolTraces = traces
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var calls = result.ToolCalls.Take(AiAgentAbuseGuard.MaxToolCallsPerRound).ToList();
|
||||||
|
messages.Add(new LlmChatMessage
|
||||||
|
{
|
||||||
|
Role = "assistant",
|
||||||
|
Content = AiAgentAbuseGuard.Truncate(result.Content, 400),
|
||||||
|
ToolCalls = calls
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var call in calls)
|
||||||
|
{
|
||||||
|
if (toolInvocations >= AiAgentAbuseGuard.MaxToolInvocationsPerRequest)
|
||||||
|
{
|
||||||
|
messages.Add(new LlmChatMessage
|
||||||
|
{
|
||||||
|
Role = "tool",
|
||||||
|
ToolCallId = call.Id,
|
||||||
|
Name = call.FunctionName,
|
||||||
|
Content = "本轮工具调用次数已达上限,请根据已有结果直接回答用户。"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
toolInvocations++;
|
||||||
|
var enabled = enabledTools.Any(t =>
|
||||||
|
string.Equals(t.ToolKey, call.FunctionName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
string toolContent;
|
||||||
|
var success = false;
|
||||||
|
if (!enabled)
|
||||||
|
{
|
||||||
|
toolContent = $"工具未启用或不存在:{call.FunctionName}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
(success, toolContent) = await ToolExecutor.InvokeAsync(
|
||||||
|
call.FunctionName,
|
||||||
|
call.ArgumentsJson,
|
||||||
|
customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
toolContent = AiAgentAbuseGuard.Truncate(toolContent, AiAgentAbuseGuard.MaxToolResultChars);
|
||||||
|
traces.Add(new AiAgentToolTraceOutput
|
||||||
|
{
|
||||||
|
ToolKey = call.FunctionName,
|
||||||
|
ArgumentsJson = AiAgentAbuseGuard.Truncate(call.ArgumentsJson, 400),
|
||||||
|
ResultPreview = toolContent.Length > 500 ? toolContent[..500] + "…" : toolContent,
|
||||||
|
Success = success
|
||||||
|
});
|
||||||
|
|
||||||
|
messages.Add(new LlmChatMessage
|
||||||
|
{
|
||||||
|
Role = "tool",
|
||||||
|
ToolCallId = call.Id,
|
||||||
|
Name = call.FunctionName,
|
||||||
|
Content = toolContent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AiAgentAbuseGuard.ConsumeLlmCall(principal);
|
||||||
|
var final = await LlmClient.ChatCompletionDetailedAsync(provider, messages, tools: null, input.Model);
|
||||||
|
return new AiAgentChatOutput
|
||||||
|
{
|
||||||
|
Content = final.Content?.Trim()
|
||||||
|
?? "已查询相关信息,请根据页面提示补充后重试,或联系客服。",
|
||||||
|
ToolTraces = traces
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<AiAgentChatMessageInput> TrimHistory(List<AiAgentChatMessageInput> raw)
|
||||||
|
{
|
||||||
|
var cleaned = (raw ?? [])
|
||||||
|
.Where(m => m != null && !string.IsNullOrWhiteSpace(m.Content))
|
||||||
|
.Select(m => new AiAgentChatMessageInput
|
||||||
|
{
|
||||||
|
Role = string.IsNullOrWhiteSpace(m.Role) ? "user" : m.Role.Trim().ToLowerInvariant(),
|
||||||
|
Content = m.Content ?? ""
|
||||||
|
})
|
||||||
|
.Where(m => m.Role is "user" or "assistant" or "system")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (cleaned.Count <= AiAgentAbuseGuard.MaxHistoryMessages)
|
||||||
|
{
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned.Skip(cleaned.Count - AiAgentAbuseGuard.MaxHistoryMessages).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureToolEnabledAsync(string toolKey)
|
||||||
|
{
|
||||||
|
var def = McpToolCatalog.Find(toolKey)
|
||||||
|
?? throw new BusinessException($"未知工具:{toolKey}");
|
||||||
|
var row = await McpToolRepository.GetByToolKeyAsync(def.ToolKey);
|
||||||
|
if (row is { Enabled: false })
|
||||||
|
{
|
||||||
|
throw new BusinessException($"工具已禁用:{def.ToolKey}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<McpToolOutput>> ResolveEnabledToolsAsync(List<string>? toolKeys)
|
||||||
|
{
|
||||||
|
var all = (await ListAsync()).Items;
|
||||||
|
var enabled = all.Where(x => x.Enabled).ToList();
|
||||||
|
if (toolKeys is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var set = new HashSet<string>(toolKeys, StringComparer.OrdinalIgnoreCase);
|
||||||
|
enabled = enabled.Where(x => set.Contains(x.ToolKey)).ToList();
|
||||||
|
}
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Oms.Domain.XhsAggregate.Entities.XhsAiProvider> ResolveProviderAsync(
|
||||||
|
int customerId,
|
||||||
|
string providerKey)
|
||||||
|
{
|
||||||
|
var provider = await AiProviderRepository.GetByProviderKeyAsync(customerId, providerKey);
|
||||||
|
if (provider == null || !provider.Enabled)
|
||||||
|
{
|
||||||
|
provider = await AiProviderRepository.GetByProviderKeyAsync(0, providerKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider == null || !provider.Enabled)
|
||||||
|
{
|
||||||
|
throw new BusinessException($"AI 提供商 {providerKey} 未配置或未启用");
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,44 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Oms.Application.Mcp;
|
||||||
using Oms.Domain.XhsAggregate.Entities;
|
using Oms.Domain.XhsAggregate.Entities;
|
||||||
|
|
||||||
namespace Oms.Application.Shared;
|
namespace Oms.Application.Shared;
|
||||||
|
|
||||||
|
public class LlmChatMessage
|
||||||
|
{
|
||||||
|
public string Role { get; set; } = "user";
|
||||||
|
public string? Content { get; set; }
|
||||||
|
public List<LlmToolCall>? ToolCalls { get; set; }
|
||||||
|
public string? ToolCallId { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LlmToolCall
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Type { get; set; } = "function";
|
||||||
|
public string FunctionName { get; set; } = string.Empty;
|
||||||
|
public string ArgumentsJson { get; set; } = "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LlmToolDefinition
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public JsonNode Parameters { get; set; } = new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LlmChatResult
|
||||||
|
{
|
||||||
|
public string? Content { get; set; }
|
||||||
|
public List<LlmToolCall> ToolCalls { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// OpenAI 兼容 Chat Completions 客户端(智谱 / DeepSeek / 豆包等)。
|
/// OpenAI 兼容 Chat Completions 客户端(智谱 / DeepSeek / 豆包等)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -26,6 +58,22 @@ public class XhsLlmClient
|
|||||||
IReadOnlyList<(string Role, string Content)> messages,
|
IReadOnlyList<(string Role, string Content)> messages,
|
||||||
string? modelOverride = null,
|
string? modelOverride = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await ChatCompletionDetailedAsync(
|
||||||
|
provider,
|
||||||
|
messages.Select(m => new LlmChatMessage { Role = m.Role, Content = m.Content }).ToList(),
|
||||||
|
tools: null,
|
||||||
|
modelOverride,
|
||||||
|
cancellationToken);
|
||||||
|
return result.Content?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LlmChatResult> ChatCompletionDetailedAsync(
|
||||||
|
XhsAiProvider provider,
|
||||||
|
IReadOnlyList<LlmChatMessage> messages,
|
||||||
|
IReadOnlyList<LlmToolDefinition>? tools = null,
|
||||||
|
string? modelOverride = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(provider.ApiKeyEnc))
|
if (string.IsNullOrWhiteSpace(provider.ApiKeyEnc))
|
||||||
{
|
{
|
||||||
@ -35,21 +83,19 @@ public class XhsLlmClient
|
|||||||
var model = modelOverride ?? provider.ApiSecretEnc;
|
var model = modelOverride ?? provider.ApiSecretEnc;
|
||||||
if (string.IsNullOrWhiteSpace(model))
|
if (string.IsNullOrWhiteSpace(model))
|
||||||
{
|
{
|
||||||
throw new BusinessException($"请先配置 {provider.DisplayName} 的模型名(ApiSecret 字段)");
|
throw new BusinessException($"请先配置 {provider.DisplayName} 的模型名(ApiSecret 字段)或在请求中传入 model");
|
||||||
}
|
}
|
||||||
|
|
||||||
var url = BuildChatUrl(provider.BaseUrl);
|
var url = BuildChatUrl(provider.BaseUrl);
|
||||||
var payload = new
|
var payload = BuildPayload(model, messages, tools);
|
||||||
{
|
|
||||||
model,
|
|
||||||
messages = messages.Select(x => new { role = x.Role, content = x.Content }).ToList(),
|
|
||||||
temperature = 0.7
|
|
||||||
};
|
|
||||||
|
|
||||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", provider.ApiKeyEnc.Trim());
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", provider.ApiKeyEnc.Trim());
|
||||||
request.Content = JsonContent.Create(payload);
|
request.Content = new StringContent(payload.ToJsonString(new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
}), Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
using var response = await client.SendAsync(request, cancellationToken);
|
using var response = await client.SendAsync(request, cancellationToken);
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
@ -62,10 +108,40 @@ public class XhsLlmClient
|
|||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
if (root.TryGetProperty("choices", out var choices) &&
|
if (root.TryGetProperty("choices", out var choices) &&
|
||||||
choices.GetArrayLength() > 0 &&
|
choices.GetArrayLength() > 0 &&
|
||||||
choices[0].TryGetProperty("message", out var message) &&
|
choices[0].TryGetProperty("message", out var message))
|
||||||
message.TryGetProperty("content", out var content))
|
|
||||||
{
|
{
|
||||||
return content.GetString()?.Trim() ?? string.Empty;
|
var result = new LlmChatResult();
|
||||||
|
if (message.TryGetProperty("content", out var content) && content.ValueKind != JsonValueKind.Null)
|
||||||
|
{
|
||||||
|
result.Content = content.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.TryGetProperty("tool_calls", out var toolCalls) &&
|
||||||
|
toolCalls.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var call in toolCalls.EnumerateArray())
|
||||||
|
{
|
||||||
|
var id = call.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? "" : "";
|
||||||
|
var fn = call.TryGetProperty("function", out var fnEl) ? fnEl : default;
|
||||||
|
var name = fn.ValueKind == JsonValueKind.Object && fn.TryGetProperty("name", out var n)
|
||||||
|
? n.GetString() ?? ""
|
||||||
|
: "";
|
||||||
|
var args = fn.ValueKind == JsonValueKind.Object && fn.TryGetProperty("arguments", out var a)
|
||||||
|
? a.GetString() ?? "{}"
|
||||||
|
: "{}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
result.ToolCalls.Add(new LlmToolCall
|
||||||
|
{
|
||||||
|
Id = string.IsNullOrWhiteSpace(id) ? Guid.NewGuid().ToString("N") : id,
|
||||||
|
FunctionName = name,
|
||||||
|
ArgumentsJson = string.IsNullOrWhiteSpace(args) ? "{}" : args
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (root.TryGetProperty("error", out var error) &&
|
if (root.TryGetProperty("error", out var error) &&
|
||||||
@ -77,6 +153,78 @@ public class XhsLlmClient
|
|||||||
throw new BusinessException("AI 响应格式无法解析");
|
throw new BusinessException("AI 响应格式无法解析");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static JsonObject BuildPayload(
|
||||||
|
string model,
|
||||||
|
IReadOnlyList<LlmChatMessage> messages,
|
||||||
|
IReadOnlyList<LlmToolDefinition>? tools)
|
||||||
|
{
|
||||||
|
var msgArr = new JsonArray();
|
||||||
|
foreach (var m in messages)
|
||||||
|
{
|
||||||
|
var obj = new JsonObject { ["role"] = m.Role };
|
||||||
|
if (m.Role == "tool")
|
||||||
|
{
|
||||||
|
obj["tool_call_id"] = m.ToolCallId ?? "";
|
||||||
|
if (!string.IsNullOrWhiteSpace(m.Name)) obj["name"] = m.Name;
|
||||||
|
obj["content"] = m.Content ?? "";
|
||||||
|
}
|
||||||
|
else if (m.ToolCalls is { Count: > 0 })
|
||||||
|
{
|
||||||
|
if (m.Content != null) obj["content"] = m.Content;
|
||||||
|
var calls = new JsonArray();
|
||||||
|
foreach (var c in m.ToolCalls)
|
||||||
|
{
|
||||||
|
calls.Add(new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = c.Id,
|
||||||
|
["type"] = "function",
|
||||||
|
["function"] = new JsonObject
|
||||||
|
{
|
||||||
|
["name"] = c.FunctionName,
|
||||||
|
["arguments"] = c.ArgumentsJson
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
obj["tool_calls"] = calls;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
obj["content"] = m.Content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
msgArr.Add(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = new JsonObject
|
||||||
|
{
|
||||||
|
["model"] = model,
|
||||||
|
["messages"] = msgArr,
|
||||||
|
["temperature"] = 0.3
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tools is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var toolArr = new JsonArray();
|
||||||
|
foreach (var t in tools)
|
||||||
|
{
|
||||||
|
toolArr.Add(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "function",
|
||||||
|
["function"] = new JsonObject
|
||||||
|
{
|
||||||
|
["name"] = t.Name,
|
||||||
|
["description"] = t.Description,
|
||||||
|
["parameters"] = t.Parameters.DeepClone()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
payload["tools"] = toolArr;
|
||||||
|
payload["tool_choice"] = "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
private static string BuildChatUrl(string baseUrl)
|
private static string BuildChatUrl(string baseUrl)
|
||||||
{
|
{
|
||||||
var url = (baseUrl ?? string.Empty).Trim().TrimEnd('/');
|
var url = (baseUrl ?? string.Empty).Trim().TrimEnd('/');
|
||||||
@ -90,8 +238,6 @@ public class XhsLlmClient
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI / DeepSeek:.../v1 → .../v1/chat/completions
|
|
||||||
// 智谱:.../api/paas/v4 → .../v4/chat/completions(勿再拼 /v1)
|
|
||||||
if (url.EndsWith("/v1", StringComparison.OrdinalIgnoreCase) ||
|
if (url.EndsWith("/v1", StringComparison.OrdinalIgnoreCase) ||
|
||||||
url.EndsWith("/v4", StringComparison.OrdinalIgnoreCase))
|
url.EndsWith("/v4", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@ -120,6 +266,10 @@ public static class XhsLlmServiceExtensions
|
|||||||
{
|
{
|
||||||
client.Timeout = TimeSpan.FromMinutes(3);
|
client.Timeout = TimeSpan.FromMinutes(3);
|
||||||
});
|
});
|
||||||
|
services.AddHttpClient(McpToolExecutor.TrackHttpClientName, client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(45);
|
||||||
|
});
|
||||||
services.AddTransient<XhsLlmClient>();
|
services.AddTransient<XhsLlmClient>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/Oms.Domain/McpAggregate/Entities/McpTool.cs
Normal file
22
src/Oms.Domain/McpAggregate/Entities/McpTool.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using Oms.Domain.Shared;
|
||||||
|
using SqlSugar;
|
||||||
|
|
||||||
|
namespace Oms.Domain.McpAggregate.Entities;
|
||||||
|
|
||||||
|
[SugarTable("t_mcp_tool")]
|
||||||
|
public class McpTool : OmsAuditedAggregateRoot
|
||||||
|
{
|
||||||
|
public string ToolKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[SugarColumn(Length = 1000)]
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
[SugarColumn(IsNullable = true, Length = 512)]
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using Fake.Domain.Repositories;
|
||||||
|
using Oms.Domain.McpAggregate.Entities;
|
||||||
|
|
||||||
|
namespace Oms.Domain.McpAggregate.Repositories;
|
||||||
|
|
||||||
|
public interface IMcpToolRepository : IRepository<McpTool>
|
||||||
|
{
|
||||||
|
Task<McpTool?> GetByToolKeyAsync(string toolKey, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
14
src/Oms.SqlSugarCore/Mcp/McpToolRepository.cs
Normal file
14
src/Oms.SqlSugarCore/Mcp/McpToolRepository.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
using Fake.DependencyInjection;
|
||||||
|
using Oms.Domain.McpAggregate.Entities;
|
||||||
|
using Oms.Domain.McpAggregate.Repositories;
|
||||||
|
using Oms.SqlSugarCore.Repositories;
|
||||||
|
|
||||||
|
namespace Oms.SqlSugarCore.Mcp;
|
||||||
|
|
||||||
|
public class McpToolRepository : OmsSqlSugarRepository<McpTool>, IMcpToolRepository, IScopedDependency
|
||||||
|
{
|
||||||
|
public Task<McpTool?> GetByToolKeyAsync(string toolKey, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return FirstOrDefaultAsync(x => x.ToolKey == toolKey, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user