Initial project import
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user