admin/src/Oms.Application/Shared/AiLlmClient.cs
zzh 592054f062 feat: extract global AI service and persist sales-product publish AI reports
Move provider/chat APIs from /xhs/ai to /ai, rename t_ai_provider, and require adopted AI analysis on price publish.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 15:56:40 +08:00

278 lines
9.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.AiAggregate.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>
public class AiLlmClient
{
public const string HttpClientName = nameof(AiLlmClient);
private readonly IHttpClientFactory _httpClientFactory;
public AiLlmClient(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<string> ChatCompletionAsync(
AiProvider 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<LlmChatResult> ChatCompletionDetailedAsync(
AiProvider provider,
IReadOnlyList<LlmChatMessage> messages,
IReadOnlyList<LlmToolDefinition>? 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<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('/');
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 AiLlmServiceExtensions
{
public static IServiceCollection AddAiLlmClient(this IServiceCollection services)
{
services.AddHttpClient(AiLlmClient.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromMinutes(3);
});
services.AddHttpClient(McpToolExecutor.TrackHttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(45);
});
services.AddTransient<AiLlmClient>();
return services;
}
}