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.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Fake.Application;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Oms.Application.Mcp;
|
||||
using Oms.Domain.XhsAggregate.Entities;
|
||||
|
||||
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>
|
||||
/// OpenAI 兼容 Chat Completions 客户端(智谱 / DeepSeek / 豆包等)。
|
||||
/// </summary>
|
||||
@ -26,6 +58,22 @@ public class XhsLlmClient
|
||||
IReadOnlyList<(string Role, string Content)> messages,
|
||||
string? modelOverride = null,
|
||||
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))
|
||||
{
|
||||
@ -35,21 +83,19 @@ public class XhsLlmClient
|
||||
var model = modelOverride ?? provider.ApiSecretEnc;
|
||||
if (string.IsNullOrWhiteSpace(model))
|
||||
{
|
||||
throw new BusinessException($"请先配置 {provider.DisplayName} 的模型名(ApiSecret 字段)");
|
||||
throw new BusinessException($"请先配置 {provider.DisplayName} 的模型名(ApiSecret 字段)或在请求中传入 model");
|
||||
}
|
||||
|
||||
var url = BuildChatUrl(provider.BaseUrl);
|
||||
var payload = new
|
||||
{
|
||||
model,
|
||||
messages = messages.Select(x => new { role = x.Role, content = x.Content }).ToList(),
|
||||
temperature = 0.7
|
||||
};
|
||||
var payload = BuildPayload(model, messages, tools);
|
||||
|
||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
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);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
@ -62,10 +108,40 @@ public class XhsLlmClient
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("choices", out var choices) &&
|
||||
choices.GetArrayLength() > 0 &&
|
||||
choices[0].TryGetProperty("message", out var message) &&
|
||||
message.TryGetProperty("content", out var content))
|
||||
choices[0].TryGetProperty("message", out var message))
|
||||
{
|
||||
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) &&
|
||||
@ -77,6 +153,78 @@ public class XhsLlmClient
|
||||
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)
|
||||
{
|
||||
var url = (baseUrl ?? string.Empty).Trim().TrimEnd('/');
|
||||
@ -90,8 +238,6 @@ public class XhsLlmClient
|
||||
return url;
|
||||
}
|
||||
|
||||
// OpenAI / DeepSeek:.../v1 → .../v1/chat/completions
|
||||
// 智谱:.../api/paas/v4 → .../v4/chat/completions(勿再拼 /v1)
|
||||
if (url.EndsWith("/v1", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.EndsWith("/v4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@ -120,6 +266,10 @@ public static class XhsLlmServiceExtensions
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(3);
|
||||
});
|
||||
services.AddHttpClient(McpToolExecutor.TrackHttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(45);
|
||||
});
|
||||
services.AddTransient<XhsLlmClient>();
|
||||
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