feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构

- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService
- 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放
- UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面
- 配置: api_client baseUrl 适配当前 WiFi IP
This commit is contained in:
MingNian
2026-07-06 12:44:59 +08:00
parent 4507083f3f
commit 7a93237069
38 changed files with 4165 additions and 1157 deletions

View File

@@ -28,12 +28,15 @@ public sealed record OpenConversationResult(
public interface IAiConversationService
{
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct);
Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
}
public interface IAiConversationRepository

View File

@@ -5,6 +5,7 @@ namespace Health.Application.AI;
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
{
private const int HistoryConversationLimit = 7;
private readonly IAiConversationRepository _conversations = conversations;
public async Task<OpenConversationResult> OpenAsync(
@@ -33,14 +34,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
};
await _conversations.AddConversationAsync(conversation, ct);
await _conversations.SaveChangesAsync(ct);
await PruneHistoryAsync(userId, ct);
return new OpenConversationResult(true, true, conversation.Id);
}
public async Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct);
public async Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, metadataJson, ct);
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, ct);
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson: null, ct);
public async Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson, ct);
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
{
@@ -51,7 +56,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
{
var conversations = await _conversations.ListAsync(userId, ct);
return conversations.Select(ToDto).ToList();
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
}
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
@@ -63,6 +68,17 @@ public sealed class AiConversationService(IAiConversationRepository conversation
return messages.Select(ToMessageDto).ToList();
}
public async Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct)
{
var conversation = await _conversations.GetAsync(conversationId, ct)
?? throw new InvalidOperationException("会话不存在");
var normalized = summary.Trim();
if (string.IsNullOrWhiteSpace(normalized)) return;
conversation.Summary = normalized.Length > 24 ? normalized[..24] : normalized;
await _conversations.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
{
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
@@ -72,7 +88,19 @@ public sealed class AiConversationService(IAiConversationRepository conversation
await _conversations.SaveChangesAsync(ct);
}
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, CancellationToken ct)
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
{
var list = await _conversations.ListAsync(userId, ct);
foreach (var conv in list)
{
_conversations.Delete(conv);
}
if (list.Count > 0)
await _conversations.SaveChangesAsync(ct);
return list.Count;
}
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, string? metadataJson, CancellationToken ct)
{
var conversation = await _conversations.GetAsync(conversationId, ct)
?? throw new InvalidOperationException("会话不存在");
@@ -82,6 +110,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation
ConversationId = conversationId,
Role = role,
Content = content,
MetadataJson = metadataJson,
CreatedAt = DateTime.UtcNow,
}, ct);
@@ -90,6 +119,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
if (updateSummary)
conversation.Summary = content.Length > 100 ? content[..100] : content;
await _conversations.SaveChangesAsync(ct);
await PruneHistoryAsync(conversation.UserId, ct);
}
private async Task PruneHistoryAsync(Guid userId, CancellationToken ct)
{
var list = await _conversations.ListAsync(userId, ct);
var expired = list.Skip(HistoryConversationLimit).ToList();
if (expired.Count == 0) return;
foreach (var conversation in expired)
_conversations.Delete(conversation);
await _conversations.SaveChangesAsync(ct);
}
private static AiConversationDto ToDto(Conversation conversation) => new(

View File

@@ -0,0 +1,20 @@
namespace Health.Application.AI;
/// <summary>
/// 用户消息附带的图片/PDF 解析结果。
/// 既用作 LLM 上下文拼装,也持久化到 ConversationMessage.MetadataJson。
/// </summary>
public sealed record AttachmentContext(
string Kind, // "image" 或 "pdf"
string? Category, // 仅图片food / report / wound / drug / chart / other
string? FileName, // PDF 文件原名
string Summary, // 简短摘要,用于持久化和历史回顾
string LlmContent); // 完整文本,拼到当前轮 LLM user content 前
public interface IAttachmentContextBuilder
{
/// <summary>
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
/// </summary>
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct);
}

View File

@@ -97,6 +97,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
{
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return null;
if (item.ScheduledDate != BeijingToday())
throw new ValidationException("只能打卡今天的运动任务");
if (item.IsRestDay)
throw new ValidationException("休息日无需打卡");
item.IsCompleted = !item.IsCompleted;
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
item.Plan.UpdatedAt = DateTime.UtcNow;
@@ -108,6 +112,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
{
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return false;
if (item.ScheduledDate != BeijingToday())
throw new ValidationException("只能打卡今天的运动任务");
if (item.IsRestDay)
throw new ValidationException("休息日无需打卡");
item.IsCompleted = true;
item.CompletedAt = DateTime.UtcNow;
item.Plan.UpdatedAt = DateTime.UtcNow;

View File

@@ -8,10 +8,10 @@ public sealed class ReportService(
IReportFileStorage fileStorage,
IReportAnalysisQueue queue) : IReportService
{
private const long MaxReportImageBytes = 10 * 1024 * 1024;
private const long MaxReportFileBytes = 20 * 1024 * 1024;
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".webp"
".jpg", ".jpeg", ".png", ".webp", ".pdf"
};
private readonly IReportRepository _reports = reports;
@@ -35,12 +35,12 @@ public sealed class ReportService(
if (file.Length <= 0)
return Fail(400, "未上传文件");
if (file.Length > MaxReportImageBytes)
return Fail(400, "报告图片不能超过 10MB请压缩后重新上传");
if (file.Length > MaxReportFileBytes)
return Fail(400, "报告文件不能超过 20MB请压缩后重新上传");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return Fail(400, "目前支持上传 JPG、PNG、WEBP 格式的报告图片");
return Fail(400, "目前支持上传 JPG、PNG、WEBP 图片或 PDF 报告");
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
var report = new Report
@@ -48,7 +48,7 @@ public sealed class ReportService(
Id = Guid.NewGuid(),
UserId = userId,
FileUrl = storedFile.FileUrl,
FileType = ReportFileType.Image,
FileType = ext == ".pdf" ? ReportFileType.Pdf : ReportFileType.Image,
Category = ReportCategory.Other,
Status = ReportStatus.Analyzing,
AiSummary = null,