feat: 问诊对话+建档引导+报告+日历+趋势页重写+视觉统一
- 问诊对话页: 新建consultation_provider, 完整AI分身聊天UI - 首次建档引导: OnboardingPrompt+ManageArchiveTool+前端卡片 - 报告模块: POST端点 + report_agent_handler接VLM - 健康日历: GET /api/calendar端点 + 前端接API - 趋势页重写: 删除Mock数据 + 真实API + 手动录入 - 饮食保存: submit按钮接后端API - 侧边栏: 健康概览横排 + 统一紫色配色 - 运动计划: 修复JSON反序列化(record→手动解析) - VLM: prompt优化 + 模型切qwen3-vl-plus + 1280px压缩 - UI颜色: 加深主色 8B9CF7→6C5CE7 - 个人信息页精简 + 健康档案可编辑重设计 - 保洁: 删除无用agent_bar + 删除意见反馈/关于我们 - 新增AI提示词文档
This commit is contained in:
@@ -138,7 +138,8 @@ public enum AgentType
|
||||
Diet, // 拍饮食
|
||||
Medication, // 药管家
|
||||
Report, // 看报告
|
||||
Exercise // 运动计划
|
||||
Exercise, // 运动计划
|
||||
Onboarding // 建档引导
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -61,4 +61,73 @@ public static class CommonAgentHandler
|
||||
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
||||
};
|
||||
}
|
||||
|
||||
public static readonly ToolDefinition ManageArchiveTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "manage_archive", Description = "管理用户健康档案",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "update_diagnosis/update_surgery/update_allergies/update_chronic_diseases/update_diet_restrictions/query" },
|
||||
diagnosis = new { type = "string" },
|
||||
surgery_type = new { type = "string" },
|
||||
surgery_date = new { type = "string" },
|
||||
allergies = new { type = "array", items = new { type = "string" } },
|
||||
chronic_diseases = new { type = "array", items = new { type = "string" } },
|
||||
diet_restrictions = new { type = "array", items = new { type = "string" } },
|
||||
},
|
||||
required = new[] { "action" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static async Task<object> ExecuteManageArchive(AppDbContext db, Guid userId, JsonElement args)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(x => x.UserId == userId);
|
||||
if (archive == null)
|
||||
{
|
||||
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
|
||||
db.HealthArchives.Add(archive);
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "update_diagnosis":
|
||||
archive.Diagnosis = args.TryGetProperty("diagnosis", out var d) ? d.GetString() : archive.Diagnosis;
|
||||
break;
|
||||
case "update_surgery":
|
||||
archive.SurgeryType = args.TryGetProperty("surgery_type", out var st) ? st.GetString() : archive.SurgeryType;
|
||||
if (args.TryGetProperty("surgery_date", out var sd))
|
||||
archive.SurgeryDate = DateOnly.TryParse(sd.GetString(), out var date) ? date : archive.SurgeryDate;
|
||||
break;
|
||||
case "update_allergies":
|
||||
if (args.TryGetProperty("allergies", out var al) && al.ValueKind == JsonValueKind.Array)
|
||||
archive.Allergies = [.. al.EnumerateArray().Select(x => x.GetString()!)];
|
||||
break;
|
||||
case "update_chronic_diseases":
|
||||
if (args.TryGetProperty("chronic_diseases", out var cd) && cd.ValueKind == JsonValueKind.Array)
|
||||
archive.ChronicDiseases = [.. cd.EnumerateArray().Select(x => x.GetString()!)];
|
||||
break;
|
||||
case "update_diet_restrictions":
|
||||
if (args.TryGetProperty("diet_restrictions", out var dr) && dr.ValueKind == JsonValueKind.Array)
|
||||
archive.DietRestrictions = [.. dr.EnumerateArray().Select(x => x.GetString()!)];
|
||||
break;
|
||||
default: // query
|
||||
return new
|
||||
{
|
||||
found = true, archive.Diagnosis, archive.SurgeryType,
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
||||
};
|
||||
}
|
||||
|
||||
archive.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return new { success = true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,39 @@ public static class ReportAgentHandler
|
||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<object> AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient)
|
||||
{
|
||||
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
|
||||
if (string.IsNullOrEmpty(imageUrl))
|
||||
return new { success = false, message = "缺少报告图片" };
|
||||
|
||||
var prompt = """
|
||||
你是一个医学报告解读专家。请分析以下检查报告图片,以JSON格式返回:
|
||||
{
|
||||
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
|
||||
"indicators": [
|
||||
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
|
||||
],
|
||||
"summary": "初步分析摘要",
|
||||
"needsDoctorReview": true/false
|
||||
}
|
||||
只返回JSON,不要其他内容。
|
||||
""";
|
||||
|
||||
var response = await visionClient.VisionAsync(prompt, [imageUrl], userText: "请分析这份检查报告");
|
||||
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||||
|
||||
// 保存到报告记录
|
||||
var report = await db.Reports
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.FirstOrDefaultAsync(r => r.UserId == userId && r.Status == ReportStatus.PendingDoctor);
|
||||
if (report != null)
|
||||
{
|
||||
report.AiSummary = content;
|
||||
report.AiIndicators = content;
|
||||
}
|
||||
|
||||
return new { success = true, analysis = content };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
|
||||
throw new HttpRequestException($"VLM API {response.StatusCode}: {errorBody}");
|
||||
}
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
Console.WriteLine($"[VLM RAW] Model={_model}, BodyLen={body.Length}, Body={body[..Math.Min(body.Length, 500)]}");
|
||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class PromptManager
|
||||
AgentType.Medication => MedicationPrompt,
|
||||
AgentType.Report => ReportPrompt,
|
||||
AgentType.Exercise => ExercisePrompt,
|
||||
AgentType.Onboarding => OnboardingPrompt,
|
||||
_ => DefaultPrompt
|
||||
};
|
||||
|
||||
@@ -115,4 +116,24 @@ public sealed class PromptManager
|
||||
4. 运动强度要循序渐进
|
||||
5. 避免剧烈运动
|
||||
""";
|
||||
|
||||
private const string OnboardingPrompt = """
|
||||
你是一个健康管家助手,正在帮助新用户建立健康档案。
|
||||
|
||||
必须严格遵守以下流程,一次只问一个问题:
|
||||
|
||||
步骤1:询问主要诊断(冠心病/高血压/糖尿病/其他)
|
||||
步骤2:询问手术史(PCI支架植入/心脏搭桥/其他/没有)→ 如果用户说有手术,追问手术日期
|
||||
步骤3:询问过敏史(青霉素/头孢/海鲜/鸡蛋/无过敏)→ 可多选
|
||||
步骤4:询问慢性病史(高血脂/糖尿病/痛风/无)
|
||||
步骤5:询问饮食限制(低盐/低脂/低糖/无限制)
|
||||
|
||||
规则:
|
||||
- 每次只问一个问题,给出2-4个快捷选项让用户点击
|
||||
- 用户回答每个问题后,立即调用 manage_archive 工具保存
|
||||
- 用户说"跳过"/"以后再说"/"再说"/"不用了"→ 礼貌结束建档:"好的,随时可以跟我说'完善档案'来继续"
|
||||
- 全部完成后,生成结构化总结卡片
|
||||
- 回复语气温暖、像朋友一样关怀患者
|
||||
- 每步只问一个问题不要多问
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class AiChatEndpoints
|
||||
AppDbContext db,
|
||||
DeepSeekClient llmClient,
|
||||
PromptManager promptManager,
|
||||
VisionClient visionClient,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// 支持 token 通过 query string(浏览器 EventSource)或 header 传递
|
||||
@@ -153,7 +154,7 @@ public static class AiChatEndpoints
|
||||
object toolResult;
|
||||
try
|
||||
{
|
||||
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value);
|
||||
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value, visionClient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -265,24 +266,23 @@ public static class AiChatEndpoints
|
||||
await file.CopyToAsync(stream, ct);
|
||||
|
||||
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
|
||||
CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 75L);
|
||||
CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 85L);
|
||||
var compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct);
|
||||
var base64 = Convert.ToBase64String(compressedBytes);
|
||||
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
||||
}
|
||||
|
||||
var prompt = """
|
||||
识别图片中的食物,以 JSON 数组格式返回,每项包含:
|
||||
- name: 食物名称
|
||||
- portion: 份量描述(如"约1碗"、"约200g")
|
||||
- calories: 估算热量(千卡,整数)
|
||||
只返回 JSON 数组,不要任何其他文字。
|
||||
示例:[{"name":"米饭","portion":"约1碗","calories":150},{"name":"番茄炒蛋","portion":"约1份","calories":200},{...}]
|
||||
Describe everything you see in this image in detail. What objects are present? What are their colors, shapes, labels?
|
||||
If there are any food items, drinks, or beverages, identify them specifically by name.
|
||||
Then, for each food/drink item, estimate the portion size and calories (kcal).
|
||||
Return ONLY a JSON array: [{"name":"item name","portion":"estimated portion","calories":estimated_calories}]
|
||||
If you cannot identify something, say so in the name field.
|
||||
""";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", ct: ct);
|
||||
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", maxTokens: 8192, ct: ct);
|
||||
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
}
|
||||
@@ -328,10 +328,11 @@ public static class AiChatEndpoints
|
||||
AgentType.Consultation => ConsultationAgentHandler.Tools,
|
||||
AgentType.Report => ReportAgentHandler.Tools,
|
||||
AgentType.Exercise => ExerciseAgentHandler.Tools,
|
||||
AgentType.Onboarding => [CommonAgentHandler.ManageArchiveTool, CommonAgentHandler.CheckArchiveTool],
|
||||
_ => CommonAgentHandler.Tools,
|
||||
};
|
||||
|
||||
private static Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId)
|
||||
private static Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId, VisionClient? visionClient = null)
|
||||
{
|
||||
using var jsonDoc = JsonDocument.Parse(arguments);
|
||||
var root = jsonDoc.RootElement;
|
||||
@@ -343,8 +344,11 @@ public static class AiChatEndpoints
|
||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
|
||||
"estimate_food_text" => DietAgentHandler.Execute(toolName, root, db, userId),
|
||||
"analyze_report" => ReportAgentHandler.Execute(toolName, root, db, userId),
|
||||
"analyze_report" => visionClient != null
|
||||
? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient)
|
||||
: Task.FromResult<object>(new { success = false, message = "VLM服务未配置" }),
|
||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId),
|
||||
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root),
|
||||
"request_doctor" => ConsultationAgentHandler.Execute(toolName, root, db, userId),
|
||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
||||
};
|
||||
|
||||
65
backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs
Normal file
65
backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class CalendarEndpoints
|
||||
{
|
||||
public static void MapCalendarEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapGet("/api/calendar", async (
|
||||
int year, int month,
|
||||
HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var start = new DateOnly(year, month, 1);
|
||||
var end = start.AddMonths(1);
|
||||
|
||||
// 用药
|
||||
var medications = await db.Medications
|
||||
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0)
|
||||
.Select(m => new { m.Id, m.Name, m.TimeOfDay })
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 运动
|
||||
var plans = await db.ExercisePlans
|
||||
.Where(p => p.UserId == userId && p.WeekStartDate < end)
|
||||
.Include(p => p.Items)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 健康数据日期
|
||||
var healthDates = await db.HealthRecords
|
||||
.Where(r => r.UserId == userId
|
||||
&& r.RecordedAt >= start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)
|
||||
&& r.RecordedAt < end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc))
|
||||
.Select(r => DateOnly.FromDateTime(r.RecordedAt))
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 复查随访
|
||||
var followups = await db.FollowUps
|
||||
.Where(f => f.UserId == userId
|
||||
&& f.ScheduledAt >= start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)
|
||||
&& f.ScheduledAt < end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc))
|
||||
.Select(f => new { f.Id, f.Title, f.Department,
|
||||
Date = DateOnly.FromDateTime(f.ScheduledAt) })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var result = new List<object>();
|
||||
for (var d = start; d < end; d = d.AddDays(1))
|
||||
{
|
||||
var events = new List<string>();
|
||||
if (medications.Any()) events.Add("medication");
|
||||
if (plans.Any(p => p.Items.Any(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay)))
|
||||
events.Add("exercise");
|
||||
if (followups.Any(f => f.Date == d)) events.Add("followup");
|
||||
if (healthDates.Contains(d)) events.Add("health_record");
|
||||
|
||||
if (events.Count > 0)
|
||||
result.Add(new { date = d.ToString("yyyy-MM-dd"), events });
|
||||
}
|
||||
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
}).RequireAuthorization();
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
@@ -29,13 +29,31 @@ public static class ExerciseEndpoints
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/", async (CreateExercisePlanRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = req.WeekStartDate };
|
||||
if (req.Items != null)
|
||||
foreach (var item in req.Items)
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = item.DayOfWeek, ExerciseType = item.ExerciseType, DurationMinutes = item.DurationMinutes, IsRestDay = item.IsRestDay });
|
||||
request.EnableBuffering();
|
||||
using var reader = new StreamReader(request.Body);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
request.Body.Position = 0;
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var root = json.RootElement;
|
||||
var ws = root.GetProperty("weekStartDate").GetString()!;
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = DateOnly.Parse(ws) };
|
||||
if (root.TryGetProperty("items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DayOfWeek = item.GetProperty("dayOfWeek").GetInt32(),
|
||||
ExerciseType = item.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "休息",
|
||||
DurationMinutes = item.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 0,
|
||||
IsRestDay = item.TryGetProperty("isRestDay", out var rd) && rd.GetBoolean(),
|
||||
});
|
||||
}
|
||||
}
|
||||
db.ExercisePlans.Add(plan);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
|
||||
@@ -55,15 +73,3 @@ public static class ExerciseEndpoints
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
|
||||
public sealed class CreateExercisePlanRequest
|
||||
{
|
||||
public DateOnly WeekStartDate { get; init; }
|
||||
public List<ExerciseItemDto>? Items { get; init; }
|
||||
}
|
||||
public sealed class ExerciseItemDto
|
||||
{
|
||||
public int DayOfWeek { get; init; }
|
||||
public string ExerciseType { get; init; } = "";
|
||||
public int DurationMinutes { get; init; }
|
||||
public bool IsRestDay { get; init; }
|
||||
}
|
||||
|
||||
@@ -19,8 +19,26 @@ public static class ReportEndpoints
|
||||
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
||||
return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPost("/", async (CreateReportRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var report = new Report
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId,
|
||||
FileUrl = req.FileUrl, FileType = req.FileType,
|
||||
Category = req.Category ?? ReportCategory.Other,
|
||||
Status = ReportStatus.PendingDoctor,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Reports.Add(report);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { report.Id }, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
|
||||
public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);
|
||||
|
||||
@@ -77,7 +77,7 @@ builder.Services.AddHttpClient<VisionClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? "");
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
// ---- 后台服务 ----
|
||||
@@ -128,5 +128,6 @@ app.MapExerciseEndpoints();
|
||||
app.MapUserEndpoints();
|
||||
app.MapAiChatEndpoints();
|
||||
app.MapFileEndpoints();
|
||||
app.MapCalendarEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
Reference in New Issue
Block a user