538 lines
20 KiB
C#
538 lines
20 KiB
C#
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);
|
||
}
|
||
}
|