后端: - 新增 ai_intent_router 意图路由 - 新增 AiEntryDraft 草稿存储 + EF 迁移 - 新增 Prompts 模块化(global/modules/rag/router) - AI Agent handlers 扩展 前端: - 新增 AI 同意门 (ai_consent_gate/provider/details_page) - HealthMetricVisuals 视觉统一 - 设备扫描/管理页面优化 - agent 插图替换 + 趋势指标图标 配置: - DeepSeek 模型改 deepseek-v4-flash - .gitignore 加 artifacts/audit - build.gradle.kts 签名配置调整
184 lines
6.9 KiB
C#
184 lines
6.9 KiB
C#
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-v4-flash";
|
||
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,
|
||
Thinking = new { type = "disabled" },
|
||
};
|
||
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,
|
||
string? toolChoice = null,
|
||
CancellationToken ct = default)
|
||
{
|
||
var request = new ChatCompletionRequest
|
||
{
|
||
Model = _model,
|
||
Messages = messages,
|
||
Stream = false,
|
||
MaxTokens = maxTokens,
|
||
Temperature = temperature,
|
||
Tools = tools,
|
||
Thinking = new { type = "disabled" },
|
||
};
|
||
if (tools?.Count > 0) request.ToolChoice = 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] + "...";
|
||
}
|