Files
AI-Health/backend/src/Health.Infrastructure/AI/ai_clients.cs
MingNian 73d99d56f6 fix: 趋势页恢复体重 + 图标换Material Icons + VLM禁用思考模式
- 健康概览页恢复体重指标的标签/单位/加载/筛选/录入
- 历史记录图标从emoji改为Material Icons
- VLM请求显式设置enable_thinking=false,禁用混合思考加速识图
2026-06-22 16:45:51 +08:00

169 lines
6.6 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 Microsoft.Extensions.Configuration;
using System.Net.Http.Headers;
namespace Health.Infrastructure.AI;
/// <summary>
/// DeepSeek LLM 客户端(对话 + Tool Calling
/// </summary>
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
};
/// <summary>
/// 流式 Chat Completions
/// </summary>
public async IAsyncEnumerable<string> ChatStreamAsync(
List<ChatMessage> messages,
List<ToolDefinition>? 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;
}
}
/// <summary>
/// 非流式 Chat Completions用于 Tool Calling
/// </summary>
public async Task<ChatCompletionResponse> ChatAsync(
List<ChatMessage> messages,
List<ToolDefinition>? 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<ChatCompletionResponse>(body, _jsonOptions, "DeepSeek");
}
}
/// <summary>
/// VLM 视觉客户端——支持千问/豆包,通过 .env 切换
/// </summary>
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<ChatCompletionResponse> VisionAsync(
string systemPrompt,
List<string> imageUrls,
string? userText = null,
int maxTokens = 2048,
CancellationToken ct = default)
{
var contentParts = new List<object>();
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<ChatMessage>
{
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<ChatCompletionResponse>(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<string> 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<T>(string body, JsonSerializerOptions options, string provider)
{
try
{
return JsonSerializer.Deserialize<T>(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] + "...";
}