Files
AI-Health/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs
MingNian 0e0e8ce72b feat: 问诊对话+建档引导+报告+日历+趋势页重写+视觉统一
- 问诊对话页: 新建consultation_provider, 完整AI分身聊天UI
- 首次建档引导: OnboardingPrompt+ManageArchiveTool+前端卡片
- 报告模块: POST端点 + report_agent_handler接VLM
- 健康日历: GET /api/calendar端点 + 前端接API
- 趋势页重写: 删除Mock数据 + 真实API + 手动录入
- 饮食保存: submit按钮接后端API
- 侧边栏: 健康概览横排 + 统一紫色配色
- 运动计划: 修复JSON反序列化(record→手动解析)
- VLM: prompt优化 + 模型切qwen3-vl-plus + 1280px压缩
- UI颜色: 加深主色 8B9CF7→6C5CE7
- 个人信息页精简 + 健康档案可编辑重设计
- 保洁: 删除无用agent_bar + 删除意见反馈/关于我们
- 新增AI提示词文档
2026-06-04 13:49:43 +08:00

452 lines
20 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.Drawing;
using System.Drawing.Imaging;
using Health.Infrastructure.AI;
using Health.Infrastructure.AI.AgentHandlers;
namespace Health.WebApi.Endpoints;
/// <summary>
/// AI 对话 SSE 端点——支持 7 个 Agent
/// </summary>
public static class AiChatEndpoints
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
public static void MapAiChatEndpoints(this WebApplication app)
{
// SSE 流式对话GET 方式token 通过 query string 传递)
app.MapGet("/api/ai/{agentType}/chat", async (
string message,
string? conversationId,
string token,
string agentType,
HttpContext http,
AppDbContext db,
DeepSeekClient llmClient,
PromptManager promptManager,
VisionClient visionClient,
CancellationToken ct) =>
{
// 支持 token 通过 query string浏览器 EventSource或 header 传递
var userId = GetUserId(http) ?? GetUserIdFromToken(token);
if (userId == null)
{
http.Response.StatusCode = 401;
http.Response.ContentType = "application/json";
await http.Response.WriteAsync(JsonSerializer.Serialize(new { code = 40002, data = (object?)null, message = "未登录" }), ct);
return;
}
if (!Enum.TryParse<AgentType>(agentType, ignoreCase: true, out var parsedType))
parsedType = AgentType.Default;
// SSE 响应头
http.Response.ContentType = "text/event-stream";
http.Response.Headers.CacheControl = "no-cache";
http.Response.Headers.Connection = "keep-alive";
http.Response.Headers["X-Accel-Buffering"] = "no";
// 创建或获取对话
Conversation? conversation = null;
if (!string.IsNullOrEmpty(conversationId) && Guid.TryParse(conversationId, out var convId))
conversation = await db.Conversations.FindAsync([convId], ct);
if (conversation == null)
{
conversation = new Conversation
{
Id = Guid.NewGuid(), UserId = userId.Value, AgentType = parsedType,
Title = message.Length > 30 ? message[..30] : message,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
};
db.Conversations.Add(conversation);
await db.SaveChangesAsync(ct);
await SseWriteAsync(http, new { action = "conversation_id", data = conversation.Id.ToString() }, ct);
}
// 保存用户消息
var userMsg = new ConversationMessage
{
Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.User,
Content = message, CreatedAt = DateTime.UtcNow,
};
db.ConversationMessages.Add(userMsg);
conversation.MessageCount++;
conversation.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
// 加载上下文
var systemPrompt = promptManager.GetSystemPrompt(parsedType);
var patientContext = await BuildPatientContext(db, userId.Value, ct);
var messages = new List<ChatMessage>
{
new() { Role = "system", Content = systemPrompt + "\n\n当前患者信息\n" + patientContext },
};
// 加载历史对话(最近 10 条)
var history = await db.ConversationMessages
.Where(m => m.ConversationId == conversation.Id)
.OrderByDescending(m => m.CreatedAt)
.Take(12)
.ToListAsync(ct);
foreach (var h in history.Reverse<ConversationMessage>())
{
messages.Add(new ChatMessage
{
Role = h.Role == MessageRole.User ? "user" : "assistant",
Content = h.Content,
});
}
// Tool Calling 循环
var tools = GetToolsForAgent(parsedType);
var maxIterations = 5;
var fullResponse = "";
var completedNormally = false;
var messageType = "text";
var metadata = new Dictionary<string, object>();
for (int i = 0; i < maxIterations; i++)
{
await SseWriteAsync(http, new { action = "notice", message = i == 0 ? "正在分析..." : "正在处理..." }, ct);
var response = await llmClient.ChatAsync(messages, tools: tools.Count > 0 ? tools : null, ct: ct);
var choice = response.Choices?.FirstOrDefault();
if (choice == null) break;
if (choice.FinishReason == "stop")
{
await foreach (var chunk in llmClient.ChatStreamAsync(messages, tools: null, ct: ct))
{
try
{
var delta = JsonSerializer.Deserialize<ChatCompletionResponse>(chunk, JsonOpts);
var content = delta?.Choices?.FirstOrDefault()?.Delta?.Content;
if (!string.IsNullOrEmpty(content))
{
fullResponse += content;
await SseWriteAsync(http, new { action = "answer", data = content, type = messageType }, ct);
}
}
catch (JsonException) { /* 跳过解析失败的 chunk */ }
}
completedNormally = true;
break;
}
else if (choice.FinishReason == "tool_calls" && choice.Message?.ToolCalls != null)
{
messages.Add(new ChatMessage
{
Role = "assistant",
Content = choice.Message.Content ?? "",
ToolCalls = choice.Message.ToolCalls,
});
foreach (var tc in choice.Message.ToolCalls)
{
object toolResult;
try
{
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value, visionClient);
}
catch (Exception ex)
{
toolResult = new { success = false, message = $"工具执行异常: {ex.Message}" };
}
await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct);
_UpdateMessageTypeAndMetadata(tc.Function.Name, toolResult, ref messageType, ref metadata);
messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id });
}
}
else break;
}
// 保存 AI 回复
if (!string.IsNullOrEmpty(fullResponse))
{
db.ConversationMessages.Add(new ConversationMessage
{
Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.Assistant,
Content = fullResponse, CreatedAt = DateTime.UtcNow,
});
conversation.MessageCount++;
conversation.Summary = fullResponse.Length > 100 ? fullResponse[..100] : fullResponse;
conversation.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
});
// 获取对话列表
app.MapGet("/api/ai/conversations", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
var conversations = await db.Conversations
.Where(c => c.UserId == userId.Value)
.OrderByDescending(c => c.UpdatedAt)
.Select(c => new { c.Id, AgentType = c.AgentType.ToString(), c.Title, c.Summary, c.MessageCount, c.CreatedAt, c.UpdatedAt })
.ToListAsync(ct);
return Results.Ok(new { code = 0, data = conversations, message = (string?)null });
});
// 获取对话历史
app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
var messages = await db.ConversationMessages
.Where(m => m.ConversationId == id && m.Conversation.UserId == userId.Value)
.OrderBy(m => m.CreatedAt)
.Select(m => new { m.Id, Role = m.Role.ToString(), m.Content, m.Intent, m.MetadataJson, m.CreatedAt })
.ToListAsync(ct);
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
});
// 删除对话
app.MapDelete("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
var conv = await db.Conversations.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId.Value, ct);
if (conv != null)
{
db.Conversations.Remove(conv);
await db.SaveChangesAsync(ct);
}
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
// VLM 食物识别
app.MapPost("/api/ai/analyze-food-image", async (
HttpRequest httpRequest, HttpContext http,
VisionClient visionClient, AppDbContext db,
CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
var form = await httpRequest.ReadFormAsync(ct);
var files = form.Files.GetFiles("images");
if (files == null || files.Count == 0)
return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" });
var imageUrls = new List<string>();
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
Directory.CreateDirectory(uploadsDir);
foreach (var file in files)
{
if (file.Length > 20 * 1024 * 1024)
return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小超过 20MB 限制" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not ".jpg" and not ".jpeg" and not ".png" and not ".heic")
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的图片格式,仅支持 JPG/PNG/HEIC" });
var safeName = $"{Guid.NewGuid()}_{Path.GetFileName(file.FileName)}";
var filePath = Path.Combine(uploadsDir, safeName);
using (var stream = new FileStream(filePath, FileMode.Create))
await file.CopyToAsync(stream, ct);
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 85L);
var compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct);
var base64 = Convert.ToBase64String(compressedBytes);
imageUrls.Add($"data:image/jpeg;base64,{base64}");
}
var prompt = """
Describe everything you see in this image in detail. What objects are present? What are their colors, shapes, labels?
If there are any food items, drinks, or beverages, identify them specifically by name.
Then, for each food/drink item, estimate the portion size and calories (kcal).
Return ONLY a JSON array: [{"name":"item name","portion":"estimated portion","calories":estimated_calories}]
If you cannot identify something, say so in the name field.
""";
try
{
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", maxTokens: 8192, ct: ct);
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
return Results.Ok(new { code = 0, data = result, message = (string?)null });
}
catch (Exception ex)
{
return Results.Ok(new { code = 50001, data = (object?)null, message = $"食物识别失败:{ex.Message}" });
}
});
}
// ── SSE / 认证辅助 ──
private static async Task SseWriteAsync(HttpContext http, object data, CancellationToken ct)
{
var json = JsonSerializer.Serialize(data, JsonOpts);
await http.Response.WriteAsync($"data: {json}\n\n", ct);
await http.Response.Body.FlushAsync(ct);
}
private static Guid? GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : null;
private static Guid? GetUserIdFromToken(string? token)
{
if (string.IsNullOrEmpty(token)) return null;
try
{
var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
var sub = jwt.Claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return sub != null && Guid.TryParse(sub, out var id) ? id : null;
}
catch (Exception) { return null; }
}
// ── Agent / Tool 调度 ──
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch
{
AgentType.Health => HealthDataAgentHandler.Tools,
AgentType.Medication => MedicationAgentHandler.Tools,
AgentType.Diet => DietAgentHandler.Tools,
AgentType.Consultation => ConsultationAgentHandler.Tools,
AgentType.Report => ReportAgentHandler.Tools,
AgentType.Exercise => ExerciseAgentHandler.Tools,
AgentType.Onboarding => [CommonAgentHandler.ManageArchiveTool, CommonAgentHandler.CheckArchiveTool],
_ => CommonAgentHandler.Tools,
};
private static Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId, VisionClient? visionClient = null)
{
using var jsonDoc = JsonDocument.Parse(arguments);
var root = jsonDoc.RootElement;
return toolName switch
{
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, db, userId),
"query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId),
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
"estimate_food_text" => DietAgentHandler.Execute(toolName, root, db, userId),
"analyze_report" => visionClient != null
? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient)
: Task.FromResult<object>(new { success = false, message = "VLM服务未配置" }),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId),
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root),
"request_doctor" => ConsultationAgentHandler.Execute(toolName, root, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
// ── 患者上下文构建 ──
private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
{
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
var recentRecords = await db.HealthRecords.Where(r => r.UserId == userId)
.OrderByDescending(r => r.RecordedAt).Take(10).ToListAsync(ct);
var sb = new System.Text.StringBuilder();
if (archive != null)
{
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
}
if (recentRecords.Count > 0)
{
sb.AppendLine("近期健康数据:");
foreach (var r in recentRecords)
sb.AppendLine($" {r.MetricType}: {RecordValue(r)} ({r.RecordedAt:MM-dd HH:mm})");
}
return sb.ToString();
}
private static string RecordValue(HealthRecord r) => r.MetricType switch
{
HealthMetricType.BloodPressure => $"{r.Systolic}/{r.Diastolic}",
HealthMetricType.HeartRate => $"{r.Value}次/分",
HealthMetricType.Glucose => $"{r.Value}",
HealthMetricType.SpO2 => $"{r.Value}%",
HealthMetricType.Weight => $"{r.Value}kg",
_ => "—"
};
// ── 消息类型判断 ──
private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata)
{
switch (toolName)
{
case "record_health_data":
messageType = "data_confirm";
if (toolResult is IDictionary<string, object> resultDict)
{
if (resultDict.TryGetValue("type", out var type))
metadata["type"] = type.ToString();
if (resultDict.TryGetValue("success", out var success) && success is bool b && b)
metadata["success"] = true;
}
break;
case "manage_medication":
messageType = "medication_confirm";
if (toolResult is IDictionary<string, object> medDict)
{
if (medDict.TryGetValue("name", out var name))
metadata["name"] = name.ToString();
if (medDict.TryGetValue("dosage", out var dosage))
metadata["dosage"] = dosage.ToString();
}
break;
case "estimate_food_text":
messageType = "diet_analysis";
break;
case "analyze_report":
messageType = "report_analysis";
break;
}
}
// ── 图片处理 ──
private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality)
{
using var image = Image.FromFile(inputPath);
var width = image.Width;
var height = image.Height;
if (width > maxWidth)
{
height = (int)((double)height / width * maxWidth);
width = maxWidth;
}
using var bitmap = new Bitmap(width, height);
using var graphics = Graphics.FromImage(bitmap);
graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
graphics.DrawImage(image, 0, 0, width, height);
var jpegCodec = ImageCodecInfo.GetImageEncoders().First(c => c.MimeType == "image/jpeg");
var parameters = new EncoderParameters(1);
parameters.Param[0] = new EncoderParameter(Encoder.Quality, quality);
bitmap.Save(outputPath, jpegCodec, parameters);
}
}
public sealed record ChatRequest(string Message, string? ConversationId);