using System.Net.Http.Headers; 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? 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 ToolCalls { get; set; } = []; } /// /// OpenAI 兼容 Chat Completions 客户端(智谱 / DeepSeek / 豆包等)。 /// public class XhsLlmClient { public const string HttpClientName = nameof(XhsLlmClient); private readonly IHttpClientFactory _httpClientFactory; public XhsLlmClient(IHttpClientFactory httpClientFactory) { _httpClientFactory = httpClientFactory; } public async Task ChatCompletionAsync( XhsAiProvider provider, 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 ChatCompletionDetailedAsync( XhsAiProvider provider, IReadOnlyList messages, IReadOnlyList? tools = null, string? modelOverride = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(provider.ApiKeyEnc)) { throw new BusinessException($"请先配置 {provider.DisplayName} 的 API Key"); } var model = modelOverride ?? provider.ApiSecretEnc; if (string.IsNullOrWhiteSpace(model)) { throw new BusinessException($"请先配置 {provider.DisplayName} 的模型名(ApiSecret 字段)或在请求中传入 model"); } var url = BuildChatUrl(provider.BaseUrl); 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 = 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); if (!response.IsSuccessStatusCode) { throw new BusinessException($"AI 请求失败 ({(int)response.StatusCode}): {TrimError(body)}"); } using var doc = JsonDocument.Parse(body); var root = doc.RootElement; if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0 && choices[0].TryGetProperty("message", out var message)) { 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) && error.TryGetProperty("message", out var errorMessage)) { throw new BusinessException(errorMessage.GetString() ?? "AI 返回错误"); } throw new BusinessException("AI 响应格式无法解析"); } private static JsonObject BuildPayload( string model, IReadOnlyList messages, IReadOnlyList? 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('/'); if (string.IsNullOrEmpty(url)) { throw new BusinessException("AI 提供商 BaseUrl 未配置"); } if (url.EndsWith("/chat/completions", StringComparison.OrdinalIgnoreCase)) { return url; } if (url.EndsWith("/v1", StringComparison.OrdinalIgnoreCase) || url.EndsWith("/v4", StringComparison.OrdinalIgnoreCase)) { return url + "/chat/completions"; } return url + "/v1/chat/completions"; } private static string TrimError(string body) { if (string.IsNullOrWhiteSpace(body)) { return "空响应"; } return body.Length <= 300 ? body : body[..300]; } } public static class XhsLlmServiceExtensions { public static IServiceCollection AddXhsLlmClient(this IServiceCollection services) { services.AddHttpClient(XhsLlmClient.HttpClientName, client => { client.Timeout = TimeSpan.FromMinutes(3); }); services.AddHttpClient(McpToolExecutor.TrackHttpClientName, client => { client.Timeout = TimeSpan.FromSeconds(45); }); services.AddTransient(); return services; } }