Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
@@ -0,0 +1,533 @@
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 == 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<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);
}
}