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:
MingNian
2026-06-04 13:49:43 +08:00
parent c2399b952f
commit 0e0e8ce72b
29 changed files with 1621 additions and 3361 deletions

View File

@@ -138,7 +138,8 @@ public enum AgentType
Diet, // 拍饮食
Medication, // 药管家
Report, // 看报告
Exercise // 运动计划
Exercise, // 运动计划
Onboarding // 建档引导
}
/// <summary>

View File

@@ -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 };
}
}

View File

@@ -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 };
}
}

View File

@@ -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)!;
}
}

View File

@@ -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//尿/
2PCI支架植入///
3////
4/尿//
5///
- 2-4
- manage_archive
- "跳过"/"以后再说"/"再说"/"不用了" "好的,随时可以跟我说'完善档案'来继续"
-
- 怀
-
""";
}

View File

@@ -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}" })
};

View 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;
}

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -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();