using System.Security.Claims; using System.Text; using System.Text.Json; using MiaoJiZhang.Api.Contracts; using MiaoJiZhang.Api.Services; using MiaoJiZhang.Domain.Entities; using MiaoJiZhang.Domain.Enums; using MiaoJiZhang.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace MiaoJiZhang.Api.Controllers; [ApiController] [Authorize] [Route("api/chat")] [RequireAiPermission] public class ChatController( AppDbContext db, ILlmClient llm, LedgerResolver ledgers, AgentService agent, AiChatQuotaService quotas, ILogger logger) : ControllerBase { private long Uid => long.Parse( User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!); [HttpGet("messages")] public async Task>> History( [FromQuery] int limit = 50, [FromQuery] long? beforeId = null) { var boundaryId = await CurrentBoundaryId(); var query = db.ChatMessages.Where(m => m.UserId == Uid && m.Id > boundaryId && m.Type != ChatMessageType.ContextBoundary); if (beforeId.HasValue) query = query.Where(m => m.Id < beforeId.Value); var list = await query .OrderByDescending(m => m.Id) .Take(Math.Min(limit, 100)) .ToListAsync(); list.Reverse(); var transactionIds = list .Where(m => m.TransactionId.HasValue) .Select(m => m.TransactionId!.Value) .ToList(); var transactions = await db.Transactions .Include(t => t.Category) .IgnoreQueryFilters() .Where(t => t.UserId == Uid && transactionIds.Contains(t.Id)) .ToDictionaryAsync(t => t.Id); return Ok(list.Select(m => ToDto(m, transactions)).ToList()); } [HttpPost("context/clear")] public async Task ClearContext() { db.ChatMessages.Add(new ChatMessage { UserId = Uid, Role = ChatRole.System, Type = ChatMessageType.ContextBoundary, Content = "新会话", CreatedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); return NoContent(); } [HttpPost("messages")] [EnableRateLimiting("ai")] public async Task> Send( SendChatRequest req, CancellationToken ct) { return await SendWithAgent(req, ct); } [HttpPost("messages/stream")] [EnableRateLimiting("ai")] public async Task StreamSend(SendChatRequest req, CancellationToken ct) { await StreamWithAgent(req, ct); } private async Task> SendWithAgent( SendChatRequest req, CancellationToken ct) { if (string.IsNullOrWhiteSpace(req.Content)) return BadRequest(new ApiError("CONTENT_EMPTY", "内容不能为空")); var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId, ct); if (!ledgerId.HasValue) return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问")); if (req.Type == "sticker" && !await FeatureEnabled("feature.sticker_enabled", ct)) return StatusCode(403, new ApiError("FEATURE_DISABLED", "表情包功能已由后台关闭")); var allowAutoBook = await FeatureEnabled("feature.ai_auto_book", ct); var quota = await quotas.TryConsumeAsync(Uid, ct); if (!quota.Allowed) return StatusCode( StatusCodes.Status429TooManyRequests, QuotaExceededPayload(quota)); var content = req.Content.Trim(); var now = DateTime.UtcNow; var userMessage = new ChatMessage { UserId = Uid, Role = ChatRole.User, Type = req.Type == "sticker" ? ChatMessageType.Sticker : ChatMessageType.Text, Content = content, CreatedAt = now, }; db.ChatMessages.Add(userMessage); await db.SaveChangesAsync(ct); var (_, _, tic, personaPrompt) = await LoadUserConfig(ct); var replies = new List(); var transactions = new List(); if (userMessage.Type == ChatMessageType.Sticker) { var (text, sticker) = await StickerLlmAsync(content, tic, ct); replies.Add(AssistantText(text, now.AddMilliseconds(1))); if (sticker is not null) { replies.Add(new ChatMessage { UserId = Uid, Role = ChatRole.Assistant, Type = ChatMessageType.Sticker, Content = sticker, CreatedAt = now.AddMilliseconds(2), }); } } else { try { var result = await agent.RunAsync( Uid, ledgerId.Value, allowAutoBook, content, userMessage, MakeSys(personaPrompt, tic, ""), await LoadConversationContext(userMessage.Id, ct), ct: ct); transactions.AddRange(result.Transactions); var reply = string.IsNullOrWhiteSpace(result.Reply) ? AgentFallback(transactions, tic) : result.Reply; replies.Add(AssistantText(reply, now.AddMilliseconds(1))); } catch (Exception ex) { logger.LogWarning(ex, "Agent chat failed for user {UserId}", Uid); transactions.AddRange(await TransactionsForMessage( userMessage.Id, ct)); var reply = transactions.Count > 0 ? AgentFallback(transactions, tic) : "AI 暂时无法完成这次操作,没有写入任何账单,请稍后重试"; replies.Add(AssistantText(reply, now.AddMilliseconds(1))); } for (var index = 0; index < transactions.Count; index++) { replies.Add(BillCard( transactions[index].Id, now.AddMilliseconds(index + 2))); } } db.ChatMessages.AddRange(replies); await db.SaveChangesAsync(ct); var transactionMap = transactions.ToDictionary(t => t.Id); return Ok(new SendChatResponse( new[] { userMessage } .Concat(replies) .Select(message => ToDto(message, transactionMap)) .ToList())); } private async Task StreamWithAgent( SendChatRequest req, CancellationToken ct) { if (string.IsNullOrWhiteSpace(req.Content)) { HttpContext.Response.StatusCode = 400; return; } var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId, ct); if (!ledgerId.HasValue) { HttpContext.Response.StatusCode = 400; await HttpContext.Response.WriteAsJsonAsync( new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"), ct); return; } var quota = await quotas.TryConsumeAsync(Uid, ct); if (!quota.Allowed) { HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; await HttpContext.Response.WriteAsJsonAsync( QuotaExceededPayload(quota), ct); return; } var allowAutoBook = await FeatureEnabled("feature.ai_auto_book", ct); var content = req.Content.Trim(); var now = DateTime.UtcNow; var userMessage = new ChatMessage { UserId = Uid, Role = ChatRole.User, Type = ChatMessageType.Text, Content = content, CreatedAt = now, }; db.ChatMessages.Add(userMessage); await db.SaveChangesAsync(ct); HttpContext.Response.ContentType = "text/event-stream; charset=utf-8"; HttpContext.Response.Headers["Cache-Control"] = "no-cache, no-transform"; HttpContext.Response.Headers["Connection"] = "keep-alive"; HttpContext.Response.Headers["X-Accel-Buffering"] = "no"; await HttpContext.Response.Body.FlushAsync(ct); await WriteSsePayload(new { status = "thinking" }, ct); var (_, _, tic, personaPrompt) = await LoadUserConfig(ct); var fullReply = new StringBuilder(); var transactions = new List(); var responseDisconnected = false; try { var result = await agent.RunAsync( Uid, ledgerId.Value, allowAutoBook, content, userMessage, MakeSys(personaPrompt, tic, ""), await LoadConversationContext(userMessage.Id, ct), async (token, tokenCt) => { fullReply.Append(token); await WriteSse("t", token, tokenCt); }, (tool, toolCt) => WriteSsePayload( new { status = "tool_running", tool }, toolCt), ct); transactions.AddRange(result.Transactions); if (fullReply.Length == 0 && !string.IsNullOrWhiteSpace(result.Reply)) fullReply.Append(result.Reply); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { responseDisconnected = true; fullReply.Clear(); transactions.AddRange(await TransactionsForMessage( userMessage.Id, CancellationToken.None)); if (transactions.Count == 0) return; } catch (Exception ex) { logger.LogWarning(ex, "Streaming agent chat failed for user {UserId}", Uid); transactions.AddRange(await TransactionsForMessage( userMessage.Id, CancellationToken.None)); } if (responseDisconnected || fullReply.Length == 0) { var fallback = transactions.Count > 0 ? AgentFallback(transactions, tic) : "AI 暂时无法完成这次操作,没有写入任何账单,请稍后重试"; fullReply.Clear(); fullReply.Append(fallback); if (!responseDisconnected) await WriteSse("t", fallback, ct); } var replies = new List { AssistantText(fullReply.ToString(), now.AddMilliseconds(1)), }; for (var index = 0; index < transactions.Count; index++) { replies.Add(BillCard( transactions[index].Id, now.AddMilliseconds(index + 2))); } db.ChatMessages.AddRange(replies); await db.SaveChangesAsync( responseDisconnected ? CancellationToken.None : ct); if (responseDisconnected) return; var transactionMap = transactions.ToDictionary(t => t.Id); await WriteSsePayload( new { done = true, messages = replies .Select(message => ToDto(message, transactionMap)) .ToList(), }, ct); } private static object QuotaExceededPayload(AiChatQuotaStatus quota) => new { code = "AI_CHAT_QUOTA_EXCEEDED", message = $"本周期 AI 对话次数已用完,将于 {ChinaClock.ToLocal(quota.ResetAt):MM月dd日 HH:mm} 重置", quota = new { quota.Limit, quota.Used, quota.Remaining, period = AiChatQuotaService.PeriodKey(quota.Period), quota.ResetAt, }, }; private async Task> TransactionsForMessage( long messageId, CancellationToken ct) => await db.Transactions .Include(transaction => transaction.Category) .Where(transaction => transaction.UserId == Uid && transaction.SourceChatMessageId == messageId) .OrderBy(transaction => transaction.Id) .ToListAsync(ct); private static string AgentFallback( IReadOnlyList transactions, string tic) { if (transactions.Count == 0) return "这句话里的金额或收支方向还不够明确,可以再说具体一点"; if (transactions.Count == 1) { var transaction = transactions[0]; var type = transaction.Type == TransactionType.Income ? "收入" : "支出"; return $"已记录{type}:{transaction.Category.Name} ¥{transaction.Amount:F2}{tic}"; } var income = transactions .Where(t => t.Type == TransactionType.Income) .Sum(t => t.Amount); var expense = transactions .Where(t => t.Type == TransactionType.Expense) .Sum(t => t.Amount); return $"已记录 {transactions.Count} 笔,其中收入 ¥{income:F2}、支出 ¥{expense:F2}{tic}"; } private ChatMessage AssistantText(string content, DateTime createdAt) => new() { UserId = Uid, Role = ChatRole.Assistant, Type = ChatMessageType.Text, Content = content, CreatedAt = createdAt, }; private ChatMessage BillCard(long transactionId, DateTime createdAt) => new() { UserId = Uid, Role = ChatRole.Assistant, Type = ChatMessageType.BillCard, Content = "", TransactionId = transactionId, CreatedAt = createdAt, }; private async Task CurrentBoundaryId(CancellationToken ct = default) => await db.ChatMessages .Where(m => m.UserId == Uid && m.Type == ChatMessageType.ContextBoundary) .MaxAsync(m => (long?)m.Id, ct) ?? 0; private async Task LoadConversationContext( long beforeMessageId, CancellationToken ct) { var boundaryId = await CurrentBoundaryId(ct); var messages = await db.ChatMessages .Where(m => m.UserId == Uid && m.Id > boundaryId && m.Id < beforeMessageId && m.Type == ChatMessageType.Text && m.Role != ChatRole.System) .OrderByDescending(m => m.Id) .Take(12) .Select(m => new { m.Role, m.Content }) .ToListAsync(ct); if (messages.Count == 0) return string.Empty; messages.Reverse(); var lines = messages.Select(m => (m.Role == ChatRole.User ? "用户:" : "助手:") + (m.Content.Length > 240 ? m.Content[..240] : m.Content)); return "\n当前会话最近消息(仅用于保持上下文,不要重复):\n" + string.Join("\n", lines); } private async Task WriteSse( string key, string value, CancellationToken ct) { var json = JsonSerializer.Serialize( new Dictionary { [key] = value }, new JsonSerializerOptions(JsonSerializerDefaults.Web)); await HttpContext.Response.WriteAsync($"data: {json}\n\n", ct); await HttpContext.Response.Body.FlushAsync(ct); } private async Task WriteSsePayload(object payload, CancellationToken ct) { var json = JsonSerializer.Serialize( payload, new JsonSerializerOptions(JsonSerializerDefaults.Web)); await HttpContext.Response.WriteAsync($"data: {json}\n\n", ct); await HttpContext.Response.Body.FlushAsync(ct); } private async Task FeatureEnabled(string key, CancellationToken ct) { var value = await db.AppConfigs .Where(config => config.Key == key) .Select(config => config.Value) .FirstOrDefaultAsync(ct); return value is null || !value.Equals("false", StringComparison.OrdinalIgnoreCase); } private async Task<(string PersonaKey, string AvatarKey, string Tic, string? PersonaPrompt)> LoadUserConfig(CancellationToken ct) { var config = await db.AiCompanionSettings .FirstOrDefaultAsync(s => s.UserId == Uid, ct); var personaKey = config?.PersonaKey ?? "sassy_cat"; var avatarKey = config?.AvatarKey ?? "cat"; var tic = await db.AiAvatars .Where(a => a.Key == avatarKey) .Select(a => a.SpeechTic) .FirstOrDefaultAsync(ct) ?? ""; var personaPrompt = await db.AiPersonas .Where(p => p.Key == personaKey && p.IsEnabled) .Select(p => p.PromptTemplate) .FirstOrDefaultAsync(ct); return (personaKey, avatarKey, tic, personaPrompt); } private async Task<(string Text, string? Sticker)> StickerLlmAsync( string stickerKey, string tic, CancellationToken ct) { var prompt = stickerKey == "salary" ? "用户发了发工资的表情。问他发了多少。口癖: " + tic : "用户发了表情包。简短回复1句话。口癖: " + tic; var reply = llm.IsEnabled ? await llm.TryGenerateReplyAsync(prompt, stickerKey, ct) : null; return ( reply ?? (stickerKey == "salary" ? "发工资了?多少" + tic : "收到" + tic), stickerKey == "salary" ? null : "happy"); } private static string MakeSys( string? personaPrompt, string tic, string context) { var basePrompt = !string.IsNullOrWhiteSpace(personaPrompt) ? personaPrompt.Replace("{tic}", tic).Trim() : "你是记之 AI 助手。回复简短风趣。口癖: " + tic; return basePrompt + "\n" + context; } private static ChatMessageDto ToDto( ChatMessage message, IReadOnlyDictionary transactions) { TransactionDto? transaction = null; if (message.TransactionId.HasValue && transactions.TryGetValue( message.TransactionId.Value, out var value)) { transaction = TransactionsController.ToDto( value, value.Category); } return new ChatMessageDto( message.Id, message.Role == ChatRole.User ? "user" : "assistant", message.Type switch { ChatMessageType.Sticker => "sticker", ChatMessageType.BillCard => "bill_card", _ => "text", }, message.Content, transaction, message.CreatedAt); } }