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.AiAggregate.Repositories; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.McpAggregate.Entities; using Oms.Domain.McpAggregate.Repositories; namespace Oms.Application.Services.Mcp; [ApiExplorerSettings(GroupName = "OMS-Mcp")] public class McpToolService : ApplicationService { private IMcpToolRepository McpToolRepository => LazyServiceProvider.GetRequiredService(); private IMcpToolExecutor ToolExecutor => LazyServiceProvider.GetRequiredService(); private IAiProviderRepository AiProviderRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); private AiLlmClient LlmClient => LazyServiceProvider.GetRequiredService(); /// 列出 MCP 工具(内置目录 + 库表覆盖) [HttpPost("/mcp/tool/list")] public async Task> 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 { Items = items }; } /// 保存工具启用状态与描述 [HttpPost("/mcp/tool/save")] public async Task 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 }; } /// 手动试跑工具 [HttpPost("/mcp/tool/call")] public async Task 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 }; } /// AI Agent 对话(可自动调用已启用 MCP 工具;带防滥用限流) [HttpPost("/mcp/agent/chat")] [HttpPost("/ai/agent/chat")] // 兼容旧路径;生产 OMS 反代请走 /mcp/* public async Task 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()}"); 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 { new() { Role = "system", Content = "你是 OMS 客户侧物流助手。可用工具:" + "oms.price_check(查价格)、oms.channel_guide(查对客说明/品名可否承运)、oms.track_query(查轨迹)。" + "\n【强制规则】" + "\n1) 查价:必填仅「目的国家 + 实重kg」。邮编/尺寸/货物属性/运输方式为选填。" + "不要索要起运地、发货城市、仓库(用户提到深圳等可忽略或作备注)。" + "国家可用中文,调工具时转二字码(美国→US、英国→GB)。" + "用户已给目的国+重量时,必须立刻调用 oms.price_check,用一两句中文总结结果。" + "\n2) 对客说明/可承运/走什么渠道:用户问某品名能不能寄、能不能走、走什么渠道时,必须立刻调用 oms.channel_guide。" + "参数示例:{\"productCode\":\"\",\"productName\":\"\",\"cargoName\":\"lipstick\"}。" + "cargoName 必填(从用户话里提取品名);productCode/productName 仅在客户已点名渠道时填写,否则留空由工具按品名检索。" + "有结果后用一两句中文说明可走/不可走的渠道;未命中时如实说,不要编造。" + "\n3) 查轨迹:消息里已有运单号/跟踪号/客户单号(如 IDE 开头)时,立刻调用 oms.track_query,禁止再向用户要单号。" + "\n4) 禁止空回复;信息不足时用一两句中文追问缺项;不要复述 JSON 或大段工具原文。" + "\n【省 token】回复尽量短;一次只调必需工具。" } }; 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(); 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) { var text = result.Content?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(text)) { text = "我这边没有生成有效回复。请换个说法再试,或直接发:目的国+重量查价,或运单号查轨迹。"; } return new AiAgentChatOutput { Content = text, 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 TrimHistory(List 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> ResolveEnabledToolsAsync(List? toolKeys) { var all = (await ListAsync()).Items; var enabled = all.Where(x => x.Enabled).ToList(); if (toolKeys is { Count: > 0 }) { var set = new HashSet(toolKeys, StringComparer.OrdinalIgnoreCase); enabled = enabled.Where(x => set.Contains(x.ToolKey)).ToList(); } return enabled; } private async Task 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; } }