Initial commit: 健康管家 AI 健康陪伴助手

- Backend: .NET 10 Minimal API + EF Core + PostgreSQL
- Frontend: Flutter + Riverpod + GoRouter + Dio
- AI: DeepSeek LLM + Qwen VLM (OpenAI-compatible)
- Auth: SMS + JWT (access/refresh tokens)
- Features: AI chat, health tracking, medication management, diet analysis, exercise plans, doctor consultations, report analysis
This commit is contained in:
MingNian
2026-06-02 11:11:29 +08:00
commit 14d7c30d3d
144 changed files with 11436 additions and 0 deletions

View File

@@ -0,0 +1,152 @@
using Microsoft.Extensions.Configuration;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace Health.Infrastructure.AI;
/// <summary>
/// DeepSeek LLM 客户端(对话 + Tool Calling
/// </summary>
public sealed class DeepSeekClient
{
private readonly HttpClient _http;
private readonly string _model;
private readonly JsonSerializerOptions _jsonOptions;
public DeepSeekClient(HttpClient http, IConfiguration config)
{
_http = http;
_model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat";
_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);
response.EnsureSuccessStatusCode();
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");
var response = await _http.PostAsync("chat/completions", content, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
}
}
/// <summary>
/// 千问 VL 视觉客户端(食物识别 + 报告解读)
/// </summary>
public sealed class QwenVisionClient
{
private readonly HttpClient _http;
private readonly string _model;
private readonly JsonSerializerOptions _jsonOptions;
public QwenVisionClient(HttpClient http, IConfiguration config)
{
_http = http;
_model = config["QWEN_VISION_MODEL"] ?? "qwen-vl-max";
_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 messages = new List<ChatMessage>();
if (!string.IsNullOrEmpty(systemPrompt))
messages.Add(new ChatMessage { Role = "system", Content = systemPrompt });
var contentParts = new List<object>();
foreach (var url in imageUrls)
contentParts.Add(new { type = "image_url", image_url = new { url } });
if (!string.IsNullOrEmpty(userText))
contentParts.Add(new { type = "text", text = userText });
var userMessage = new ChatMessage
{
Role = "user",
Content = JsonSerializer.Serialize(contentParts, _jsonOptions)
};
messages.Add(userMessage);
var request = new ChatCompletionRequest
{
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
};
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.PostAsync("chat/completions", content, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
}
}

View File

@@ -0,0 +1,235 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace Health.Infrastructure.AI;
/// <summary>
/// OpenAI 兼容协议 HTTP 客户端,统一调用 DeepSeek / 千问 VL
/// </summary>
public sealed class OpenAiCompatibleClient
{
private readonly HttpClient _http;
private readonly string _model;
private readonly JsonSerializerOptions _jsonOptions;
public OpenAiCompatibleClient(string baseUrl, string apiKey, string model)
{
_http = new HttpClient
{
BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(60)
};
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_model = model;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
}
/// <summary>
/// 流式 Chat CompletionsSSE
/// </summary>
public async IAsyncEnumerable<string> ChatStreamAsync(
List<ChatMessage> messages,
List<ToolDefinition>? tools = null,
int maxTokens = 2048,
float temperature = 0.7f)
{
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"));
var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != 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
/// </summary>
public async Task<ChatCompletionResponse> ChatAsync(
List<ChatMessage> messages,
List<ToolDefinition>? tools = null,
int maxTokens = 2048,
float temperature = 0.7f)
{
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");
var response = await _http.PostAsync("chat/completions", content);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
}
/// <summary>
/// Vision 图片理解(非流式)
/// </summary>
public async Task<ChatCompletionResponse> VisionAsync(
string systemPrompt,
List<string> imageUrls,
string? userText = null,
int maxTokens = 2048)
{
var messages = new List<ChatMessage>();
if (!string.IsNullOrEmpty(systemPrompt))
messages.Add(new ChatMessage { Role = "system", Content = systemPrompt });
// 构建多模态消息内容
var contentParts = new List<object>();
foreach (var url in imageUrls)
contentParts.Add(new { type = "image_url", image_url = new { url } });
if (!string.IsNullOrEmpty(userText))
contentParts.Add(new { type = "text", text = userText });
var userMessage = new ChatMessage
{
Role = "user",
Content = JsonSerializer.Serialize(contentParts, _jsonOptions)
};
messages.Add(userMessage);
var request = new ChatCompletionRequest
{
Model = _model,
Messages = messages,
MaxTokens = maxTokens,
Stream = false,
};
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _http.PostAsync("chat/completions", content);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
}
}
#region /
public sealed class ChatCompletionRequest
{
public string Model { get; set; } = string.Empty;
public List<ChatMessage> Messages { get; set; } = [];
public bool Stream { get; set; }
public int MaxTokens { get; set; } = 2048;
public float Temperature { get; set; } = 0.7f;
public List<ToolDefinition>? Tools { get; set; }
public string? ToolChoice { get; set; }
}
public sealed class ChatMessage
{
public string Role { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public string? ToolCallId { get; set; }
public List<ToolCall>? ToolCalls { get; set; }
}
public sealed class ToolDefinition
{
public string Type { get; set; } = "function";
public ToolFunction Function { get; set; } = new();
}
public sealed class ToolFunction
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public object Parameters { get; set; } = new();
}
public sealed class ToolCall
{
public string Id { get; set; } = string.Empty;
public string Type { get; set; } = "function";
public ToolCallFunction Function { get; set; } = new();
}
public sealed class ToolCallFunction
{
public string Name { get; set; } = string.Empty;
public string Arguments { get; set; } = string.Empty;
}
public sealed class ChatCompletionResponse
{
public string Id { get; set; } = string.Empty;
public List<Choice> Choices { get; set; } = [];
}
public sealed class Choice
{
public int Index { get; set; }
public ResponseMessage? Message { get; set; }
public ResponseDelta? Delta { get; set; }
public string? FinishReason { get; set; }
}
public sealed class ResponseMessage
{
public string Role { get; set; } = string.Empty;
public string? Content { get; set; }
public List<ToolCall>? ToolCalls { get; set; }
}
public sealed class ResponseDelta
{
public string? Content { get; set; }
public string? Role { get; set; }
}
#endregion

View File

@@ -0,0 +1,116 @@
using Health.Domain.Enums;
namespace Health.Infrastructure.AI;
/// <summary>
/// System Prompt 模板管理
/// </summary>
public sealed class PromptManager
{
/// <summary>
/// 获取指定 Agent 的 System Prompt
/// </summary>
public string GetSystemPrompt(AgentType agentType) => agentType switch
{
AgentType.Default => DefaultPrompt,
AgentType.Consultation => ConsultationPrompt,
AgentType.Health => HealthDataPrompt,
AgentType.Diet => DietPrompt,
AgentType.Medication => MedicationPrompt,
AgentType.Report => ReportPrompt,
AgentType.Exercise => ExercisePrompt,
_ => DefaultPrompt
};
private const string DefaultPrompt = """
AI "阿福"
怀
1.
2.
3.
4.
-
-
- /
""";
private const string ConsultationPrompt = """
1.
2. 2-3
3.
4.
5. >160/100>120<50
6. "以上为AI分析具体请咨询医生"
7.
""";
private const string HealthDataPrompt = """
1. ////
2. +
3. "120""收缩压还是血糖?"
4.
5.
6.
- 90-139 mmHg 60-89 mmHg
- 60-100 /
- 3.9-6.1 mmol/L
- 95-100%
""";
private const string DietPrompt = """
1. VLM食物识别结果后
2.
3. "能不能吃"///
4. 1-5
5. +
6. ///
""";
private const string MedicationPrompt = """
1. ///
2. "早饭后"
3.
4.
5.
""";
private const string ReportPrompt = """
1.
2. //
3.
4. "AI预解读待医生确认"
5. /CT"需医生人工审阅"
""";
private const string ExercisePrompt = """
1. //
2.
3.
4.
5.
""";
}