admin/src/Oms.Application/Shared/XhsLlmClient.cs
zzh 7c410b1625 feat: 新增轻量 MCP 工具与 AI Agent,并限制 token 滥用
提供查价格/对客说明/查轨迹工具与管理接口;扩展 LLM function calling;按用户 Redis 限流并压缩上下文与工具结果。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 12:03:45 +08:00

277 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.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>
public class XhsLlmClient
{
public const string HttpClientName = nameof(XhsLlmClient);
private readonly IHttpClientFactory _httpClientFactory;
public XhsLlmClient(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<string> 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<LlmChatResult> ChatCompletionDetailedAsync(
XhsAiProvider 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 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<XhsLlmClient>();
return services;
}
}