Files
jizhi/backend/MiaoJiZhang.Api/Controllers/ChatController.cs
T

538 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ChatController> logger) : ControllerBase
{
private long Uid => long.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ??
User.FindFirstValue("sub")!);
[HttpGet("messages")]
public async Task<ActionResult<List<ChatMessageDto>>> 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<IActionResult> 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<ActionResult<SendChatResponse>> 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<ActionResult<SendChatResponse>> 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<ChatMessage>();
var transactions = new List<Transaction>();
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<Transaction>();
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<ChatMessage>
{
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<List<Transaction>> 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<Transaction> transactions,
string tic)
{
if (transactions.Count == 0)
return "这句话里的金额或收支方向还不够明确,可以再说具体一点";
if (transactions.Count == 1)
{
var transaction = transactions[0];
var type = transaction.Type switch
{
TransactionType.Income => "收入",
TransactionType.Transfer when transaction.TransferDirection == TransferDirection.In => "转入",
TransactionType.Transfer => "转出",
_ => "支出",
};
return $"已记录{type}{transaction.Category.Name} ¥{transaction.Amount:F2}{tic}";
}
var income = transactions
.Where(t => t.Type.IsIncome(t.TransferDirection))
.Sum(t => t.Amount);
var expense = transactions
.Where(t => t.Type.IsExpense(t.TransferDirection))
.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<long> 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<string> 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<string, string> { [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<bool> 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<long, Transaction> 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);
}
}