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:
@@ -28,6 +28,8 @@ public static class AiChatEndpoints
|
||||
app.MapGet("/api/ai/{agentType}/chat", async (
|
||||
string message,
|
||||
string? conversationId,
|
||||
string? imageUrl,
|
||||
string? pdfUrl,
|
||||
string token,
|
||||
string agentType,
|
||||
HttpContext http,
|
||||
@@ -37,6 +39,7 @@ public static class AiChatEndpoints
|
||||
IAiToolExecutionService toolExecution,
|
||||
IAiWriteConfirmationStore confirmations,
|
||||
IAiConversationService conversations,
|
||||
IAttachmentContextBuilder attachments,
|
||||
IPatientContextService patientContexts,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
@@ -85,7 +88,24 @@ public static class AiChatEndpoints
|
||||
if (opened.Created)
|
||||
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
|
||||
|
||||
await conversations.AddUserMessageAsync(activeConversationId, message, ct);
|
||||
// 附件解析(图片走 VLM、PDF 走 PdfPig),结果同时拼 LLM 上下文 + 持久化到 user message metadata
|
||||
var attachment = await attachments.BuildAsync(imageUrl, pdfUrl, ct);
|
||||
string? userMessageMetadataJson = null;
|
||||
if (attachment != null)
|
||||
{
|
||||
userMessageMetadataJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
kind = attachment.Kind,
|
||||
category = attachment.Category,
|
||||
fileName = attachment.FileName,
|
||||
summary = attachment.Summary,
|
||||
imageUrl,
|
||||
pdfUrl,
|
||||
}, JsonOpts);
|
||||
await SseWriteAsync(http, new { action = "attachment", data = new { attachment.Kind, attachment.Category, attachment.Summary } }, ct);
|
||||
}
|
||||
|
||||
await conversations.AddUserMessageAsync(activeConversationId, message, userMessageMetadataJson, ct);
|
||||
|
||||
var urgentWarning = DetectUrgentRisk(message);
|
||||
if (!string.IsNullOrEmpty(urgentWarning))
|
||||
@@ -99,6 +119,7 @@ public static class AiChatEndpoints
|
||||
""";
|
||||
|
||||
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
|
||||
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
|
||||
|
||||
await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct);
|
||||
await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct);
|
||||
@@ -131,16 +152,46 @@ public static class AiChatEndpoints
|
||||
new() { Role = "system", Content = enhancedSystem },
|
||||
};
|
||||
|
||||
// 加载历史对话(最近 10 条)
|
||||
// 加载历史对话(最近 12 条)
|
||||
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
|
||||
|
||||
foreach (var h in history)
|
||||
{
|
||||
messages.Add(new ChatMessage
|
||||
var role = h.Role == MessageRole.User.ToString() ? "user" : "assistant";
|
||||
var content = h.Content;
|
||||
|
||||
// 历史 user message 如果带过附件,把附件摘要拼回 content,让 AI 能回忆起来
|
||||
if (role == "user" && !string.IsNullOrWhiteSpace(h.MetadataJson))
|
||||
{
|
||||
Role = h.Role == MessageRole.User.ToString() ? "user" : "assistant",
|
||||
Content = h.Content,
|
||||
});
|
||||
try
|
||||
{
|
||||
using var meta = JsonDocument.Parse(h.MetadataJson);
|
||||
if (meta.RootElement.TryGetProperty("summary", out var sumEl) &&
|
||||
sumEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var sum = sumEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(sum) &&
|
||||
// 跳过本轮:本轮 user message 会在下面单独拼 LlmContent,避免重复
|
||||
h.Content != message)
|
||||
{
|
||||
content = $"[历史附件回忆:{sum}]\n{content}";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* 忽略损坏的 metadata */ }
|
||||
}
|
||||
|
||||
messages.Add(new ChatMessage { Role = role, Content = content });
|
||||
}
|
||||
|
||||
// 本轮如果带了附件,把完整识别结果拼到最后一条 user message 前
|
||||
if (attachment != null && messages.Count > 0 && messages[^1].Role == "user")
|
||||
{
|
||||
messages[^1] = new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = $"{attachment.LlmContent}\n{messages[^1].Content}",
|
||||
};
|
||||
}
|
||||
|
||||
// Tool Calling 循环
|
||||
@@ -213,7 +264,16 @@ public static class AiChatEndpoints
|
||||
|
||||
// 保存 AI 回复
|
||||
if (!string.IsNullOrEmpty(fullResponse))
|
||||
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct);
|
||||
{
|
||||
string? assistantMetadataJson = null;
|
||||
if (metadata.Count > 0 || messageType != "text")
|
||||
{
|
||||
metadata["messageType"] = messageType;
|
||||
assistantMetadataJson = JsonSerializer.Serialize(metadata, JsonOpts);
|
||||
}
|
||||
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, assistantMetadataJson, ct);
|
||||
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
|
||||
}
|
||||
|
||||
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
||||
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||
@@ -265,6 +325,16 @@ public static class AiChatEndpoints
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
|
||||
// 一键清空当前用户的全部对话
|
||||
app.MapDelete("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||
|
||||
var count = await conversations.DeleteAllAsync(userId.Value, ct);
|
||||
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
||||
});
|
||||
|
||||
app.MapPost("/api/ai/analyze-food-image", async (
|
||||
HttpRequest httpRequest,
|
||||
HttpContext http,
|
||||
@@ -317,6 +387,116 @@ public static class AiChatEndpoints
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
private static async Task TryUpdateConversationSummaryAsync(
|
||||
IAiConversationService conversations,
|
||||
DeepSeekClient llmClient,
|
||||
Guid userId,
|
||||
Guid conversationId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var messages = await conversations.GetMessagesAsync(userId, conversationId, ct);
|
||||
var transcript = BuildSummaryTranscript(messages);
|
||||
if (string.IsNullOrWhiteSpace(transcript)) return;
|
||||
|
||||
var prompt = $"""
|
||||
请把下面这次健康助手会话压缩成一个“对话记录标题”。
|
||||
|
||||
要求:
|
||||
- 只输出标题,不要解释
|
||||
- 8到18个汉字
|
||||
- 优先保留用户真正要解决的问题、对象、最终动作或结论
|
||||
- 像列表标题,不要像完整句子
|
||||
- 不要写“本次会话、用户咨询、健康建议、进行分析、相关问题”
|
||||
- 如果是上传图片/PDF,标题要体现附件内容和目的
|
||||
|
||||
示例:
|
||||
- 早餐热量识别
|
||||
- 血压偏高记录
|
||||
- 服药时间调整
|
||||
- 报告指标解读
|
||||
- 跑步计划打卡
|
||||
|
||||
会话内容:
|
||||
{transcript}
|
||||
""";
|
||||
|
||||
var response = await llmClient.ChatAsync(
|
||||
[
|
||||
new ChatMessage { Role = "system", Content = "你是对话标题生成助手,只输出短标题。" },
|
||||
new ChatMessage { Role = "user", Content = prompt },
|
||||
],
|
||||
maxTokens: 120,
|
||||
temperature: 0.2f,
|
||||
ct: ct);
|
||||
|
||||
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(summary)) return;
|
||||
|
||||
summary = Regex.Replace(summary, @"\s+", "");
|
||||
summary = summary.Trim(' ', '。', '.', '"', '"', '\'', '“', '”', ':', ':');
|
||||
summary = TrimSummaryPrefix(summary);
|
||||
await conversations.UpdateSummaryAsync(conversationId, summary, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 摘要只是历史列表体验增强,失败不能影响主对话。
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildSummaryTranscript(IReadOnlyList<AiConversationMessageDto> messages)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
foreach (var message in messages.TakeLast(24))
|
||||
{
|
||||
var role = message.Role == MessageRole.User.ToString() ? "用户" : "AI";
|
||||
var content = message.Content?.Trim() ?? "";
|
||||
|
||||
if (message.Role == MessageRole.User.ToString() &&
|
||||
!string.IsNullOrWhiteSpace(message.MetadataJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var meta = JsonDocument.Parse(message.MetadataJson);
|
||||
if (meta.RootElement.TryGetProperty("summary", out var sumEl) &&
|
||||
sumEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var attachmentSummary = sumEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(attachmentSummary))
|
||||
content = $"[附件:{attachmentSummary}] {content}";
|
||||
}
|
||||
}
|
||||
catch (JsonException) { }
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content)) continue;
|
||||
if (content.Length > 800) content = content[..800] + "...";
|
||||
sb.Append(role).Append(":").AppendLine(content);
|
||||
if (sb.Length > 6000) break;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string TrimSummaryPrefix(string summary)
|
||||
{
|
||||
var prefixes = new[]
|
||||
{
|
||||
"本次会话",
|
||||
"用户咨询",
|
||||
"用户询问",
|
||||
"健康建议",
|
||||
"相关问题",
|
||||
};
|
||||
foreach (var prefix in prefixes)
|
||||
{
|
||||
if (summary.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return summary[prefix.Length..].Trim(' ', ':', ':', ',', ',');
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
// ── Agent / Tool 调度 ──
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user