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>
278 lines
9.3 KiB
C#
278 lines
9.3 KiB
C#
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;
|
||
}
|
||
}
|
||
|