using System.Text.Json;
using System.Text.RegularExpressions;
using Health.Application.AI;
using Health.Application.Diets;
using Health.Application.Exercises;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
using Health.Infrastructure.AI;
using Health.Infrastructure.AI.AgentHandlers;
namespace Health.WebApi.Endpoints;
///
/// AI 对话 SSE 端点——支持 7 个 Agent
///
public static class AiChatEndpoints
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
public static void MapAiChatEndpoints(this WebApplication app)
{
// SSE 流式对话。认证统一走 ASP.NET Core JWT 中间件。
app.MapGet("/api/ai/{agentType}/chat", async (
string message,
string? conversationId,
string? imageUrl,
string? pdfUrl,
string agentType,
HttpContext http,
DeepSeekClient llmClient,
PromptManager promptManager,
FastGptKnowledgeClient knowledgeClient,
IAiToolExecutionService toolExecution,
IAiWriteConfirmationStore confirmations,
IAiConversationService conversations,
IAttachmentContextBuilder attachments,
IPatientContextService patientContexts,
CancellationToken ct) =>
{
var userId = GetUserId(http);
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, 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";
// 创建或获取对话。传入 conversationId 时必须校验归属,避免多账号切换或缓存异常导致串号。
Guid? requestedConversationId = null;
if (!string.IsNullOrWhiteSpace(conversationId))
{
if (!Guid.TryParse(conversationId, out var convId))
{
await SseWriteAsync(http, new { action = "answer", data = "当前会话参数异常,请重新开始一次对话。" }, ct);
await SseWriteAsync(http, new { action = "status", data = "error" }, ct);
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
return;
}
requestedConversationId = convId;
}
var opened = await conversations.OpenAsync(userId.Value, requestedConversationId, parsedType, message, ct);
if (!opened.Found)
{
await SseWriteAsync(http, new { action = "answer", data = "当前会话不存在或不属于当前账号,请重新开始一次对话。" }, ct);
await SseWriteAsync(http, new { action = "status", data = "error" }, ct);
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
return;
}
var activeConversationId = opened.ConversationId;
if (opened.Created)
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
// 附件解析(图片走 VLM、PDF 走 PdfPig),结果同时拼 LLM 上下文 + 持久化到 user message metadata
var attachment = await attachments.BuildAsync(userId.Value, imageUrl, pdfUrl, ct);
string? userMessageMetadataJson = null;
if (attachment != null)
{
userMessageMetadataJson = JsonSerializer.Serialize(new
{
kind = attachment.Kind,
category = attachment.Category,
fileName = attachment.FileName,
summary = attachment.Summary,
imageUrl,
pdfUrl,
}, JsonOpts);
await SseWriteAsync(http, new { action = "attachment", data = new { attachment.Kind, attachment.Category, attachment.Summary } }, ct);
}
await conversations.AddUserMessageAsync(activeConversationId, message, userMessageMetadataJson, ct);
var urgentWarning = DetectUrgentRisk(message);
if (!string.IsNullOrEmpty(urgentWarning))
{
var urgentResponse = $"""
{urgentWarning}
这种情况需要把安全放在第一位。请先停止自行判断和等待 AI 分析,尽快联系医生、互联网医院或前往急诊评估;如果症状正在加重,建议立即拨打当地急救电话。若身边有人,请让家人或同伴陪同,不要独自开车就医。
以上为 AI 安全提醒,不能替代医生诊断和治疗建议。
""";
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct);
await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct);
await SseWriteAsync(http, new { action = "status", data = "done" }, ct);
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
return;
}
// 加载上下文
var systemPrompt = promptManager.GetSystemPrompt(parsedType);
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
// ── RAG 知识库检索 ──
// 仅对需要医学知识的 Agent 启用检索;记数据/管用药/排运动这些纯工具调用类跳过。
// 失败/空结果不阻塞流程,仅作为 prompt 增强。
var knowledgeSnippet = "";
if (NeedsKnowledgeBase(parsedType) && knowledgeClient.IsEnabled)
{
knowledgeSnippet = await knowledgeClient.SearchAsync(message, ct);
}
var enhancedSystem = systemPrompt + "\n\n当前患者信息:\n" + patientContext;
if (!string.IsNullOrWhiteSpace(knowledgeSnippet))
{
enhancedSystem += "\n\n知识库参考资料(如与用户问题相关请优先采用):\n" + knowledgeSnippet;
}
var messages = new List
{
new() { Role = "system", Content = enhancedSystem },
};
// 加载历史对话(最近 12 条)
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
foreach (var h in history)
{
var role = h.Role == MessageRole.User.ToString() ? "user" : "assistant";
var content = h.Content;
// 历史 user message 如果带过附件,把附件摘要拼回 content,让 AI 能回忆起来
if (role == "user" && !string.IsNullOrWhiteSpace(h.MetadataJson))
{
try
{
using var meta = JsonDocument.Parse(h.MetadataJson);
if (meta.RootElement.TryGetProperty("summary", out var sumEl) &&
sumEl.ValueKind == JsonValueKind.String)
{
var sum = sumEl.GetString();
if (!string.IsNullOrWhiteSpace(sum) &&
// 跳过本轮:本轮 user message 会在下面单独拼 LlmContent,避免重复
h.Content != message)
{
content = $"[历史附件回忆:{sum}]\n{content}";
}
}
}
catch (JsonException) { /* 忽略损坏的 metadata */ }
}
messages.Add(new ChatMessage { Role = role, Content = content });
}
// 本轮如果带了附件,把完整识别结果拼到最后一条 user message 前
if (attachment != null && messages.Count > 0 && messages[^1].Role == "user")
{
messages[^1] = new ChatMessage
{
Role = "user",
Content = $"{attachment.LlmContent}\n{messages[^1].Content}",
};
}
// Tool Calling 循环
var tools = GetToolsForAgent(parsedType);
var maxIterations = 5;
var fullResponse = "";
var completedNormally = false;
var messageType = "text";
var metadata = new Dictionary();
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(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, metadata = metadata.Count > 0 ? metadata : null }, 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 = IsWriteToolCall(tc.Function.Name, tc.Function.Arguments)
? await PreparePendingWriteAsync(confirmations, userId.Value, tc.Function.Name, tc.Function.Arguments, ct)
: await toolExecution.ExecuteAsync(tc.Function.Name, tc.Function.Arguments, userId.Value, ct);
}
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))
{
string? assistantMetadataJson = null;
if (metadata.Count > 0 || messageType != "text")
{
metadata["messageType"] = messageType;
assistantMetadataJson = JsonSerializer.Serialize(metadata, JsonOpts);
}
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, assistantMetadataJson, ct);
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
}
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
}).RequireAuthorization();
app.MapPost("/api/ai/confirm-write/{commandId:guid}", async (
Guid commandId,
HttpContext http,
IAiToolExecutionService toolExecution,
CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null)
return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
var result = await toolExecution.ConfirmAsync(commandId, userId.Value, ct);
return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
}).RequireAuthorization();
// 获取对话列表
app.MapGet("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
var result = await conversations.ListAsync(userId.Value, ct);
return Results.Ok(new { code = 0, data = result, message = (string?)null });
});
// 获取对话历史
app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
var messages = await conversations.GetMessagesAsync(userId.Value, id, ct);
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
});
// 删除对话
app.MapDelete("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
var deleted = await conversations.DeleteAsync(userId.Value, id, ct);
return deleted
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
: Results.Json(
new { code = 40401, data = (object?)null, message = "对话不存在" },
statusCode: StatusCodes.Status404NotFound);
}).RequireAuthorization();
// 一键清空当前用户的全部对话
app.MapDelete("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
var count = await conversations.DeleteAllAsync(userId.Value, ct);
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
});
// 饮食分析页专用建议:不创建会话,不保存提示词或回复到历史记录。
app.MapPost("/api/ai/diet-commentary", async (
DietCommentaryRequest request,
HttpContext http,
DeepSeekClient llmClient,
IPatientContextService patientContexts,
CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null)
return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
var foods = request.Foods?
.Where(food => !string.IsNullOrWhiteSpace(food.Name) &&
!string.IsNullOrWhiteSpace(food.Portion) &&
food.Calories > 0)
.Take(20)
.Select(food => new DietCommentaryFood(food.Name, food.Portion, food.Calories))
.ToList() ?? [];
if (foods.Count == 0)
return Results.Ok(new { code = 40001, data = (object?)null, message = "请先完善食物信息" });
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
var foodDescription = DietCommentaryPolicy.BuildFoodDescription(foods);
var response = await llmClient.ChatAsync(
[
new ChatMessage
{
Role = "system",
Content = DietCommentaryPolicy.SystemPrompt + "\n\n当前用户健康档案:\n" + patientContext,
},
new ChatMessage { Role = "user", Content = "本餐食物:" + foodDescription },
],
maxTokens: 300,
temperature: 0.3f,
ct: ct);
var commentary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
if (string.IsNullOrWhiteSpace(commentary))
return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" });
return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null });
}).RequireAuthorization();
app.MapPost("/api/ai/analyze-food-image", async (
HttpRequest httpRequest,
HttpContext http,
IDietImageAnalysisCoordinator dietAnalysis,
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");
var uploads = files
.Select(file => new DietImageUploadFile(file.FileName, file.Length, file.OpenReadStream()))
.ToList();
try
{
var result = await dietAnalysis.AnalyzeAsync(uploads, ct);
return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
}
finally
{
foreach (var upload in uploads)
await upload.Content.DisposeAsync();
}
});
}
// ── 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 async Task TryUpdateConversationSummaryAsync(
IAiConversationService conversations,
DeepSeekClient llmClient,
Guid userId,
Guid conversationId,
CancellationToken ct)
{
try
{
var messages = await conversations.GetMessagesAsync(userId, conversationId, ct);
var transcript = BuildSummaryTranscript(messages);
if (string.IsNullOrWhiteSpace(transcript)) return;
var prompt = $"""
请把下面这次健康助手会话压缩成一个“对话记录标题”。
要求:
- 只输出标题,不要解释
- 8到18个汉字
- 优先保留用户真正要解决的问题、对象、最终动作或结论
- 像列表标题,不要像完整句子
- 不要写“本次会话、用户咨询、健康建议、进行分析、相关问题”
- 如果是上传图片/PDF,标题要体现附件内容和目的
示例:
- 早餐热量识别
- 血压偏高记录
- 服药时间调整
- 报告指标解读
- 跑步计划打卡
会话内容:
{transcript}
""";
var response = await llmClient.ChatAsync(
[
new ChatMessage { Role = "system", Content = "你是对话标题生成助手,只输出短标题。" },
new ChatMessage { Role = "user", Content = prompt },
],
maxTokens: 120,
temperature: 0.2f,
ct: ct);
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
if (string.IsNullOrWhiteSpace(summary)) return;
summary = Regex.Replace(summary, @"\s+", "");
summary = summary.Trim(' ', '。', '.', '"', '"', '\'', '“', '”', ':', ':');
summary = TrimSummaryPrefix(summary);
await conversations.UpdateSummaryAsync(conversationId, summary, ct);
}
catch
{
// 摘要只是历史列表体验增强,失败不能影响主对话。
}
}
private static string BuildSummaryTranscript(IReadOnlyList messages)
{
var sb = new System.Text.StringBuilder();
foreach (var message in messages.TakeLast(24))
{
var role = message.Role == MessageRole.User.ToString() ? "用户" : "AI";
var content = message.Content?.Trim() ?? "";
if (message.Role == MessageRole.User.ToString() &&
!string.IsNullOrWhiteSpace(message.MetadataJson))
{
try
{
using var meta = JsonDocument.Parse(message.MetadataJson);
if (meta.RootElement.TryGetProperty("summary", out var sumEl) &&
sumEl.ValueKind == JsonValueKind.String)
{
var attachmentSummary = sumEl.GetString();
if (!string.IsNullOrWhiteSpace(attachmentSummary))
content = $"[附件:{attachmentSummary}] {content}";
}
}
catch (JsonException) { }
}
if (string.IsNullOrWhiteSpace(content)) continue;
if (content.Length > 800) content = content[..800] + "...";
sb.Append(role).Append(":").AppendLine(content);
if (sb.Length > 6000) break;
}
return sb.ToString();
}
private static string TrimSummaryPrefix(string summary)
{
var prefixes = new[]
{
"本次会话",
"用户咨询",
"用户询问",
"健康建议",
"相关问题",
};
foreach (var prefix in prefixes)
{
if (summary.StartsWith(prefix, StringComparison.Ordinal))
return summary[prefix.Length..].Trim(' ', ':', ':', ',', ',');
}
return summary;
}
// ── Agent / Tool 调度 ──
///
/// 哪些 Agent 需要走 FastGPT 知识库 RAG。
/// 纯工具调用类(Health/Medication/Exercise)跳过 RAG,避免拖慢响应。
///
private static bool NeedsKnowledgeBase(AgentType agentType) => agentType switch
{
AgentType.Default => true,
AgentType.Consultation => true,
AgentType.Diet => true,
AgentType.Report => true,
AgentType.Unified => true,
_ => false,
};
private static List 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.Unified => [
HealthDataAgentHandler.RecordHealthDataTool,
CommonAgentHandler.QueryHealthRecordsTool,
MedicationAgentHandler.ManageMedicationTool,
ExerciseAgentHandler.ManageExerciseTool,
CommonAgentHandler.CheckArchiveTool,
],
_ => CommonAgentHandler.Tools,
};
private static bool IsWriteToolCall(string toolName, string arguments)
{
if (toolName == "record_health_data") return true;
if (toolName == "manage_archive") return GetToolAction(arguments) != "query";
if (toolName == "manage_medication") return GetToolAction(arguments) is "create" or "confirm";
if (toolName == "manage_exercise") return GetToolAction(arguments) is "create" or "checkin";
return false;
}
private static string GetToolAction(string arguments)
{
try
{
using var json = JsonDocument.Parse(arguments);
return json.RootElement.TryGetProperty("action", out var action)
? action.GetString()?.ToLowerInvariant() ?? ""
: "";
}
catch
{
return "";
}
}
private static async Task