using Microsoft.Extensions.Configuration; using System.Net.Http.Headers; namespace Health.Infrastructure.AI; /// /// DeepSeek LLM 客户端(对话 + Tool Calling) /// public sealed class DeepSeekClient(HttpClient http, IConfiguration config) { private readonly HttpClient _http = http; private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat"; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true }; /// /// 流式 Chat Completions /// public async IAsyncEnumerable ChatStreamAsync( List messages, List? tools = null, int maxTokens = 2048, float temperature = 0.7f, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) { var request = new ChatCompletionRequest { Model = _model, Messages = messages, Stream = true, MaxTokens = maxTokens, Temperature = temperature, Tools = tools, }; if (tools?.Count > 0) request.ToolChoice = "auto"; var json = JsonSerializer.Serialize(request, _jsonOptions); var content = new StringContent(json, Encoding.UTF8, "application/json"); var httpRequest = new HttpRequestMessage(HttpMethod.Post, "chat/completions") { Content = content }; httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); using var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct); await AiHttpResponseGuard.ThrowIfFailedAsync(response, "DeepSeek", ct); using var stream = await response.Content.ReadAsStreamAsync(ct); using var reader = new StreamReader(stream); string? line; while ((line = await reader.ReadLineAsync(ct)) != null) { if (string.IsNullOrWhiteSpace(line)) continue; if (!line.StartsWith("data: ")) continue; var data = line["data: ".Length..]; if (data == "[DONE]") break; yield return data; } } /// /// 非流式 Chat Completions(用于 Tool Calling) /// public async Task ChatAsync( List messages, List? tools = null, int maxTokens = 2048, float temperature = 0.7f, CancellationToken ct = default) { var request = new ChatCompletionRequest { Model = _model, Messages = messages, Stream = false, MaxTokens = maxTokens, Temperature = temperature, Tools = tools, }; if (tools?.Count > 0) request.ToolChoice = "auto"; var json = JsonSerializer.Serialize(request, _jsonOptions); var content = new StringContent(json, Encoding.UTF8, "application/json"); using var response = await _http.PostAsync("chat/completions", content, ct); var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "DeepSeek", ct); return AiHttpResponseGuard.DeserializeRequired(body, _jsonOptions, "DeepSeek"); } } /// /// VLM 视觉客户端——支持千问/豆包,通过 .env 切换 /// public sealed class VisionClient(HttpClient http, IConfiguration config) { private readonly HttpClient _http = http; private readonly string _model = config["VLM_MODEL"] ?? "doubao-vision-pro"; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true }; public async Task VisionAsync( string systemPrompt, List imageUrls, string? userText = null, int maxTokens = 2048, CancellationToken ct = default) { var contentParts = new List(); foreach (var url in imageUrls) contentParts.Add(new { type = "image_url", image_url = new { url } }); if (!string.IsNullOrEmpty(systemPrompt)) contentParts.Add(new { type = "text", text = systemPrompt }); if (!string.IsNullOrEmpty(userText)) contentParts.Add(new { type = "text", text = userText }); var messages = new List { new() { Role = "user", Content = contentParts } }; var request = new ChatCompletionRequest { Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false, Temperature = 0.1f, VlHighResolutionImages = true, EnableThinking = false, }; var json = JsonSerializer.Serialize(request, _jsonOptions); var content = new StringContent(json, Encoding.UTF8, "application/json"); using var response = await _http.PostAsync("chat/completions", content, ct); var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "VLM", ct); return AiHttpResponseGuard.DeserializeRequired(body, _jsonOptions, "VLM"); } } internal static class AiHttpResponseGuard { private const int MaxErrorBodyLength = 2000; public static async Task ThrowIfFailedAsync(HttpResponseMessage response, string provider, CancellationToken ct) { if (response.IsSuccessStatusCode) return; var body = await response.Content.ReadAsStringAsync(ct); throw new HttpRequestException($"{provider} API {(int)response.StatusCode} ({response.StatusCode}): {Truncate(body)}"); } public static async Task ReadSuccessBodyAsync(HttpResponseMessage response, string provider, CancellationToken ct) { await ThrowIfFailedAsync(response, provider, ct); var body = await response.Content.ReadAsStringAsync(ct); if (string.IsNullOrWhiteSpace(body)) throw new InvalidDataException($"{provider} API返回了空响应"); return body; } public static T DeserializeRequired(string body, JsonSerializerOptions options, string provider) { try { return JsonSerializer.Deserialize(body, options) ?? throw new InvalidDataException($"{provider} API响应内容为空"); } catch (JsonException ex) { throw new InvalidDataException($"{provider} API响应格式无效", ex); } } private static string Truncate(string value) => value.Length <= MaxErrorBodyLength ? value : value[..MaxErrorBodyLength] + "..."; }