Files
AI-Health/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs
MingNian 4d213b5a44 feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
2026-06-20 20:41:42 +08:00

613 lines
28 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.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;
/// <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,
DeepSeekClient llmClient,
PromptManager promptManager,
IAiToolExecutionService toolExecution,
IAiWriteConfirmationStore confirmations,
IAiConversationService conversations,
IPatientContextService patientContexts,
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";
// 创建或获取对话。传入 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);
await conversations.AddUserMessageAsync(activeConversationId, message, ct);
var urgentWarning = DetectUrgentRisk(message);
if (!string.IsNullOrEmpty(urgentWarning))
{
var urgentResponse = $"""
{urgentWarning}
这种情况需要把安全放在第一位。请先停止自行判断和等待 AI 分析,尽快联系医生、互联网医院或前往急诊评估;如果症状正在加重,建议立即拨打当地急救电话。若身边有人,请让家人或同伴陪同,不要独自开车就医。
以上为 AI 安全提醒,不能替代医生诊断和治疗建议。
""";
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, 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);
var messages = new List<ChatMessage>
{
new() { Role = "system", Content = systemPrompt + "\n\n当前患者信息\n" + patientContext },
};
// 加载历史对话(最近 10 条)
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
foreach (var h in history)
{
messages.Add(new ChatMessage
{
Role = h.Role == MessageRole.User.ToString() ? "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, 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))
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct);
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
});
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);
await conversations.DeleteAsync(userId.Value, id, ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
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 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.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<object> PreparePendingWriteAsync(
IAiWriteConfirmationStore confirmations,
Guid userId,
string toolName,
string arguments,
CancellationToken ct)
{
var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct);
using var json = JsonDocument.Parse(arguments);
var args = json.RootElement;
var preview = new Dictionary<string, object?>
{
["success"] = true,
["pendingConfirmation"] = true,
["confirmationId"] = command.Id,
["message"] = "等待用户确认后写入",
};
switch (toolName)
{
case "record_health_data":
AddHealthPreview(preview, args);
break;
case "manage_medication":
preview["type"] = "medication";
preview["name"] = GetString(args, "name") ?? "服药确认";
preview["dosage"] = GetString(args, "dosage") ?? "";
preview["frequency"] = GetString(args, "frequency") ?? "Daily";
preview["time"] = GetStringArray(args, "time_of_day");
preview["duration_days"] = GetInt(args, "duration_days") ?? 0;
preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd");
break;
case "manage_exercise":
preview["type"] = "exercise";
preview["exercise_type"] = GetString(args, "exercise_type") ?? "运动";
preview["duration_minutes"] = GetInt(args, "duration_minutes") ?? 30;
preview["day_count"] = GetInt(args, "duration_days") ?? 7;
preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd");
preview["reminder_time"] = GetString(args, "reminder_time") ?? "19:00";
break;
case "manage_archive":
preview["type"] = "archive";
preview["value"] = "健康档案更新";
preview["unit"] = "";
break;
}
return preview;
}
private static void AddHealthPreview(Dictionary<string, object?> preview, JsonElement args)
{
var type = GetString(args, "type") ?? "";
switch (type)
{
case "blood_pressure":
var systolic = GetInt(args, "systolic");
var diastolic = GetInt(args, "diastolic");
preview["type"] = HealthMetricType.BloodPressure.ToString();
preview["value"] = $"{systolic}/{diastolic}";
preview["unit"] = "mmHg";
preview["isAbnormal"] = systolic >= 140 || diastolic >= 90 || systolic <= 89 || diastolic <= 59;
break;
case "heart_rate":
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
break;
case "glucose":
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v >= 7.0m || v <= 3.8m);
break;
case "spo2":
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
break;
case "weight":
AddMetricPreview(preview, HealthMetricType.Weight, GetDecimal(args, "weight"), "kg", _ => false);
break;
}
}
private static void AddMetricPreview(
Dictionary<string, object?> preview,
HealthMetricType type,
decimal? value,
string unit,
Func<decimal, bool> isAbnormal)
{
preview["type"] = type.ToString();
preview["value"] = value?.ToString() ?? "";
preview["unit"] = unit;
preview["isAbnormal"] = value.HasValue && isAbnormal(value.Value);
}
private static string? GetString(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
private static int? GetInt(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : null;
private static decimal? GetDecimal(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) && value.TryGetDecimal(out var result) ? result : null;
private static string GetStringArray(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Array
? string.Join(", ", value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)))
: "";
private static string? DetectUrgentRisk(string message)
{
var text = message.Trim();
if (string.IsNullOrEmpty(text)) return null;
if (ContainsAny(text, "剧烈胸痛", "胸口剧痛", "胸痛明显", "胸闷憋气", "喘不上气", "呼吸困难", "呼吸很困难", "意识模糊", "昏厥", "晕厥", "说话不清", "口角歪斜", "一侧无力"))
return "你描述的症状可能属于需要紧急评估的危险信号。";
var bp = Regex.Match(text, @"(?:血压|bp|BP)?\s*(?<sys>\d{2,3})\s*/\s*(?<dia>\d{2,3})");
if (bp.Success &&
int.TryParse(bp.Groups["sys"].Value, out var systolic) &&
int.TryParse(bp.Groups["dia"].Value, out var diastolic) &&
(systolic >= 180 || diastolic >= 120))
{
return $"你提到的血压 {systolic}/{diastolic} mmHg 已达到明显危险范围。";
}
var spo2 = MatchMetricValue(text, "血氧|氧饱和|SpO2|spo2");
if (spo2.HasValue && spo2.Value <= 90)
return $"你提到的血氧 {spo2.Value:0.#}% 明显偏低。";
var glucose = MatchMetricValue(text, "血糖|葡萄糖|glucose");
if (glucose.HasValue && (glucose.Value >= 16.7m || glucose.Value <= 3.0m))
return $"你提到的血糖 {glucose.Value:0.#} mmol/L 属于需要尽快处理的异常范围。";
var heartRate = MatchMetricValue(text, "心率|脉搏|heart rate|HR|hr");
if (heartRate.HasValue && (heartRate.Value >= 130 || heartRate.Value <= 45))
return $"你提到的心率 {heartRate.Value:0.#} 次/分属于明显异常范围。";
return null;
}
private static bool ContainsAny(string text, params string[] keywords) =>
keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase));
private static decimal? MatchMetricValue(string text, string metricPattern)
{
var match = Regex.Match(text, $@"(?:{metricPattern})[^\d]{{0,8}}(?<value>\d{{1,3}}(?:\.\d+)?)", RegexOptions.IgnoreCase);
return match.Success && decimal.TryParse(match.Groups["value"].Value, out var value) ? value : null;
}
// ── JSON 类型安全转换 ──
private static int _ToInt(object? v) => v switch
{
JsonElement je => je.ValueKind == JsonValueKind.Number ? je.GetInt32() : (int.TryParse(je.GetRawText().Trim('"'), out var i) ? i : 0),
int i => i,
long l => (int)l,
string s => int.TryParse(s, out var si) ? si : 0,
_ => 0
};
private static bool _ToBool(object? v) => v switch
{
JsonElement je => je.ValueKind == JsonValueKind.True || je.ValueKind == JsonValueKind.False ? je.GetBoolean() : false,
bool b => b,
_ => false
};
// ── 消息类型判断 ──
private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata)
{
// 将匿名对象统一转成 DictionarytoolResult 可能是匿名类型,不是 IDictionary
var resultDict = toolResult as IDictionary<string, object>;
if (resultDict == null)
{
try
{
var json = JsonSerializer.Serialize(toolResult, JsonOpts);
resultDict = JsonSerializer.Deserialize<Dictionary<string, object>>(json, JsonOpts);
}
catch { resultDict = new Dictionary<string, object>(); }
}
var isPendingConfirmation = resultDict != null
&& resultDict.TryGetValue("pendingConfirmation", out var pendingValue)
&& _ToBool(pendingValue);
if (isPendingConfirmation && resultDict!.TryGetValue("confirmationId", out var confirmationIdValue))
{
var confirmationId = confirmationIdValue?.ToString()?.Trim('"');
if (!string.IsNullOrWhiteSpace(confirmationId))
{
if (!metadata.TryGetValue("confirmationIds", out var idsValue) || idsValue is not List<string> ids)
{
ids = [];
metadata["confirmationIds"] = ids;
}
ids.Add(confirmationId);
}
}
switch (toolName)
{
case "record_health_data":
if (!isPendingConfirmation) break;
messageType = "data_confirm";
if (resultDict != null)
{
if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type?.ToString() ?? "";
if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val?.ToString() ?? "";
if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit?.ToString() ?? "";
if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = _ToBool(abn);
if (resultDict.TryGetValue("success", out var success)) metadata["success"] = _ToBool(success);
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
}
break;
case "manage_medication":
if (!isPendingConfirmation) break;
messageType = "medication_confirm";
if (resultDict != null)
{
if (resultDict.TryGetValue("name", out var name))
metadata["name"] = name?.ToString() ?? "";
if (resultDict.TryGetValue("dosage", out var dosage))
metadata["dosage"] = dosage?.ToString() ?? "";
if (resultDict.TryGetValue("time", out var time))
metadata["time"] = time?.ToString() ?? "";
if (resultDict.TryGetValue("frequency", out var freq))
metadata["frequency"] = freq?.ToString() ?? "";
if (resultDict.TryGetValue("duration_days", out var dd2))
metadata["duration_days"] = _ToInt(dd2);
if (resultDict.TryGetValue("start_date", out var sd))
metadata["startDate"] = sd?.ToString() ?? "";
}
break;
case "manage_exercise":
if (!isPendingConfirmation) break;
messageType = "data_confirm";
if (resultDict != null)
{
metadata["type"] = "exercise";
resultDict.TryGetValue("exercise_type", out var et);
metadata["value"] = et?.ToString() ?? "";
resultDict.TryGetValue("duration_minutes", out var dm);
metadata["unit"] = dm != null ? $"每天{dm}分钟" : "";
resultDict.TryGetValue("day_count", out var dc);
if (dc != null) metadata["durationDays"] = dc.ToString() ?? "";
resultDict.TryGetValue("success", out var ok);
metadata["success"] = _ToBool(ok);
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
}
break;
case "manage_archive":
if (!isPendingConfirmation) break;
messageType = "data_confirm";
metadata["type"] = "archive";
metadata["value"] = "健康档案更新";
metadata["unit"] = "";
metadata["success"] = true;
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
break;
case "analyze_report":
messageType = "report_analysis";
break;
}
}
}
public sealed record ChatRequest(string Message, string? ConversationId);