From d64571d18cb358b1eed5fc6fb27f2bf57bcfe75b Mon Sep 17 00:00:00 2001 From: colin Date: Tue, 27 Jan 2026 14:43:53 +0800 Subject: [PATCH] feat: Add persistent conversation --- .../LINGYUN/Abp/AI/Agent/AgentService.cs | 57 ++++++++++- .../LINGYUN.Abp.AI.Core.csproj | 1 + .../LINGYUN/Abp/AI/AbpAICoreModule.cs | 4 + .../LINGYUN/Abp/AI/AbpAIErrorCodes.cs | 4 + .../LINGYUN/Abp/AI/Chats/IChatMessageStore.cs | 2 +- .../Abp/AI/Chats/IConversationStore.cs | 13 +++ .../Abp/AI/Chats/InMemoryChatMessageStore.cs | 18 ++-- .../Abp/AI/Chats/InMemoryConversationStore.cs | 48 ++++++++++ .../Abp/AI/Localization/Resources/en.json | 4 +- .../AI/Localization/Resources/zh-Hans.json | 4 +- .../LINGYUN/Abp/AI/Models/ChatMessage.cs | 4 +- .../LINGYUN/Abp/AI/Models/Conversation.cs | 28 ++++++ .../LINGYUN/Abp/AI/Models/TokenUsageInfo.cs | 15 +-- .../FodyWeavers.xsd | 30 ++++++ .../FodyWeavers.xsd | 30 ++++++ .../Chats/ConversationRecordConsts.cs | 5 + .../AbpAIManagementDomainMappers.cs | 8 +- .../AIManagement/Chats/ChatMessageRecord.cs | 8 ++ .../AIManagement/Chats/ChatMessageStore.cs | 33 +++++-- .../Chats/ConversationCleanupOptions.cs | 15 +++ .../AIManagement/Chats/ConversationRecord.cs | 34 +++++++ .../AIManagement/Chats/ConversationStore.cs | 95 +++++++++++++++++++ .../Chats/IConversationRecordRepository.cs | 21 ++++ .../AIManagementDbContext.cs | 5 +- ...nagementDbContextModelBuilderExtensions.cs | 10 ++ ...bpAIManagementEntityFrameworkCoreModule.cs | 1 + .../EfCoreConversationRecordRepository.cs | 44 +++++++++ .../IAIManagementDbContext.cs | 5 +- .../FodyWeavers.xsd | 30 ++++++ .../FodyWeavers.xsd | 30 ++++++ 30 files changed, 560 insertions(+), 46 deletions(-) create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/IConversationStore.cs create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/InMemoryConversationStore.cs create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/Conversation.cs create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Application.Contracts/FodyWeavers.xsd create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain.Shared/FodyWeavers.xsd create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain.Shared/LINGYUN/Abp/AIManagement/Chats/ConversationRecordConsts.cs create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationCleanupOptions.cs create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationRecord.cs create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationStore.cs create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/IConversationRecordRepository.cs create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/EfCoreConversationRecordRepository.cs create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.HttpApi.Client/FodyWeavers.xsd create mode 100644 aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.HttpApi/FodyWeavers.xsd diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Agent/LINGYUN/Abp/AI/Agent/AgentService.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Agent/LINGYUN/Abp/AI/Agent/AgentService.cs index 6fe8db044..78c9f0508 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Agent/LINGYUN/Abp/AI/Agent/AgentService.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Agent/LINGYUN/Abp/AI/Agent/AgentService.cs @@ -1,14 +1,18 @@ using LINGYUN.Abp.AI.Chats; +using LINGYUN.Abp.AI.Localization; using LINGYUN.Abp.AI.Models; using LINGYUN.Abp.AI.Tokens; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Localization; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; +using Volo.Abp; using Volo.Abp.DependencyInjection; +using Volo.Abp.Guids; using Volo.Abp.Timing; using AIChatMessage = Microsoft.Extensions.AI.ChatMessage; @@ -16,30 +20,43 @@ namespace LINGYUN.Abp.AI.Agent; public class AgentService : IAgentService, IScopedDependency { private readonly IClock _clock; + private readonly IGuidGenerator _guidGenerator; private readonly IAgentFactory _agentFactory; private readonly ITokenUsageStore _tokenUsageStore; private readonly IChatMessageStore _chatMessageStore; + private readonly IConversationStore _conversationStore; + private readonly IStringLocalizer _localizerResource; public AgentService( IClock clock, + IGuidGenerator guidGenerator, IAgentFactory agentFactory, ITokenUsageStore tokenUsageStore, - IChatMessageStore chatMessageStore) + IChatMessageStore chatMessageStore, + IConversationStore conversationStore, + IStringLocalizer localizerResource) { _clock = clock; + _guidGenerator = guidGenerator; _agentFactory = agentFactory; _tokenUsageStore = tokenUsageStore; _chatMessageStore = chatMessageStore; + _conversationStore = conversationStore; + _localizerResource = localizerResource; } public async virtual IAsyncEnumerable SendMessageAsync(Models.ChatMessage message) { + var conversationId = await StoreConversation(message); + + message.WithConversationId(conversationId); + var messages = await BuildChatMessages(message); var agent = await _agentFactory.CreateAsync(message.Workspace); var agentRunRes = agent.RunStreamingAsync(messages); - var tokenUsageInfo = new TokenUsageInfo(message.Workspace); + var tokenUsageInfo = new TokenUsageInfo(message.Workspace, conversationId); var agentMessageBuilder = new StringBuilder(); await foreach (var response in agentRunRes) @@ -51,7 +68,6 @@ public class AgentService : IAgentService, IScopedDependency var messageId = await StoreChatMessage(message, agentMessageBuilder.ToString()); - tokenUsageInfo.WithConversationId(message.ConversationId); tokenUsageInfo.WithMessageId(messageId); #if DEBUG @@ -70,9 +86,10 @@ public class AgentService : IAgentService, IScopedDependency { var historyMessages = await _chatMessageStore.GetHistoryMessagesAsync(message.ConversationId.Value); + // TODO: 应用摘要提示压缩 foreach (var chatMessage in historyMessages) { - messages.Add(new AIChatMessage(ChatRole.System, chatMessage.GetMessagePrompt())); + messages.Add(new AIChatMessage(chatMessage.Role, chatMessage.GetMessagePrompt())); } } @@ -81,13 +98,43 @@ public class AgentService : IAgentService, IScopedDependency return messages; } - protected async virtual Task StoreChatMessage(Models.ChatMessage message, string agentMessage) + protected async virtual Task StoreChatMessage(Models.ChatMessage message, string agentMessage) { message.WithReply(agentMessage, _clock.Now); return await _chatMessageStore.SaveMessageAsync(message); } + protected async virtual Task StoreConversation(Models.ChatMessage message) + { + if (message.ConversationId.HasValue) + { + var conversation = await _conversationStore.FindAsync(message.ConversationId.Value); + if (conversation == null || conversation.ExpiredAt <= _clock.Now) + { + throw new BusinessException( + AbpAIErrorCodes.ConversationHasExpired, + "The conversation has expired. Please create a new one!"); + } + + conversation.UpdateAt = _clock.Now; + await _conversationStore.SaveAsync(conversation); + + return conversation.Id; + } + else + { + var conversation = new Conversation( + _guidGenerator.Create(), + _localizerResource["NewConversation"], + _clock.Now); + + await _conversationStore.SaveAsync(conversation); + + return conversation.Id; + } + } + protected async virtual Task StoreTokenUsageInfo(TokenUsageInfo tokenUsageInfo) { await _tokenUsageStore.SaveTokenUsageAsync(tokenUsageInfo); diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN.Abp.AI.Core.csproj b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN.Abp.AI.Core.csproj index 4dc562c9c..b6e35c892 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN.Abp.AI.Core.csproj +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN.Abp.AI.Core.csproj @@ -22,6 +22,7 @@ + diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/AbpAICoreModule.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/AbpAICoreModule.cs index 2ab1185a5..9bd80e7b7 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/AbpAICoreModule.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/AbpAICoreModule.cs @@ -5,15 +5,19 @@ using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; using Volo.Abp.AI; +using Volo.Abp.Guids; using Volo.Abp.Localization; using Volo.Abp.Localization.ExceptionHandling; using Volo.Abp.Modularity; +using Volo.Abp.Timing; using Volo.Abp.VirtualFileSystem; namespace LINGYUN.Abp.AI; [DependsOn( typeof(AbpAIModule), + typeof(AbpGuidsModule), + typeof(AbpTimingModule), typeof(AbpLocalizationModule))] public class AbpAICoreModule : AbpModule { diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/AbpAIErrorCodes.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/AbpAIErrorCodes.cs index bb261dea1..e5538e47e 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/AbpAIErrorCodes.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/AbpAIErrorCodes.cs @@ -6,4 +6,8 @@ public static class AbpAIErrorCodes /// 工作区不可用: {Workspace}! /// public const string WorkspaceIsNotEnabled = Namespace + ":110001"; + /// + /// 对话已过期, 请重新创建会话! + /// + public const string ConversationHasExpired = Namespace + ":110101"; } diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/IChatMessageStore.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/IChatMessageStore.cs index 886a0e953..724eb5a43 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/IChatMessageStore.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/IChatMessageStore.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; namespace LINGYUN.Abp.AI.Chats; public interface IChatMessageStore { - Task SaveMessageAsync(ChatMessage message); + Task SaveMessageAsync(ChatMessage message); Task> GetHistoryMessagesAsync(Guid conversationId); } diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/IConversationStore.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/IConversationStore.cs new file mode 100644 index 000000000..e13119c9d --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/IConversationStore.cs @@ -0,0 +1,13 @@ +using LINGYUN.Abp.AI.Models; +using System; +using System.Threading.Tasks; + +namespace LINGYUN.Abp.AI.Chats; +public interface IConversationStore +{ + Task SaveAsync(Conversation conversation); + + Task FindAsync(Guid conversationId); + + Task CleanupAsync(); +} diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/InMemoryChatMessageStore.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/InMemoryChatMessageStore.cs index b81ccef2b..3519ea30c 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/InMemoryChatMessageStore.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/InMemoryChatMessageStore.cs @@ -12,7 +12,7 @@ namespace LINGYUN.Abp.AI.Chats; [Dependency(ServiceLifetime.Singleton, TryRegister = true)] public class InMemoryChatMessageStore : IChatMessageStore { - private static readonly ConcurrentDictionary> _userMessageCache = new ConcurrentDictionary>(); + private static readonly ConcurrentDictionary> _userMessageCache = new ConcurrentDictionary>(); public Task> GetHistoryMessagesAsync(Guid conversationId) { @@ -30,23 +30,23 @@ public class InMemoryChatMessageStore : IChatMessageStore .OrderBy(x => x.CreatedAt)); } - public Task SaveMessageAsync(ChatMessage message) + public Task SaveMessageAsync(ChatMessage message) { var messageId = message.Id; - if (messageId.IsNullOrWhiteSpace()) + if (!messageId.HasValue) { - messageId = Guid.NewGuid().ToString(); - message.WithMessageId(messageId); + messageId = Guid.NewGuid(); + message.WithMessageId(messageId.Value); } - if (_userMessageCache.ContainsKey(messageId)) + if (_userMessageCache.ContainsKey(messageId.Value)) { - _userMessageCache[messageId].Add(message); + _userMessageCache[messageId.Value].Add(message); } else { - _userMessageCache[messageId] = new List() { message }; + _userMessageCache[messageId.Value] = new List() { message }; } - return Task.FromResult(messageId); + return Task.FromResult(messageId.Value); } } diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/InMemoryConversationStore.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/InMemoryConversationStore.cs new file mode 100644 index 000000000..d273e47eb --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Chats/InMemoryConversationStore.cs @@ -0,0 +1,48 @@ +using LINGYUN.Abp.AI.Models; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace LINGYUN.Abp.AI.Chats; + +[Dependency(ServiceLifetime.Singleton, TryRegister = true)] +public class InMemoryConversationStore : IConversationStore +{ + private static readonly ConcurrentDictionary _conversationCache = new ConcurrentDictionary(); + public Task SaveAsync(Conversation conversation) + { + if (_conversationCache.ContainsKey(conversation.Id)) + { + conversation.ExpiredAt = DateTime.Now.AddHours(2); + _conversationCache[conversation.Id] = conversation; + } + else + { + _conversationCache.TryAdd(conversation.Id, conversation); + } + + return Task.CompletedTask; + } + + public Task FindAsync(Guid conversationId) + { + _conversationCache.TryGetValue(conversationId, out var conversation); + return Task.FromResult(conversation); + } + + public Task CleanupAsync() + { + // Configure it... + var expiredTime = DateTime.Now.AddHours(-2); + var expiredConversationIds = _conversationCache.Values + .Where(x => x.UpdateAt <= expiredTime) + .Select(x => x.Id); + _conversationCache.RemoveAll(x => expiredConversationIds.Contains(x.Key)); + + return Task.CompletedTask; + } +} diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Localization/Resources/en.json b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Localization/Resources/en.json index 399e2a7c9..5e631f34c 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Localization/Resources/en.json +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Localization/Resources/en.json @@ -1,6 +1,8 @@ { "culture": "en", "texts": { - "Abp.AI:110001": "Workspace is not enabled: {Workspace}!" + "Abp.AI:110001": "Workspace is not enabled: {Workspace}!", + "Abp.AI:110101": "The conversation has expired. Please create a new one!", + "NewConversation": "New Conversation" } } \ No newline at end of file diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Localization/Resources/zh-Hans.json b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Localization/Resources/zh-Hans.json index 3fc69d30d..8681eedaf 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Localization/Resources/zh-Hans.json +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Localization/Resources/zh-Hans.json @@ -1,6 +1,8 @@ { "culture": "zh-Hans", "texts": { - "Abp.AI:110001": "工作区不可用: {Workspace}!" + "Abp.AI:110001": "工作区不可用: {Workspace}!", + "Abp.AI:110101": "对话已过期, 请重新创建会话!", + "NewConversation": "新对话" } } \ No newline at end of file diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/ChatMessage.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/ChatMessage.cs index 46770a3f5..143a537de 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/ChatMessage.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/ChatMessage.cs @@ -6,7 +6,7 @@ public abstract class ChatMessage { public string Workspace { get; } - public string? Id { get; private set; } + public Guid? Id { get; private set; } public Guid? ConversationId { get; private set; } @@ -27,7 +27,7 @@ public abstract class ChatMessage CreatedAt = createdAt ?? DateTime.Now; } - public virtual ChatMessage WithMessageId(string id) + public virtual ChatMessage WithMessageId(Guid id) { Id = id; return this; diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/Conversation.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/Conversation.cs new file mode 100644 index 000000000..52943a7a1 --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/Conversation.cs @@ -0,0 +1,28 @@ +using System; + +namespace LINGYUN.Abp.AI.Models; +public class Conversation +{ + public Guid Id { get; private set; } + public string Name { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? ExpiredAt { get; set; } + public DateTime? UpdateAt { get; set; } + public Conversation( + Guid id, + string name, + DateTime createdAt) + { + Id = id; + Name = name; + CreatedAt = createdAt; + UpdateAt = createdAt; + } + + public Conversation WithName(string name) + { + Name = name; + + return this; + } +} diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/TokenUsageInfo.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/TokenUsageInfo.cs index 770454a3a..f74b1867e 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/TokenUsageInfo.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AI.Core/LINGYUN/Abp/AI/Models/TokenUsageInfo.cs @@ -5,29 +5,24 @@ namespace LINGYUN.Abp.AI.Models; public class TokenUsageInfo { public string Workspace { get; } - public string? MessageId { get; private set; } - public Guid? ConversationId { get; private set; } + public Guid? MessageId { get; private set; } + public Guid ConversationId { get; private set; } public long? InputTokenCount { get; set; } public long? OutputTokenCount { get; set; } public long? TotalTokenCount { get; set; } public long? CachedInputTokenCount { get; set; } public long? ReasoningTokenCount { get; set; } - public TokenUsageInfo(string workspace) + public TokenUsageInfo(string workspace, Guid conversationId) { Workspace = workspace; + ConversationId = conversationId; } - public virtual TokenUsageInfo WithMessageId(string id) + public virtual TokenUsageInfo WithMessageId(Guid id) { MessageId = id; return this; } - public virtual TokenUsageInfo WithConversationId(Guid? conversationId) - { - ConversationId = conversationId; - return this; - } - public override string ToString() { var sb = new StringBuilder(); diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Application.Contracts/FodyWeavers.xsd b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Application.Contracts/FodyWeavers.xsd new file mode 100644 index 000000000..3f3946e28 --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Application.Contracts/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain.Shared/FodyWeavers.xsd b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain.Shared/FodyWeavers.xsd new file mode 100644 index 000000000..3f3946e28 --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain.Shared/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain.Shared/LINGYUN/Abp/AIManagement/Chats/ConversationRecordConsts.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain.Shared/LINGYUN/Abp/AIManagement/Chats/ConversationRecordConsts.cs new file mode 100644 index 000000000..30c3868cb --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain.Shared/LINGYUN/Abp/AIManagement/Chats/ConversationRecordConsts.cs @@ -0,0 +1,5 @@ +namespace LINGYUN.Abp.AIManagement.Chats; +public static class ConversationRecordConsts +{ + public static int MaxNameLength { get; set; } = 50; +} diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/AbpAIManagementDomainMappers.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/AbpAIManagementDomainMappers.cs index 75b113067..93cdb0261 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/AbpAIManagementDomainMappers.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/AbpAIManagementDomainMappers.cs @@ -1,5 +1,5 @@ using LINGYUN.Abp.AI.Models; -using LINGYUN.Abp.AIManagement.Messages; +using LINGYUN.Abp.AIManagement.Chats; using Riok.Mapperly.Abstractions; using Volo.Abp.Mapperly; using Volo.Abp.ObjectExtending; @@ -8,8 +8,8 @@ namespace LINGYUN.Abp.AIManagement; [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [MapExtraProperties(DefinitionChecks = MappingPropertyDefinitionChecks.None)] -public partial class UserTextMessageRecordToUserTextMessageMapper : MapperBase +public partial class TextChatMessageRecordToUserTextMessageMapper : MapperBase { - public override partial TextChatMessage Map(UserTextMessageRecord source); - public override partial void Map(UserTextMessageRecord source, TextChatMessage destination); + public override partial TextChatMessage Map(TextChatMessageRecord source); + public override partial void Map(TextChatMessageRecord source, TextChatMessage destination); } diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ChatMessageRecord.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ChatMessageRecord.cs index 91bd1c0f1..805aaadd3 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ChatMessageRecord.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ChatMessageRecord.cs @@ -15,6 +15,8 @@ public abstract class ChatMessageRecord : AuditedAggregateRoot, IMultiTena public DateTime CreatedAt { get; private set; } + public Guid? UserId { get; private set; } + public Guid? ConversationId { get; private set; } public string? ReplyMessage { get; private set; } @@ -41,6 +43,12 @@ public abstract class ChatMessageRecord : AuditedAggregateRoot, IMultiTena TenantId = tenantId; } + public virtual ChatMessageRecord SetUserId(Guid userId) + { + UserId = userId; + return this; + } + public virtual ChatMessageRecord SetConversationId(Guid conversationId) { ConversationId = conversationId; diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ChatMessageStore.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ChatMessageStore.cs index add01791f..724af7db4 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ChatMessageStore.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ChatMessageStore.cs @@ -3,6 +3,7 @@ using LINGYUN.Abp.AI.Models; using LINGYUN.Abp.AIManagement.Settings; using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Volo.Abp.DependencyInjection; using Volo.Abp.Guids; @@ -46,21 +47,35 @@ public class ChatMessageStore : IChatMessageStore, ITransientDependency var userTextMessages = await _messageRecordRepository.GetHistoryMessagesAsync(conversationId, maxLatestHistoryMessagesToKeep); - return _objectMapper.Map, IEnumerable>(userTextMessages); + return userTextMessages.Select(msg => + { + var chatMessage = new TextChatMessage(msg.Workspace, msg.Content, msg.Role, msg.CreatedAt); + chatMessage.WithMessageId(msg.Id); + if (msg.ConversationId.HasValue) + { + chatMessage.WithConversationId(msg.ConversationId.Value); + } + if (!msg.ReplyMessage.IsNullOrWhiteSpace() && msg.ReplyAt.HasValue) + { + chatMessage.WithReply(msg.ReplyMessage, msg.ReplyAt.Value); + } + + return chatMessage; + }); } - public async virtual Task SaveMessageAsync(ChatMessage message) + public async virtual Task SaveMessageAsync(ChatMessage message) { var messageId = message.Id; - if (messageId.IsNullOrWhiteSpace()) + if (!messageId.HasValue) { - messageId = _guidGenerator.Create().ToString(); - message.WithMessageId(messageId); + messageId = _guidGenerator.Create(); + message.WithMessageId(messageId.Value); } - await StoreMessageAsync(Guid.Parse(messageId), message); + await StoreMessageAsync(messageId.Value, message); - return messageId; + return messageId.Value; } protected async virtual Task StoreMessageAsync(Guid messageId, ChatMessage message) @@ -102,9 +117,9 @@ public class ChatMessageStore : IChatMessageStore, ITransientDependency private static void UpdateUserMessageRecord(ChatMessageRecord messageRecord, ChatMessage message) { - if (!message.ConversationId.IsNullOrWhiteSpace()) + if (message.ConversationId.HasValue) { - messageRecord.SetConversationId(message.ConversationId); + messageRecord.SetConversationId(message.ConversationId.Value); } if (!message.ReplyMessage.IsNullOrWhiteSpace()) { diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationCleanupOptions.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationCleanupOptions.cs new file mode 100644 index 000000000..22f6cbe26 --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationCleanupOptions.cs @@ -0,0 +1,15 @@ +using System; + +namespace LINGYUN.Abp.AIManagement.Chats; +public class ConversationCleanupOptions +{ + public bool IsCleanupEnabled { get; set; } + public TimeSpan ExpiredTime { get; set; } + public int CleanupPeriod { get; set; } + public ConversationCleanupOptions() + { + IsCleanupEnabled = true; + CleanupPeriod = 3_600_000; + ExpiredTime = TimeSpan.FromHours(2); + } +} diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationRecord.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationRecord.cs new file mode 100644 index 000000000..cd81fcac6 --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationRecord.cs @@ -0,0 +1,34 @@ +using System; +using Volo.Abp; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace LINGYUN.Abp.AIManagement.Chats; +public class ConversationRecord : AuditedEntity, IMultiTenant +{ + public Guid? TenantId { get; private set; } + + public string Name { get; private set; } + + public DateTime CreatedAt { get; private set; } + + public DateTime ExpiredAt { get; set; } + + public DateTime? UpdateAt { get; set; } + public ConversationRecord( + Guid id, + string name, + DateTime createdAt, + DateTime expiredAt, + Guid? tenantId = null) + : base(id) + { + Name = Check.NotNullOrWhiteSpace(name, nameof(name), ConversationRecordConsts.MaxNameLength); + CreatedAt = createdAt; + ExpiredAt = expiredAt; + + UpdateAt = createdAt; + + TenantId = tenantId; + } +} diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationStore.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationStore.cs new file mode 100644 index 000000000..3d7d2ed5e --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/ConversationStore.cs @@ -0,0 +1,95 @@ +using LINGYUN.Abp.AI.Chats; +using LINGYUN.Abp.AI.Models; +using Microsoft.Extensions.Options; +using System; +using System.Threading.Tasks; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Specifications; +using Volo.Abp.Timing; + +namespace LINGYUN.Abp.AIManagement.Chats; + +[Dependency(ReplaceServices = true)] +public class ConversationStore : IConversationStore, ITransientDependency +{ + private readonly IClock _clock; + private readonly ICurrentTenant _currentTenant; + private readonly ConversationCleanupOptions _cleanupOptions; + private readonly IConversationRecordRepository _conversationRecordRepository; + + public ConversationStore( + IClock clock, + ICurrentTenant currentTenant, + IOptions cleanupOptions, + IConversationRecordRepository conversationRecordRepository) + { + _clock = clock; + _currentTenant = currentTenant; + _cleanupOptions = cleanupOptions.Value; + _conversationRecordRepository = conversationRecordRepository; + } + + public async virtual Task CleanupAsync() + { + if (!_cleanupOptions.IsCleanupEnabled) + { + return; + } + + var specification = new ExpressionSpecification( + x => x.ExpiredAt <= _clock.Now); + + var totalCount = await _conversationRecordRepository.GetCountAsync(specification); + var expiredRecords = await _conversationRecordRepository.GetListAsync(specification, maxResultCount: totalCount); + + await _conversationRecordRepository.DeleteManyAsync(expiredRecords); + } + + public async virtual Task FindAsync(Guid conversationId) + { + var conversationRecord = await _conversationRecordRepository.FindAsync(conversationId); + if (conversationRecord == null) + { + return null; + } + + var conversation = new Conversation( + conversationRecord.Id, + conversationRecord.Name, + conversationRecord.CreatedAt) + { + UpdateAt = conversationRecord.UpdateAt, + ExpiredAt = conversationRecord.ExpiredAt, + }; + + + return conversation; + } + + public async virtual Task SaveAsync(Conversation conversation) + { + var conversationRecord = await _conversationRecordRepository.FindAsync(conversation.Id); + if (conversationRecord == null) + { + var expiredTime = conversation.CreatedAt.Add(_cleanupOptions.ExpiredTime); + conversationRecord = new ConversationRecord( + conversation.Id, + conversation.Name, + conversation.CreatedAt, + expiredTime, + _currentTenant.Id); + + await _conversationRecordRepository.InsertAsync(conversationRecord); + } + else + { + var expiredTime = (conversation.UpdateAt ?? _clock.Now).Add(_cleanupOptions.ExpiredTime); + conversationRecord.UpdateAt = conversation.UpdateAt; + conversationRecord.ExpiredAt = expiredTime; + + await _conversationRecordRepository.UpdateAsync(conversationRecord); + } + } +} diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/IConversationRecordRepository.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/IConversationRecordRepository.cs new file mode 100644 index 000000000..6ed7e0f1a --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.Domain/LINGYUN/Abp/AIManagement/Chats/IConversationRecordRepository.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Specifications; + +namespace LINGYUN.Abp.AIManagement.Chats; +public interface IConversationRecordRepository : IBasicRepository +{ + Task GetCountAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + Task> GetListAsync( + ISpecification specification, + string? sorting = $"{nameof(ConversationRecord.CreatedAt)} DESC", + int maxResultCount = 10, + int skipCount = 0, + CancellationToken cancellationToken = default); +} diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AIManagementDbContext.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AIManagementDbContext.cs index 865365326..b43c95e49 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AIManagementDbContext.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AIManagementDbContext.cs @@ -1,4 +1,4 @@ -using LINGYUN.Abp.AIManagement.Messages; +using LINGYUN.Abp.AIManagement.Chats; using LINGYUN.Abp.AIManagement.Workspaces; using Microsoft.EntityFrameworkCore; using Volo.Abp.Data; @@ -10,7 +10,8 @@ namespace LINGYUN.Abp.AIManagement.EntityFrameworkCore; public class AIManagementDbContext : AbpDbContext, IAIManagementDbContext { public DbSet WorkspaceDefinitions { get; set; } - public DbSet UserTextMessageRecords { get; set; } + public DbSet TextChatMessageRecords { get; set; } + public DbSet ConversationRecords { get; set; } public AIManagementDbContext( DbContextOptions options) : base(options) { diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AIManagementDbContextModelBuilderExtensions.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AIManagementDbContextModelBuilderExtensions.cs index 185fd3c86..79612bacf 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AIManagementDbContextModelBuilderExtensions.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AIManagementDbContextModelBuilderExtensions.cs @@ -14,6 +14,16 @@ public static class AIManagementDbContextModelBuilderExtensions { Check.NotNull(builder, nameof(builder)); + builder.Entity(b => + { + b.ToTable(AbpAIManagementDbProperties.DbTablePrefix + "Conversations", AbpAIManagementDbProperties.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.Name) + .HasMaxLength(ConversationRecordConsts.MaxNameLength) + .IsRequired(); + }); builder.Entity(b => { b.ToTable(AbpAIManagementDbProperties.DbTablePrefix + "TextChatMessages", AbpAIManagementDbProperties.DbSchema); diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AbpAIManagementEntityFrameworkCoreModule.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AbpAIManagementEntityFrameworkCoreModule.cs index f372b441f..0802cbc47 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AbpAIManagementEntityFrameworkCoreModule.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/AbpAIManagementEntityFrameworkCoreModule.cs @@ -17,6 +17,7 @@ public class AbpAIManagementEntityFrameworkCoreModule : AbpModule { options.AddDefaultRepositories(); + options.AddRepository(); options.AddRepository(); options.AddRepository(); diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/EfCoreConversationRecordRepository.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/EfCoreConversationRecordRepository.cs new file mode 100644 index 000000000..d79ea9709 --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/EfCoreConversationRecordRepository.cs @@ -0,0 +1,44 @@ +using LINGYUN.Abp.AIManagement.Chats; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Dynamic.Core; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; +using Volo.Abp.Specifications; + +namespace LINGYUN.Abp.AIManagement.EntityFrameworkCore; +public class EfCoreConversationRecordRepository : EfCoreRepository, IConversationRecordRepository +{ + public EfCoreConversationRecordRepository( + IDbContextProvider dbContextProvider) + : base(dbContextProvider) + { + } + + public async virtual Task GetCountAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await (await GetQueryableAsync()) + .Where(specification.ToExpression()) + .CountAsync(GetCancellationToken(cancellationToken)); + } + + public async virtual Task> GetListAsync( + ISpecification specification, + string? sorting = $"{nameof(ConversationRecord.CreatedAt)} DESC", + int maxResultCount = 10, + int skipCount = 0, + CancellationToken cancellationToken = default) + { + return await (await GetQueryableAsync()) + .Where(specification.ToExpression()) + .OrderBy(!sorting.IsNullOrWhiteSpace() ? sorting : $"{nameof(ConversationRecord.CreatedAt)} DESC") + .PageBy(skipCount, maxResultCount) + .ToListAsync(GetCancellationToken(cancellationToken)); + } +} diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/IAIManagementDbContext.cs b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/IAIManagementDbContext.cs index d253a4aa6..33d66185c 100644 --- a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/IAIManagementDbContext.cs +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.EntityFrameworkCore/LINGYUN/Abp/AIManagement/EntityFrameworkCore/IAIManagementDbContext.cs @@ -1,4 +1,4 @@ -using LINGYUN.Abp.AIManagement.Messages; +using LINGYUN.Abp.AIManagement.Chats; using LINGYUN.Abp.AIManagement.Workspaces; using Microsoft.EntityFrameworkCore; using Volo.Abp.Data; @@ -10,5 +10,6 @@ namespace LINGYUN.Abp.AIManagement.EntityFrameworkCore; public interface IAIManagementDbContext : IEfCoreDbContext { DbSet WorkspaceDefinitions { get; } - DbSet UserTextMessageRecords { get; } + DbSet TextChatMessageRecords { get; } + DbSet ConversationRecords { get; } } diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.HttpApi.Client/FodyWeavers.xsd b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.HttpApi.Client/FodyWeavers.xsd new file mode 100644 index 000000000..3f3946e28 --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.HttpApi.Client/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.HttpApi/FodyWeavers.xsd b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.HttpApi/FodyWeavers.xsd new file mode 100644 index 000000000..3f3946e28 --- /dev/null +++ b/aspnet-core/modules/ai/LINGYUN.Abp.AIManagement.HttpApi/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file