diff --git a/.gitignore b/.gitignore index 5472437..b7d3702 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ health_app/android/key.properties # .NET build outputs backend/**/bin/ backend/**/obj/ +backend/artifacts/ + +# Generated local audit reports +audit/*.html # PostgreSQL local data backend/pgdata/ diff --git a/backend/.env.example b/backend/.env.example index 94823f7..f0ed607 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,7 +9,7 @@ JWT_AUDIENCE=health-manager-app # DeepSeek LLM DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 DEEPSEEK_API_KEY=sk-your-key-here -DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_MODEL=deepseek-v4-flash # 千问 VLM QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 diff --git a/backend/src/Health.Application/AI/AiEntryDraftContracts.cs b/backend/src/Health.Application/AI/AiEntryDraftContracts.cs new file mode 100644 index 0000000..605b161 --- /dev/null +++ b/backend/src/Health.Application/AI/AiEntryDraftContracts.cs @@ -0,0 +1,24 @@ +namespace Health.Application.AI; + +public sealed record AiEntryDraft( + Guid Id, + Guid UserId, + Guid ConversationId, + string EntryType, + string Payload, + string MissingFields, + DateTime ExpiresAt); + +public interface IAiEntryDraftStore +{ + Task GetActiveAsync(Guid userId, Guid conversationId, string entryType, CancellationToken ct); + Task UpsertAsync( + Guid userId, + Guid conversationId, + string entryType, + string payload, + string missingFields, + TimeSpan lifetime, + CancellationToken ct); + Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Users/UserContracts.cs b/backend/src/Health.Application/Users/UserContracts.cs index d17a8e2..8c0ebdb 100644 --- a/backend/src/Health.Application/Users/UserContracts.cs +++ b/backend/src/Health.Application/Users/UserContracts.cs @@ -14,7 +14,8 @@ public sealed record UserProfileDto( public sealed record UserProfileUpdateRequest( string? Name, string? Gender, - DateOnly? BirthDate); + DateOnly? BirthDate, + string? AvatarUrl); public sealed record AccountFileReferences( IReadOnlyList FileUrls, diff --git a/backend/src/Health.Application/Users/UserService.cs b/backend/src/Health.Application/Users/UserService.cs index 8a0968b..dd5cab8 100644 --- a/backend/src/Health.Application/Users/UserService.cs +++ b/backend/src/Health.Application/Users/UserService.cs @@ -28,6 +28,7 @@ public sealed class UserService( if (request.Name != null) user.Name = request.Name; if (request.Gender != null) user.Gender = request.Gender; if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value; + if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl; user.UpdatedAt = DateTime.UtcNow; await _users.SaveChangesAsync(ct); return true; diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs index 37b4524..05e2c61 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs @@ -8,6 +8,28 @@ namespace Health.Infrastructure.AI.AgentHandlers; /// public static class CommonAgentHandler { + public static readonly ToolDefinition SearchMedicalKnowledgeTool = new() + { + Function = new() + { + Name = "search_medical_knowledge", + Description = "按需检索医学知识库。仅用于症状分析、健康建议、疾病/药品/检查指标解释等需要医学资料支撑的回答;健康数据录入、用药或运动计划创建/查询/打卡、普通寒暄不得调用。", + Parameters = new + { + type = "object", + properties = new + { + query = new + { + type = "string", + description = "需要检索的医学问题,保留关键症状、疾病、药品或指标名称", + }, + }, + required = new[] { "query" }, + }, + }, + }; + public static readonly ToolDefinition QueryHealthRecordsTool = new() { Function = new() diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs index 92456b9..5eab40a 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs @@ -32,6 +32,31 @@ public static class ExerciseAgentHandler public static List Tools => [ManageExerciseTool]; + public static string? ValidateWriteArguments(JsonElement args) + { + var action = args.TryGetProperty("action", out var actionValue) + ? actionValue.GetString()?.ToLowerInvariant() + : null; + if (action != "create") return null; + + if (!args.TryGetProperty("exercise_type", out var typeValue) || + string.IsNullOrWhiteSpace(typeValue.GetString())) + return "创建运动计划前需要确认运动项目"; + if (!args.TryGetProperty("duration_minutes", out var minutesValue) || + !minutesValue.TryGetInt32(out var minutes) || minutes is <= 0 or > 300) + return "创建运动计划前需要确认合理的单次运动时长"; + if (!args.TryGetProperty("duration_days", out var daysValue) || + !daysValue.TryGetInt32(out var days) || days is <= 0 or > 366) + return "创建运动计划前需要确认计划持续天数"; + if (!args.TryGetProperty("start_date", out var startValue) || + !DateOnly.TryParse(startValue.GetString(), out _)) + return "创建运动计划前需要确认开始日期"; + if (!args.TryGetProperty("reminder_time", out var reminderValue) || + !TimeOnly.TryParse(reminderValue.GetString(), out _)) + return "创建运动计划前需要确认具体提醒时间"; + return null; + } + public static async Task Execute( string toolName, JsonElement args, @@ -47,6 +72,10 @@ public static class ExerciseAgentHandler : null; if (action == "create") { + var validationError = ValidateWriteArguments(args); + if (validationError != null) + return new { success = false, message = validationError }; + var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart) ? parsedStart : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); @@ -134,6 +163,33 @@ public static class ExerciseAgentHandler var result = filtered.ToList(); var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).ToList(); var nextPlan = plans.Where(plan => plan.StartDate > queryDate).OrderBy(plan => plan.StartDate).FirstOrDefault(); + string authoritativeAnswer; + if (result.Count == 0) + { + authoritativeAnswer = isDateScope + ? $"没有查到 {queryDate:yyyy-MM-dd} 的运动安排。" + : "当前没有查到符合条件的运动计划。"; + } + else if (isDateScope) + { + var taskLines = scheduledTasks.Select(item => + $"- {item.ExerciseType}:{item.DurationMinutes} 分钟,{(item.IsCompleted ? "已完成" : "未完成")}"); + authoritativeAnswer = + $"{queryDate:yyyy-MM-dd} 的运动安排:\n{string.Join('\n', taskLines)}"; + } + else + { + var planLines = result.Select(plan => + { + var representative = plan.items.FirstOrDefault(item => !item.IsRestDay); + var exerciseType = representative?.ExerciseType ?? "运动"; + var durationMinutes = representative?.DurationMinutes ?? 0; + return $"- {exerciseType}:{plan.StartDate:yyyy-MM-dd} 至 {plan.EndDate:yyyy-MM-dd}," + + $"每天 {durationMinutes} 分钟,提醒时间 {plan.ReminderTime:HH:mm}"; + }); + authoritativeAnswer = + $"查到 {result.Count} 个符合条件的运动计划:\n{string.Join('\n', planLines)}"; + } return new { found = result.Count > 0, @@ -147,6 +203,7 @@ public static class ExerciseAgentHandler next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"), days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber, plans = result, + authoritative_answer = authoritativeAnswer, }; } } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs index 308f29f..714e6fd 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs @@ -7,29 +7,154 @@ namespace Health.Infrastructure.AI.AgentHandlers; /// public static class HealthDataAgentHandler { + private static readonly string[] MetricTypes = ["blood_pressure", "heart_rate", "glucose", "spo2", "weight"]; + public static readonly ToolDefinition RecordHealthDataTool = new() { Function = new() { Name = "record_health_data", - Description = "记录健康数据(血压/心率/血糖/血氧/体重)", + Description = "记录健康数据(血压/心率/血糖/血氧/体重)。用户说“脉搏”时按“心率”处理,type 使用 heart_rate,数值写入 heart_rate。", Parameters = new { type = "object", properties = new { type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight" } }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" }, recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" } }, required = new[] { "type" } } } }; - public static List Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool]; + public static readonly ToolDefinition RecordHealthDataBatchTool = new() + { + Function = new() + { + Name = "record_health_data_batch", + Description = "提交当前这一轮要录入的全部健康指标。一次只调用一次;即使只有一个指标也放入 metrics。血压可先只提供已知的一侧,脉搏按心率 heart_rate 处理。后端会整批校验、追问或生成一张确认卡。", + Parameters = new + { + type = "object", + properties = new + { + metrics = new + { + type = "array", + minItems = 1, + description = "当前录入批次的全部指标,不得遗漏;不要带入普通历史对话或上一张已生成卡片的数据", + items = new + { + type = "object", + properties = new + { + type = new { type = "string", @enum = MetricTypes }, + systolic = new { type = "integer" }, + diastolic = new { type = "integer" }, + heart_rate = new { type = "number" }, + glucose = new { type = "number" }, + spo2 = new { type = "number" }, + weight = new { type = "number" }, + recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" }, + }, + required = new[] { "type" }, + }, + }, + }, + required = new[] { "metrics" }, + }, + }, + }; - public static async Task Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords) + public static List Tools => [RecordHealthDataBatchTool, CommonAgentHandler.QueryHealthRecordsTool]; + + public static string? ValidateWriteArguments(JsonElement args) + { + var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null; + return type switch + { + "blood_pressure" => ValidateBloodPressure(args), + "heart_rate" => ValidateValue(args, "heart_rate", "心率", 1m, 250m), + "glucose" => ValidateValue(args, "glucose", "血糖", 0.1m, 50m), + "spo2" => ValidateValue(args, "spo2", "血氧", 1m, 100m), + "weight" => ValidateValue(args, "weight", "体重", 1m, 500m), + _ => "缺少有效的健康指标类型", + }; + } + + public static string? GetUrgentWarning(JsonElement args) + { + var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null; + return type switch + { + "blood_pressure" + when args.TryGetProperty("systolic", out var systolic) && + systolic.TryGetInt32(out var systolicValue) && + args.TryGetProperty("diastolic", out var diastolic) && + diastolic.TryGetInt32(out var diastolicValue) && + (systolicValue >= 180 || diastolicValue >= 120) + => "这次血压数值已达到明显危险范围,请不要继续等待录入,尽快联系医生或前往急诊评估。", + "heart_rate" when IsValueOutside(args, "heart_rate", 45m, 130m) + => "这次心率数值明显异常,请尽快联系医生评估;如伴有胸痛、晕厥或呼吸困难,请立即就医。", + "glucose" when IsValueOutside(args, "glucose", 3.0m, 16.7m) + => "这次血糖数值属于需要尽快处理的危险范围,请立即联系医生或前往医疗机构评估。", + "spo2" + when args.TryGetProperty("spo2", out var spo2) && + spo2.TryGetDecimal(out var spo2Value) && + spo2Value <= 90m + => "这次血氧数值明显偏低,请尽快就医评估;如有呼吸困难、胸闷或意识异常,请立即拨打当地急救电话。", + _ => null, + }; + } + + public static IReadOnlyList GetBatchMetrics(JsonElement args) => + args.TryGetProperty("metrics", out var metrics) && metrics.ValueKind == JsonValueKind.Array + ? metrics.EnumerateArray().Select(metric => metric.Clone()).ToList() + : []; + + public static Dictionary CreatePreview(JsonElement args) + { + var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() ?? "" : ""; + var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args); + if (metricType == null) + return []; + + var request = new HealthRecordUpsertRequest( + metricType.Value, + systolic, + diastolic, + value, + unit, + HealthRecordSource.AiEntry, + null); + return new Dictionary + { + ["type"] = metricType.Value.ToString(), + ["value"] = metricType == HealthMetricType.BloodPressure + ? $"{systolic}/{diastolic}" + : value?.ToString() ?? "", + ["unit"] = unit, + ["abnormal"] = HealthRecordRules.CheckAbnormal(request), + }; + } + + public static async Task Execute( + string toolName, + JsonElement args, + Guid userId, + IHealthRecordService healthRecords, + CancellationToken ct) { return toolName switch { - "record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args), + "record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args, ct), + "record_health_data_batch" => await ExecuteRecordHealthDataBatch(healthRecords, userId, args, ct), _ => new { success = false, message = $"未知工具: {toolName}" } }; } - private static async Task ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args) + private static async Task ExecuteRecordHealthData( + IHealthRecordService healthRecords, + Guid userId, + JsonElement args, + CancellationToken ct) { + var validationError = ValidateWriteArguments(args); + if (validationError != null) + return new { success = false, message = validationError }; + var type = args.TryGetProperty("type", out var t) ? t.GetString()! : ""; var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args); if (metricType == null) @@ -49,7 +174,7 @@ public static class HealthDataAgentHandler HealthRecordSource.AiEntry, recordedAt); - var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None); + var id = await healthRecords.CreateAsync(userId, request, ct); var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? ""; return new { @@ -62,6 +187,75 @@ public static class HealthDataAgentHandler }; } + private static async Task ExecuteRecordHealthDataBatch( + IHealthRecordService healthRecords, + Guid userId, + JsonElement args, + CancellationToken ct) + { + var metrics = GetBatchMetrics(args); + if (metrics.Count == 0) + return new { success = false, message = "当前录入批次没有健康指标" }; + + var requests = new List(metrics.Count); + var previews = new List>(metrics.Count); + foreach (var metric in metrics) + { + var validationError = ValidateWriteArguments(metric); + if (validationError != null) + return new { success = false, message = validationError }; + + var type = metric.GetProperty("type").GetString()!; + var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, metric); + if (metricType == null) + return new { success = false, message = $"未知指标类型: {type}" }; + + var recordedAt = metric.TryGetProperty("recorded_at", out var recordedAtValue) && + AiDateTime.TryParseUserDateTime(recordedAtValue.GetString(), out var parsedRecordedAt) + ? parsedRecordedAt + : DateTime.UtcNow; + requests.Add(new HealthRecordUpsertRequest( + metricType.Value, + systolic, + diastolic, + value, + unit, + HealthRecordSource.AiEntry, + recordedAt)); + previews.Add(CreatePreview(metric)); + } + + var ids = await healthRecords.CreateManyAsync(userId, requests, ct); + return new + { + success = true, + record_ids = ids, + items = previews, + }; + } + + private static string? ValidateBloodPressure(JsonElement args) + { + if (!args.TryGetProperty("systolic", out var systolicValue) || !systolicValue.TryGetInt32(out var systolic) || + !args.TryGetProperty("diastolic", out var diastolicValue) || !diastolicValue.TryGetInt32(out var diastolic)) + return "录入血压前需要同时提供收缩压和舒张压"; + if (systolic is <= 0 or > 260 || diastolic is <= 0 or > 160 || systolic <= diastolic) + return "血压数值明显无效,请核对收缩压和舒张压后重新提供"; + return null; + } + + private static string? ValidateValue(JsonElement args, string property, string label, decimal minimum, decimal maximum) + { + if (!args.TryGetProperty(property, out var valueElement) || !valueElement.TryGetDecimal(out var value)) + return $"录入{label}前需要提供数值"; + return value < minimum || value > maximum ? $"{label}数值明显无效,请重新提供" : null; + } + + private static bool IsValueOutside(JsonElement args, string property, decimal minimum, decimal maximum) => + args.TryGetProperty(property, out var valueElement) && + valueElement.TryGetDecimal(out var value) && + (value <= minimum || value >= maximum); + private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args) { return type switch diff --git a/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs index c0f12dd..e31c007 100644 --- a/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs +++ b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs @@ -40,7 +40,8 @@ public sealed class AiToolExecutionService( var root = json.RootElement; return await (toolName switch { - "record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords), + "record_health_data" or "record_health_data_batch" + => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords, ct), "query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), "check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), "manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct), diff --git a/backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs b/backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs new file mode 100644 index 0000000..cbd4ba1 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs @@ -0,0 +1,108 @@ +using Health.Application.AI; +using Health.Infrastructure.Data; +using Health.Infrastructure.Data.Records; +using Microsoft.EntityFrameworkCore; + +namespace Health.Infrastructure.AI; + +public sealed class EfAiEntryDraftStore(AppDbContext db) : IAiEntryDraftStore +{ + private readonly AppDbContext _db = db; + + public async Task GetActiveAsync( + Guid userId, + Guid conversationId, + string entryType, + CancellationToken ct) + { + var now = DateTime.UtcNow; + await ExpireAsync(now, ct); + var record = await _db.AiEntryDrafts.AsNoTracking().FirstOrDefaultAsync(x => + x.UserId == userId && + x.ConversationId == conversationId && + x.EntryType == entryType && + x.Status == "Pending" && + x.ExpiresAt > now, ct); + return record == null ? null : ToDraft(record); + } + + public async Task UpsertAsync( + Guid userId, + Guid conversationId, + string entryType, + string payload, + string missingFields, + TimeSpan lifetime, + CancellationToken ct) + { + var now = DateTime.UtcNow; + await ExpireAsync(now, ct); + var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x => + x.UserId == userId && + x.ConversationId == conversationId && + x.EntryType == entryType && + x.Status == "Pending", ct); + if (record == null) + { + record = new AiEntryDraftRecord + { + Id = Guid.NewGuid(), + UserId = userId, + ConversationId = conversationId, + EntryType = entryType, + CreatedAt = now, + }; + await _db.AiEntryDrafts.AddAsync(record, ct); + } + + record.Payload = payload; + record.MissingFields = missingFields; + record.ExpiresAt = now.Add(lifetime); + record.UpdatedAt = now; + await _db.SaveChangesAsync(ct); + return ToDraft(record); + } + + public async Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct) + { + var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x => + x.Id == draftId && x.UserId == userId && x.Status == "Pending", ct); + if (record == null) return; + + record.Status = "Completed"; + record.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + } + + private async Task ExpireAsync(DateTime now, CancellationToken ct) + { + if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory") + { + var expired = await _db.AiEntryDrafts + .Where(x => x.Status == "Pending" && x.ExpiresAt <= now) + .ToListAsync(ct); + foreach (var item in expired) + { + item.Status = "Expired"; + item.UpdatedAt = now; + } + if (expired.Count > 0) await _db.SaveChangesAsync(ct); + return; + } + + await _db.AiEntryDrafts + .Where(x => x.Status == "Pending" && x.ExpiresAt <= now) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Expired") + .SetProperty(x => x.UpdatedAt, now), ct); + } + + private static AiEntryDraft ToDraft(AiEntryDraftRecord record) => new( + record.Id, + record.UserId, + record.ConversationId, + record.EntryType, + record.Payload, + record.MissingFields, + record.ExpiresAt); +} diff --git a/backend/src/Health.Infrastructure/AI/Prompts/global/identity.md b/backend/src/Health.Infrastructure/AI/Prompts/global/identity.md new file mode 100644 index 0000000..8c9cdfa --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/global/identity.md @@ -0,0 +1,8 @@ +你是患者端统一 AI 健康助手“小脉”。你的任务是理解用户当前真正想做的事情,调用经过授权的业务工具,并用自然、温和、清楚的中文回复。 + +核心原则: +- 个人健康数据、计划、完成状态和报告状态必须以当前业务工具返回为准,不能根据聊天历史或患者背景猜测。 +- 工具没有返回的数据不得编造;暂时没有对应能力时应如实说明。 +- 一条消息可能包含多个意图,必须分别处理,不能为了简化而遗漏其中一项。 +- 当前工具结果与历史聊天冲突时,以当前工具结果为准。 +- 普通聊天历史用于理解语境,不等同于待写入数据;只有后端明确提供的待补充草稿才允许跨消息合并录入字段。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/global/medical_safety.md b/backend/src/Health.Infrastructure/AI/Prompts/global/medical_safety.md new file mode 100644 index 0000000..f5057df --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/global/medical_safety.md @@ -0,0 +1,6 @@ +医疗安全边界: +- 你是健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。 +- 可以解释健康数据、报告指标和症状可能方向,但不能作确定诊断。 +- 不要求用户自行新增、停用、更换药物或调整剂量;涉及治疗变化时建议咨询医生或药师。 +- 出现剧烈胸痛、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等危险信号时,立即停止普通流程并优先建议及时就医或急诊评估。 +- 医疗分析使用“可能、建议、需要结合医生判断”等表达。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/global/response_style.md b/backend/src/Health.Infrastructure/AI/Prompts/global/response_style.md new file mode 100644 index 0000000..1e21004 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/global/response_style.md @@ -0,0 +1,8 @@ +回复要求: +- 语气自然、温和、专业,不使用系统报错式措辞。 +- 信息不完整时,每次只追问最关键的缺失内容;可以在一句话中列出同一批次明确缺少的字段。 +- 禁止使用粗体、斜体、删除线、HTML 和 Markdown 表格。 +- 标题只使用三级标题;列表使用短横线。 +- 数值和单位之间留空格,例如 120 mmHg、6.2 mmol/L。 +- 不输出任何以 $ 开头的占位符。 +- 不重复堆砌免责声明。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/diet_analysis.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/diet_analysis.md new file mode 100644 index 0000000..8ebd1d7 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/diet_analysis.md @@ -0,0 +1,6 @@ +新饮食图片分析模块: +- 仅用于本轮新上传的饮食图片;查询已经保存的饮食记录应进入个人数据查询模块。 +- 识别食物名称、估算份量和热量,并结合患者健康档案评价。 +- 对过敏、低盐、低脂、低糖等限制给出清楚提醒,不作绝对化医疗结论。 +- 饮食建议需要医学资料支撑时可以按需调用知识库。 +- 图片分析结果不能在用户未确认时冒充已经保存的饮食记录。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/exercise_entry.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/exercise_entry.md new file mode 100644 index 0000000..926a234 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/exercise_entry.md @@ -0,0 +1,9 @@ +运动计划录入模块: +- 用户明确创建运动计划时调用 manage_exercise(action="create")。 +- 完整计划需要:运动项目、每天运动时长、持续天数、开始日期和具体提醒时间。 +- 中文时长和周期应正确换算,例如半小时=30 分钟、一小时=60 分钟、一周=7 天、一个月=30 天。 +- 只传用户明确提供的字段;当前产品不自动补默认运动项目、天数、开始日期或提醒时间。 +- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮补充时与草稿合并。 +- 用户明确开始新的运动计划时不得继承旧草稿内容。 +- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。 +- 单纯创建运动计划不调用医学知识库。用户先询问该运动是否适合自己时,额外进入医疗咨询模块。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/general_chat.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/general_chat.md new file mode 100644 index 0000000..93f7c81 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/general_chat.md @@ -0,0 +1,4 @@ +普通聊天与应用帮助模块: +- 问候、感谢和普通闲聊自然简短回复,不调用业务工具或医学知识库。 +- 用户询问应用功能位置或使用方法时可以直接说明;已有固定 App 内链接时可自然提供一个跳转链接。 +- 不确定用户是想咨询数值还是保存记录时,只做一次简短澄清,不生成确认卡。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/health_entry.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/health_entry.md new file mode 100644 index 0000000..450e076 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/health_entry.md @@ -0,0 +1,13 @@ +健康指标录入模块: +- 支持血压、心率、血糖、血氧、体重;“脉搏”与“心率”是同一指标,统一使用 heart_rate。 +- 必须理解常见完整血压表达,例如 113/86、113-86、113—86,前者为收缩压,后者为舒张压。 +- 用户明确要求录入时必须调用 record_health_data_batch,而且每个录入批次只调用一次;即使只有一个指标,也必须放进 metrics 数组。信息不完整也要调用,只传用户明确提供的字段,让后端保存整个待补充批次草稿。 +- 不能自行补测量数值。测量时间未说明时可以由后端使用当前时间。 +- 一次消息可以包含多个指标,必须完整提取,不得遗漏。多指标属于同一录入批次,最终应汇总在一张确认卡中。 +- metrics 必须包含用户本轮明确要录入的全部指标;不要为每个指标分别调用工具。 +- 如果同一批次存在不完整或明显无效的指标,应保留本批次其他已识别指标,先根据后端结果追问缺失或错误部分;全部补齐后再生成整批确认卡。 +- 用户只补充一个缺失值时,必须结合后端提供的当前草稿,不能只记录最后一句。 +- 已经生成确认卡的批次立即结束;无论用户是否点击,下一条新的录入请求都属于新批次,不能带入上一张卡的数据。 +- 普通聊天历史中的指标不得自动加入当前卡;只有明确的待补充草稿可以跨消息合并。 +- 后端返回 pendingConfirmation=true 才代表确认卡真实生成。确认卡出现前不得要求用户点击卡片,点击前不得声称已经保存或录入成功。 +- 后端返回危险提醒或无效数值时,不生成成功话术,按返回内容提醒用户。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/medical_consultation.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/medical_consultation.md new file mode 100644 index 0000000..b10265d --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/medical_consultation.md @@ -0,0 +1,7 @@ +医疗咨询与健康建议模块: +- 症状求助时先判断是否存在危险信号;非紧急情况下每轮只追问一个最关键问题,逐步了解部位、开始时间、程度、诱因和伴随表现。 +- 信息足够后给出可能相关方向、严重程度以及观察、门诊、尽快就医或急诊建议,不作正式诊断。 +- 解释用户个人指标或趋势时,应先调用相应个人数据查询工具获取真实数据。 +- 疾病、药品、检查指标、康复、饮食或运动建议需要医学资料支撑时,调用 search_medical_knowledge。 +- 不需要外部医学资料的简单澄清、问候或业务操作不调用知识库。 +- 用户同时要求录入或创建计划时,医疗解释不能代替对应录入工具和确认卡。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/medication_entry.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/medication_entry.md new file mode 100644 index 0000000..5faa683 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/medication_entry.md @@ -0,0 +1,9 @@ +用药计划录入模块: +- 用户明确创建用药计划时调用 manage_medication(action="create")。 +- 只传用户明确提供的字段,不自行新增药品、猜测剂量、频率、具体服药时间、开始日期或疗程。 +- 完整计划需要:药品名称、每次剂量、频率、具体服药时间、开始日期,以及疗程天数或明确长期服用。 +- “早饭后、晚上”等模糊时间不能擅自换算为具体时刻,应追问具体时间。 +- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮只补缺失字段时与草稿合并。 +- 存在旧草稿但用户明确开始另一种药品的新计划时,应开始新批次,禁止继承旧药品的剂量、时间、频率或疗程。 +- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。 +- 用药计划录入本身不调用医学知识库。用户同时询问药品知识或副作用时,额外进入医疗咨询模块。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/personal_query.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/personal_query.md new file mode 100644 index 0000000..73aa268 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/personal_query.md @@ -0,0 +1,7 @@ +个人数据查询模块: +- 查询用户自己的健康记录、健康档案、用药计划、运动计划、饮食记录、复查安排、已有报告和通知时,必须调用对应只读业务工具。 +- 不能根据患者背景、聊天历史或以前的工具结果回答当前状态。 +- 必须区分“记录存在”“计划当前有效”“指定日期有安排”“任务已经完成”。 +- 查询某一天任务与查询计划生命周期不能混用;时间意图不明确时只追问时间范围。 +- 工具没有返回数据时明确说明未查到,不得编造。 +- 个人数据查询通常不调用医学知识库;用户同时要求解释或建议时,额外进入医疗咨询模块。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/report_analysis.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/report_analysis.md new file mode 100644 index 0000000..d602652 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/report_analysis.md @@ -0,0 +1,7 @@ +新报告分析模块: +- 仅用于本轮新上传的医学检查报告图片或报告文件;查询以前保存的报告应进入个人数据查询模块。 +- 提取报告名称、日期、关键指标、参考范围、异常标记和原文结论。 +- 区分结构化检验指标与影像/医生描述,不把影像描述改写成确定诊断。 +- 给出患者可理解的初步解释和下一步建议,注明需要结合医生判断。 +- 报告内容需要进一步医学解释时可以按需调用知识库。 +- 用户没有明确要求时,不把报告中的指标直接写入健康记录。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/rag/retrieval_rules.md b/backend/src/Health.Infrastructure/AI/Prompts/rag/retrieval_rules.md new file mode 100644 index 0000000..4687509 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/rag/retrieval_rules.md @@ -0,0 +1,7 @@ +医学知识库使用规则: +- 知识库是医疗咨询、指标解释、药品知识、报告解释和健康建议的辅助能力,不是独立用户意图。 +- 健康指标录入、用药或运动计划创建、个人数据查询、服药确认、运动打卡、普通聊天时禁止调用。 +- 一条消息同时包含业务操作和医学咨询时,先完成必要业务工具调用;只有咨询部分确实需要资料时才检索。 +- 检索问题应保留关键症状、疾病、药品或指标名称,删除无关寒暄和操作指令。 +- 只能依据真实返回的知识片段作参考;没有结果时应谨慎回答,不得虚构检索内容或来源。 +- 知识库内容不能覆盖患者当前业务数据,也不能替代紧急安全规则。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/router/intent_router.md b/backend/src/Health.Infrastructure/AI/Prompts/router/intent_router.md new file mode 100644 index 0000000..fcd6001 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/router/intent_router.md @@ -0,0 +1,25 @@ +你是主聊天的意图路由器,只负责识别意图,不回答用户问题。 + +请结合用户当前消息、附件类型、最近对话和后端提供的待补充草稿,选择一个或多个意图: +- health_entry:录入健康指标,或继续补充健康指标草稿。 +- medication_entry:创建用药计划,或继续补充用药计划草稿。 +- exercise_entry:创建运动计划,或继续补充运动计划草稿。 +- personal_query:查询用户自己的健康数据、健康档案、用药/运动计划、饮食、复查、报告或通知。 +- medical_consultation:症状求助、指标解释、疾病/药品知识、健康评价、饮食/运动/康复建议。 +- report_analysis:分析新上传的医学报告图片或报告文件。 +- diet_analysis:分析新上传的饮食图片。 +- app_help:询问应用功能位置或使用方法。 +- general_chat:问候、感谢、普通闲聊或不需要业务工具的对话。 + +判断规则: +- “帮我记录血压 116/89”是 health_entry。 +- “血压 116/89 正常吗”是 medical_consultation,不是录入。 +- “记录血压 150/95,并告诉我是否偏高”同时包含 health_entry 和 medical_consultation。 +- 查询已有报告属于 personal_query;分析本轮新上传报告属于 report_analysis。 +- 创建计划与询问建议必须区分;“创建每天散步 30 分钟的计划”是 exercise_entry,“我适合每天散步 30 分钟吗”是 medical_consultation。 +- 只有确实在补充草稿缺失字段时才标记为 continue;存在草稿不代表所有后续消息都必须继续草稿。 +- 用户明确开始新的同类录入时标记 start_new,不能继承旧草稿字段。 +- 用户明确说取消、不录了时标记 cancel。 +- 无法确定是咨询还是录入时选择 general_chat,让主助手自然澄清。 + +必须通过 route_user_intent 工具返回结构化结果,不得输出解释文字。 diff --git a/backend/src/Health.Infrastructure/AI/ai_clients.cs b/backend/src/Health.Infrastructure/AI/ai_clients.cs index 10780e1..bc17b4d 100644 --- a/backend/src/Health.Infrastructure/AI/ai_clients.cs +++ b/backend/src/Health.Infrastructure/AI/ai_clients.cs @@ -9,7 +9,7 @@ namespace Health.Infrastructure.AI; public sealed class DeepSeekClient(HttpClient http, IConfiguration config) { private readonly HttpClient _http = http; - private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat"; + private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-v4-flash"; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, @@ -28,8 +28,13 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config) { var request = new ChatCompletionRequest { - Model = _model, Messages = messages, Stream = true, - MaxTokens = maxTokens, Temperature = temperature, Tools = tools, + Model = _model, + Messages = messages, + Stream = true, + MaxTokens = maxTokens, + Temperature = temperature, + Tools = tools, + Thinking = new { type = "disabled" }, }; if (tools?.Count > 0) request.ToolChoice = "auto"; @@ -64,14 +69,20 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config) List? 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, + Model = _model, + Messages = messages, + Stream = false, + MaxTokens = maxTokens, + Temperature = temperature, + Tools = tools, + Thinking = new { type = "disabled" }, }; - if (tools?.Count > 0) request.ToolChoice = "auto"; + if (tools?.Count > 0) request.ToolChoice = toolChoice ?? "auto"; var json = JsonSerializer.Serialize(request, _jsonOptions); var content = new StringContent(json, Encoding.UTF8, "application/json"); @@ -117,8 +128,12 @@ public sealed class VisionClient(HttpClient http, IConfiguration config) var request = new ChatCompletionRequest { - Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false, - Temperature = 0.1f, VlHighResolutionImages = true, + Model = _model, + Messages = messages, + MaxTokens = maxTokens, + Stream = false, + Temperature = 0.1f, + VlHighResolutionImages = true, EnableThinking = false, }; diff --git a/backend/src/Health.Infrastructure/AI/ai_contracts.cs b/backend/src/Health.Infrastructure/AI/ai_contracts.cs index 4cbee8a..1a01e41 100644 --- a/backend/src/Health.Infrastructure/AI/ai_contracts.cs +++ b/backend/src/Health.Infrastructure/AI/ai_contracts.cs @@ -10,6 +10,7 @@ public sealed class ChatCompletionRequest public float? TopP { get; set; } public List? Tools { get; set; } public string? ToolChoice { get; set; } + public object? Thinking { get; set; } [System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")] public bool VlHighResolutionImages { get; set; } diff --git a/backend/src/Health.Infrastructure/AI/ai_intent_router.cs b/backend/src/Health.Infrastructure/AI/ai_intent_router.cs new file mode 100644 index 0000000..aca2099 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/ai_intent_router.cs @@ -0,0 +1,185 @@ +using System.Text.Json; + +namespace Health.Infrastructure.AI; + +public sealed record AiIntentRoute( + IReadOnlyList Intents, + string DraftAction, + string DraftType) +{ + public static AiIntentRoute GeneralChat { get; } = + new(["general_chat"], "none", "none"); + + public static AiIntentRoute Compatibility { get; } = new( + [ + "health_entry", + "medication_entry", + "exercise_entry", + "personal_query", + "medical_consultation", + "report_analysis", + "diet_analysis", + "general_chat", + ], + "none", + "none"); +} + +public static class AiIntentRouter +{ + private static readonly HashSet AllowedIntents = new( + [ + "health_entry", + "medication_entry", + "exercise_entry", + "personal_query", + "medical_consultation", + "report_analysis", + "diet_analysis", + "app_help", + "general_chat", + ], + StringComparer.OrdinalIgnoreCase); + + public static readonly ToolDefinition RouteTool = new() + { + Function = new() + { + Name = "route_user_intent", + Description = "返回当前用户消息的一个或多个业务意图,不回答用户问题", + Parameters = new + { + type = "object", + properties = new + { + intents = new + { + type = "array", + minItems = 1, + uniqueItems = true, + items = new + { + type = "string", + @enum = AllowedIntents.OrderBy(x => x).ToArray(), + }, + }, + draft_action = new + { + type = "string", + @enum = new[] { "none", "continue", "start_new", "cancel" }, + description = "当前消息与待补充录入草稿的关系", + }, + draft_type = new + { + type = "string", + @enum = new[] { "none", "health_entry", "medication_entry", "exercise_entry" }, + }, + }, + required = new[] { "intents", "draft_action", "draft_type" }, + }, + }, + }; + + public static async Task RouteAsync( + DeepSeekClient client, + PromptManager prompts, + string currentMessage, + string attachmentContext, + string draftContext, + string recentConversation, + CancellationToken ct) + { + var userContext = $""" + 当前用户消息: + {currentMessage} + + 本轮附件: + {(string.IsNullOrWhiteSpace(attachmentContext) ? "无" : attachmentContext)} + + 当前待补充录入草稿: + {(string.IsNullOrWhiteSpace(draftContext) ? "无" : draftContext)} + + 最近对话: + {(string.IsNullOrWhiteSpace(recentConversation) ? "无" : recentConversation)} + """; + + try + { + var response = await client.ChatAsync( + [ + new ChatMessage { Role = "system", Content = prompts.GetIntentRouterPrompt() }, + new ChatMessage { Role = "user", Content = userContext }, + ], + tools: [RouteTool], + maxTokens: 300, + temperature: 0.1f, + toolChoice: "required", + ct: ct); + + var call = response.Choices? + .FirstOrDefault()? + .Message? + .ToolCalls? + .FirstOrDefault(item => item.Function.Name == "route_user_intent"); + return call == null ? AiIntentRoute.Compatibility : Parse(call.Function.Arguments); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + // Routing failure must not make the whole chat unavailable. The broad + // compatibility prompt/tool set remains the safe fallback. + return AiIntentRoute.Compatibility; + } + } + + internal static AiIntentRoute Parse(string arguments) + { + try + { + using var json = JsonDocument.Parse(arguments); + var root = json.RootElement; + var intents = root.TryGetProperty("intents", out var values) && + values.ValueKind == JsonValueKind.Array + ? values.EnumerateArray() + .Where(value => value.ValueKind == JsonValueKind.String) + .Select(value => value.GetString()?.Trim().ToLowerInvariant() ?? "") + .Where(AllowedIntents.Contains) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() + : []; + if (intents.Count == 0) intents.Add("general_chat"); + + var draftAction = ReadEnum( + root, + "draft_action", + ["none", "continue", "start_new", "cancel"], + "none"); + var draftType = ReadEnum( + root, + "draft_type", + ["none", "health_entry", "medication_entry", "exercise_entry"], + "none"); + return new AiIntentRoute(intents, draftAction, draftType); + } + catch (JsonException) + { + return AiIntentRoute.Compatibility; + } + } + + private static string ReadEnum( + JsonElement root, + string property, + IReadOnlyCollection allowed, + string fallback) + { + var value = root.TryGetProperty(property, out var element) && + element.ValueKind == JsonValueKind.String + ? element.GetString()?.Trim().ToLowerInvariant() + : null; + return value != null && allowed.Contains(value) ? value : fallback; + } +} diff --git a/backend/src/Health.Infrastructure/AI/fastgpt_knowledge_client.cs b/backend/src/Health.Infrastructure/AI/fastgpt_knowledge_client.cs index 6eb1f57..aa2f5d9 100644 --- a/backend/src/Health.Infrastructure/AI/fastgpt_knowledge_client.cs +++ b/backend/src/Health.Infrastructure/AI/fastgpt_knowledge_client.cs @@ -6,7 +6,7 @@ namespace Health.Infrastructure.AI; /// /// FastGPT 知识库检索客户端(仅做 RAG 检索,不参与生成)。 /// 我们后端的 DeepSeek 依旧负责对话生成与工具调用; -/// 这里只把检索到的医学知识片段拼回 system prompt,提升回答准确度。 +/// 检索结果作为按需工具结果返回给主对话,提升回答准确度。 /// public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration config, ILogger logger) { diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index 3d928cb..e95f4cf 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -1,231 +1,123 @@ +using System.Reflection; + namespace Health.Infrastructure.AI; /// -/// System Prompt 模板管理 +/// Loads small, responsibility-focused prompt resources and composes only the +/// sections required by the current intent route. /// public sealed class PromptManager { - /// - /// 获取指定 Agent 的 System Prompt - /// - public string GetSystemPrompt(AgentType agentType) - { - var prompt = agentType switch - { - AgentType.Default => DefaultPrompt, - AgentType.Consultation => ConsultationPrompt, - AgentType.Health => HealthDataPrompt, - AgentType.Diet => DietPrompt, - AgentType.Medication => MedicationPrompt, - AgentType.Report => ReportPrompt, - AgentType.Exercise => ExercisePrompt, - AgentType.Unified => UnifiedPrompt, - _ => DefaultPrompt - }; + private static readonly Assembly PromptAssembly = typeof(PromptManager).Assembly; + private static readonly IReadOnlyDictionary PromptCache = LoadPrompts(); - return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}"; + private static readonly string[] GlobalSections = + [ + "global/identity.md", + "global/medical_safety.md", + "global/response_style.md", + ]; + + public string GetIntentRouterPrompt() => Read("router/intent_router.md"); + + public string ComposeForIntents(IEnumerable intents) + { + var normalized = intents + .Select(NormalizeIntent) + .Where(intent => intent.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (normalized.Count == 0) normalized.Add("general_chat"); + + var sections = new List(GlobalSections); + foreach (var intent in normalized) + { + sections.Add(intent switch + { + "health_entry" => "modules/health_entry.md", + "medication_entry" => "modules/medication_entry.md", + "exercise_entry" => "modules/exercise_entry.md", + "personal_query" => "modules/personal_query.md", + "medical_consultation" => "modules/medical_consultation.md", + "report_analysis" => "modules/report_analysis.md", + "diet_analysis" => "modules/diet_analysis.md", + "app_help" or "general_chat" => "modules/general_chat.md", + _ => "modules/general_chat.md", + }); + } + + if (normalized.Contains("medical_consultation") || + normalized.Contains("report_analysis") || + normalized.Contains("diet_analysis")) + { + sections.Add("rag/retrieval_rules.md"); + } + + return Compose(sections); } - private const string SmartLinkRules = """ - 智能跳转链接(按需嵌入): - - 当用户明确询问食物的 热量/卡路里 时,在回答的自然位置嵌入 Markdown 链接 [热量分析](app://diet),引导用户使用专门的饮食拍照分析功能。 - - 当用户上传医学检查报告、化验单、影像报告、出院小结等需要专业解读时,在回答末尾嵌入 [报告分析](app://report),引导上传到报告分析功能获得详细解读。 - - 当用户询问血压、血糖、血氧等需要长期追踪的设备测量场景时,可嵌入 [蓝牙设备](app://device)。 + /// + /// Compatibility entry for older clients that still address a specific agent. + /// The main Flutter app uses Unified and is routed through ComposeForIntents. + /// + public string GetSystemPrompt(AgentType agentType) => agentType switch + { + AgentType.Health => ComposeForIntents(["health_entry", "personal_query"]), + AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]), + AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]), + AgentType.Consultation => ComposeForIntents(["medical_consultation"]), + AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]), + AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]), + AgentType.Unified => ComposeForIntents([ + "health_entry", + "medication_entry", + "exercise_entry", + "personal_query", + "medical_consultation", + "report_analysis", + "diet_analysis", + "general_chat", + ]), + _ => ComposeForIntents(["general_chat", "medical_consultation"]), + }; - 硬性约束: - - 每条回复最多嵌入一个跳转链接,且必须在合适语境下自然提及,不要堆砌。 - - 仅"问热量/卡路里"才用 app://diet;"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。 - - 链接的 Markdown 文本只能用上面列出的 4 个固定文案,不要自创其他文案。 - """; + private static string Compose(IEnumerable sections) => + string.Join( + "\n\n", + sections + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(Read) + .Where(content => !string.IsNullOrWhiteSpace(content))); - private const string MedicalBoundaryRules = """ - 医疗边界(必须遵守): - - 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。 - - 可以解释健康数据、报告指标和症状可能方向,但不要给出确定诊断,不要说“你就是/一定是/已经确诊”。 - - 不要要求用户自行新增、停用、更换药物或调整剂量;涉及用药变化时,必须建议咨询医生或药师。 - - 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等情况,优先建议及时就医或急诊评估。 - - 回答用语使用“可能、建议、需要结合医生判断”等表达,避免绝对化结论。 - - 医疗相关分析末尾用一句自然的话提醒:以上为 AI 预分析,不能替代医生诊断和治疗建议。 + private static string NormalizeIntent(string? intent) => + intent?.Trim().Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant() ?? ""; - 回复格式规则(严格遵守): - - 禁止使用 **粗体**、*斜体*、~~删除线~~ 等 Markdown 内联标记 - - 禁止使用 HTML 标签 - - 禁止使用 Markdown 表格(|--|--|) - - 标题只使用 ### 三级标题,禁止使用 #、##、#### - - 列表使用 - 开头,每项一行 - - 数值和单位之间加空格:120 mmHg、6.2 mmol/L - - 不要输出任何 $ 开头的占位符 - - 回复结尾不再重复免责声明,MedicalBoundaryRules 已统一处理 - """; + private static string Read(string path) => + PromptCache.TryGetValue(NormalizePath(path), out var content) + ? content + : throw new InvalidOperationException($"AI prompt resource not found: {path}"); - private const string DefaultPrompt = """ - 你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。 + private static IReadOnlyDictionary LoadPrompts() + { + const string marker = ".AI.Prompts."; + var prompts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var resourceName in PromptAssembly.GetManifestResourceNames()) + { + var markerIndex = resourceName.IndexOf(marker, StringComparison.Ordinal); + if (markerIndex < 0 || !resourceName.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + continue; - 职责: - 1. 理解用户的健康需求,解析健康数据 - 2. 主动查看患者近期数据,发现异常时提醒 - 3. 回答健康知识问题 - 4. 每次回复末尾,如有需要提醒的事项,简短温馨地提醒一句 + using var stream = PromptAssembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Unable to open AI prompt resource: {resourceName}"); + using var reader = new StreamReader(stream); + var relative = resourceName[(markerIndex + marker.Length)..]; + var lastDot = relative.LastIndexOf(".md", StringComparison.OrdinalIgnoreCase); + relative = relative[..lastDot].Replace('.', '/') + ".md"; + prompts[NormalizePath(relative)] = reader.ReadToEnd().Trim(); + } + return prompts; + } - 规则: - - 不要提供超出你能力范围的医疗建议 - - 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医 - - 饮食/运动建议要结合患者档案中的疾病和限制 - - 回复语气温暖、专业、像朋友一样关怀患者 - """; - - private const string ConsultationPrompt = """ - 你是一个患者端 AI 预问诊助手,负责帮助心脏术后患者梳理症状和就医前信息。 - - 规则: - 1. 每次只问一个问题,不要一次问多个 - 2. 给出 2-3 个快捷选项让患者点击 - 3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析 - 4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸 - 5. 遇到以下情况建议咨询医生:血压持续>160/100、心率>120或<50 - 6. 只给出初步分析和下一步建议,不作诊断结论 - 7. 问诊结束给出结构化小结 - """; - - private const string HealthDataPrompt = """ - 你是一个健康数据录入助手。 - - 规则: - 1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重) - 2. 指标明确+数值明确→调用 record_health_data 生成待确认写入命令,不要直接声称已经录入 - 3. 用户可以一次性说多个指标(如"血压120/80,血糖6.2,血氧97")→多次调用 record_health_data - 4. 用户可以分时段说(如"早上血压120,下午血压130")→分别生成待确认命令,recorded_at 参数用不同时间 - 5. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?" - 6. 时间模糊→取当前时间 - 7. 数值超出正常范围→附带异常提醒 - 8. 每次调用 record_health_data 后继续处理下一个;工具返回仅表示等待确认,必须提示用户点击确认卡片,不能说“录入成功” - - 正常值参考范围: - - 收缩压 90-139 mmHg,舒张压 60-89 mmHg - - 心率 60-100 次/分 - - 空腹血糖 3.9-6.1 mmol/L - - 血氧 95-100% - """; - - private const string DietPrompt = """ - 你是一个营养分析专家,专门为心脏术后患者提供饮食指导。 - - 规则: - 1. 收到VLM食物识别结果后,必须先调用 check_archive 查询患者档案 - 2. 逐项判断"能不能吃": - - 过敏食物 → 严格禁止,红色警告 - - 饮食限制(如低盐/低脂/低糖)→ 黄色提醒 - - 疾病禁忌(如冠心病忌高脂、糖尿病忌高糖)→ 红色警告 - 3. 给出 1-5 星健康评分 - 4. 结合患者档案中的诊断(如冠心病PCI术后)、慢病史(高血压/高血脂等)给出个性化建议 - 5. 单项警告 + 整体饮食建议 + 替代推荐 - 6. 追问餐次归属:早餐/午餐/晚餐/加餐 - """; - - private const string MedicationPrompt = """ - 你是一个用药记录与提醒助手。 - - 规则: - 1. 用户询问当前、某日、未来或过去的用药计划及服药完成状态时,必须先调用 manage_medication(action="query") 查询,不能根据患者背景或聊天历史猜测 - - 某一天的服药任务:今天用 scope="today";其他相对日期或指定日期用 scope="scheduled_date" 并传北京时间 date - - 计划状态:当前有效用 scope="current_plans";尚未开始用 scope="upcoming_plans";自然结束用 scope="ended_plans";人为停用用 scope="inactive_plans";全部用 scope="all_plans" - - 查询时必须明确传 scope;时间意图无法判断时先追问,不能默认成今天 - 2. 解析用户口中的药品信息(药名/剂量/频次/时间) - 3. "早饭后"等模糊时间→追问具体几点 - 4. 解析完成后调用 manage_medication(action="create") 生成待确认命令并展示确认卡片;用户点击确认前不得声称已保存 - 5. 处方拍照→提取药品信息→生成用药计划→让用户确认 - 6. 可以解释常见用药注意事项,但不要建议用户自行新增、停用、更换药物或调整剂量 - """; - - private const string ReportPrompt = """ - 你是一个患者端医学报告预解读助手。 - - 规则: - 1. 收到报告图片后,提取所有指标及其数值 - 2. 标注异常指标(偏高/偏低/正常) - 3. 给出初步分析和可能方向,不作诊断结论 - 4. 所有内容标注"AI预解读,待医生确认" - 5. 图像类报告(彩超/CT)注明"需医生人工审阅" - """; - - private const string ExercisePrompt = """ - 你是一个运动康复教练,专门为心脏术后患者制定运动计划。 - - 规则: - 1. 帮助用户设定每周运动计划(类型/时长/天数) - 2. 以周为单位,每天指定运动类型和时长 - 3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等 - 4. 运动强度要循序渐进 - 5. 避免剧烈运动 - 6. 查询运动计划时必须调用 manage_exercise(action="query"),不能根据聊天历史猜测 - - 某一天的运动任务:今天用 scope="today";其他相对日期或指定日期用 scope="scheduled_date" 并传北京时间 date - - 计划状态:当前有效用 scope="current_plans";尚未开始用 scope="upcoming_plans";已经结束用 scope="ended_plans";全部用 scope="all_plans" - - 查询时必须明确传 scope;时间意图无法判断时先追问,不能默认成今天 - """; - - private const string UnifiedPrompt = """ - 你是患者端统一 AI 健康助手“小脉”。无论用户是否点击顶部功能入口,你都要理解其真实意图,并完成当前可用的健康解释、数据记录、用药管理、运动管理和预问诊辅助。 - - 核心原则:先识别用户想做什么,再决定调用工具或继续对话。不能只在文字中声称“已录入”“已保存”或“请点击确认”,实际却没有调用写入工具并生成确认卡片。 - - 领域判断与可用工具: - 1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data - 2. 新饮食图片分析 → 引导用户使用「拍饮食」功能;已保存饮食记录查询 → 调用 query_diet_records - 3. 用药管理 → 调用 manage_medication - 4. 运动计划 → 调用 manage_exercise - 5. 健康档案查询 → 调用 check_archive;聊天中不修改健康档案 - 6. 历史数据查询 → 调用 query_health_records - 7. 复查随访 → 调用 query_followups - 8. 已保存的检查报告 → 调用 query_reports - 9. 站内通知 → 调用 query_notifications - - 计划类问题统一采用“时间意图 + 业务对象”判断,不依赖某一句固定问法或某个单独关键词: - - 先判断用户是在查询“某一天的任务”,还是查询“计划所处的生命周期”。两者不能混用 - - 某一天的任务:北京时间今天使用 scope="today";昨天、明天、相对日期或明确日期使用 scope="scheduled_date" 并传换算后的 date - - 计划生命周期:当前有效使用 scope="current_plans";尚未开始使用 scope="upcoming_plans";已经自然结束使用 scope="ended_plans";查询全部历史和未来使用 scope="all_plans" - - 用药计划还要区分人为停用:明确询问已停药、已禁用或已取消的计划时使用 scope="inactive_plans",不能与自然到期混为一类 - - 复查使用同一语义映射:下一次 scope="next"、全部未来 scope="upcoming"、已完成 scope="completed"、指定日期 scope="date"、全部 scope="all" - - “有没有、会不会有、新的、接下来”等表达要结合完整语义判断:询问是否存在计划属于 query;明确要求新增、制定或安排才属于 create - - “今天是否完成”必须先查询当天任务,再根据逐项状态回答;计划存在、当天有任务和任务已完成是三个不同事实 - - 调用用药、运动或复查查询时 scope 必须明确传入;如果用户的时间意图确实无法判断,先只追问时间范围,禁止擅自按今天查询 - - 工具返回 results_truncated=true 或 items_truncated=true 时,只能说明当前返回的是部分结果或部分日程,不能把它表述成完整清单 - - 运动计划参数格式(manage_exercise): - - action="create", start_date="开始日期(YYYY-MM-DD,默认今天)" - - duration_days=连续天数,exercise_type="散步",duration_minutes=30,reminder_time="19:00" - - 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字 - - 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天 - - 用户说"坚持X天/月/周"则创建对应天数 - - 结束日期由 start_date + duration_days - 1 自动计算,不要按星期几生成条目 - - 用药管理参数格式(manage_medication): - - action="create", name="药名", dosage="每次剂量", frequency="Daily", time_of_day=["08:00","20:00"], start_date="YYYY-MM-DD" - - 有明确疗程时传 duration_days;明确为长期服用时传 long_term=true。两者都不明确时必须追问 - - 药名、每次剂量、频率、具体时间、开始日期以及疗程/长期属性任一缺失时,必须只追问缺失信息,禁止使用默认值生成确认卡 - - 确认服药:必须使用查询结果中的 medication_id 和 scheduled_time,不能把当前时间当作计划时间 - - 规则: - - 用户可能一句话涉及多个领域,全部处理 - - 询问“我的、今天、当前、下一次、是否完成”等用户个人事实时,必须先调用对应只读工具。不能只根据患者背景或聊天历史回答 - - 当前工具结果是最高优先级;工具结果与历史聊天冲突时,以当前工具结果为准。历史里的计划、报告和完成状态不能当作当前事实 - - 患者背景不提供动态计划状态;任何用药、运动和复查计划事实都必须调用对应工具实时查询 - - 必须区分“记录存在”“计划当前有效”“指定日期有安排”“指定任务已完成”,不能把它们当成同一件事 - - 日期、今天、昨天、当前时间都按系统提供的北京时间解释 - - 工具没有返回数据时应明确说未查到;没有相应读取能力时应说明暂时无法查询,禁止编造 - - 查询健康记录时,根据用户问题选择最小且最相关的指标和时间范围:问某一指标就只查该指标;“今天/昨天”使用对应 scope;趋势问题按用户提到的7天、30天等范围查询;用户没有说明时间范围时默认 recent_days=7 - - 健康记录工具最多返回最近100条,这是有意的上限;不得为了获得更多原始记录而反复扩大或拆分查询。需要更长周期但现有结果不足时,应说明当前只能基于最近结果判断 - - 先判断用户是在咨询数值,还是希望记录数据,不能仅凭消息中出现指标和数值就自动录入 - - 明确提问时先回答问题,不调用 record_health_data。例如“血氧98%是不是太高了”是在咨询,应解释正常范围,不生成录入确认卡片;除非用户同时明确要求记录 - - 明确要求“记录、录入、保存、记一下”时调用 record_health_data。例如“帮我记录血压116/89”必须生成待确认命令 - - 只有指标和数值、没有明显疑问或记录用语时,结合当前对话上下文判断;仍无法确定意图时,先简短询问用户是想了解数值还是保存记录 - - 指标或数值缺失时只追问缺失部分,不猜测数值 - - 多个明确指标要逐项调用 record_health_data,不能遗漏 - - 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库 - - 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认 - - 没有真实调用写入工具时,禁止告诉用户“点击确认按钮”或暗示已经出现确认卡片 - - 用户描述症状或身体不适时进入预问诊:每轮只追问一个最关键问题,连续追问症状部位、开始时间、程度、诱因和伴随表现;信息足够后给出可能相关方向、严重程度以及观察/门诊/尽快就医/急诊建议,不作正式确诊 - - 预问诊通常追问 2-5 轮;发现危险信号时立即停止普通追问并优先给出就医提醒 - - 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医 - - 回复语气温和、清楚、专业,避免假装医生或作确定诊断 - """; + private static string NormalizePath(string path) => + path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant(); } diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260723054148_AddAiEntryDrafts.Designer.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260723054148_AddAiEntryDrafts.Designer.cs new file mode 100644 index 0000000..459a88a --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260723054148_AddAiEntryDrafts.Designer.cs @@ -0,0 +1,1528 @@ +// +using System; +using System.Collections.Generic; +using Health.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260723054148_AddAiEntryDrafts")] + partial class AddAiEntryDrafts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId"); + + b.ToTable("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsultationId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SenderName") + .HasColumnType("text"); + + b.Property("SenderType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConsultationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("ConsultationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageCount") + .HasColumnType("integer"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UpdatedAt") + .IsDescending(false, true); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Intent") + .HasColumnType("text"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "CreatedAt"); + + b.ToTable("ConversationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("PushToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Calories") + .HasColumnType("integer"); + + b.Property("CarbsGrams") + .HasColumnType("numeric"); + + b.Property("DietRecordId") + .HasColumnType("uuid"); + + b.Property("FatGrams") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Portion") + .HasColumnType("text"); + + b.Property("ProteinGrams") + .HasColumnType("numeric"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Warning") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DietRecordId"); + + b.ToTable("DietFoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HealthScore") + .HasColumnType("integer"); + + b.Property("MealType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("date"); + + b.Property("TotalCalories") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("DietRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("Introduction") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProfessionalDirection") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Doctors"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Hospital") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DoctorProfiles"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("ReminderTime") + .HasColumnType("time without time zone"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "StartDate", "EndDate"); + + b.ToTable("ExercisePlans"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationMinutes") + .HasColumnType("integer"); + + b.Property("ExerciseType") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsRestDay") + .HasColumnType("boolean"); + + b.Property("PlanId") + .HasColumnType("uuid"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("PlanId", "ScheduledDate") + .IsUnique(); + + b.ToTable("ExercisePlanItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Allergies") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection>("ChronicDiseases") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Diagnosis") + .HasColumnType("text"); + + b.PrimitiveCollection>("DietRestrictions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FamilyHistory") + .HasColumnType("text"); + + b.Property("SurgeryDate") + .HasColumnType("date"); + + b.Property("SurgeryHistoryStatus") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("SurgeryType") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("HealthArchives"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HealthArchiveId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("HealthArchiveId", "SortOrder"); + + b.ToTable("HealthArchiveSurgeries", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Diastolic") + .HasColumnType("integer"); + + b.Property("IsAbnormal") + .HasColumnType("boolean"); + + b.Property("MetricType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Systolic") + .HasColumnType("integer"); + + b.Property("Unit") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "MetricType"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.PrimitiveCollection>("TimeOfDay") + .IsRequired() + .HasColumnType("time[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsActive"); + + b.ToTable("Medications"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MedicationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("MedicationLogs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AbnormalAlert") + .HasColumnType("boolean"); + + b.Property("DndEnabled") + .HasColumnType("boolean"); + + b.Property("DndEndMinutes") + .HasColumnType("integer"); + + b.Property("DndStartMinutes") + .HasColumnType("integer"); + + b.Property("DoctorReply") + .HasColumnType("boolean"); + + b.Property("FollowUpReminder") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminder") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderBloodPressure") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderGlucose") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderHeartRate") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderSpO2") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderWeight") + .HasColumnType("boolean"); + + b.Property("MedicationReminder") + .HasColumnType("boolean"); + + b.Property("PushEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NotificationPreferences"); + }); + + modelBuilder.Entity("Health.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiIndicators") + .HasColumnType("jsonb"); + + b.Property("AiSummary") + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorComment") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("DoctorRecommendation") + .HasColumnType("text"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppleUserId") + .HasColumnType("text"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Gender") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("User"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AppleUserId") + .IsUnique(); + + b.HasIndex("DoctorId"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Health.Domain.Entities.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionTargetId") + .HasColumnType("text"); + + b.Property("ActionType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceId") + .IsUnique(); + + b.HasIndex("UserId", "IsDeleted", "IsRead", "CreatedAt"); + + b.ToTable("UserNotifications", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsUsed") + .HasColumnType("boolean"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Phone", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("VerificationCodes"); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.AiEntryDraftRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ConversationId", "EntryType", "Status", "ExpiresAt"); + + b.ToTable("AiEntryDrafts", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Arguments") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status", "ExpiresAt"); + + b.ToTable("AiWriteCommands", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.DietImageAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePaths") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ResultCode") + .HasColumnType("integer"); + + b.Property("ResultData") + .HasColumnType("text"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("DietImageAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.MedicationReminderTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.HasIndex("MedicationId", "ScheduledDate", "ScheduledTime") + .IsUnique(); + + b.ToTable("MedicationReminderTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.NotificationOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SourceTaskId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceTaskId") + .IsUnique(); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("NotificationOutbox", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.ReportAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ReportId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ReportId"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("ReportAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany("Consultations") + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Consultations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.HasOne("Health.Domain.Entities.Consultation", "Consultation") + .WithMany("Messages") + .HasForeignKey("ConsultationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Consultation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Conversations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.HasOne("Health.Domain.Entities.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DeviceTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.HasOne("Health.Domain.Entities.DietRecord", "DietRecord") + .WithMany("FoodItems") + .HasForeignKey("DietRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DietRecord"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DietRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("DoctorProfile") + .HasForeignKey("Health.Domain.Entities.DoctorProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("ExercisePlans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.HasOne("Health.Domain.Entities.ExercisePlan", "Plan") + .WithMany("Items") + .HasForeignKey("PlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Plan"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("FollowUps") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("HealthArchive") + .HasForeignKey("Health.Domain.Entities.HealthArchive", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.HasOne("Health.Domain.Entities.HealthArchive", "HealthArchive") + .WithMany("Surgeries") + .HasForeignKey("HealthArchiveId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("HealthArchive"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("HealthRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Medications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.HasOne("Health.Domain.Entities.Medication", "Medication") + .WithMany("Logs") + .HasForeignKey("MedicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Medication"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("NotificationPreference") + .HasForeignKey("Health.Domain.Entities.NotificationPreference", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Reports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Doctor"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Navigation("FoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Navigation("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Navigation("Surgeries"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Navigation("Consultations"); + + b.Navigation("Conversations"); + + b.Navigation("DeviceTokens"); + + b.Navigation("DietRecords"); + + b.Navigation("DoctorProfile"); + + b.Navigation("ExercisePlans"); + + b.Navigation("FollowUps"); + + b.Navigation("HealthArchive"); + + b.Navigation("HealthRecords"); + + b.Navigation("Medications"); + + b.Navigation("NotificationPreference"); + + b.Navigation("Reports"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260723054148_AddAiEntryDrafts.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260723054148_AddAiEntryDrafts.cs new file mode 100644 index 0000000..0e3ff7a --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260723054148_AddAiEntryDrafts.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + /// + public partial class AddAiEntryDrafts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AiEntryDrafts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ConversationId = table.Column(type: "uuid", nullable: false), + EntryType = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Payload = table.Column(type: "jsonb", nullable: false), + MissingFields = table.Column(type: "jsonb", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AiEntryDrafts", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AiEntryDrafts_UserId_ConversationId_EntryType_Status_Expire~", + table: "AiEntryDrafts", + columns: new[] { "UserId", "ConversationId", "EntryType", "Status", "ExpiresAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AiEntryDrafts"); + + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 5511618..bbe1567 100644 --- a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -956,6 +956,52 @@ namespace Health.Infrastructure.Data.Migrations b.ToTable("VerificationCodes"); }); + modelBuilder.Entity("Health.Infrastructure.Data.Records.AiEntryDraftRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ConversationId", "EntryType", "Status", "ExpiresAt"); + + b.ToTable("AiEntryDrafts", (string)null); + }); + modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b => { b.Property("Id") diff --git a/backend/src/Health.Infrastructure/Data/Records/AiEntryDraftRecord.cs b/backend/src/Health.Infrastructure/Data/Records/AiEntryDraftRecord.cs new file mode 100644 index 0000000..dbc7478 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Records/AiEntryDraftRecord.cs @@ -0,0 +1,19 @@ +namespace Health.Infrastructure.Data.Records; + +/// +/// Stores an incomplete AI-assisted entry until the user provides every required field. +/// This is deliberately separate from confirmed health, medication and exercise data. +/// +public sealed class AiEntryDraftRecord +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public Guid ConversationId { get; set; } + public string EntryType { get; set; } = string.Empty; + public string Payload { get; set; } = "{}"; + public string MissingFields { get; set; } = "[]"; + public string Status { get; set; } = "Pending"; + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/src/Health.Infrastructure/Data/app_db_context.cs b/backend/src/Health.Infrastructure/Data/app_db_context.cs index 298df71..3a55924 100644 --- a/backend/src/Health.Infrastructure/Data/app_db_context.cs +++ b/backend/src/Health.Infrastructure/Data/app_db_context.cs @@ -37,6 +37,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon public DbSet UserNotifications => Set(); public DbSet DeviceTokens => Set(); public DbSet AiWriteCommands => Set(); + public DbSet AiEntryDrafts => Set(); public DbSet ReportAnalysisTasks => Set(); public DbSet DietImageAnalysisTasks => Set(); public DbSet MedicationReminderTasks => Set(); @@ -194,6 +195,16 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon e.Property(x => x.Arguments).HasColumnType("jsonb"); }); + builder.Entity(e => + { + e.ToTable("AiEntryDrafts"); + e.HasIndex(x => new { x.UserId, x.ConversationId, x.EntryType, x.Status, x.ExpiresAt }); + e.Property(x => x.EntryType).HasMaxLength(32); + e.Property(x => x.Status).HasMaxLength(32); + e.Property(x => x.Payload).HasColumnType("jsonb"); + e.Property(x => x.MissingFields).HasColumnType("jsonb"); + }); + builder.Entity(e => { e.ToTable("ReportAnalysisTasks"); diff --git a/backend/src/Health.Infrastructure/Health.Infrastructure.csproj b/backend/src/Health.Infrastructure/Health.Infrastructure.csproj index f8fffa8..6406cd0 100644 --- a/backend/src/Health.Infrastructure/Health.Infrastructure.csproj +++ b/backend/src/Health.Infrastructure/Health.Infrastructure.csproj @@ -17,6 +17,10 @@ + + + + net10.0 enable diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index 34b8e94..8e0f3c1 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -1,4 +1,6 @@ +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; using Health.Application.AI; using Health.Application.Diets; @@ -37,6 +39,7 @@ public static class AiChatEndpoints FastGptKnowledgeClient knowledgeClient, IAiToolExecutionService toolExecution, IAiWriteConfirmationStore confirmations, + IAiEntryDraftStore entryDrafts, IMedicationService medications, IAiConversationService conversations, IAttachmentContextBuilder attachments, @@ -60,6 +63,9 @@ public static class AiChatEndpoints http.Response.Headers.CacheControl = "no-cache"; http.Response.Headers.Connection = "keep-alive"; http.Response.Headers["X-Accel-Buffering"] = "no"; + http.Features + .Get() + ?.DisableBuffering(); // 创建或获取对话。传入 conversationId 时必须校验归属,避免多账号切换或缓存异常导致串号。 Guid? requestedConversationId = null; @@ -127,37 +133,77 @@ public static class AiChatEndpoints return; } - // 加载上下文 - var systemPrompt = promptManager.GetSystemPrompt(parsedType); - var patientContext = await patientContexts.BuildAsync(userId.Value, ct); - - // ── RAG 知识库检索 ── - // 仅对需要医学知识的 Agent 启用检索;记数据/管用药/排运动这些纯工具调用类跳过。 - // 失败/空结果不阻塞流程,仅作为 prompt 增强。 - var knowledgeSnippet = ""; - if (NeedsKnowledgeBase(parsedType) && knowledgeClient.IsEnabled) + // Load conversation state before routing. A bare reply such as "87" + // only has meaning when the router can see the active structured draft. + var entryDraftContext = await BuildEntryDraftContextAsync( + entryDrafts, + userId.Value, + activeConversationId, + ct); + var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct); + var routingHistory = string.Join( + '\n', + history + .TakeLast(8) + .Select(item => + $"{(item.Role == MessageRole.User.ToString() ? "用户" : "助手")}:{item.Content}")); + var attachmentRoutingContext = attachment == null + ? "" + : $"类型={attachment.Kind};分类={attachment.Category};摘要={attachment.Summary}"; + var intentRoute = parsedType == AgentType.Unified + ? await AiIntentRouter.RouteAsync( + llmClient, + promptManager, + message, + attachmentRoutingContext, + entryDraftContext, + routingHistory, + ct) + : RouteForAgent(parsedType); + if (intentRoute.DraftAction is "start_new" or "cancel") { - knowledgeSnippet = await knowledgeClient.SearchAsync(message, ct); + await CloseRoutedDraftAsync( + entryDrafts, + userId.Value, + activeConversationId, + intentRoute.DraftType, + ct); + entryDraftContext = await BuildEntryDraftContextAsync( + entryDrafts, + userId.Value, + activeConversationId, + ct); } + // Only compose the modules selected for this turn. This keeps entry, + // query and consultation rules from competing in one giant prompt. + var systemPrompt = parsedType == AgentType.Unified + ? promptManager.ComposeForIntents(intentRoute.Intents) + : promptManager.GetSystemPrompt(parsedType); + var patientContext = await patientContexts.BuildAsync(userId.Value, ct); + var beijingNow = DateTime.UtcNow.AddHours(8); var enhancedSystem = systemPrompt + $"\n\n系统当前北京时间:{beijingNow:yyyy-MM-dd HH:mm:ss}(UTC+8)。所有‘今天/昨天/当前/下一次’均以此时间为准。" + + $"\n\n本轮意图路由:{string.Join("、", intentRoute.Intents)};草稿动作:{intentRoute.DraftAction};草稿类型:{intentRoute.DraftType}。" + "\n\n当前患者背景信息(仅作医学背景;个人计划和完成状态必须调用对应工具获取最新数据):\n" + patientContext; - if (!string.IsNullOrWhiteSpace(knowledgeSnippet)) + if (intentRoute.DraftAction == "cancel") + enhancedSystem += "\n\n用户已取消对应待补充草稿。本轮不要调用写入工具,只需自然确认已取消继续补充。"; + + if (parsedType == AgentType.Default) { - enhancedSystem += "\n\n知识库参考资料(如与用户问题相关请优先采用):\n" + knowledgeSnippet; + enhancedSystem += "\n\nWhen the user explicitly asks to record health measurements, call record_health_data_batch exactly once with every metric in the current batch. A record is pending until its confirmation card is tapped; never claim it was saved before confirmation. If the user asks to confirm but no card was generated, say that there is no pending record instead of claiming success."; } + if (!string.IsNullOrWhiteSpace(entryDraftContext)) + enhancedSystem += "\n\n当前会话尚未补齐的录入草稿(新消息只提供缺失字段时必须与这里合并,禁止遗忘已有字段):\n" + entryDraftContext; + 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"; @@ -198,9 +244,27 @@ public static class AiChatEndpoints } // Tool Calling 循环 - var tools = GetToolsForAgent(parsedType); + var tools = parsedType == AgentType.Unified + ? GetToolsForIntents(intentRoute.Intents) + : GetToolsForAgent(parsedType); + var requiredEntryTools = new HashSet(StringComparer.OrdinalIgnoreCase); + if (intentRoute.DraftAction != "cancel") + { + if (intentRoute.Intents.Contains("health_entry", StringComparer.OrdinalIgnoreCase)) + requiredEntryTools.Add("record_health_data_batch"); + if (intentRoute.Intents.Contains("medication_entry", StringComparer.OrdinalIgnoreCase)) + requiredEntryTools.Add("manage_medication"); + if (intentRoute.Intents.Contains("exercise_entry", StringComparer.OrdinalIgnoreCase)) + requiredEntryTools.Add("manage_exercise"); + } + var processedEntryTools = new HashSet(StringComparer.OrdinalIgnoreCase); + var requiresPersonalQuery = intentRoute.Intents.Contains("personal_query", StringComparer.OrdinalIgnoreCase); + var personalQueryHandled = false; + var authoritativePersonalAnswers = new List(); + var allPersonalQueryResultsAreAuthoritative = true; var maxIterations = 5; var fullResponse = ""; + var answerStreamed = false; var completedNormally = false; var messageType = "text"; var metadata = new Dictionary(); @@ -209,27 +273,73 @@ public static class AiChatEndpoints { 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 missingEntryTools = requiredEntryTools + .Where(name => !processedEntryTools.Contains(name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + List iterationTools; + string? iterationToolChoice = null; + if (missingEntryTools.Count > 0) + { + iterationTools = tools + .Where(tool => missingEntryTools.Contains(tool.Function.Name)) + .ToList(); + iterationToolChoice = "required"; + } + else if (requiresPersonalQuery && !personalQueryHandled) + { + iterationTools = tools + .Where(tool => IsPersonalQueryTool(tool.Function.Name)) + .ToList(); + iterationToolChoice = "required"; + } + else + { + iterationTools = tools; + } + + if (iterationTools.Count == 0) + { + fullResponse = await StreamModelAnswerAsync(http, llmClient, messages, ct); + answerStreamed = true; + completedNormally = true; + break; + } + + var response = await llmClient.ChatAsync( + messages, + tools: iterationTools.Count > 0 ? iterationTools : null, + toolChoice: iterationToolChoice, + 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)) + if (messageType is "data_confirm" or "medication_confirm") { - 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; } + + if (authoritativePersonalAnswers.Count > 0 && + allPersonalQueryResultsAreAuthoritative && + !intentRoute.Intents.Contains("medical_consultation", StringComparer.OrdinalIgnoreCase)) + { + fullResponse = string.Join("\n\n", authoritativePersonalAnswers); + await StreamAnswerTextAsync(http, fullResponse, ct); + } + else if (requiredEntryTools.Count > 0 && + GetLatestToolError(messages) is { Length: > 0 } toolError) + { + fullResponse = $"还需要你补充一下:{toolError}"; + await StreamAnswerTextAsync(http, fullResponse, ct); + } + else + { + fullResponse = await StreamModelAnswerAsync(http, llmClient, messages, ct); + } + answerStreamed = true; completedNormally = true; break; } @@ -242,14 +352,109 @@ public static class AiChatEndpoints ToolCalls = choice.Message.ToolCalls, }); + var mergedHealthBatchArguments = choice.Message.ToolCalls + .Where(call => call.Function.Name == "record_health_data_batch") + .Select(call => call.Function.Arguments) + .Aggregate( + (string?)null, + (merged, incoming) => MergeHealthBatchDraftArguments(merged, incoming)); + var healthBatchHandled = false; foreach (var tc in choice.Message.ToolCalls) { + if (requiredEntryTools.Contains(tc.Function.Name) && + IsExpectedEntryToolCall(tc.Function.Name, tc.Function.Arguments)) + processedEntryTools.Add(tc.Function.Name); object toolResult; + var toolArguments = tc.Function.Name == "record_health_data_batch" + ? mergedHealthBatchArguments ?? tc.Function.Arguments + : tc.Function.Arguments; + if (tc.Function.Name == "record_health_data_batch" && healthBatchHandled) + { + toolResult = new + { + success = true, + mergedIntoCurrentBatch = true, + message = "该工具调用已合并到本轮同一健康指标批次", + }; + await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct); + messages.Add(new ChatMessage + { + Role = "tool", + Content = JsonSerializer.Serialize(toolResult, JsonOpts), + ToolCallId = tc.Id, + }); + continue; + } + if (tc.Function.Name == "record_health_data_batch") + healthBatchHandled = true; + AiEntryDraft? activeEntryDraft = null; + var draftEntryType = GetDraftEntryType(tc.Function.Name, toolArguments); try { - toolResult = IsWriteToolCall(tc.Function.Name, tc.Function.Arguments) - ? await PreparePendingWriteAsync(confirmations, medications, userId.Value, tc.Function.Name, tc.Function.Arguments, ct) - : await toolExecution.ExecuteAsync(tc.Function.Name, tc.Function.Arguments, userId.Value, ct); + if (draftEntryType != null) + { + activeEntryDraft = await entryDrafts.GetActiveAsync( + userId.Value, + activeConversationId, + draftEntryType, + ct); + toolArguments = tc.Function.Name == "record_health_data_batch" + ? MergeHealthBatchDraftArguments(activeEntryDraft?.Payload, toolArguments) + : MergeDraftArguments(activeEntryDraft?.Payload, toolArguments); + } + + if (tc.Function.Name == "search_medical_knowledge") + { + toolResult = await SearchMedicalKnowledgeAsync( + knowledgeClient, + toolArguments, + ct); + } + else if (tc.Function.Name == "record_health_data" && + !IsHealthWriteGroundedInCurrentTurn( + message, + toolArguments, + CompletesBloodPressureDraft( + activeEntryDraft?.Payload, + tc.Function.Arguments))) + { + toolResult = new + { + success = false, + message = "该指标只出现在之前已结束的录入批次中,本轮没有重新提供,不能带入新的确认卡。", + }; + } + else + { + toolResult = IsWriteToolCall(tc.Function.Name, toolArguments) + ? await PreparePendingWriteAsync(confirmations, medications, userId.Value, tc.Function.Name, toolArguments, ct) + : await toolExecution.ExecuteAsync(tc.Function.Name, toolArguments, userId.Value, ct); + } + + if (draftEntryType != null) + { + if (IsPendingConfirmationResult(toolResult)) + { + if (activeEntryDraft != null) + await entryDrafts.CompleteAsync(activeEntryDraft.Id, userId.Value, ct); + } + else if (IsUrgentResult(toolResult)) + { + if (activeEntryDraft != null) + await entryDrafts.CompleteAsync(activeEntryDraft.Id, userId.Value, ct); + } + else if (GetResultMessage(toolResult) is { Length: > 0 } validationMessage) + { + await entryDrafts.UpsertAsync( + userId.Value, + activeConversationId, + draftEntryType, + toolArguments, + JsonSerializer.Serialize(new[] { validationMessage }, JsonOpts), + TimeSpan.FromMinutes(30), + ct); + } + } } catch (Exception ex) { @@ -258,6 +463,19 @@ public static class AiChatEndpoints await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct); _UpdateMessageTypeAndMetadata(tc.Function.Name, toolResult, ref messageType, ref metadata); + if (IsPersonalQueryToolCall(tc.Function.Name, toolArguments)) + { + personalQueryHandled = true; + var authoritativeAnswer = GetAuthoritativeAnswer(toolResult); + if (string.IsNullOrWhiteSpace(authoritativeAnswer)) + { + allPersonalQueryResultsAreAuthoritative = false; + } + else + { + authoritativePersonalAnswers.Add(authoritativeAnswer); + } + } messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id }); } @@ -265,6 +483,42 @@ public static class AiChatEndpoints else break; } + if (authoritativePersonalAnswers.Count > 0 && + allPersonalQueryResultsAreAuthoritative && + !intentRoute.Intents.Contains("medical_consultation", StringComparer.OrdinalIgnoreCase)) + { + fullResponse = string.Join("\n\n", authoritativePersonalAnswers); + } + + // Confirmation cards must not depend on the model producing prose. A tool-only + // response used to create pending commands successfully but never sent the + // client an `answer` event, so there was no card to confirm. + var hasConfirmationCard = messageType is "data_confirm" or "medication_confirm"; + if (hasConfirmationCard) + { + completedNormally = true; + metadata["messageType"] = messageType; + fullResponse = "请核对以下信息并确认。"; + await SseWriteAsync(http, new + { + action = "confirmation", + type = messageType, + metadata, + }, ct); + } + else if (!string.IsNullOrWhiteSpace(fullResponse)) + { + if (!answerStreamed && ClaimsMissingConfirmationCard(fullResponse)) + fullResponse = await RewriteUnsupportedConfirmationClaimAsync( + messages, + llmClient, + ct); + if (!answerStreamed) + { + await StreamAnswerTextAsync(http, fullResponse, ct); + } + } + // 保存 AI 回复 if (!string.IsNullOrEmpty(fullResponse)) { @@ -421,6 +675,52 @@ public static class AiChatEndpoints await http.Response.Body.FlushAsync(ct); } + private static async Task StreamAnswerTextAsync( + HttpContext http, + string text, + CancellationToken ct) + { + const int CharacterIntervalMilliseconds = 14; + + foreach (var rune in text.EnumerateRunes()) + { + await SseWriteAsync(http, new + { + action = "answer", + data = rune.ToString(), + type = "text", + }, ct); + await Task.Delay(CharacterIntervalMilliseconds, ct); + } + } + + private static async Task StreamModelAnswerAsync( + HttpContext http, + DeepSeekClient llmClient, + List messages, + CancellationToken ct) + { + var responseText = new StringBuilder(); + 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)) + continue; + + responseText.Append(content); + await StreamAnswerTextAsync(http, content, ct); + } + catch (JsonException) + { + // Ignore one malformed provider chunk without interrupting the answer. + } + } + return responseText.ToString(); + } + private static Guid? GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : null; @@ -536,30 +836,90 @@ public static class AiChatEndpoints // ── Agent / Tool 调度 ── - /// - /// 哪些 Agent 需要走 FastGPT 知识库 RAG。 - /// 纯工具调用类(Health/Medication/Exercise)跳过 RAG,避免拖慢响应。 - /// - private static bool NeedsKnowledgeBase(AgentType agentType) => agentType switch + private static AiIntentRoute RouteForAgent(AgentType agentType) => agentType switch { - AgentType.Default => true, - AgentType.Consultation => true, - AgentType.Diet => true, - AgentType.Report => true, - AgentType.Unified => true, - _ => false, + AgentType.Health => new(["health_entry", "personal_query"], "none", "none"), + AgentType.Medication => new(["medication_entry", "personal_query", "medical_consultation"], "none", "none"), + AgentType.Exercise => new(["exercise_entry", "personal_query", "medical_consultation"], "none", "none"), + AgentType.Consultation => new(["medical_consultation"], "none", "none"), + AgentType.Diet => new(["diet_analysis", "medical_consultation"], "none", "none"), + AgentType.Report => new(["report_analysis", "personal_query", "medical_consultation"], "none", "none"), + _ => AiIntentRoute.Compatibility, }; + private static List GetToolsForIntents(IEnumerable intents) + { + var tools = new List(); + foreach (var intent in intents.Distinct(StringComparer.OrdinalIgnoreCase)) + { + switch (intent.ToLowerInvariant()) + { + case "health_entry": + tools.Add(HealthDataAgentHandler.RecordHealthDataBatchTool); + break; + case "medication_entry": + tools.Add(MedicationAgentHandler.ManageMedicationTool); + break; + case "exercise_entry": + tools.Add(ExerciseAgentHandler.ManageExerciseTool); + break; + case "personal_query": + tools.AddRange([ + CommonAgentHandler.QueryHealthRecordsTool, + MedicationAgentHandler.ManageMedicationTool, + ExerciseAgentHandler.ManageExerciseTool, + CommonAgentHandler.CheckArchiveTool, + PatientReadAgentHandler.QueryDietRecordsTool, + PatientReadAgentHandler.QueryFollowUpsTool, + PatientReadAgentHandler.QueryReportsTool, + PatientReadAgentHandler.QueryNotificationsTool, + ]); + break; + case "medical_consultation": + tools.AddRange([ + CommonAgentHandler.QueryHealthRecordsTool, + CommonAgentHandler.CheckArchiveTool, + CommonAgentHandler.SearchMedicalKnowledgeTool, + ]); + break; + case "report_analysis": + tools.AddRange([ + ReportAgentHandler.AnalyzeReportTool, + CommonAgentHandler.QueryHealthRecordsTool, + CommonAgentHandler.SearchMedicalKnowledgeTool, + ]); + break; + case "diet_analysis": + tools.AddRange([ + CommonAgentHandler.CheckArchiveTool, + CommonAgentHandler.SearchMedicalKnowledgeTool, + ]); + break; + } + } + + return tools + .GroupBy(tool => tool.Function.Name, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + } + 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.Health => [.. HealthDataAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool], + AgentType.Medication => [.. MedicationAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool], + AgentType.Diet => [.. DietAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool], + AgentType.Consultation => [.. ConsultationAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool], + AgentType.Report => [.. ReportAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool], + AgentType.Exercise => [.. ExerciseAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool], + AgentType.Default => [ + HealthDataAgentHandler.RecordHealthDataBatchTool, + CommonAgentHandler.QueryHealthRecordsTool, + CommonAgentHandler.CheckArchiveTool, + CommonAgentHandler.SearchMedicalKnowledgeTool, + ], AgentType.Unified => [ - HealthDataAgentHandler.RecordHealthDataTool, + HealthDataAgentHandler.RecordHealthDataBatchTool, CommonAgentHandler.QueryHealthRecordsTool, MedicationAgentHandler.ManageMedicationTool, ExerciseAgentHandler.ManageExerciseTool, @@ -568,18 +928,240 @@ public static class AiChatEndpoints PatientReadAgentHandler.QueryFollowUpsTool, PatientReadAgentHandler.QueryReportsTool, PatientReadAgentHandler.QueryNotificationsTool, + CommonAgentHandler.SearchMedicalKnowledgeTool, ], - _ => CommonAgentHandler.Tools, + _ => [.. CommonAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool], }; + private static bool IsPersonalQueryTool(string toolName) => toolName is + "query_health_records" or + "check_archive" or + "query_diet_records" or + "query_followups" or + "query_reports" or + "query_notifications" or + "manage_medication" or + "manage_exercise"; + + private static bool IsPersonalQueryToolCall(string toolName, string arguments) + { + if (!IsPersonalQueryTool(toolName)) + return false; + return toolName switch + { + "manage_medication" or "manage_exercise" => GetToolAction(arguments) == "query", + _ => true, + }; + } + + private static bool IsExpectedEntryToolCall(string toolName, string arguments) => toolName switch + { + "record_health_data_batch" => true, + "manage_medication" or "manage_exercise" => GetToolAction(arguments) == "create", + _ => false, + }; + + private static string? GetAuthoritativeAnswer(object result) + { + try + { + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result, JsonOpts)); + return json.RootElement.TryGetProperty("authoritative_answer", out var answer) && + answer.ValueKind == JsonValueKind.String + ? answer.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } + private static bool IsWriteToolCall(string toolName, string arguments) { - if (toolName == "record_health_data") return true; + if (toolName is "record_health_data" or "record_health_data_batch") return true; 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? GetDraftEntryType(string toolName, string arguments) => toolName switch + { + "record_health_data_batch" => "health_entry_batch", + "record_health_data" when GetHealthMetricTypeFromArguments(arguments) == "blood_pressure" + => "health_blood_pressure_entry", + "manage_medication" when GetToolAction(arguments) == "create" => "medication_entry", + "manage_exercise" when GetToolAction(arguments) == "create" => "exercise_entry", + _ => null, + }; + + private static async Task BuildEntryDraftContextAsync( + IAiEntryDraftStore entryDrafts, + Guid userId, + Guid conversationId, + CancellationToken ct) + { + var lines = new List(); + foreach (var (entryType, label) in new[] + { + ("health_entry_batch", "健康指标批次"), + ("health_blood_pressure_entry", "血压"), + ("medication_entry", "用药计划"), + ("exercise_entry", "运动计划"), + }) + { + var draft = await entryDrafts.GetActiveAsync(userId, conversationId, entryType, ct); + if (draft == null) continue; + lines.Add($"- {label}:已有字段={draft.Payload};仍缺少={draft.MissingFields}"); + } + return string.Join('\n', lines); + } + + private static async Task CloseRoutedDraftAsync( + IAiEntryDraftStore entryDrafts, + Guid userId, + Guid conversationId, + string routedDraftType, + CancellationToken ct) + { + var entryType = routedDraftType switch + { + "health_entry" => "health_entry_batch", + "medication_entry" => "medication_entry", + "exercise_entry" => "exercise_entry", + _ => null, + }; + if (entryType == null) return; + + var draft = await entryDrafts.GetActiveAsync(userId, conversationId, entryType, ct); + if (draft != null) + await entryDrafts.CompleteAsync(draft.Id, userId, ct); + } + + private static string MergeDraftArguments(string? existingPayload, string newArguments) + { + JsonObject merged; + try + { + merged = string.IsNullOrWhiteSpace(existingPayload) + ? new JsonObject() + : JsonNode.Parse(existingPayload)?.AsObject() ?? new JsonObject(); + } + catch (JsonException) + { + merged = new JsonObject(); + } + + try + { + var incoming = JsonNode.Parse(newArguments)?.AsObject(); + if (incoming != null) + { + foreach (var property in incoming) + merged[property.Key] = property.Value?.DeepClone(); + } + } + catch (JsonException) + { + return newArguments; + } + return merged.ToJsonString(JsonOpts); + } + + private static string MergeHealthBatchDraftArguments(string? existingPayload, string newArguments) + { + var mergedByType = new Dictionary(StringComparer.OrdinalIgnoreCase); + var orderedTypes = new List(); + + static IEnumerable ReadMetrics(string? payload) + { + if (string.IsNullOrWhiteSpace(payload)) + return []; + try + { + var root = JsonNode.Parse(payload)?.AsObject(); + return root?["metrics"] is JsonArray metrics + ? metrics.OfType().Select(metric => (JsonObject)metric.DeepClone()).ToList() + : []; + } + catch (JsonException) + { + return []; + } + } + + void MergeMetric(JsonObject incoming) + { + var type = incoming["type"]?.GetValue()?.Trim(); + if (string.IsNullOrWhiteSpace(type)) + return; + + if (!mergedByType.TryGetValue(type, out var current)) + { + current = new JsonObject(); + mergedByType[type] = current; + orderedTypes.Add(type); + } + + foreach (var property in incoming) + current[property.Key] = property.Value?.DeepClone(); + } + + foreach (var metric in ReadMetrics(existingPayload)) + MergeMetric(metric); + foreach (var metric in ReadMetrics(newArguments)) + MergeMetric(metric); + + var metricsArray = new JsonArray(); + foreach (var type in orderedTypes) + metricsArray.Add(mergedByType[type]); + return new JsonObject { ["metrics"] = metricsArray }.ToJsonString(JsonOpts); + } + + private static bool IsPendingConfirmationResult(object result) + { + try + { + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result, JsonOpts)); + return json.RootElement.TryGetProperty("pendingConfirmation", out var pending) && + pending.ValueKind == JsonValueKind.True; + } + catch (JsonException) + { + return false; + } + } + + private static string? GetResultMessage(object result) + { + try + { + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result, JsonOpts)); + return json.RootElement.TryGetProperty("message", out var message) && + message.ValueKind == JsonValueKind.String + ? message.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } + + private static bool IsUrgentResult(object result) + { + try + { + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result, JsonOpts)); + return json.RootElement.TryGetProperty("urgent", out var urgent) && + urgent.ValueKind == JsonValueKind.True; + } + catch (JsonException) + { + return false; + } + } + private static string GetToolAction(string arguments) { try @@ -595,6 +1177,33 @@ public static class AiChatEndpoints } } + private static async Task SearchMedicalKnowledgeAsync( + FastGptKnowledgeClient knowledgeClient, + string arguments, + CancellationToken ct) + { + string query; + try + { + using var json = JsonDocument.Parse(arguments); + query = GetString(json.RootElement, "query")?.Trim() ?? ""; + } + catch (JsonException) + { + return new { success = false, found = false, message = "知识库检索参数无效" }; + } + + if (string.IsNullOrWhiteSpace(query)) + return new { success = false, found = false, message = "知识库检索缺少问题" }; + if (!knowledgeClient.IsEnabled) + return new { success = true, found = false, message = "医学知识库未启用,请基于通用医学常识谨慎回答" }; + + var content = await knowledgeClient.SearchAsync(query, ct); + return string.IsNullOrWhiteSpace(content) + ? new { success = true, found = false, message = "医学知识库未检索到相关资料" } + : new { success = true, found = true, content }; + } + private static async Task PreparePendingWriteAsync( IAiWriteConfirmationStore confirmations, IMedicationService medications, @@ -606,6 +1215,38 @@ public static class AiChatEndpoints using var json = JsonDocument.Parse(arguments); var args = json.RootElement; AiMedicationPlanDto? medicationPlan = null; + IReadOnlyList healthBatchMetrics = []; + if (toolName == "record_health_data") + { + var validationError = HealthDataAgentHandler.ValidateWriteArguments(args); + if (validationError != null) + return new { success = false, message = validationError }; + var urgentWarning = HealthDataAgentHandler.GetUrgentWarning(args); + if (urgentWarning != null) + return new { success = false, urgent = true, message = urgentWarning }; + } + if (toolName == "record_health_data_batch") + { + healthBatchMetrics = HealthDataAgentHandler.GetBatchMetrics(args); + if (healthBatchMetrics.Count == 0) + return new { success = false, message = "请先提供至少一项要录入的健康指标" }; + + var urgentWarnings = healthBatchMetrics + .Select(HealthDataAgentHandler.GetUrgentWarning) + .Where(warning => !string.IsNullOrWhiteSpace(warning)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (urgentWarnings.Count > 0) + return new { success = false, urgent = true, message = string.Join("\n", urgentWarnings) }; + + var validationErrors = healthBatchMetrics + .Select(HealthDataAgentHandler.ValidateWriteArguments) + .Where(error => !string.IsNullOrWhiteSpace(error)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (validationErrors.Count > 0) + return new { success = false, message = string.Join(";", validationErrors) }; + } if (toolName == "manage_medication") { var validationError = MedicationAgentHandler.ValidateWriteArguments(args); @@ -628,6 +1269,12 @@ public static class AiChatEndpoints return new { success = false, message = "没有找到这顿尚未确认的今日服药安排,请重新查询后再确认" }; } } + if (toolName == "manage_exercise") + { + var validationError = ExerciseAgentHandler.ValidateWriteArguments(args); + if (validationError != null) + return new { success = false, message = validationError }; + } var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct); @@ -644,6 +1291,13 @@ public static class AiChatEndpoints case "record_health_data": AddHealthPreview(preview, args); break; + case "record_health_data_batch": + preview["type"] = "health_batch"; + preview["items"] = healthBatchMetrics + .Select(HealthDataAgentHandler.CreatePreview) + .ToList(); + preview["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); + break; case "manage_medication": preview["type"] = "medication"; var medicationAction = GetString(args, "action"); @@ -732,7 +1386,7 @@ public static class AiChatEndpoints if (ContainsAny(text, "剧烈胸痛", "胸口剧痛", "胸痛明显", "胸闷憋气", "喘不上气", "呼吸困难", "呼吸很困难", "意识模糊", "昏厥", "晕厥", "说话不清", "口角歪斜", "一侧无力")) return "你描述的症状可能属于需要紧急评估的危险信号。"; - var bp = Regex.Match(text, @"(?:血压|bp|BP)?\s*(?\d{2,3})\s*/\s*(?\d{2,3})"); + var bp = Regex.Match(text, @"(?:血压|bp|BP)?\s*(?\d{2,3})\s*[//\-—–]\s*(?\d{2,3})"); if (bp.Success && int.TryParse(bp.Groups["sys"].Value, out var systolic) && int.TryParse(bp.Groups["dia"].Value, out var diastolic) && @@ -759,6 +1413,178 @@ public static class AiChatEndpoints private static bool ContainsAny(string text, params string[] keywords) => keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase)); + private static bool ClaimsMissingConfirmationCard(string text) => + Regex.IsMatch( + text, + @"(?:点击|点一下|按下).{0,12}(?:确认|卡片|按钮)|(?:确认卡|卡片).{0,12}(?:下方|已生成|确认|保存)|核对.{0,16}(?:信息)?.{0,8}确认|(?:已经|已).{0,6}(?:录入|记录|保存)(?:成功|完成|好了)?", + RegexOptions.IgnoreCase); + + private static async Task RewriteUnsupportedConfirmationClaimAsync( + IReadOnlyList conversationMessages, + DeepSeekClient llmClient, + CancellationToken ct) + { + var latestToolError = GetLatestToolError(conversationMessages); + var rewriteMessages = new List + { + new() + { + Role = "system", + Content = """ + 你是自然、温和的健康助手。当前没有生成任何确认卡,也没有保存任何数据。 + 请结合最近对话重新回答用户:如果信息不完整,只追问最关键的一项; + 如果用户只是陈述数据,先自然判断或确认其录入意图。 + 禁止提到“点击卡片、下方按钮、已经生成卡片、已经保存或录入成功”。 + 回复要口语自然、简短,不要使用系统报错式措辞。 + """, + }, + }; + rewriteMessages.AddRange(conversationMessages + .Where(message => message.Role is "user" or "assistant") + .TakeLast(8) + .Select(message => new ChatMessage { Role = message.Role, Content = message.Content })); + + try + { + var response = await llmClient.ChatAsync( + rewriteMessages, + maxTokens: 300, + temperature: 0.5f, + ct: ct); + var rewritten = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim(); + if (!string.IsNullOrWhiteSpace(rewritten) && + !ClaimsMissingConfirmationCard(rewritten)) + return rewritten; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + // 使用自然兜底,不能让措辞润色失败破坏主对话。 + } + + return latestToolError != null + ? $"还需要你补充一下:{latestToolError}" + : "这次还没有生成确认卡。请把要录入的内容再完整告诉我一次,我会先核对缺少的字段。"; + } + + private static string? GetLatestToolError(IReadOnlyList messages) + { + foreach (var message in messages.Reverse()) + { + if (message.Role != "tool" || message.Content is not string content) + continue; + try + { + using var json = JsonDocument.Parse(content); + var root = json.RootElement; + if (root.TryGetProperty("success", out var success) && + success.ValueKind == JsonValueKind.False && + root.TryGetProperty("message", out var error) && + error.ValueKind == JsonValueKind.String) + return error.GetString(); + } + catch (JsonException) + { + // Ignore malformed historical tool output and continue searching. + } + } + return null; + } + + private static string GetHealthMetricTypeFromArguments(string arguments) + { + try + { + using var json = JsonDocument.Parse(arguments); + return json.RootElement.TryGetProperty("type", out var type) + ? NormalizeHealthMetricType(type.GetString()) + : ""; + } + catch (JsonException) + { + return ""; + } + } + + private static bool IsHealthWriteGroundedInCurrentTurn( + string currentMessage, + string arguments, + bool hasMatchingIncompleteDraft) + { + if (hasMatchingIncompleteDraft) + return true; + + return GetHealthMetricTypeFromArguments(arguments) switch + { + "blood_pressure" => Regex.IsMatch( + currentMessage, + @"血压|收缩压|舒张压|高压|低压|\bbp\b", + RegexOptions.IgnoreCase), + "heart_rate" => Regex.IsMatch( + currentMessage, + @"心率|脉搏|心跳|heart\s*rate|\bhr\b", + RegexOptions.IgnoreCase), + "glucose" => Regex.IsMatch( + currentMessage, + @"血糖|葡萄糖|glucose", + RegexOptions.IgnoreCase), + "spo2" => Regex.IsMatch( + currentMessage, + @"血氧|氧饱和度|spo2", + RegexOptions.IgnoreCase), + "weight" => Regex.IsMatch( + currentMessage, + @"体重|重量|weight", + RegexOptions.IgnoreCase), + _ => false, + }; + } + + private static bool CompletesBloodPressureDraft(string? existingPayload, string incomingArguments) + { + if (string.IsNullOrWhiteSpace(existingPayload)) return false; + try + { + using var existing = JsonDocument.Parse(existingPayload); + using var incoming = JsonDocument.Parse(incomingArguments); + var existingRoot = existing.RootElement; + var incomingRoot = incoming.RootElement; + if (GetString(existingRoot, "type") != "blood_pressure" || + GetString(incomingRoot, "type") != "blood_pressure") + return false; + + var suppliesMissingSystolic = + !existingRoot.TryGetProperty("systolic", out _) && + incomingRoot.TryGetProperty("systolic", out var systolic) && + systolic.TryGetInt32(out _); + var suppliesMissingDiastolic = + !existingRoot.TryGetProperty("diastolic", out _) && + incomingRoot.TryGetProperty("diastolic", out var diastolic) && + diastolic.TryGetInt32(out _); + return suppliesMissingSystolic || suppliesMissingDiastolic; + } + catch (JsonException) + { + return false; + } + } + + private static string NormalizeHealthMetricType(string? type) => + type?.Replace("_", "", StringComparison.Ordinal) + .Replace("-", "", StringComparison.Ordinal) + .ToLowerInvariant() switch + { + "bloodpressure" => "blood_pressure", + "heartrate" or "pulse" => "heart_rate", + "glucose" or "bloodglucose" => "glucose", + "spo2" or "bloodoxygen" => "spo2", + "weight" => "weight", + _ => type?.ToLowerInvariant() ?? "", + }; + private static decimal? MatchMetricValue(string text, string metricPattern) { var match = Regex.Match(text, $@"(?:{metricPattern})[^\d]{{0,8}}(?\d{{1,3}}(?:\.\d+)?)", RegexOptions.IgnoreCase); @@ -816,6 +1642,31 @@ public static class AiChatEndpoints switch (toolName) { + case "record_health_data_batch": + if (!isPendingConfirmation || resultDict == null) break; + messageType = "data_confirm"; + metadata["type"] = "health_batch"; + metadata["success"] = true; + if (resultDict.TryGetValue("items", out var batchItems)) + { + try + { + var itemsJson = batchItems is JsonElement itemsElement + ? itemsElement.GetRawText() + : JsonSerializer.Serialize(batchItems, JsonOpts); + var items = JsonSerializer.Deserialize>>(itemsJson, JsonOpts); + if (items is { Count: > 0 }) + metadata["items"] = items; + } + catch (JsonException) + { + messageType = "text"; + metadata.Remove("items"); + } + } + if (resultDict.TryGetValue("recordTime", out var batchRecordTime)) + metadata["recordTime"] = batchRecordTime?.ToString()?.Trim('"') ?? ""; + break; case "record_health_data": if (!isPendingConfirmation) break; messageType = "data_confirm"; @@ -826,6 +1677,22 @@ public static class AiChatEndpoints 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); + + // A reply may contain several health writes. Preserve every preview + // so one confirmation card can accurately show the full batch. + if (!metadata.TryGetValue("items", out var itemsValue) || + itemsValue is not List> items) + { + items = []; + metadata["items"] = items; + } + items.Add(new Dictionary + { + ["type"] = resultDict.TryGetValue("type", out var itemType) ? itemType?.ToString() ?? "" : "", + ["value"] = resultDict.TryGetValue("value", out var itemValue) ? itemValue?.ToString() ?? "" : "", + ["unit"] = resultDict.TryGetValue("unit", out var itemUnit) ? itemUnit?.ToString() ?? "" : "", + ["abnormal"] = resultDict.TryGetValue("isAbnormal", out var itemAbnormal) && _ToBool(itemAbnormal), + }); metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); } break; diff --git a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs index 1e0e83b..38c9d04 100644 --- a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs @@ -20,11 +20,12 @@ public static class UserEndpoints var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null; var updated = await users.UpdateProfileAsync( GetUserId(http), - new UserProfileUpdateRequest(req.Name, req.Gender, birthDate), + new UserProfileUpdateRequest(req.Name, req.Gender, birthDate, req.AvatarUrl), ct); if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" }); - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + var profile = await users.GetProfileAsync(GetUserId(http), ct); + return Results.Ok(new { code = 0, data = profile, message = (string?)null }); }); group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) => @@ -66,7 +67,7 @@ public static class UserEndpoints Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; } -public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate); +public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate, string? AvatarUrl); public sealed record UpdateArchiveRequest( string? Diagnosis, string? SurgeryType, string? SurgeryDate, List? Allergies, List? DietRestrictions, diff --git a/backend/src/Health.WebApi/Middleware/exception_middleware.cs b/backend/src/Health.WebApi/Middleware/exception_middleware.cs index bcc41d1..f2e49bc 100644 --- a/backend/src/Health.WebApi/Middleware/exception_middleware.cs +++ b/backend/src/Health.WebApi/Middleware/exception_middleware.cs @@ -16,8 +16,19 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger FocusManager.instance.primaryFocus?.unfocus(), - child: _BootGate(child: child!), + child: AiConsentGate(child: _BootGate(child: child!)), ), ), ), @@ -71,6 +73,15 @@ final appReadyProvider = Provider((ref) { if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash final role = auth.user?.role ?? 'User'; if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡 + final consent = ref.watch(aiConsentProvider); + if (consent.userId == auth.user?.id && + !consent.isLoading && + !consent.granted) { + return true; + } + if (currentRoute == 'aiConsentDetails' || currentRoute == 'staticText') { + return true; + } if (!ref.watch(elderModeProvider).isLoaded) return false; // 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死) final health = ref.watch(latestHealthProvider); diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 8fcd9e1..0fc6893 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: kReleaseMode ? 'https://erpapi.datalumina.cn/xiaomai' - : 'http://10.4.178.175:5000', + : 'http://10.4.225.209:5000', ); class ApiException implements Exception { diff --git a/health_app/lib/core/app_module_visuals.dart b/health_app/lib/core/app_module_visuals.dart index a3b641f..fd2ffb9 100644 --- a/health_app/lib/core/app_module_visuals.dart +++ b/health_app/lib/core/app_module_visuals.dart @@ -168,3 +168,20 @@ class AppModuleVisuals { static AppModuleVisual of(AppModule module) => values[module] ?? ai; } + +/// 健康概览及所有健康指标卡片共用的图标与颜色。 +class HealthMetricVisuals { + HealthMetricVisuals._(); + + static const bloodPressureIcon = Icons.bloodtype_outlined; + static const heartRateIcon = LucideIcons.heartPulse; + static const glucoseIcon = Icons.water_drop_outlined; + static const spo2Icon = Icons.air_outlined; + static const weightIcon = Icons.monitor_weight_outlined; + + static const bloodPressureColor = Color(0xFF8B5CF6); + static const heartRateColor = Color(0xFFF43F5E); + static const glucoseColor = Color(0xFFF59E0B); + static const spo2Color = Color(0xFF0EA5E9); + static const weightColor = Color(0xFF10B981); +} diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index 4996a36..82ba55f 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -13,6 +13,7 @@ import '../pages/consultation/consultation_pages.dart'; import '../pages/settings/settings_pages.dart'; import '../pages/settings/elder_mode_page.dart'; import '../pages/settings/notification_prefs_page.dart'; +import '../pages/settings/ai_consent_details_page.dart'; import '../pages/notifications/notification_center_page.dart'; import '../pages/history/conversation_history_page.dart'; import '../pages/profile/profile_page.dart'; @@ -132,6 +133,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) { return const ElderModePage(); case 'notificationPrefs': return const NotificationPrefsPage(); + case 'aiConsentDetails': + return const AiConsentDetailsPage(); case 'notifications': return const NotificationCenterPage(); case 'conversationHistory': diff --git a/health_app/lib/models/ble_device.dart b/health_app/lib/models/ble_device.dart index 0b2dc1b..61637ec 100644 --- a/health_app/lib/models/ble_device.dart +++ b/health_app/lib/models/ble_device.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import '../core/app_module_visuals.dart'; import 'bp_reading.dart'; enum BleDeviceType { bloodPressure, glucose, weightScale, pulseOximeter } @@ -43,10 +44,10 @@ extension BleDeviceTypeX on BleDeviceType { }; IconData get icon => switch (this) { - BleDeviceType.bloodPressure => Icons.speed_rounded, - BleDeviceType.glucose => Icons.water_drop_rounded, + BleDeviceType.bloodPressure => HealthMetricVisuals.bloodPressureIcon, + BleDeviceType.glucose => HealthMetricVisuals.glucoseIcon, BleDeviceType.weightScale => Icons.monitor_weight_outlined, - BleDeviceType.pulseOximeter => Icons.air_rounded, + BleDeviceType.pulseOximeter => HealthMetricVisuals.spo2Icon, }; static BleDeviceType? fromCode(String? code) { diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index 95886b6..80b5183 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -3,9 +3,9 @@ import 'dart:math'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; @@ -27,11 +27,11 @@ const Set supportedTrendMetricTypes = { class _TrendColors { _TrendColors._(); - static const bloodPressure = Color(0xFFD45B68); - static const heartRate = Color(0xFFD97757); - static const glucose = Color(0xFF4F6EF7); - static const spo2 = Color(0xFF168F7A); - static const weight = Color(0xFF7559C7); + static const bloodPressure = HealthMetricVisuals.bloodPressureColor; + static const heartRate = HealthMetricVisuals.heartRateColor; + static const glucose = HealthMetricVisuals.glucoseColor; + static const spo2 = HealthMetricVisuals.spo2Color; + static const weight = HealthMetricVisuals.weightColor; static const systolic = bloodPressure; static const diastolic = Color(0xFF3B82F6); } @@ -132,31 +132,31 @@ class _TrendPageState extends ConsumerState { 'key': 'blood_pressure', 'label': '血压', 'color': _TrendColors.bloodPressure, - 'icon': LucideIcons.gauge, + 'icon': HealthMetricVisuals.bloodPressureIcon, }, { 'key': 'heart_rate', 'label': '心率', 'color': _TrendColors.heartRate, - 'icon': LucideIcons.heartPulse, + 'icon': HealthMetricVisuals.heartRateIcon, }, { 'key': 'glucose', 'label': '血糖', 'color': _TrendColors.glucose, - 'icon': LucideIcons.droplet, + 'icon': HealthMetricVisuals.glucoseIcon, }, { 'key': 'spo2', 'label': '血氧', 'color': _TrendColors.spo2, - 'icon': LucideIcons.wind, + 'icon': HealthMetricVisuals.spo2Icon, }, { 'key': 'weight', 'label': '体重', 'color': _TrendColors.weight, - 'icon': LucideIcons.scale, + 'icon': HealthMetricVisuals.weightIcon, }, ]; @@ -176,6 +176,14 @@ class _TrendPageState extends ConsumerState { 'weight': '体重', }; + static const _latestCardArt = { + 'blood_pressure': 'assets/branding/trend_metric_blood_pressure.png', + 'heart_rate': 'assets/branding/trend_metric_heart_rate.png', + 'glucose': 'assets/branding/trend_metric_glucose.png', + 'spo2': 'assets/branding/trend_metric_spo2.png', + 'weight': 'assets/branding/trend_metric_weight.png', + }; + String get _unit => _units[_selected] ?? ''; String get _name => _names[_selected] ?? ''; bool get _isBP => _selected == 'blood_pressure'; @@ -433,19 +441,9 @@ class _TrendPageState extends ConsumerState { padding: const EdgeInsets.all(14), child: Column( children: [ - _buildMetricTabs(), + _buildMetricOverview(), const SizedBox(height: 16), - if (_latestRecord != null) ...[ - _buildLatestSummary(), - const SizedBox(height: 16), - ], - _buildPeriodSelector(), - const SizedBox(height: 12), - _buildChart(), - if (_chartRecords.isNotEmpty) ...[ - const SizedBox(height: 12), - _buildStatistics(), - ], + _buildTrendInsightPanel(), const SizedBox(height: 24), _buildHistory(), const SizedBox(height: 80), @@ -456,47 +454,57 @@ class _TrendPageState extends ConsumerState { ); } - // ---- 指标选择标签 ---- - Widget _buildMetricTabs() { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, + // ---- 顶部指标概览,同时作为切换入口 ---- + Widget _buildMetricOverview() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.pillBorder, + border: Border.all(color: AppColors.divider.withValues(alpha: 0.7)), + ), child: Row( children: _metrics.map((m) { final key = m['key'] as String; final color = m['color'] as Color; final sel = _selected == key; - return GestureDetector( - onTap: () => _switchMetric(key), - child: Container( - margin: const EdgeInsets.only(right: 8), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: sel ? AppColors.primarySoft : Colors.white, - borderRadius: AppRadius.xlBorder, - border: Border.all( - color: sel ? AppColors.primary : AppColors.borderLight, + return Expanded( + child: InkWell( + onTap: () => _switchMetric(key), + borderRadius: AppRadius.pillBorder, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: sel ? color : Colors.transparent, + borderRadius: AppRadius.pillBorder, ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - m['icon'] as IconData, - size: 17, - color: sel ? AppColors.primaryDark : color, - ), - const SizedBox(width: 6), - Text( - m['label'] as String, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: sel - ? AppColors.primaryDark - : AppColors.textSecondary, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + m['icon'] as IconData, + size: 16, + color: sel ? Colors.white : color, ), - ), - ], + const SizedBox(width: 4), + Flexible( + child: Text( + m['label'] as String, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: sel ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ], + ), ), ), ); @@ -510,95 +518,167 @@ class _TrendPageState extends ConsumerState { final date = record['date'] as DateTime; final status = trendStatusLabel(record, _selected); final abnormal = status.isNotEmpty; - return Container( - width: double.infinity, - padding: AppSpacing.panel, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: AppRadius.lgBorder, - ), - child: Row( + final art = _latestCardArt[_selected] ?? _latestCardArt['heart_rate']!; + return SizedBox( + height: 214, + child: Stack( + clipBehavior: Clip.hardEdge, children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: _color.withValues(alpha: 0.10), - borderRadius: AppRadius.mdBorder, + Positioned( + left: 0, + right: -48, + top: -12, + bottom: -12, + child: Image.asset( + art, + fit: BoxFit.cover, + alignment: Alignment.centerRight, ), - child: Icon(_getMetricIcon(_selected), color: _color, size: 25), ), - const SizedBox(width: 14), - Expanded( + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.white.withValues(alpha: 0.96), + Colors.white.withValues(alpha: 0.72), + Colors.white.withValues(alpha: 0.04), + ], + stops: const [0, 0.42, 0.78], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(6, 8, 178, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '最新$_name', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 4), - Wrap( - crossAxisAlignment: WrapCrossAlignment.end, - spacing: 6, + Row( children: [ - Text( - _displayValue(record), - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - height: 1.05, + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: _color.withValues(alpha: 0.12), + borderRadius: AppRadius.mdBorder, + ), + child: Icon( + _getMetricIcon(_selected), + color: _color, + size: 23, ), ), - Padding( - padding: const EdgeInsets.only(bottom: 3), - child: Text( - _unit, - style: const TextStyle( - fontSize: 15, - color: AppColors.textHint, - ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '最新$_name', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + _formatRecordTime(date), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: AppColors.textHint, + fontWeight: FontWeight.w500, + ), + ), + ], ), ), ], ), + const SizedBox(height: 18), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.end, + spacing: 7, + runSpacing: 4, + children: [ + Text( + _displayValue(record), + style: const TextStyle( + fontSize: 44, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + height: 0.98, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + _StatusBadge( + label: abnormal ? status : '正常', + abnormal: abnormal, + ), ], ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - abnormal ? status : '正常', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: abnormal ? AppColors.errorText : AppColors.successText, - ), - ), - const SizedBox(height: 6), - Text( - _formatRecordTime(date), - style: const TextStyle(fontSize: 13, color: AppColors.textHint), - ), - ], + Positioned( + left: 6, + right: 178, + bottom: 8, + child: _buildPeriodSelector(), ), ], ), ); } + Widget _buildTrendInsightPanel() { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.cardBorder, + boxShadow: AppShadows.panel, + ), + child: Column( + children: [ + if (_latestRecord != null) ...[ + _buildLatestSummary(), + const SizedBox(height: 4), + ], + if (_latestRecord == null) ...[ + _buildPeriodSelector(), + const SizedBox(height: 14), + ], + _buildChart(), + if (_chartRecords.isNotEmpty) ...[ + const SizedBox(height: 4), + _buildStatistics(), + ], + ], + ), + ); + } + Widget _buildPeriodSelector() { return Container( - padding: const EdgeInsets.all(3), + constraints: const BoxConstraints(maxWidth: 310), + padding: const EdgeInsets.all(2), decoration: BoxDecoration( - color: Colors.white, - borderRadius: AppRadius.mdBorder, + color: _color.withValues(alpha: 0.06), + borderRadius: AppRadius.lgBorder, + border: Border.all(color: _color.withValues(alpha: 0.14)), ), child: Row( children: [ @@ -611,21 +691,21 @@ class _TrendPageState extends ConsumerState { }), borderRadius: AppRadius.smBorder, child: Container( - height: 38, + height: 30, alignment: Alignment.center, decoration: BoxDecoration( color: _periodDays == days - ? AppColors.primaryLight + ? _color.withValues(alpha: 0.92) : Colors.transparent, borderRadius: AppRadius.smBorder, ), child: Text( '$days天', style: TextStyle( - fontSize: 15, + fontSize: 12, fontWeight: FontWeight.w700, color: _periodDays == days - ? AppColors.primaryDark + ? Colors.white : AppColors.textSecondary, ), ), @@ -645,6 +725,7 @@ class _TrendPageState extends ConsumerState { .toList(); String average; String highest; + String lowest; if (_isBP) { final systolic = records .map((record) => (record['systolic'] as num?)?.toDouble()) @@ -657,29 +738,42 @@ class _TrendPageState extends ConsumerState { average = systolic.isEmpty || diastolic.isEmpty ? '--' : '${(systolic.reduce((a, b) => a + b) / systolic.length).round()}/${(diastolic.reduce((a, b) => a + b) / diastolic.length).round()}'; - final highestRecord = records - .where((record) { - return record['systolic'] is num && record['diastolic'] is num; - }) - .fold?>(null, (current, record) { - if (current == null) return record; - final currentValue = (current['systolic'] as num).toDouble(); - final recordValue = (record['systolic'] as num).toDouble(); - return recordValue > currentValue ? record : current; - }); + final bpRecords = records.where((record) { + return record['systolic'] is num && record['diastolic'] is num; + }); + final highestRecord = bpRecords.fold?>(null, ( + current, + record, + ) { + if (current == null) return record; + final currentValue = (current['systolic'] as num).toDouble(); + final recordValue = (record['systolic'] as num).toDouble(); + return recordValue > currentValue ? record : current; + }); + final lowestRecord = bpRecords.fold?>(null, ( + current, + record, + ) { + if (current == null) return record; + final currentValue = (current['systolic'] as num).toDouble(); + final recordValue = (record['systolic'] as num).toDouble(); + return recordValue < currentValue ? record : current; + }); highest = highestRecord == null ? '--' : _displayValue(highestRecord); + lowest = lowestRecord == null ? '--' : _displayValue(lowestRecord); } else { average = values.isEmpty ? '--' : formatTrendNumber(values.reduce((a, b) => a + b) / values.length); highest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(max)); + lowest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(min)); } return Container( - padding: const EdgeInsets.symmetric(vertical: 14), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), decoration: BoxDecoration( - color: Colors.white, - borderRadius: AppRadius.lgBorder, + color: _color.withValues(alpha: 0.045), + borderRadius: AppRadius.xlBorder, ), child: Row( children: [ @@ -687,7 +781,7 @@ class _TrendPageState extends ConsumerState { const SizedBox(height: 46, child: VerticalDivider(width: 1)), _StatisticItem(label: '最高', value: highest, unit: _unit), const SizedBox(height: 46, child: VerticalDivider(width: 1)), - _StatisticItem(label: '记录', value: '${records.length}', unit: '次'), + _StatisticItem(label: '最低', value: lowest, unit: _unit), ], ), ); @@ -796,16 +890,30 @@ class _TrendPageState extends ConsumerState { } // X 轴刻度间隔:日标签短,可以排密一点 - final xInterval = spots.length > 60 - ? 5.0 - : spots.length > 30 - ? 2.0 - : 1.0; + const xInterval = 1.0; + final dayStartIndices = []; + for (var i = 0; i < records.length; i++) { + if (i == 0 || + !_isSameChartDay( + records[i - 1]['date'] as DateTime, + records[i]['date'] as DateTime, + )) { + dayStartIndices.add(i); + } + } + final dayLabelStep = dayStartIndices.length > 7 + ? (dayStartIndices.length / 7).ceil() + : 1; + final visibleDayLabels = { + for (var i = 0; i < dayStartIndices.length; i += dayLabelStep) + dayStartIndices[i], + if (dayStartIndices.isNotEmpty) dayStartIndices.last, + }; return Container( - padding: const EdgeInsets.fromLTRB(8, 20, 16, 12), + padding: const EdgeInsets.fromLTRB(4, 10, 12, 6), decoration: BoxDecoration( - color: Colors.white, + color: Colors.transparent, borderRadius: AppRadius.lgBorder, ), child: Column( @@ -853,7 +961,7 @@ class _TrendPageState extends ConsumerState { child: LayoutBuilder( builder: (context, constraints) { final chartWidth = constraints.maxWidth; - final paintWidth = chartWidth - 40.0; // 减左侧轴 + final paintWidth = chartWidth - 32.0; // 减左侧轴 final paintHeight = 200.0 - 36.0; // 减底部轴 // 计算选中点的像素位置 Offset? tooltipPos; @@ -868,7 +976,7 @@ class _TrendPageState extends ConsumerState { : (r['value'] as num?)?.toDouble(); if (v != null && spots.length > 1) { final px = - 40.0 + + 32.0 + (_selectedIdx! / (spots.length - 1)) * paintWidth; final py = paintHeight * (1 - (v - minV) / (maxV - minV)); final d = r['date'] as DateTime; @@ -904,7 +1012,7 @@ class _TrendPageState extends ConsumerState { leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 40, + reservedSize: 32, interval: yStep, getTitlesWidget: (v, meta) => Padding( padding: const EdgeInsets.only(right: 6), @@ -929,6 +1037,9 @@ class _TrendPageState extends ConsumerState { if (idx < 0 || idx >= records.length) { return const SizedBox.shrink(); } + if (!visibleDayLabels.contains(idx)) { + return const SizedBox.shrink(); + } return Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -953,9 +1064,10 @@ class _TrendPageState extends ConsumerState { lineBarsData: [ LineChartBarData( spots: spots, - isCurved: false, + isCurved: true, + curveSmoothness: 0.28, color: _color, - barWidth: 2.5, + barWidth: 3, isStrokeCapRound: true, dotData: FlDotData( show: spots.length <= 30, @@ -978,14 +1090,25 @@ class _TrendPageState extends ConsumerState { ); }, ), - belowBarData: BarAreaData(show: false), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + _color.withValues(alpha: 0.18), + _color.withValues(alpha: 0.02), + ], + ), + ), ), if (_isBP && diastolicSpots.isNotEmpty) LineChartBarData( spots: diastolicSpots, - isCurved: false, + isCurved: true, + curveSmoothness: 0.28, color: _TrendColors.diastolic, - barWidth: 2.5, + barWidth: 3, isStrokeCapRound: true, dotData: FlDotData( show: diastolicSpots.length <= 30, @@ -999,7 +1122,12 @@ class _TrendPageState extends ConsumerState { strokeColor: _TrendColors.diastolic, ), ), - belowBarData: BarAreaData(show: false), + belowBarData: BarAreaData( + show: true, + color: _TrendColors.diastolic.withValues( + alpha: 0.05, + ), + ), ), ], lineTouchData: LineTouchData( @@ -1087,18 +1215,15 @@ class _TrendPageState extends ConsumerState { String _chartAxisLabel(List> records, int index) { final date = records[index]['date'] as DateTime; - final sameDayCount = records.where((record) { - final other = record['date'] as DateTime; - return other.year == date.year && - other.month == date.month && - other.day == date.day; - }).length; - if (sameDayCount > 1) { - return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - } return '${date.month}-${date.day}'; } + bool _isSameChartDay(DateTime first, DateTime second) { + return first.year == second.year && + first.month == second.month && + first.day == second.day; + } + // ---- 历史记录列表 ---- Widget _buildHistory() { if (_filtered.isEmpty) return const SizedBox.shrink(); @@ -1113,7 +1238,7 @@ class _TrendPageState extends ConsumerState { ), const Spacer(), Text( - '最近${min(30, _filtered.length)}条', + '共${_filtered.length}条', style: const TextStyle( fontSize: 16, color: AppColors.textSecondary, @@ -1127,6 +1252,7 @@ class _TrendPageState extends ConsumerState { decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, + boxShadow: AppShadows.soft, ), child: Column( children: [ @@ -1230,40 +1356,47 @@ class _TrendPageState extends ConsumerState { ), const SizedBox(width: 12), Expanded( - child: Text( - _formatRecordTime(date), - style: const TextStyle( - fontSize: 14, - color: AppColors.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '$display $_unit', - style: TextStyle( - fontSize: 19, - fontWeight: FontWeight.w700, - color: abnormal - ? AppColors.errorText - : AppColors.textPrimary, - ), - ), - if (status.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - status, - style: const TextStyle( - fontSize: 12, - color: AppColors.errorText, - fontWeight: FontWeight.w600, + child: Row( + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + _formatRecordTime(date), + maxLines: 1, + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), ), ), + const SizedBox(width: 6), + _StatusBadge( + label: abnormal ? status : '正常', + abnormal: abnormal, + ), ], - ], + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 116, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Text( + '$display $_unit', + maxLines: 1, + style: const TextStyle( + fontSize: 21, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ), ), ], ), @@ -1359,6 +1492,43 @@ class _StatisticItem extends StatelessWidget { } } +class _StatusBadge extends StatelessWidget { + final String label; + final bool abnormal; + + const _StatusBadge({required this.label, required this.abnormal}); + + @override + Widget build(BuildContext context) { + final color = abnormal ? AppColors.warningText : AppColors.successText; + final background = abnormal + ? AppColors.warningLight + : AppColors.successLight; + return Container( + constraints: const BoxConstraints(maxWidth: 78), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: background, + borderRadius: AppRadius.pillBorder, + border: Border.all(color: color.withValues(alpha: 0.18)), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + label, + maxLines: 1, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: color, + height: 1, + ), + ), + ), + ); + } +} + class _ChartLegend extends StatelessWidget { final Color color; final String label; diff --git a/health_app/lib/pages/device/device_management_page.dart b/health_app/lib/pages/device/device_management_page.dart index e72471c..b49d6c8 100644 --- a/health_app/lib/pages/device/device_management_page.dart +++ b/health_app/lib/pages/device/device_management_page.dart @@ -162,10 +162,13 @@ class _DeviceManagementPageState extends ConsumerState Future _ensureBlePermissions() async { Map statuses; try { - statuses = await [ - Permission.bluetoothScan, - Permission.bluetoothConnect, - ].request(); + final permissions = [ + if (Platform.isAndroid) Permission.bluetoothScan, + if (Platform.isAndroid) Permission.bluetoothConnect, + // Android 11 and below require location permission for BLE scans. + if (Platform.isAndroid) Permission.locationWhenInUse, + ]; + statuses = await permissions.request(); } catch (_) { if (mounted) { setState(() => _pageMessage = '无法读取蓝牙权限状态'); diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index 8313eed..e2a6958 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -27,14 +27,10 @@ class DeviceScanPage extends ConsumerStatefulWidget { class _DeviceScanPageState extends ConsumerState with SingleTickerProviderStateMixin { - static const _visibleDeviceWindow = Duration(seconds: 12); - final _results = []; StreamSubscription>? _scanSub; Timer? _scanStopTimer; - Timer? _resultPruneTimer; String? _connectingId; - DateTime? _scanStartedAt; bool _scanning = false; late final AnimationController _pulseCtrl; @@ -59,7 +55,6 @@ class _DeviceScanPageState extends ConsumerState void dispose() { _pulseCtrl.dispose(); _scanStopTimer?.cancel(); - _resultPruneTimer?.cancel(); _scanSub?.cancel(); unawaited(FlutterBluePlus.stopScan()); unawaited(ref.read(healthBleServiceProvider).disconnect()); @@ -99,7 +94,6 @@ class _DeviceScanPageState extends ConsumerState try { await FlutterBluePlus.stopScan(); } catch (_) {} - _scanStartedAt = DateTime.now(); await _scanSub?.cancel(); _scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults); await FlutterBluePlus.startScan( @@ -112,38 +106,24 @@ class _DeviceScanPageState extends ConsumerState _scanStopTimer = Timer(const Duration(seconds: 30), () { if (mounted) setState(() => _scanning = false); }); - _resultPruneTimer?.cancel(); - _resultPruneTimer = Timer.periodic(const Duration(seconds: 1), (_) { - if (!mounted || _connectingId != null) return; - final pruned = _visibleResults(_results); - if (_sameResults(_results, pruned)) return; - setState(() { - _results - ..clear() - ..addAll(pruned); - }); - }); } void _onScanResults(List list) { if (!mounted || _connectingId != null) return; - final scanStartedAt = _scanStartedAt; - if (scanStartedAt == null) return; final boundDevices = ref.read(omronDeviceProvider).devices; - final supported = list.where((result) { - if (result.timeStamp.isBefore(scanStartedAt)) return false; - if (!result.advertisementData.connectable) return false; - if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) { - return false; - } - if (!HealthBleService.isSupportedHealthDevice(result)) return false; + final candidates = list.where((result) { + // Keep the result in the list first. Many health devices expose their + // standard service only after connection, not in the advertisement. + // Also avoid relying on ScanResult.timeStamp/connectable here: both can + // be stale or conservative while a device is in communication mode. + if (!_looksLikeHealthDevice(result)) return false; return boundDevices.every( (device) => device.id != result.device.remoteId.toString(), ); }); final next = [..._results]; - for (final result in supported) { + for (final result in candidates) { final index = next.indexWhere( (item) => item.device.remoteId == result.device.remoteId, ); @@ -163,33 +143,32 @@ class _DeviceScanPageState extends ConsumerState } List _visibleResults(List results) { - final visible = - results - .where( - (result) => - DateTime.now().difference(result.timeStamp) <= - _visibleDeviceWindow, - ) - .toList() - ..sort( - (a, b) => a.device.remoteId.toString().compareTo( - b.device.remoteId.toString(), - ), - ); + final visible = [...results] + ..sort( + (a, b) => a.device.remoteId.toString().compareTo( + b.device.remoteId.toString(), + ), + ); return visible; } + bool _looksLikeHealthDevice(ScanResult result) { + if (HealthBleService.deviceTypeFromScan(result) != null) return true; + final name = [ + result.advertisementData.advName, + result.device.platformName, + ].join(' ').toUpperCase(); + return name.contains('OMRON') || + name.contains('HEM') || + name.contains('BLESMART') || + name.contains('J735') || + name.contains('BPM') || + name.contains('BP'); + } + Future _connectBindAndSync(ScanResult result) async { final remoteId = result.device.remoteId.toString(); if (_connectingId != null) return; - final scanStartedAt = _scanStartedAt; - if (scanStartedAt == null || - result.timeStamp.isBefore(scanStartedAt) || - !result.advertisementData.connectable || - DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) { - AppToast.show(context, '设备已离线,请重新进入通信状态', type: AppToastType.warning); - return; - } if (ref.read(omronDeviceProvider).isBound && ref.read(omronDeviceProvider).findById(remoteId) != null) { AppToast.show(context, '该设备已绑定', type: AppToastType.info); @@ -348,10 +327,15 @@ class _DeviceScanPageState extends ConsumerState } Future _ensureBlePermissions() async { - final statuses = await [ - Permission.bluetoothScan, - Permission.bluetoothConnect, - ].request(); + final permissions = [ + if (Platform.isAndroid) Permission.bluetoothScan, + if (Platform.isAndroid) Permission.bluetoothConnect, + // Android 11 and below require a location runtime permission for BLE + // scanning. Requesting it here also keeps older devices from silently + // returning an empty scan stream. + if (Platform.isAndroid) Permission.locationWhenInUse, + ]; + final statuses = await permissions.request(); final granted = statuses.values.every((status) => status.isGranted); if (granted) return true; if (!mounted) return false; diff --git a/health_app/lib/pages/doctor/doctor_dashboard_page.dart b/health_app/lib/pages/doctor/doctor_dashboard_page.dart index bc4f1c5..faecf3f 100644 --- a/health_app/lib/pages/doctor/doctor_dashboard_page.dart +++ b/health_app/lib/pages/doctor/doctor_dashboard_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/backoffice_refresh_providers.dart'; @@ -124,7 +125,7 @@ class DoctorDashboardPage extends ConsumerWidget { child: _StatCard( '待审核报告', '${stats['pendingReports'] ?? 0}', - Icons.description, + AppModuleVisuals.report.icon, const Color(0xFFF59E0B), () { ref.read(doctorPageProvider.notifier).set('reports'); @@ -136,7 +137,7 @@ class DoctorDashboardPage extends ConsumerWidget { child: _StatCard( '今日随访', '${stats['todayFollowUps'] ?? 0}', - Icons.event_note, + AppModuleVisuals.followup.icon, const Color(0xFFEF4444), () { ref.read(doctorPageProvider.notifier).set('followups'); @@ -166,7 +167,7 @@ class DoctorDashboardPage extends ConsumerWidget { // 待办:待审核报告 _TodoSection( title: '待审核报告', - icon: Icons.description_outlined, + icon: AppModuleVisuals.report.icon, color: const Color(0xFFF59E0B), items: (data?['pendingReports'] as List?) @@ -182,7 +183,7 @@ class DoctorDashboardPage extends ConsumerWidget { // 今日随访 _TodoSection( title: '今日随访', - icon: Icons.event_note_outlined, + icon: AppModuleVisuals.followup.icon, color: const Color(0xFFEF4444), items: (data?['todayFollowUps'] as List?) diff --git a/health_app/lib/pages/doctor/doctor_followups_page.dart b/health_app/lib/pages/doctor/doctor_followups_page.dart index f4380dc..54adad3 100644 --- a/health_app/lib/pages/doctor/doctor_followups_page.dart +++ b/health_app/lib/pages/doctor/doctor_followups_page.dart @@ -1,6 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/backoffice_refresh_providers.dart'; @@ -92,18 +93,18 @@ class _DoctorFollowupsPageState extends ConsumerState { ), Expanded( child: items.isEmpty && refresh.isLoading - ? const BackofficeLoadingState(message: '正在加载随访') + ? BackofficeLoadingState(message: '正在加载随访') : items.isEmpty && refresh.hasError ? BackofficeErrorState(onRetry: () => ref.invalidate(_fupRefresh)) : RefreshIndicator( onRefresh: () => ref.refresh(_fupRefresh.future), child: items.isEmpty ? ListView( - children: const [ + children: [ SizedBox( height: 360, child: BackofficeEmptyState( - icon: Icons.event_note_outlined, + icon: AppModuleVisuals.followup.icon, title: '暂无随访', description: '新建的复查随访会显示在这里', ), diff --git a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart index 63e7a0a..ec3ed1c 100644 --- a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../utils/backoffice_formatters.dart'; @@ -250,7 +251,7 @@ class DoctorPatientDetailPage extends ConsumerWidget { Expanded( child: _LinkCard( '报告 (${reports.length})', - Icons.description_outlined, + AppModuleVisuals.report.icon, () {}, ), ), @@ -258,7 +259,7 @@ class DoctorPatientDetailPage extends ConsumerWidget { Expanded( child: _LinkCard( '随访 (${followUps.length})', - Icons.event_note_outlined, + AppModuleVisuals.followup.icon, () {}, ), ), diff --git a/health_app/lib/pages/doctor/doctor_report_detail_page.dart b/health_app/lib/pages/doctor/doctor_report_detail_page.dart index 65b94ff..8c8b561 100644 --- a/health_app/lib/pages/doctor/doctor_report_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_report_detail_page.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/backoffice_refresh_providers.dart'; @@ -116,8 +117,8 @@ class _DoctorReportDetailPageState onRetry: () => ref.invalidate(_reportDetailProvider(widget.id)), ), data: (data) => data == null - ? const BackofficeEmptyState( - icon: Icons.description_outlined, + ? BackofficeEmptyState( + icon: AppModuleVisuals.report.icon, title: '报告不存在', ) : _buildBody(data), diff --git a/health_app/lib/pages/doctor/doctor_reports_page.dart b/health_app/lib/pages/doctor/doctor_reports_page.dart index 8d33234..bbf8930 100644 --- a/health_app/lib/pages/doctor/doctor_reports_page.dart +++ b/health_app/lib/pages/doctor/doctor_reports_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/backoffice_refresh_providers.dart'; @@ -71,8 +72,8 @@ class _DoctorReportsPageState extends ConsumerState { .where((item) => item['status'] == _filter) .toList(); return items.isEmpty - ? const BackofficeEmptyState( - icon: Icons.description_outlined, + ? BackofficeEmptyState( + icon: AppModuleVisuals.report.icon, title: '暂无报告', description: '患者上传的待审核报告会显示在这里', ) @@ -93,8 +94,8 @@ class _DoctorReportsPageState extends ConsumerState { color: AppColors.iconBg, borderRadius: BorderRadius.circular(12), ), - child: const Icon( - Icons.description_outlined, + child: Icon( + AppModuleVisuals.report.icon, color: AppColors.primary, ), ), diff --git a/health_app/lib/pages/exercise/exercise_plan_page.dart b/health_app/lib/pages/exercise/exercise_plan_page.dart index de0d055..6762b4a 100644 --- a/health_app/lib/pages/exercise/exercise_plan_page.dart +++ b/health_app/lib/pages/exercise/exercise_plan_page.dart @@ -36,6 +36,7 @@ class _EnterpriseExercisePlanPageState try { await ref.read(exerciseServiceProvider).checkIn(itemId); ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); + await ref.read(exercisePlansProvider.future); } catch (error) { if (mounted) { AppToast.show( @@ -149,8 +150,6 @@ class _EnterpriseExercisePlanPageState _TodayExercisePanel( items: todayItems, completed: completedToday, - busyItems: _busyItems, - onCheckIn: _toggleCheckIn, ), const SizedBox(height: 18), for (final phase in CarePlanPhase.values) @@ -178,15 +177,8 @@ class _EnterpriseExercisePlanPageState class _TodayExercisePanel extends StatelessWidget { final List> items; final int completed; - final Set busyItems; - final Future Function(String) onCheckIn; - const _TodayExercisePanel({ - required this.items, - required this.completed, - required this.busyItems, - required this.onCheckIn, - }); + const _TodayExercisePanel({required this.items, required this.completed}); @override Widget build(BuildContext context) { @@ -256,12 +248,6 @@ class _TodayExercisePanel extends StatelessWidget { ], ), ), - if (next != null) - _CompactActionButton( - busy: busyItems.contains(next['id']?.toString() ?? ''), - completed: false, - onPressed: () => onCheckIn(next['id']?.toString() ?? ''), - ), ], ), const SizedBox(height: 14), diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 38ae51b..db33417 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -384,7 +384,18 @@ class _HomePageState extends ConsumerState ) => (label: label, visual: visual); final module = switch (agent) { - ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health), + ActiveAgent.health => ( + label: '记数据', + visual: AppModuleVisual( + module: AppModule.health, + label: AppModuleVisuals.health.label, + icon: AppModuleVisuals.healthOverviewIcon, + color: AppModuleVisuals.health.color, + lightColor: AppModuleVisuals.health.lightColor, + borderColor: AppModuleVisuals.health.borderColor, + gradient: AppModuleVisuals.health.gradient, + ), + ), ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet), ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication), ActiveAgent.report => fromModule('报告', AppModuleVisuals.report), @@ -427,9 +438,9 @@ class _HomePageState extends ConsumerState child: Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 8), + const SizedBox(height: 4), _buildAgentBar(), - const SizedBox(height: 12), + const SizedBox(height: 5), if (_pickedImagePath != null) _buildImagePreview(), _buildInputBar(), ], @@ -479,13 +490,13 @@ class _HomePageState extends ConsumerState chatProvider.select((state) => state.isStreaming), ); return Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), + padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), child: Container( padding: EdgeInsets.fromLTRB( 6, - elderMode ? 7 : 5, + elderMode ? 5 : 3, 6, - elderMode ? 7 : 5, + elderMode ? 5 : 3, ), decoration: BoxDecoration( color: Colors.white, @@ -529,7 +540,7 @@ class _HomePageState extends ConsumerState isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 4, - vertical: elderMode ? 15 : 12, + vertical: elderMode ? 12 : 9, ), border: InputBorder.none, enabledBorder: InputBorder.none, @@ -960,7 +971,7 @@ class _VoiceHoldSurface extends StatelessWidget { return Container( padding: EdgeInsets.symmetric( horizontal: 4, - vertical: elderMode ? 14 : 11, + vertical: elderMode ? 12 : 9, ), alignment: Alignment.center, child: Text( diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 69e5fe3..bb12c9e 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -134,12 +134,13 @@ class ChatMessagesView extends ConsumerWidget { constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(28), + border: Border.all(color: const Color(0xFFE8ECF3)), boxShadow: [ BoxShadow( - color: const Color(0xFF6B5CE7).withValues(alpha: 0.12), - blurRadius: 28, - offset: const Offset(0, 14), + color: const Color(0xFF101828).withValues(alpha: 0.10), + blurRadius: 24, + offset: const Offset(0, 12), ), BoxShadow( color: const Color(0xFF101828).withValues(alpha: 0.06), @@ -152,126 +153,54 @@ class ChatMessagesView extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Stack( - children: [ - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.white, - agentColors.bg.withValues(alpha: 0.86), - agentColors.accent.withValues(alpha: 0.12), - ], - stops: const [0.0, 0.58, 1.0], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - ), - ), - Positioned( - right: 18, - top: 18, - child: _MiniSignalStrip(colors: agentColors), - ), - Positioned( - right: 18, - bottom: 18, - child: _LinearAccentMark(colors: agentColors), - ), - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(18, 20, 18, 22), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _AgentMark(icon: info.$1, colors: agentColors), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.76), - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: agentColors.accent.withValues( - alpha: 0.18, - ), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.graphic_eq, - size: 14, - color: agentColors.accent, - ), - const SizedBox(width: 4), - Text( - 'AI 智能助手', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: agentColors.accent, - ), - ), - ], - ), - ), - const SizedBox(height: 9), - Text( - info.$2, - style: AppTextStyles.chatTitle.copyWith( - fontSize: 22, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text(info.$3, style: AppTextStyles.summarySubtitle), - ], - ), - ), - ], - ), - ), - ], - ), - Image.asset( - artworkPath, + Container( width: double.infinity, - height: 206, - fit: BoxFit.cover, - ), - if (agent == ActiveAgent.consultation) ...[ - const SizedBox(height: 14), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF0F4FF), - borderRadius: BorderRadius.circular(14), - ), - child: const Text( - '小脉会陪您一步步梳理症状。请先告诉我哪里不舒服,或说说您现在最担心的问题。', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: AppColors.textSecondary, + padding: const EdgeInsets.fromLTRB(22, 22, 22, 8), + color: Colors.white, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AgentMark(icon: info.$1, colors: agentColors), + const SizedBox(width: 18), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.$2, + style: AppTextStyles.chatTitle.copyWith( + fontSize: 28, + height: 1.1, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + Text( + info.$3, + style: AppTextStyles.summarySubtitle.copyWith( + fontSize: 16, + height: 1.45, + color: AppColors.textSecondary, + ), + ), + ], + ), ), ), - ), + ], ), - ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 2), + child: SizedBox( + height: 214, + width: double.infinity, + child: Image.asset(artworkPath, fit: BoxFit.contain), + ), + ), if (actions.isNotEmpty) ...[ const SizedBox(height: 14), Padding( @@ -399,13 +328,15 @@ class ChatMessagesView extends ConsumerWidget { String _agentArtworkPath(ActiveAgent agent) { return switch (agent) { - ActiveAgent.consultation => 'assets/branding/agent_consultation_card.png', - ActiveAgent.health => 'assets/branding/agent_health_card.png', - ActiveAgent.diet => 'assets/branding/agent_diet_card.png', - ActiveAgent.medication => 'assets/branding/agent_medication_card.png', - ActiveAgent.report => 'assets/branding/agent_report_card.png', - ActiveAgent.exercise => 'assets/branding/agent_exercise_card.png', - _ => 'assets/branding/agent_health_card.png', + ActiveAgent.consultation => + 'assets/branding/agent_illustration_consultation.png', + ActiveAgent.health => 'assets/branding/agent_illustration_health.png', + ActiveAgent.diet => 'assets/branding/agent_illustration_diet.png', + ActiveAgent.medication => + 'assets/branding/agent_illustration_medication.png', + ActiveAgent.report => 'assets/branding/agent_illustration_report.png', + ActiveAgent.exercise => 'assets/branding/agent_illustration_exercise.png', + _ => 'assets/branding/agent_illustration_health.png', }; } @@ -432,123 +363,189 @@ class ChatMessagesView extends ConsumerWidget { backendType != 'exercise'); final isExercise = backendType == 'exercise'; final isHealth = !isMedication && !isExercise; - final visual = isMedication - ? AppModuleVisuals.medication - : isExercise - ? AppModuleVisuals.exercise - : AppModuleVisuals.health; + final rawItems = meta['items']; + final healthItems = rawItems is List + ? rawItems + .whereType() + .map((item) => Map.from(item)) + .toList() + : >[]; + const confirmGradient = AppColors.primaryGradient; + const confirmOutline = AppColors.actionOutlineGradient; - // 根据类型获取显示字段 String title = '数据确认'; - IconData titleIcon = Icons.assignment; + IconData titleIcon = Icons.assignment_outlined; String mainLabel = ''; String mainValue = ''; String mainUnit = ''; bool abnormal = meta['abnormal'] as bool? ?? false; + var headerColor = AppColors.primary; String recordTime = meta['recordTime'] as String? ?? ''; + var itemCount = 1; + var confirmationHint = '请核对后确认,本次将一并录入'; + final fields = <_ConfirmField>[]; - List<_ConfirmField> fields = []; + if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); if (isMedication) { - // 药品录入 — 主展示区已显示药名+剂量,详情只列额外信息 title = isMedicationCheckIn ? '服药打卡确认' : '药品录入确认'; - titleIcon = Icons.medication_liquid_outlined; - mainLabel = meta['name'] as String? ?? ''; - mainValue = meta['dosage'] as String? ?? ''; - mainUnit = ''; + titleIcon = AppModuleVisuals.medication.icon; + final name = meta['name']?.toString() ?? ''; + final dosage = meta['dosage']?.toString() ?? ''; + final frequency = meta['frequency']?.toString() ?? ''; + final time = meta['time']?.toString() ?? ''; + final duration = + int.tryParse(meta['duration_days']?.toString() ?? '') ?? 0; - if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); - final time = meta['time'] as String? ?? ''; - if (time.isNotEmpty) { - final timeValue = meta['服药时间'] as String? ?? time; - fields.add(_ConfirmField(label: '服药时间', value: timeValue)); + if (name.isNotEmpty) { + fields.add( + _ConfirmField( + icon: AppModuleVisuals.medication.icon, + label: '药品', + value: name, + ), + ); + } + if (dosage.isNotEmpty) { + fields.add( + _ConfirmField( + icon: Icons.science_outlined, + label: '剂量', + value: dosage, + ), + ); } - final frequency = meta['frequency'] as String? ?? ''; if (frequency.isNotEmpty) { - final freqValue = meta['频率'] as String? ?? _freqLabel(frequency); - fields.add(_ConfirmField(label: '频率', value: freqValue)); + fields.add( + _ConfirmField( + icon: Icons.event_repeat_outlined, + label: '频率', + value: _freqLabel(frequency), + ), + ); } - final duration = meta['duration_days'] as int?; - if (duration != null && duration > 0) { - final durationValue = meta['服用天数'] as String? ?? '$duration 天'; - fields.add(_ConfirmField(label: '服用天数', value: durationValue)); + if (time.isNotEmpty) { + fields.add( + _ConfirmField( + icon: Icons.schedule_outlined, + label: '时间', + value: time, + ), + ); } + if (duration > 0) { + fields.add( + _ConfirmField( + icon: Icons.calendar_today_outlined, + label: '周期', + value: '$duration 天', + ), + ); + } + confirmationHint = isMedicationCheckIn + ? '请核对后确认,本次将完成服药打卡' + : '请核对后确认,本次将创建用药计划'; } else if (isExercise) { - // 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数 title = '运动计划确认'; titleIcon = AppModuleVisuals.exercise.icon; - final exName = - (meta['value'] ?? - meta['name'] ?? - meta['exerciseType'] ?? - meta['运动类型'] ?? - '') + final exerciseName = + (meta['value'] ?? meta['name'] ?? meta['exerciseType'] ?? '运动计划') .toString(); - final exUnitRaw = - (meta['unit'] ?? meta['duration_unit'] ?? meta['durationUnit'] ?? '') - .toString(); - final exDaysRaw = meta['durationDays'] ?? meta['day_count']; - final exDays = int.tryParse(exDaysRaw?.toString() ?? '') ?? 0; + final duration = (meta['unit'] ?? meta['durationUnit'] ?? '').toString(); + final days = + int.tryParse( + (meta['durationDays'] ?? meta['day_count'])?.toString() ?? '', + ) ?? + 0; + final reminder = (meta['reminderTime'] ?? meta['reminder_time'] ?? '') + .toString(); - // 解析 "每天30分钟" → 频率=每天, 时长=30分钟 - final freqMatch = RegExp(r'^(每天|每周|每月)').firstMatch(exUnitRaw); - final durMatch = RegExp(r'(\d+分钟)').firstMatch(exUnitRaw); - final freq = freqMatch?.group(1) ?? ''; - final dur = durMatch?.group(1) ?? exUnitRaw; - - mainLabel = '运动计划'; - mainValue = exName.isNotEmpty ? exName : '运动计划'; - mainUnit = ''; - - if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); - if (dur.isNotEmpty) { - fields.add(_ConfirmField(label: '运动时长', value: dur)); + fields.add( + _ConfirmField( + icon: Icons.directions_walk_outlined, + label: '运动', + value: exerciseName, + ), + ); + if (duration.isNotEmpty) { + fields.add( + _ConfirmField( + icon: Icons.timer_outlined, + label: '时长', + value: duration, + ), + ); } - if (freq.isNotEmpty) { - fields.add(_ConfirmField(label: '频率', value: freq)); + if (days > 0) { + fields.add( + _ConfirmField( + icon: Icons.calendar_today_outlined, + label: '周期', + value: '连续 $days 天', + ), + ); } - if (exDays > 0) { - fields.add(_ConfirmField(label: '计划天数', value: '$exDays天')); - } - final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString(); - if (intensity.isNotEmpty) { - fields.add(_ConfirmField(label: '运动强度', value: intensity)); - } - final calories = - (meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '') - .toString(); - if (calories.isNotEmpty) { - fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal')); + if (reminder.isNotEmpty) { + fields.add( + _ConfirmField( + icon: Icons.notifications_none_rounded, + label: '提醒', + value: reminder, + ), + ); } + confirmationHint = '请核对后确认,本次将创建运动计划'; } else { - // 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息 title = '健康数据确认'; - titleIcon = Icons.monitor_heart_outlined; - mainLabel = _getMetricName(backendType); - mainValue = meta['value'] as String? ?? ''; - mainUnit = meta['unit'] as String? ?? _getMetricUnit(backendType); - - if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); - final timeValue = meta['记录时间'] as String? ?? recordTime; - fields.add(_ConfirmField(label: '记录时间', value: timeValue)); - final note = meta['note'] as String? ?? ''; - if (note.isNotEmpty) { - final noteValue = meta['备注'] as String? ?? note; - fields.add(_ConfirmField(label: '备注', value: noteValue)); + titleIcon = AppModuleVisuals.healthOverviewIcon; + final items = healthItems.isNotEmpty + ? healthItems + : [ + { + 'type': backendType, + 'value': meta['value']?.toString() ?? '', + 'unit': meta['unit']?.toString() ?? _getMetricUnit(backendType), + 'abnormal': meta['abnormal'] == true, + }, + ]; + itemCount = items.length; + abnormal = items.any((item) => item['abnormal'] == true); + for (final item in items) { + final type = item['type']?.toString() ?? ''; + if (items.length == 1) headerColor = _getMetricColor(type); + fields.add( + _ConfirmField( + icon: _getMetricIconData(type), + label: _getMetricName(type), + value: item['value']?.toString() ?? '', + unit: item['unit']?.toString() ?? _getMetricUnit(type), + abnormal: item['abnormal'] == true, + color: _getMetricColor(type), + ), + ); } + confirmationHint = itemCount > 1 + ? '请核对后确认,本次将一并录入 $itemCount 项健康数据' + : '请核对后确认,本次将录入 1 项健康数据'; } + final showLegacySummary = fields.isEmpty; + return Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 16), constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( - color: Colors.white, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFFFFF), Color(0xFFF7F4FF)], + ), borderRadius: BorderRadius.circular(28), boxShadow: [ BoxShadow( - color: visual.color.withValues(alpha: 0.10), + color: AppColors.primary.withValues(alpha: 0.16), blurRadius: 28, offset: const Offset(0, 14), ), @@ -568,10 +565,11 @@ class ChatMessagesView extends ConsumerWidget { width: double.infinity, padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Colors.white, visual.lightColor], + color: Colors.white, + border: Border( + bottom: BorderSide( + color: headerColor.withValues(alpha: 0.16), + ), ), ), child: Row( @@ -581,17 +579,17 @@ class ChatMessagesView extends ConsumerWidget { width: 56, height: 56, decoration: BoxDecoration( - gradient: visual.gradient, + color: headerColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: visual.color.withValues(alpha: 0.18), + color: headerColor.withValues(alpha: 0.12), blurRadius: 10, offset: const Offset(0, 4), ), ], ), - child: Icon(titleIcon, size: 28, color: Colors.white), + child: Icon(titleIcon, size: 28, color: headerColor), ), const SizedBox(width: 14), Expanded( @@ -612,14 +610,14 @@ class ChatMessagesView extends ConsumerWidget { Icon( Icons.access_time, size: 15, - color: AppColors.iconColor, + color: AppColors.textHint, ), const SizedBox(width: 4), Text( recordTime, - style: const TextStyle( + style: TextStyle( fontSize: 15, - color: AppColors.textSecondary, + color: AppColors.textHint, ), ), ], @@ -627,107 +625,130 @@ class ChatMessagesView extends ConsumerWidget { ], ), ), + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 11, + vertical: 6, + ), + decoration: BoxDecoration( + color: headerColor.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: headerColor.withValues(alpha: 0.20), + ), + ), + child: Text( + '$itemCount 项', + style: TextStyle( + color: headerColor, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), ], ), ), // ── 主要数据展示区(大卡片) ── - Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), - child: Container( - padding: const EdgeInsets.all(1.4), - decoration: BoxDecoration( - gradient: visual.gradient, - borderRadius: BorderRadius.circular(20), - ), + if (showLegacySummary) + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), child: Container( - width: double.infinity, - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(1.4), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), + gradient: confirmOutline, + borderRadius: BorderRadius.circular(20), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 68, - height: 68, - decoration: BoxDecoration( - color: visual.lightColor, - borderRadius: BorderRadius.circular(18), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 68, + height: 68, + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(18), + ), + child: Center( + child: isHealth + ? Icon( + _getMetricIconData(backendType), + size: 34, + color: AppColors.primary, + ) + : Icon( + isMedication + ? AppModuleVisuals.medication.icon + : AppModuleVisuals.exercise.icon, + size: 36, + color: AppColors.primary, + ), + ), ), - child: Center( - child: isHealth - ? Icon( - _getMetricIconData(backendType), - size: 34, - color: visual.color, - ) - : Icon( - isMedication - ? Icons.medication_liquid_outlined - : AppModuleVisuals.exercise.icon, - size: 36, - color: visual.color, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mainLabel, + style: const TextStyle( + fontSize: 17, + color: AppColors.textSecondary, ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - mainLabel, - style: const TextStyle( - fontSize: 17, - color: AppColors.textSecondary, ), - ), - if (mainValue.isNotEmpty) ...[ - const SizedBox(height: 8), - RichText( - text: TextSpan( - children: [ - TextSpan( - text: mainValue, - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.w600, - color: abnormal - ? AppColors.error - : AppColors.textPrimary, - letterSpacing: -0.5, - ), - ), - if (mainUnit.isNotEmpty) + if (mainValue.isNotEmpty) ...[ + const SizedBox(height: 8), + RichText( + text: TextSpan( + children: [ TextSpan( - text: ' $mainUnit', + text: mainValue, style: TextStyle( - fontSize: 19, + fontSize: 36, + fontWeight: FontWeight.w600, color: abnormal ? AppColors.error - : visual.color, - fontWeight: FontWeight.w500, + : AppColors.textPrimary, + letterSpacing: -0.5, ), ), - ], + if (mainUnit.isNotEmpty) + TextSpan( + text: ' $mainUnit', + style: TextStyle( + fontSize: 19, + color: abnormal + ? AppColors.error + : AppColors.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ), - ), + ], ], - ], + ), ), - ), - ], + ], + ), ), ), ), - ), // ── 异常提示 ── if (abnormal) ...[ - const SizedBox(height: 12), + const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( @@ -782,7 +803,7 @@ class ChatMessagesView extends ConsumerWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.border), + border: Border.all(color: AppColors.primaryLight), ), child: Column( children: [ @@ -802,7 +823,43 @@ class ChatMessagesView extends ConsumerWidget { ], // ── 确认按钮(白色底+渐变边框) ── - const SizedBox(height: 18), + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 11, + ), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + const Icon( + Icons.verified_user_outlined, + size: 19, + color: AppColors.primary, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + confirmationHint, + style: const TextStyle( + fontSize: 14, + height: 1.35, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( @@ -810,11 +867,11 @@ class ChatMessagesView extends ConsumerWidget { height: 56, padding: const EdgeInsets.all(1.4), decoration: BoxDecoration( - gradient: visual.gradient, + gradient: confirmOutline, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: visual.color.withValues(alpha: 0.18), + color: AppColors.primary.withValues(alpha: 0.20), blurRadius: 18, offset: const Offset(0, 8), ), @@ -876,7 +933,7 @@ class ChatMessagesView extends ConsumerWidget { width: 32, height: 32, decoration: BoxDecoration( - gradient: visual.gradient, + gradient: confirmGradient, borderRadius: BorderRadius.circular(10), ), child: const Icon( @@ -910,26 +967,33 @@ class ChatMessagesView extends ConsumerWidget { Widget _buildFieldRow(_ConfirmField field) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - SizedBox( - width: 80, - child: Text( - field.label, - style: const TextStyle(fontSize: 15, color: AppColors.textHint), + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: (field.color ?? AppColors.primary).withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon( + field.icon ?? Icons.list_alt_rounded, + size: 22, + color: field.abnormal + ? AppColors.error + : (field.color ?? AppColors.primary), ), ), + const SizedBox(width: 14), + SizedBox(width: 72, child: _buildFieldLabel(field.label)), const SizedBox(width: 12), Expanded( - child: Text( - field.value.isNotEmpty ? field.value : '未设置', - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: _buildFieldValue(field, TextAlign.right), ), ), ], @@ -937,6 +1001,44 @@ class ChatMessagesView extends ConsumerWidget { ); } + Widget _buildFieldLabel(String label) { + return Text( + label, + style: const TextStyle( + fontSize: 15, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ); + } + + Widget _buildFieldValue(_ConfirmField field, TextAlign textAlign) { + return Text.rich( + TextSpan( + children: [ + TextSpan(text: field.value.isNotEmpty ? field.value : '未设置'), + if (field.unit.isNotEmpty) + TextSpan( + text: ' ${field.unit}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textHint, + ), + ), + ], + ), + textAlign: textAlign, + maxLines: 1, + softWrap: false, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ); + } + // ═══════════════════════════════════════════════════════════ // 3. ThinkingBubble — 思考动画 // ═══════════════════════════════════════════════════════════ @@ -1203,13 +1305,26 @@ class ChatMessagesView extends ConsumerWidget { } String _freqLabel(String freq) { - switch (freq.toLowerCase()) { + final normalized = freq.trim().toLowerCase().replaceAll( + RegExp(r'[\s_-]+'), + '', + ); + switch (normalized) { case 'daily': - return '每天'; + case 'oncedaily': + return '每天一次'; + case 'twicedaily': + return '每天两次'; + case 'threetimesdaily': + return '每天三次'; case 'everyotherday': - return '隔天'; + return '隔天一次'; case 'weekly': - return '每周'; + return '每周一次'; + case 'monthly': + return '每月一次'; + case 'asneeded': + return '按需服用'; default: return freq; } @@ -1229,7 +1344,7 @@ class ChatMessagesView extends ConsumerWidget { } String _getMetricUnit(String type) { - switch (type.toLowerCase()) { + switch (_normalizeMetricType(type)) { case 'blood_pressure': return 'mmHg'; case 'heart_rate': @@ -1248,17 +1363,17 @@ class ChatMessagesView extends ConsumerWidget { } IconData _getMetricIconData(String type) { - switch (type.toLowerCase()) { + switch (_normalizeMetricType(type)) { case 'blood_pressure': - return Icons.bloodtype_outlined; + return HealthMetricVisuals.bloodPressureIcon; case 'heart_rate': - return Icons.monitor_heart_outlined; + return HealthMetricVisuals.heartRateIcon; case 'glucose': - return Icons.water_drop_outlined; + return HealthMetricVisuals.glucoseIcon; case 'spo2': - return Icons.air_outlined; + return HealthMetricVisuals.spo2Icon; case 'weight': - return Icons.monitor_weight_outlined; + return HealthMetricVisuals.weightIcon; case 'exercise': return Icons.directions_run_outlined; default: @@ -1266,8 +1381,25 @@ class ChatMessagesView extends ConsumerWidget { } } + Color _getMetricColor(String type) { + switch (_normalizeMetricType(type)) { + case 'blood_pressure': + return HealthMetricVisuals.bloodPressureColor; + case 'heart_rate': + return HealthMetricVisuals.heartRateColor; + case 'glucose': + return HealthMetricVisuals.glucoseColor; + case 'spo2': + return HealthMetricVisuals.spo2Color; + case 'weight': + return HealthMetricVisuals.weightColor; + default: + return AppColors.primary; + } + } + String _getMetricName(String type) { - switch (type.toLowerCase()) { + switch (_normalizeMetricType(type)) { case 'blood_pressure': return '血压'; case 'heart_rate': @@ -1285,6 +1417,18 @@ class ChatMessagesView extends ConsumerWidget { } } + String _normalizeMetricType(String type) { + return switch (type.trim().toLowerCase().replaceAll('-', '_')) { + 'bloodpressure' || 'blood_pressure' => 'blood_pressure', + 'heartrate' || 'heart_rate' || 'pulse' => 'heart_rate', + 'glucose' || 'bloodglucose' || 'blood_glucose' => 'glucose', + 'spo2' || 'bloodoxygen' || 'blood_oxygen' => 'spo2', + 'weight' => 'weight', + 'exercise' => 'exercise', + final normalized => normalized, + }; + } + static _AgentColors _agentColors(ActiveAgent agent) { _AgentColors fromModule(AppModuleVisual visual) => _AgentColors( gradient: [visual.gradient.colors.first, visual.gradient.colors.last], @@ -1332,7 +1476,11 @@ class ChatMessagesView extends ConsumerWidget { static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) { return switch (agent) { - ActiveAgent.health => (LucideIcons.heartPulse, '记数据', '录入血压、血糖、心率等日常指标'), + ActiveAgent.health => ( + AppModuleVisuals.healthOverviewIcon, + '记数据', + '录入血压、血糖、心率等日常指标', + ), ActiveAgent.diet => (AppModuleVisuals.diet.icon, '拍饮食', '拍照识别食物热量和营养成分'), ActiveAgent.medication => (LucideIcons.pill, '药管家', '管理药品、提醒服药、追踪用量'), ActiveAgent.consultation => ( @@ -1351,12 +1499,12 @@ class ChatMessagesView extends ConsumerWidget { } static String _agentTip(ActiveAgent agent) => switch (agent) { - ActiveAgent.health => '直接说出您的症状或数据,如"血压130/85,心率72"', - ActiveAgent.diet => '拍照或描述您吃了什么,如"早餐吃了两个鸡蛋一杯牛奶"', - ActiveAgent.medication => '说出药品名称和用法,如"每天早晚各一片阿司匹林"', - ActiveAgent.consultation => '小脉会根据您的回答逐步追问,并给出观察或就医建议', - ActiveAgent.report => '上传您的检查报告,AI 将自动提取指标并解读', - ActiveAgent.exercise => '说出运动计划,如"每天散步30分钟坚持一周"', + ActiveAgent.health => '请提供指标名称、具体数值和测量时间,如"今天早上血压130/85、心率72"', + ActiveAgent.diet => '请提供餐次、食物和分量,或直接拍照,如"早餐2个鸡蛋、1杯牛奶"', + ActiveAgent.medication => '请提供药品名称、每次剂量、服药频率和时间,如"阿司匹林每次1片、每天早晚8点"', + ActiveAgent.consultation => '请说明症状、出现时间、持续多久和严重程度,小脉会继续追问并给出建议', + ActiveAgent.report => '请上传完整报告或清晰照片,并说明检查项目和日期,AI 将提取指标并解读', + ActiveAgent.exercise => '请提供运动类型、每次时长、每周频率和目标,如"散步30分钟、每周5天"', _ => '直接描述您的需求,AI 会自动为您记录和分析', }; @@ -1988,53 +2136,6 @@ class _AgentMark extends StatelessWidget { } } -class _LinearAccentMark extends StatelessWidget { - final _AgentColors colors; - const _LinearAccentMark({required this.colors}); - - @override - Widget build(BuildContext context) { - return Container( - width: 86, - height: 8, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(999), - gradient: LinearGradient( - colors: colors.gradient - .map((color) => color.withValues(alpha: 0.34)) - .toList(), - ), - ), - ); - } -} - -class _MiniSignalStrip extends StatelessWidget { - final _AgentColors colors; - const _MiniSignalStrip({required this.colors}); - - @override - Widget build(BuildContext context) { - final bars = [12.0, 20.0, 14.0, 26.0, 16.0, 22.0, 12.0]; - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - for (var i = 0; i < bars.length; i++) ...[ - Container( - width: 4, - height: bars[i], - decoration: BoxDecoration( - color: colors.accent.withValues(alpha: i.isEven ? 0.34 : 0.18), - borderRadius: BorderRadius.circular(99), - ), - ), - if (i != bars.length - 1) const SizedBox(width: 4), - ], - ], - ); - } -} - typedef _AgentIcon = IconData; class _AgentColors { @@ -2073,9 +2174,21 @@ class _AgentAction { } class _ConfirmField { + final IconData? icon; final String label; final String value; - const _ConfirmField({required this.label, required this.value}); + final String unit; + final bool abnormal; + final Color? color; + + const _ConfirmField({ + this.icon, + required this.label, + required this.value, + this.unit = '', + this.abnormal = false, + this.color, + }); } final _agentActions = >{ @@ -2110,7 +2223,7 @@ final _agentActions = >{ ActiveAgent.medication: [ _AgentAction( label: '用药管理', - icon: Icons.medication_liquid_outlined, + icon: AppModuleVisuals.medication.icon, isWide: true, route: 'medications', ), diff --git a/health_app/lib/pages/notifications/notification_center_page.dart b/health_app/lib/pages/notifications/notification_center_page.dart index 648c4af..e0aa9f7 100644 --- a/health_app/lib/pages/notifications/notification_center_page.dart +++ b/health_app/lib/pages/notifications/notification_center_page.dart @@ -705,7 +705,15 @@ class _NotificationVisual { return _NotificationVisual.fromModule(AppModuleVisuals.exercise); case 'health': case 'health_record_reminder': - return _NotificationVisual.fromModule(AppModuleVisuals.health); + return _NotificationVisual( + AppModuleVisuals.healthOverviewIcon, + AppModuleVisuals.health.color, + AppModuleVisuals.health.lightColor, + AppModuleVisuals.health.gradient, + Colors.white, + false, + AppModuleVisuals.health.label, + ); case 'report': return _NotificationVisual.fromModule(AppModuleVisuals.report); case 'medication': diff --git a/health_app/lib/pages/profile/profile_page.dart b/health_app/lib/pages/profile/profile_page.dart index 47edabc..0728525 100644 --- a/health_app/lib/pages/profile/profile_page.dart +++ b/health_app/lib/pages/profile/profile_page.dart @@ -1,5 +1,9 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; @@ -7,12 +11,79 @@ import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/data_providers.dart'; +import '../../widgets/app_toast.dart'; +import '../../widgets/authenticated_network_image.dart'; -class ProfilePage extends ConsumerWidget { +class ProfilePage extends ConsumerStatefulWidget { const ProfilePage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ProfilePageState(); +} + +class _ProfilePageState extends ConsumerState { + bool _uploadingAvatar = false; + + Future _changeAvatar() async { + final source = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('从相册选择'), + onTap: () => Navigator.pop(sheetContext, ImageSource.gallery), + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('拍照上传'), + onTap: () => Navigator.pop(sheetContext, ImageSource.camera), + ), + ], + ), + ), + ); + if (source == null || !mounted) return; + + final picked = await ImagePicker().pickImage( + source: source, + imageQuality: 85, + maxWidth: 1200, + ); + if (picked == null || !mounted) return; + + setState(() => _uploadingAvatar = true); + try { + final api = ref.read(apiClientProvider); + final avatarUrl = await api.uploadFile( + '/api/files/upload', + File(picked.path), + ); + if (avatarUrl == null) throw StateError('上传未返回头像地址'); + final updatedProfile = await ref + .read(userServiceProvider) + .updateProfile(avatarUrl: avatarUrl); + final savedAvatarUrl = updatedProfile?['avatarUrl']?.toString(); + if (savedAvatarUrl == null || savedAvatarUrl != avatarUrl) { + throw StateError('头像地址未保存到个人资料'); + } + await ref.read(authProvider.notifier).applyProfile(updatedProfile!); + if (mounted) { + AppToast.show(context, '头像已更新', type: AppToastType.success); + } + } catch (_) { + if (mounted) { + AppToast.show(context, '头像上传失败,请稍后重试', type: AppToastType.error); + } + } finally { + if (mounted) setState(() => _uploadingAvatar = false); + } + } + + @override + Widget build(BuildContext context) { final user = ref.watch(authProvider.select((state) => state.user)); final name = user?.name?.trim().isNotEmpty == true ? user!.name! : '未设置昵称'; final phone = user?.phone.trim().isNotEmpty == true @@ -36,8 +107,10 @@ class ProfilePage extends ConsumerWidget { name: name, phone: phone, avatarUrl: user?.avatarUrl, + uploading: _uploadingAvatar, + onChangeAvatar: _uploadingAvatar ? null : _changeAvatar, ), - const SizedBox(height: 20), + const SizedBox(height: 24), const _SectionTitle('资料管理'), const SizedBox(height: 9), _SettingsGroup( @@ -50,7 +123,7 @@ class ProfilePage extends ConsumerWidget { onTap: () => pushRoute(ref, 'profileEdit'), ), _ActionRow( - icon: AppModuleVisuals.health.icon, + icon: LucideIcons.folderHeart, iconColor: AppModuleVisuals.health.color, title: '健康档案', subtitle: '疾病、手术、过敏和生活习惯', @@ -58,19 +131,25 @@ class ProfilePage extends ConsumerWidget { ), ], ), - const SizedBox(height: 20), - const _SectionTitle('账号操作'), - const SizedBox(height: 9), - _SettingsGroup( - children: [ - _ActionRow( - icon: Icons.logout_rounded, - iconColor: AppColors.textSecondary, - title: '退出登录', - showChevron: false, - onTap: () => _logout(context, ref), + const SizedBox(height: 28), + SizedBox( + height: 52, + child: OutlinedButton.icon( + onPressed: () => _logout(context, ref), + icon: const Icon(Icons.logout_rounded), + label: const Text('退出登录'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.errorText, + side: const BorderSide(color: Color(0xFFFECACA)), + shape: RoundedRectangleBorder( + borderRadius: AppRadius.mdBorder, + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), - ], + ), ), ], ), @@ -108,59 +187,100 @@ class _AccountSummary extends StatelessWidget { final String name; final String phone; final String? avatarUrl; + final bool uploading; + final VoidCallback? onChangeAvatar; const _AccountSummary({ required this.name, required this.phone, required this.avatarUrl, + required this.uploading, + required this.onChangeAvatar, }); @override Widget build(BuildContext context) => Container( - padding: const EdgeInsets.all(18), + padding: const EdgeInsets.fromLTRB(20, 22, 20, 20), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.lgBorder, - ), - child: Row( - children: [ - Container( - width: 60, - height: 60, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: AppModuleVisuals.health.lightColor, - borderRadius: AppRadius.lgBorder, - ), - child: avatarUrl?.isNotEmpty == true - ? Image.network( - avatarUrl!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - const _AvatarFallback(), - ) - : const _AvatarFallback(), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.06), + blurRadius: 20, + offset: const Offset(0, 8), ), - const SizedBox(width: 15), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTextStyles.summaryTitle.copyWith(fontSize: 20), + ], + ), + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 92, + height: 92, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: AppModuleVisuals.health.lightColor, + shape: BoxShape.circle, ), - const SizedBox(height: 5), - Text(phone, style: AppTextStyles.listSubtitle), - const SizedBox(height: 5), - const Text( - '头像由账号系统统一管理', - style: TextStyle(fontSize: 12, color: AppColors.textHint), + child: avatarUrl?.isNotEmpty == true + ? AuthenticatedNetworkImage( + imageUrl: avatarUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const _AvatarFallback(), + ) + : const _AvatarFallback(), + ), + Positioned( + right: -2, + bottom: -2, + child: Material( + color: AppColors.primary, + shape: const CircleBorder(), + child: InkWell( + onTap: onChangeAvatar, + customBorder: const CircleBorder(), + child: SizedBox( + width: 34, + height: 34, + child: Center( + child: uploading + ? const SizedBox( + width: 17, + height: 17, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon( + Icons.camera_alt_outlined, + color: Colors.white, + size: 18, + ), + ), + ), + ), ), - ], - ), + ), + ], + ), + const SizedBox(height: 14), + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.summaryTitle.copyWith(fontSize: 21), + ), + const SizedBox(height: 5), + Text(phone, style: AppTextStyles.listSubtitle), + const SizedBox(height: 12), + TextButton.icon( + onPressed: onChangeAvatar, + icon: const Icon(Icons.edit_outlined, size: 17), + label: const Text('更换头像'), ), ], ), @@ -169,19 +289,17 @@ class _AccountSummary extends StatelessWidget { class _AvatarFallback extends StatelessWidget { const _AvatarFallback(); - @override Widget build(BuildContext context) => Icon( Icons.person_rounded, color: AppModuleVisuals.health.color, - size: 34, + size: 48, ); } class _SectionTitle extends StatelessWidget { final String text; const _SectionTitle(this.text); - @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(horizontal: 2), @@ -199,7 +317,6 @@ class _SectionTitle extends StatelessWidget { class _SettingsGroup extends StatelessWidget { final List children; const _SettingsGroup({required this.children}); - @override Widget build(BuildContext context) => Container( decoration: BoxDecoration( @@ -226,18 +343,14 @@ class _ActionRow extends StatelessWidget { final Color iconColor; final String title; final String? subtitle; - final bool showChevron; final VoidCallback onTap; - const _ActionRow({ required this.icon, required this.iconColor, required this.title, this.subtitle, - this.showChevron = true, required this.onTap, }); - @override Widget build(BuildContext context) => Material( color: Colors.transparent, @@ -264,7 +377,7 @@ class _ActionRow extends StatelessWidget { children: [ Text( title, - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -277,11 +390,7 @@ class _ActionRow extends StatelessWidget { ], ), ), - if (showChevron) - const Icon( - Icons.chevron_right_rounded, - color: AppColors.textHint, - ), + const Icon(Icons.chevron_right_rounded, color: AppColors.textHint), ], ), ), diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 6275af3..7e8fbf5 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -2467,7 +2467,7 @@ class _FollowUpListPageState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.event_note_outlined, + AppModuleVisuals.followup.icon, size: 64, color: AppColors.textHint, ), diff --git a/health_app/lib/pages/settings/ai_consent_details_page.dart b/health_app/lib/pages/settings/ai_consent_details_page.dart new file mode 100644 index 0000000..b257f6a --- /dev/null +++ b/health_app/lib/pages/settings/ai_consent_details_page.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; +import '../../providers/ai_consent_provider.dart'; +import '../../providers/auth_provider.dart'; + +class AiConsentDetailsPage extends ConsumerWidget { + const AiConsentDetailsPage({super.key}); + + Future _revoke(BuildContext context, WidgetRef ref) async { + final userId = ref.read(authProvider).user?.id; + if (userId == null || userId.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('撤回 AI 授权?'), + content: const Text('撤回后将退出当前账号,重新使用 App 前需要再次完成 AI 数据授权。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('撤回并退出'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + + final revoked = await ref.read(aiConsentProvider.notifier).revoke(userId); + if (!revoked) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('撤回授权失败,请稍后重试'))); + } + return; + } + await ref.read(authProvider.notifier).logout(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final consent = ref.watch(aiConsentProvider); + final canRevoke = consent.granted && ref.watch(authProvider).user != null; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: const Text( + 'AI 数据授权说明', + style: TextStyle( + fontSize: 19, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + centerTitle: true, + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _section( + '我们会处理什么数据', + '当你使用 AI 对话、饮食识别或报告预整理功能时,可能会处理你主动输入的文字、健康数据、健康档案摘要、图片和 PDF 报告。', + ), + _section( + '发送给哪些服务商', + 'DeepSeek:用于生成 AI 对话和健康信息整理。\n\n通义千问视觉服务:用于识别你主动上传的饮食图片、报告图片或 PDF 内容。\n\nFastGPT:用于检索与问题相关的医学参考资料。', + ), + _section( + '使用目的', + '上述数据仅用于提供 AI 对话、健康记录辅助、饮食图片识别、医学报告预整理和医学资料检索功能。AI 内容仅供健康管理参考,不能替代医生诊断或治疗。', + ), + _section( + '你的选择', + '你可以在首次进入首页时选择是否授权。未授权时无法进入本 App 的主要服务。你也可以在设置中撤回授权;撤回后将退出当前账号,再次使用前需要重新授权。', + ), + _section( + '更多信息', + '数据保存、删除、第三方服务保护措施和联系方式,请同时查看 App 内《隐私政策》和《第三方 SDK 共享清单》。', + ), + if (canRevoke) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: consent.isSaving + ? null + : () => _revoke(context, ref), + style: OutlinedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.error, + side: BorderSide( + color: AppColors.error.withValues(alpha: 0.4), + ), + minimumSize: const Size.fromHeight(50), + ), + child: consent.isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: AppColors.error, + ), + ) + : const Text('撤回 AI 数据授权'), + ), + ), + ], + ], + ), + ), + ), + ); + } + + static Widget _section(String title, String body) { + return Padding( + padding: const EdgeInsets.only(bottom: 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: AppTextStyles.sectionTitle), + const SizedBox(height: 8), + Text( + body, + style: const TextStyle( + fontSize: 15, + height: 1.65, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } +} diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index fe374bb..2c83aa2 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -68,6 +68,11 @@ class SettingsPage extends ConsumerWidget { params: {'type': 'about'}, ), ), + _SettingsTile( + icon: LucideIcons.bot, + title: 'AI 数据授权', + onTap: () => pushRoute(ref, 'aiConsentDetails'), + ), _SettingsTile( icon: LucideIcons.shield, title: '隐私协议', diff --git a/health_app/lib/providers/ai_consent_provider.dart b/health_app/lib/providers/ai_consent_provider.dart new file mode 100644 index 0000000..1a790c0 --- /dev/null +++ b/health_app/lib/providers/ai_consent_provider.dart @@ -0,0 +1,93 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'auth_provider.dart' show localDbProvider; + +const aiConsentVersion = 'ai-data-consent-v1'; + +class AiConsentState { + final bool isLoading; + final bool isSaving; + final bool granted; + final String? userId; + final String? error; + + const AiConsentState({ + this.isLoading = true, + this.isSaving = false, + this.granted = false, + this.userId, + this.error, + }); +} + +final aiConsentProvider = NotifierProvider( + AiConsentNotifier.new, +); + +class AiConsentNotifier extends Notifier { + @override + AiConsentState build() => const AiConsentState(); + + String _key(String userId) => '$aiConsentVersion:$userId'; + + Future load(String userId) async { + state = AiConsentState(isLoading: true, userId: userId); + try { + final value = await ref.read(localDbProvider).read(_key(userId)); + state = AiConsentState( + isLoading: false, + granted: value == 'granted', + userId: userId, + ); + } catch (_) { + state = AiConsentState( + isLoading: false, + userId: userId, + error: '授权状态加载失败,请重试', + ); + } + } + + Future grant(String userId) async { + state = AiConsentState( + isLoading: false, + isSaving: true, + granted: state.granted, + userId: userId, + ); + try { + await ref.read(localDbProvider).write(_key(userId), 'granted'); + state = AiConsentState(isLoading: false, granted: true, userId: userId); + return true; + } catch (_) { + state = AiConsentState( + isLoading: false, + userId: userId, + error: '授权保存失败,请稍后重试', + ); + return false; + } + } + + Future revoke(String userId) async { + state = AiConsentState( + isLoading: false, + isSaving: true, + granted: state.granted, + userId: userId, + ); + try { + await ref.read(localDbProvider).delete(_key(userId)); + state = AiConsentState(isLoading: false, granted: false, userId: userId); + return true; + } catch (_) { + state = AiConsentState( + isLoading: false, + granted: true, + userId: userId, + error: '撤回授权失败,请稍后重试', + ); + return false; + } + } +} diff --git a/health_app/lib/providers/auth_provider.dart b/health_app/lib/providers/auth_provider.dart index c5a0922..f01d722 100644 --- a/health_app/lib/providers/auth_provider.dart +++ b/health_app/lib/providers/auth_provider.dart @@ -156,6 +156,13 @@ class AuthNotifier extends Notifier { Future refreshProfile() => _loadProfile(); + Future applyProfile(Map profile) async { + final user = _userFromMap(profile, fallback: state.user); + state = AuthState(isLoggedIn: true, isLoading: false, user: user); + await _cacheUser(user); + _publishSessionIdentity(user); + } + /// 发送验证码 Future<({String? error, String? devCode})> sendSms(String phone) async { try { diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index d3c4344..5858b15 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -6,6 +6,7 @@ import 'auth_provider.dart'; import 'conversation_history_provider.dart'; import 'data_providers.dart'; import 'data_refresh_providers.dart'; +import '../core/api_client.dart'; import '../utils/sse_handler.dart'; enum MessageType { text, dataConfirm, agentWelcome, taskCard } @@ -104,6 +105,7 @@ class ChatNotifier extends Notifier { Timer? _agentTapLockTimer; bool _loadingConversation = false; int _generation = 0; + final Set _confirmingMessageIds = {}; /// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。 /// 历史记录页一键清空 / 删除当前会话时调用。 @@ -117,20 +119,23 @@ class ChatNotifier extends Notifier { /// 不可变消息操作方法(供 chat_messages_view 新版代码调用) Future confirmMessage(String id) async { + if (!_confirmingMessageIds.add(id)) return null; final msgs = state.messages.toList(); final i = msgs.indexWhere((m) => m.id == id); - if (i < 0) return '确认卡片不存在'; - if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看'; - - final rawIds = msgs[i].metadata?['confirmationIds']; - final confirmationIds = rawIds is List - ? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList() - : []; - if (confirmationIds.isEmpty) { - return '确认信息已失效,请重新发送需要录入的内容'; - } - try { + if (i < 0) return '确认卡片不存在'; + if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看'; + + final original = msgs[i]; + final metadata = _cloneMetadata(original.metadata) ?? {}; + final rawIds = metadata['confirmationIds']; + final confirmationIds = rawIds is List + ? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList() + : []; + if (confirmationIds.isEmpty) { + return '确认信息已失效,请重新发送需要录入的内容'; + } + final api = ref.read(apiClientProvider); for (final confirmationId in confirmationIds.toList()) { final response = await api.post( @@ -141,19 +146,24 @@ class ChatNotifier extends Notifier { return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败'; } confirmationIds.remove(confirmationId); - msgs[i].metadata?['confirmationIds'] = confirmationIds.toList(); + metadata['confirmationIds'] = List.from(confirmationIds); + msgs[i] = _copyMessage(original, metadata: metadata); state = state.copyWith(messages: msgs); } - } catch (e) { - return '录入失败,请检查网络后重试'; - } - msgs[i].confirmed = true; - state = state.copyWith(messages: msgs); - ref.read(medicationDataRefreshSignalProvider.notifier).trigger(); - ref.invalidate(latestHealthProvider); - ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); - return null; + msgs[i] = _copyMessage(original, metadata: metadata, confirmed: true); + state = state.copyWith(messages: msgs); + ref.read(medicationDataRefreshSignalProvider.notifier).trigger(); + ref.invalidate(latestHealthProvider); + ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); + return null; + } on ApiException catch (e) { + return e.message; + } catch (_) { + return '录入失败,请检查网络后重试'; + } finally { + _confirmingMessageIds.remove(id); + } } @override @@ -280,7 +290,7 @@ class ChatNotifier extends Notifier { // 先显示用户消息(本地显示图片路径) final userMsg = ChatMessage( - id: '${DateTime.now().millisecondsSinceEpoch}', + id: '${DateTime.now().microsecondsSinceEpoch}', role: 'user', content: text.isNotEmpty ? text : '[图片]', createdAt: DateTime.now(), @@ -349,7 +359,7 @@ class ChatNotifier extends Notifier { _resumeConversationFromHistory(); final userMsg = ChatMessage( - id: '${DateTime.now().millisecondsSinceEpoch}', + id: '${DateTime.now().microsecondsSinceEpoch}', role: 'user', content: text.isNotEmpty ? text : '请帮我看看这份 PDF', createdAt: DateTime.now(), @@ -407,7 +417,7 @@ class ChatNotifier extends Notifier { _resumeConversationFromHistory(); final userMsg = ChatMessage( - id: '${DateTime.now().millisecondsSinceEpoch}', + id: '${DateTime.now().microsecondsSinceEpoch}', role: 'user', content: text, createdAt: DateTime.now(), @@ -428,7 +438,7 @@ class ChatNotifier extends Notifier { }) async { if (generation != _generation || !state.isStreaming) return; final aiMsg = ChatMessage( - id: '${DateTime.now().millisecondsSinceEpoch}_ai', + id: '${DateTime.now().microsecondsSinceEpoch}_ai', role: 'assistant', content: '', createdAt: DateTime.now(), @@ -563,6 +573,15 @@ class ChatNotifier extends Notifier { aiMsg.content += (j['data'] as String?) ?? ''; state = state.copyWith(thinkingText: null); _update(aiMsg); + case 'confirmation': + aiMsg.type = _parseMessageType(j['type'] as String? ?? 'data_confirm'); + aiMsg.confirmed = false; + if (j['metadata'] is Map) { + aiMsg.metadata = _cloneMetadata( + Map.from(j['metadata']), + ); + } + _update(aiMsg); case 'notice': state = state.copyWith(thinkingText: j['message'] as String?); case 'tool_result': @@ -620,6 +639,27 @@ class ChatNotifier extends Notifier { state = state.copyWith(messages: u); } + Map? _cloneMetadata(Map? metadata) { + if (metadata == null) return null; + return Map.from(jsonDecode(jsonEncode(metadata)) as Map); + } + + ChatMessage _copyMessage( + ChatMessage source, { + Map? metadata, + bool? confirmed, + }) { + return ChatMessage( + id: source.id, + role: source.role, + content: source.content, + createdAt: source.createdAt, + type: source.type, + metadata: metadata ?? _cloneMetadata(source.metadata), + confirmed: confirmed ?? source.confirmed, + ); + } + void _done(ChatMessage m) { final u = state.messages.toList(); final content = m.content.trim(); diff --git a/health_app/lib/services/health_service.dart b/health_app/lib/services/health_service.dart index 8e24ef0..b9cc5e5 100644 --- a/health_app/lib/services/health_service.dart +++ b/health_app/lib/services/health_service.dart @@ -19,15 +19,23 @@ class UserService { return res.data['data']; } - Future updateProfile({ + Future?> updateProfile({ String? name, String? gender, String? birthDate, + String? avatarUrl, }) async { - await _api.put( + final response = await _api.put( '/api/user/profile', - data: {'name': name, 'gender': gender, 'birthDate': birthDate}, + data: { + 'name': name, + 'gender': gender, + 'birthDate': birthDate, + 'avatarUrl': avatarUrl, + }, ); + final data = response.data['data']; + return data is Map ? Map.from(data) : null; } Future?> getHealthArchive() async { diff --git a/health_app/lib/widgets/ai_consent_gate.dart b/health_app/lib/widgets/ai_consent_gate.dart new file mode 100644 index 0000000..61fa4a2 --- /dev/null +++ b/health_app/lib/widgets/ai_consent_gate.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/app_colors.dart'; +import '../core/app_design_tokens.dart'; +import '../core/navigation_provider.dart'; +import '../providers/ai_consent_provider.dart'; +import '../providers/auth_provider.dart'; + +class AiConsentGate extends ConsumerStatefulWidget { + final Widget child; + const AiConsentGate({super.key, required this.child}); + + @override + ConsumerState createState() => _AiConsentGateState(); +} + +class _AiConsentGateState extends ConsumerState { + String? _loadedUserId; + + @override + Widget build(BuildContext context) { + final user = ref.watch(authProvider).user; + if (user == null || user.id.isEmpty || user.role != 'User') { + return widget.child; + } + + if (_loadedUserId != user.id) { + _loadedUserId = user.id; + Future.microtask( + () => ref.read(aiConsentProvider.notifier).load(user.id), + ); + } + + final consent = ref.watch(aiConsentProvider); + final currentRoute = ref.watch(currentRouteProvider).name; + final isConsentInformationRoute = + currentRoute == 'aiConsentDetails' || currentRoute == 'staticText'; + + if (consent.userId != user.id || consent.isLoading) { + return const _ConsentLoadingPage(); + } + if (consent.granted || isConsentInformationRoute) return widget.child; + + return _ConsentPage(userId: user.id); + } +} + +class _ConsentLoadingPage extends StatelessWidget { + const _ConsentLoadingPage(); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: DecoratedBox( + decoration: const BoxDecoration(gradient: AppColors.bgGradient), + child: const SafeArea( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 30, + height: 30, + child: CircularProgressIndicator( + strokeWidth: 3, + color: AppColors.primary, + ), + ), + SizedBox(height: 14), + Text( + '正在加载授权状态…', + style: TextStyle( + fontSize: 15, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ConsentPage extends ConsumerWidget { + final String userId; + const _ConsentPage({required this.userId}); + + Future _grant(BuildContext context, WidgetRef ref) async { + final succeeded = await ref.read(aiConsentProvider.notifier).grant(userId); + if (!succeeded && context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('授权保存失败,请稍后重试'))); + } + } + + Future _decline(WidgetRef ref) async { + await ref.read(authProvider.notifier).logout(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final consent = ref.watch(aiConsentProvider); + + return Scaffold( + body: DecoratedBox( + decoration: const BoxDecoration(gradient: AppColors.bgGradient), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Container( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.96), + borderRadius: AppRadius.cardBorder, + border: Border.all(color: AppColors.primaryLight, width: 1), + boxShadow: AppShadows.panel, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + child: Image.asset( + 'assets/branding/health_login_character_transparent.png', + height: 112, + fit: BoxFit.contain, + semanticLabel: '小脉健康智能助手', + ), + ), + const SizedBox(height: 2), + Align( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: AppRadius.pillBorder, + ), + child: const Text( + '小脉健康 · AI 服务', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + ), + ), + const SizedBox(height: 14), + const Text( + 'AI 健康服务授权', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + height: 1.25, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 14), + const Text( + '小脉健康的核心功能使用第三方 AI 服务。经你同意后,你主动输入的健康信息、对话内容、图片和报告可能会发送给相关 AI 服务,用于健康记录、饮食识别、报告整理和 AI 回复。', + style: TextStyle( + fontSize: 15, + height: 1.6, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 10), + const Text( + 'AI 生成内容仅供健康管理参考,不能替代医生诊断或治疗。', + style: TextStyle( + fontSize: 14, + height: 1.5, + color: AppColors.textSecondary, + ), + ), + if (consent.error != null) ...[ + const SizedBox(height: 14), + _ConsentErrorBanner( + message: consent.error!, + onRetry: consent.isSaving + ? null + : () => ref + .read(aiConsentProvider.notifier) + .load(userId), + ), + ], + const SizedBox(height: 8), + TextButton( + onPressed: consent.isSaving + ? null + : () => pushRoute(ref, 'aiConsentDetails'), + child: const Text('查看《AI 数据处理说明》'), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _GradientConsentButton( + loading: consent.isSaving, + onTap: consent.isSaving + ? null + : () => _grant(context, ref), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton( + onPressed: consent.isSaving + ? null + : () => _decline(ref), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(50), + foregroundColor: AppColors.textSecondary, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: AppRadius.lgBorder, + ), + ), + child: const Text('不同意并退出'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ConsentErrorBanner extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + + const _ConsentErrorBanner({required this.message, this.onRetry}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: AppRadius.mdBorder, + ), + child: Row( + children: [ + const Icon( + Icons.error_outline_rounded, + size: 20, + color: AppColors.errorText, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: const TextStyle(fontSize: 13, color: AppColors.errorText), + ), + ), + if (onRetry != null) + TextButton(onPressed: onRetry, child: const Text('重试')), + ], + ), + ); + } +} + +class _GradientConsentButton extends StatelessWidget { + final bool loading; + final VoidCallback? onTap; + + const _GradientConsentButton({required this.loading, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + enabled: onTap != null, + label: loading ? '正在保存授权' : '同意并继续', + child: IgnorePointer( + ignoring: onTap == null, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 160), + opacity: onTap == null && !loading ? 0.55 : 1, + child: Container( + height: 50, + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: AppRadius.lgBorder, + boxShadow: AppColors.buttonShadow, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: AppRadius.lgBorder, + child: Center( + child: loading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: Colors.white, + ), + ) + : const Text( + '同意并继续', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/health_app/lib/widgets/authenticated_network_image.dart b/health_app/lib/widgets/authenticated_network_image.dart index 73c0e2d..b331adf 100644 --- a/health_app/lib/widgets/authenticated_network_image.dart +++ b/health_app/lib/widgets/authenticated_network_image.dart @@ -7,6 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/api_client.dart' show baseUrl; import '../providers/auth_provider.dart'; +const int _maxProtectedImageCacheEntries = 32; +final Map _protectedImageBytesCache = {}; + String protectedMediaUrl(String value) { final trimmed = value.trim(); if (trimmed.isEmpty) return trimmed; @@ -58,6 +61,7 @@ class _AuthenticatedNetworkImageState extends ConsumerState { late String _resolvedUrl; Future? _bytes; + Uint8List? _cachedBytes; @override void initState() { @@ -73,11 +77,21 @@ class _AuthenticatedNetworkImageState void _configure() { _resolvedUrl = protectedMediaUrl(widget.imageUrl); - _bytes = mediaRequiresAuthentication(_resolvedUrl) - ? _loadProtectedBytes(_resolvedUrl) + _cachedBytes = _protectedImageBytesCache[_resolvedUrl]; + _bytes = mediaRequiresAuthentication(_resolvedUrl) && _cachedBytes == null + ? _loadAndCacheProtectedBytes(_resolvedUrl) : null; } + Future _loadAndCacheProtectedBytes(String url) async { + final data = await _loadProtectedBytes(url); + if (_protectedImageBytesCache.length >= _maxProtectedImageCacheEntries) { + _protectedImageBytesCache.remove(_protectedImageBytesCache.keys.first); + } + _protectedImageBytesCache[url] = data; + return data; + } + Future _loadProtectedBytes(String url) async { final response = await ref .read(apiClientProvider) @@ -91,6 +105,18 @@ class _AuthenticatedNetworkImageState @override Widget build(BuildContext context) { + final cachedBytes = _cachedBytes; + if (cachedBytes != null) { + return Image.memory( + cachedBytes, + fit: widget.fit, + width: widget.width, + height: widget.height, + gaplessPlayback: true, + errorBuilder: widget.errorBuilder, + ); + } + final bytes = _bytes; if (bytes == null) { return Image.network( @@ -98,6 +124,7 @@ class _AuthenticatedNetworkImageState fit: widget.fit, width: widget.width, height: widget.height, + gaplessPlayback: true, loadingBuilder: widget.loadingBuilder, errorBuilder: widget.errorBuilder, ); @@ -123,6 +150,7 @@ class _AuthenticatedNetworkImageState fit: widget.fit, width: widget.width, height: widget.height, + gaplessPlayback: true, errorBuilder: widget.errorBuilder, ); }, diff --git a/health_app/lib/widgets/doctor_drawer.dart b/health_app/lib/widgets/doctor_drawer.dart index 10d4cd2..bd5c6d0 100644 --- a/health_app/lib/widgets/doctor_drawer.dart +++ b/health_app/lib/widgets/doctor_drawer.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/app_colors.dart'; +import '../core/app_module_visuals.dart'; import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../pages/doctor/doctor_home_page.dart' show doctorPageProvider; @@ -64,7 +65,7 @@ class DoctorDrawer extends ConsumerWidget { ), ), _DrawerItem( - icon: Icons.description_outlined, + icon: AppModuleVisuals.report.icon, label: '报告审核', selected: currentPage == 'reports', onTap: () => runAfterDrawerClose( @@ -73,7 +74,7 @@ class DoctorDrawer extends ConsumerWidget { ), ), _DrawerItem( - icon: Icons.event_note_outlined, + icon: AppModuleVisuals.followup.icon, label: '复查随访', selected: currentPage == 'followups', onTap: () => runAfterDrawerClose( diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index c3d06b0..31bb7a3 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -10,6 +10,7 @@ import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import '../providers/conversation_history_provider.dart'; import 'app_toast.dart'; +import 'authenticated_network_image.dart'; import '../providers/data_providers.dart'; import 'drawer_shell.dart'; @@ -136,9 +137,7 @@ class _AccountHeader extends StatelessWidget { width: 66, height: 66, decoration: BoxDecoration( - color: Colors.white, shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( color: const Color(0xFF7C5CFF).withValues(alpha: 0.16), @@ -147,10 +146,33 @@ class _AccountHeader extends StatelessWidget { ), ], ), - child: const Icon( - Icons.person_rounded, - color: Color(0xFF94A3B8), - size: 34, + child: Stack( + fit: StackFit.expand, + children: [ + ClipOval( + clipBehavior: Clip.antiAlias, + child: ColoredBox( + color: const Color(0xFFF1F5F9), + child: user?.avatarUrl?.toString().isNotEmpty == true + ? AuthenticatedNetworkImage( + imageUrl: user.avatarUrl.toString(), + fit: BoxFit.cover, + width: 66, + height: 66, + errorBuilder: (_, _, _) => const Icon( + Icons.person_rounded, + color: Color(0xFF94A3B8), + size: 34, + ), + ) + : const Icon( + Icons.person_rounded, + color: Color(0xFF94A3B8), + size: 34, + ), + ), + ), + ], ), ), ), @@ -208,11 +230,6 @@ class _HealthDashboard extends StatelessWidget { Widget build(BuildContext context) { return _Panel( title: '健康仪表盘', - trailing: _TextAction( - label: '详情', - light: true, - onTap: () => pushRoute(ref, 'trend'), - ), titleColor: Colors.white, backgroundPainter: const _HealthDashboardBackgroundPainter(), child: latestHealth.when( @@ -313,7 +330,7 @@ class _MetricTile extends StatelessWidget { onTap: onTap, borderRadius: BorderRadius.circular(18), child: Container( - height: elderMode ? 112 : 100, + height: elderMode ? 108 : 100, padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.22), @@ -627,13 +644,11 @@ class _LightSection extends StatelessWidget { class _Panel extends StatelessWidget { final String title; final Widget child; - final Widget? trailing; final CustomPainter? backgroundPainter; final Color titleColor; const _Panel({ required this.title, required this.child, - this.trailing, this.backgroundPainter, this.titleColor = AppColors.textPrimary, }); @@ -664,20 +679,13 @@ class _Panel extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded( - child: Text( - title, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: titleColor, - ), - ), - ), - ?trailing, - ], + Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: titleColor, + ), ), const SizedBox(height: 12), child, @@ -708,45 +716,6 @@ class _HealthDashboardBackgroundPainter extends CustomPainter { bool shouldRepaint(_HealthDashboardBackgroundPainter oldDelegate) => false; } -class _TextAction extends StatelessWidget { - final String label; - final VoidCallback onTap; - final bool light; - const _TextAction({ - required this.label, - required this.onTap, - this.light = false, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(999), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6), - decoration: BoxDecoration( - color: light - ? Colors.white.withValues(alpha: 0.24) - : const Color(0xFFEDE9FE), - borderRadius: BorderRadius.circular(999), - border: light - ? Border.all(color: Colors.white.withValues(alpha: 0.34)) - : null, - ), - child: Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: light ? Colors.white : Color(0xFF6D28D9), - ), - ), - ), - ); - } -} - class _IconButton extends StatelessWidget { final IconData icon; final VoidCallback onTap;