diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationService.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationService.cs index 20f4850c2..aec49b9a0 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationService.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationService.cs @@ -1,5 +1,4 @@ using BotSharp.Abstraction.Repositories.Filters; -using BotSharp.Abstraction.Users.Models; namespace BotSharp.Abstraction.Conversations; @@ -41,7 +40,7 @@ Task SendMessage(string agentId, PostbackMessageModel? replyMessage, Func onResponseReceived); - Task> GetDialogHistory(int lastCount = 100, bool fromBreakpoint = true, IEnumerable? includeMessageTypes = null); + Task> GetDialogHistory(int lastCount = 100, bool fromBreakpoint = true, IEnumerable? includeMessageTypes = null, ConversationDialogFilter? filter = null); Task CleanHistory(string agentId); /// diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationStorage.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationStorage.cs index ae4e628fc..0e734fe84 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationStorage.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationStorage.cs @@ -1,8 +1,10 @@ +using BotSharp.Abstraction.Repositories.Filters; + namespace BotSharp.Abstraction.Conversations; public interface IConversationStorage { Task Append(string conversationId, RoleDialogModel dialog); Task Append(string conversationId, IEnumerable dialogs); - Task> GetDialogs(string conversationId); + Task> GetDialogs(string conversationId, ConversationDialogFilter? filter = null); } diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/ConversationFile.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/ConversationFile.cs new file mode 100644 index 000000000..f925aec8e --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/ConversationFile.cs @@ -0,0 +1,7 @@ +namespace BotSharp.Abstraction.Conversations.Models; + +public class ConversationFile +{ + public string ConversationId { get; set; } + public string? Thumbnail { get; set; } +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationDialogFilter.cs b/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationDialogFilter.cs new file mode 100644 index 000000000..5ea6d5a30 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationDialogFilter.cs @@ -0,0 +1,6 @@ +namespace BotSharp.Abstraction.Repositories.Filters; + +public class ConversationDialogFilter +{ + public string Order { get; set; } = "asc"; +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationFileFilter.cs b/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationFileFilter.cs new file mode 100644 index 000000000..7bb14b1dc --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationFileFilter.cs @@ -0,0 +1,6 @@ +namespace BotSharp.Abstraction.Repositories.Filters; + +public class ConversationFileFilter +{ + public IEnumerable ConversationIds { get; set; } = []; +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationFilter.cs b/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationFilter.cs index e274adb78..5ae8596e7 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationFilter.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Repositories/Filters/ConversationFilter.cs @@ -31,6 +31,7 @@ public class ConversationFilter public List? Tags { get; set; } public bool IsLoadLatestStates { get; set; } + public bool IsLoadThumbnail { get; set; } public static ConversationFilter Empty() { diff --git a/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs b/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs index af96dc226..437e6cdd7 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs @@ -129,7 +129,7 @@ Task CreateNewConversation(Conversation conversation) => throw new NotImplementedException(); Task DeleteConversations(IEnumerable conversationIds) => throw new NotImplementedException(); - Task> GetConversationDialogs(string conversationId) + Task> GetConversationDialogs(string conversationId, ConversationDialogFilter? filter = null) => throw new NotImplementedException(); Task AppendConversationDialogs(string conversationId, List dialogs) => throw new NotImplementedException(); @@ -169,6 +169,12 @@ Task> GetConversationsToMigrate(int batchSize = 100) => throw new NotImplementedException(); Task MigrateConvsersationLatestStates(string conversationId) => throw new NotImplementedException(); + Task> GetConversationFiles(ConversationFileFilter filter) + => throw new NotImplementedException(); + Task SaveConversationFiles(List files) + => throw new NotImplementedException(); + Task DeleteConversationFiles(List conversationIds) + => throw new NotImplementedException(); #endregion #region LLM Completion Log diff --git a/src/Infrastructure/BotSharp.Abstraction/SideCar/Attributes/SideCarAttribute.cs b/src/Infrastructure/BotSharp.Abstraction/SideCar/Attributes/SideCarAttribute.cs index 1d93dcbaa..df9f755f9 100644 --- a/src/Infrastructure/BotSharp.Abstraction/SideCar/Attributes/SideCarAttribute.cs +++ b/src/Infrastructure/BotSharp.Abstraction/SideCar/Attributes/SideCarAttribute.cs @@ -62,13 +62,13 @@ private static MethodInfo GetMethod(string name) try { var sidecar = serviceProvider.GetService(); - var argTypes = args.Select(x => x.GetType()).ToArray(); + var argTypes = args.Select(x => x != null ? x.GetType() : null).ToArray(); var sidecarMethod = sidecar?.GetType()?.GetMethods(BindingFlags.Public | BindingFlags.Instance) .FirstOrDefault(x => x.Name == methodName && x.ReturnType == retType && x.GetParameters().Length == argTypes.Length && x.GetParameters().Select(p => p.ParameterType) - .Zip(argTypes, (paramType, argType) => paramType.IsAssignableFrom(argType)).All(y => y)); + .Zip(argTypes, (paramType, argType) => IsParameterTypeMatch(paramType, argType)).All(y => y)); return (sidecar, sidecarMethod); } @@ -78,6 +78,28 @@ private static MethodInfo GetMethod(string name) } } + private static bool IsParameterTypeMatch(Type paramType, Type? argType) + { + // If argument is null, check if parameter type is nullable + if (argType == null) + { + // Check if it's a nullable value type (e.g., int?) + if (paramType.IsGenericType && paramType.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + return true; + } + // Check if it's a reference type (which are inherently nullable) + if (!paramType.IsValueType) + { + return true; + } + return false; + } + + // Normal type matching + return paramType.IsAssignableFrom(argType); + } + private async Task<(bool, object?)> CallAsyncMethod(IConversationSideCar instance, MethodInfo method, Type retType, object[] args) { object? value = null; diff --git a/src/Infrastructure/BotSharp.Abstraction/SideCar/IConversationSideCar.cs b/src/Infrastructure/BotSharp.Abstraction/SideCar/IConversationSideCar.cs index 762860511..09469058c 100644 --- a/src/Infrastructure/BotSharp.Abstraction/SideCar/IConversationSideCar.cs +++ b/src/Infrastructure/BotSharp.Abstraction/SideCar/IConversationSideCar.cs @@ -1,3 +1,4 @@ +using BotSharp.Abstraction.Repositories.Filters; using BotSharp.Abstraction.SideCar.Options; namespace BotSharp.Abstraction.SideCar; @@ -8,7 +9,7 @@ public interface IConversationSideCar bool IsEnabled { get; } Task AppendConversationDialogs(string conversationId, List messages); - Task> GetConversationDialogs(string conversationId); + Task> GetConversationDialogs(string conversationId, ConversationDialogFilter? filter = null); Task UpdateConversationBreakpoint(string conversationId, ConversationBreakpoint breakpoint); Task GetConversationBreakpoint(string conversationId); Task UpdateConversationStates(string conversationId, List states); diff --git a/src/Infrastructure/BotSharp.Core.SideCar/Services/BotSharpConversationSideCar.cs b/src/Infrastructure/BotSharp.Core.SideCar/Services/BotSharpConversationSideCar.cs index 0d12ebc50..e229f8069 100644 --- a/src/Infrastructure/BotSharp.Core.SideCar/Services/BotSharpConversationSideCar.cs +++ b/src/Infrastructure/BotSharp.Core.SideCar/Services/BotSharpConversationSideCar.cs @@ -14,7 +14,7 @@ You may obtain a copy of the License at limitations under the License. ******************************************************************************/ -using BotSharp.Abstraction.SideCar.Options; +using BotSharp.Abstraction.Repositories.Filters; using BotSharp.Core.Infrastructures; namespace BotSharp.Core.SideCar.Services; @@ -54,16 +54,20 @@ public async Task AppendConversationDialogs(string conversationId, List> GetConversationDialogs(string conversationId) + public async Task> GetConversationDialogs(string conversationId, ConversationDialogFilter? filter = null) { if (!IsValid(conversationId)) { - return new List(); + return []; } - await Task.CompletedTask; + var dialogs = _contextStack.Peek().Dialogs ?? []; + if (filter?.Order == "desc") + { + dialogs = dialogs.OrderByDescending(x => x.MetaData?.CreatedTime).ToList(); + } - return _contextStack.Peek().Dialogs; + return await Task.FromResult(dialogs); } public async Task UpdateConversationBreakpoint(string conversationId, ConversationBreakpoint breakpoint) @@ -87,9 +91,7 @@ public async Task UpdateConversationBreakpoint(string conversationId, Conversati } var top = _contextStack.Peek().Breakpoints; - - await Task.CompletedTask; - return top.LastOrDefault(); + return await Task.FromResult(top.LastOrDefault()); } public async Task UpdateConversationStates(string conversationId, List states) diff --git a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.cs b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.cs index 75e709619..9cffd3d9d 100644 --- a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.cs +++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.cs @@ -154,14 +154,14 @@ public Task CleanHistory(string agentId) throw new NotImplementedException(); } - public async Task> GetDialogHistory(int lastCount = 100, bool fromBreakpoint = true, IEnumerable? includeMessageTypes = null) + public async Task> GetDialogHistory(int lastCount = 100, bool fromBreakpoint = true, IEnumerable? includeMessageTypes = null, ConversationDialogFilter? filter = null) { if (string.IsNullOrEmpty(_conversationId)) { throw new ArgumentNullException("ConversationId is null."); } - var dialogs = await _storage.GetDialogs(_conversationId); + var dialogs = await _storage.GetDialogs(_conversationId, filter); if (!includeMessageTypes.IsNullOrEmpty()) { @@ -190,7 +190,7 @@ public async Task> GetDialogHistory(int lastCount = 100, b var agentMsgCount = await GetAgentMessageCount(); var count = agentMsgCount.HasValue && agentMsgCount.Value > 0 ? agentMsgCount.Value : lastCount; - return dialogs.TakeLast(count).ToList(); + return filter?.Order == "desc" ? dialogs.Take(count).ToList() : dialogs.TakeLast(count).ToList(); } public async Task SetConversationId(string conversationId, List states, bool isReadOnly = false) diff --git a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs index c0cac22c9..d2dc3a99d 100644 --- a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs +++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs @@ -50,10 +50,10 @@ public async Task Append(string conversationId, IEnumerable dia await db.AppendConversationDialogs(conversationId, dialogElements); } - public async Task> GetDialogs(string conversationId) + public async Task> GetDialogs(string conversationId, ConversationDialogFilter? filter = null) { var db = _services.GetRequiredService(); - var dialogs = await db.GetConversationDialogs(conversationId); + var dialogs = await db.GetConversationDialogs(conversationId, filter); var hooks = _services.GetServices(); var results = new List(); diff --git a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs index 06b26078b..d1a2e9e86 100644 --- a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs +++ b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs @@ -1,6 +1,5 @@ using BotSharp.Abstraction.Loggers.Models; using System.IO; -using System.Threading; namespace BotSharp.Core.Repository; @@ -75,7 +74,7 @@ public Task DeleteConversations(IEnumerable conversationIds) } [SideCar] - public async Task> GetConversationDialogs(string conversationId) + public async Task> GetConversationDialogs(string conversationId, ConversationDialogFilter? filter = null) { var dialogs = new List(); var convDir = FindConversationDirectory(conversationId); @@ -93,11 +92,16 @@ public async Task> GetConversationDialogs(string conversatio var texts = await File.ReadAllTextAsync(dialogDir); try { - dialogs = JsonSerializer.Deserialize>(texts, _options) ?? new List(); + dialogs = JsonSerializer.Deserialize>(texts, _options) ?? []; } catch { - dialogs = new List(); + dialogs = []; + } + + if (filter?.Order == "desc") + { + dialogs = dialogs.OrderByDescending(x => x.MetaData?.CreatedTime).ToList(); } } finally @@ -884,6 +888,139 @@ public async Task MigrateConvsersationLatestStates(string conversationId) } + #region Files + public async Task> GetConversationFiles(ConversationFileFilter filter) + { + if (filter == null || filter.ConversationIds.IsNullOrEmpty()) + { + return []; + } + + var files = new List(); + var baseDir = Path.Combine(_dbSettings.FileRepository, _conversationSettings.DataDir); + + if (!Directory.Exists(baseDir)) + { + return files; + } + + foreach (var conversationId in filter.ConversationIds) + { + if (string.IsNullOrEmpty(conversationId)) + { + continue; + } + + var convDir = Path.Combine(baseDir, conversationId); + if (!Directory.Exists(convDir)) + { + continue; + } + + var filesFile = Path.Combine(convDir, CONV_FILES_FILE); + if (!File.Exists(filesFile)) + { + continue; + } + + try + { + var json = await File.ReadAllTextAsync(filesFile); + var conversationFile = JsonSerializer.Deserialize(json, _options); + if (conversationFile != null) + { + files.Add(conversationFile); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Error when reading conversation files for conversation {conversationId}."); + } + } + + return files; + } + + public async Task SaveConversationFiles(List files) + { + if (files.IsNullOrEmpty()) + { + return false; + } + + try + { + var baseDir = Path.Combine(_dbSettings.FileRepository, _conversationSettings.DataDir); + + foreach (var file in files) + { + if (string.IsNullOrEmpty(file.ConversationId)) + { + continue; + } + + var convDir = Path.Combine(baseDir, file.ConversationId); + if (!Directory.Exists(convDir)) + { + Directory.CreateDirectory(convDir); + } + + var convFile = Path.Combine(convDir, CONV_FILES_FILE); + var json = JsonSerializer.Serialize(file, _options); + await File.WriteAllTextAsync(convFile, json); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error when saving conversation files."); + return false; + } + } + + public async Task DeleteConversationFiles(List conversationIds) + { + if (conversationIds.IsNullOrEmpty()) + { + return false; + } + + try + { + var baseDir = Path.Combine(_dbSettings.FileRepository, _conversationSettings.DataDir); + + foreach (var conversationId in conversationIds) + { + if (string.IsNullOrEmpty(conversationId)) + { + continue; + } + + var convDir = Path.Combine(baseDir, conversationId); + if (!Directory.Exists(convDir)) + { + continue; + } + + var filesFile = Path.Combine(convDir, CONV_FILES_FILE); + if (File.Exists(filesFile)) + { + File.Delete(filesFile); + } + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error when deleting conversation files."); + return false; + } + } + #endregion + + #region Private methods private string? FindConversationDirectory(string conversationId) { diff --git a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.cs b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.cs index 98872c505..255d80a83 100644 --- a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.cs +++ b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.cs @@ -37,6 +37,7 @@ public partial class FileRepository : IBotSharpRepository private const string BREAKPOINT_FILE = "breakpoint.json"; private const string CONV_LATEST_STATE_FILE = "latest-state.json"; private const string TRANSLATION_MEMORY_FILE = "memory.json"; + private const string CONV_FILES_FILE = "conv_files.json"; private const string USERS_FOLDER = "users"; private const string USER_FILE = "user.json"; diff --git a/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.File.cs b/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.File.cs index 3bf272c6f..7aadd7c1d 100644 --- a/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.File.cs +++ b/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.File.cs @@ -1,5 +1,6 @@ using BotSharp.Abstraction.Files.Enums; using BotSharp.Abstraction.Files.Utilities; +using BotSharp.Abstraction.Repositories; namespace BotSharp.OpenAPI.Controllers; @@ -114,4 +115,34 @@ public IActionResult DownloadMessageFile([FromRoute] string conversationId, [Fro return new FileStreamResult(stream, contentType) { FileDownloadName = fName }; } #endregion + + #region Thumbnail + [HttpPost("/conversation/{conversationId}/thumbnail")] + public async Task SaveConversationThumbnail([FromRoute] string conversationId, [FromBody] ConversationFileRequest request) + { + if (request == null) + { + return false; + } + + var db = _services.GetRequiredService(); + var result = await db.SaveConversationFiles( + [ + new() + { + ConversationId = conversationId, + Thumbnail = request.Thumbnail + } + ]); + return result; + } + + [HttpDelete("/conversation/{conversationId}/thumbnail")] + public async Task DeleteConversationThumbnail([FromRoute] string conversationId) + { + var db = _services.GetRequiredService(); + var result = await db.DeleteConversationFiles([conversationId]); + return result; + } + #endregion } diff --git a/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs b/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs index 21a0c58d5..5315e9d15 100644 --- a/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs +++ b/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs @@ -3,6 +3,7 @@ using BotSharp.Abstraction.MessageHub.Models; using BotSharp.Abstraction.MessageHub.Services; using BotSharp.Abstraction.Options; +using BotSharp.Abstraction.Repositories; using BotSharp.Abstraction.Routing; using BotSharp.Abstraction.Users.Dtos; using BotSharp.Core.Infrastructures; @@ -67,12 +68,23 @@ public async Task> GetConversations([FromQuery var userIds = list.Select(x => x.User.Id).ToList(); var users = await userService.GetUsers(userIds); + var files = new List(); + if (filter.IsLoadThumbnail) + { + var db = _services.GetRequiredService(); + files = await db.GetConversationFiles(new ConversationFileFilter + { + ConversationIds = list.Select(x => x.Id) + }); + } + foreach (var item in list) { user = users.FirstOrDefault(x => x.Id == item.User.Id); item.User = UserViewModel.FromUser(user); var agent = agents.FirstOrDefault(x => x.Id == item.AgentId); item.AgentName = agent?.Name ?? "Unkown"; + item.Thumbnail = !files.IsNullOrEmpty() ? files.FirstOrDefault(x => x.ConversationId == item.Id)?.Thumbnail : null; } return new PagedItems @@ -83,11 +95,17 @@ public async Task> GetConversations([FromQuery } [HttpGet("/conversation/{conversationId}/dialogs")] - public async Task> GetDialogs([FromRoute] string conversationId, [FromQuery] int count = 100) + public async Task> GetDialogs( + [FromRoute] string conversationId, + [FromQuery] int count = 100, + [FromQuery] string order = "asc") { var conv = _services.GetRequiredService(); await conv.SetConversationId(conversationId, [], isReadOnly: true); - var history = await conv.GetDialogHistory(lastCount: count, fromBreakpoint: false); + var history = await conv.GetDialogHistory(lastCount: count, fromBreakpoint: false, filter: new() + { + Order = order + }); var userService = _services.GetRequiredService(); var agentService = _services.GetRequiredService(); @@ -142,7 +160,10 @@ public async Task> GetDialogs([FromRoute] string } [HttpGet("/conversation/{conversationId}")] - public async Task GetConversation([FromRoute] string conversationId, [FromQuery] bool isLoadStates = false) + public async Task GetConversation( + [FromRoute] string conversationId, + [FromQuery] bool isLoadStates = false, + [FromQuery] bool isLoadThumbnail = false) { var convService = _services.GetRequiredService(); var userService = _services.GetRequiredService(); @@ -184,6 +205,17 @@ public async Task> GetDialogs([FromRoute] string var conversationView = ConversationViewModel.FromSession(conversation); conversationView.User = UserViewModel.FromUser(user); conversationView.IsRealtimeEnabled = settings?.Assemblies?.Contains("BotSharp.Core.Realtime") ?? false; + + if (isLoadThumbnail) + { + var db = _services.GetRequiredService(); + var files = await db.GetConversationFiles(new ConversationFileFilter + { + ConversationIds = [conversation.Id] + }); + conversationView.Thumbnail = files?.FirstOrDefault()?.Thumbnail; + } + return conversationView; } diff --git a/src/Infrastructure/BotSharp.OpenAPI/ViewModels/Conversations/Request/ConversationFileRequest.cs b/src/Infrastructure/BotSharp.OpenAPI/ViewModels/Conversations/Request/ConversationFileRequest.cs new file mode 100644 index 000000000..63dab7fd8 --- /dev/null +++ b/src/Infrastructure/BotSharp.OpenAPI/ViewModels/Conversations/Request/ConversationFileRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace BotSharp.OpenAPI.ViewModels.Conversations; + +public class ConversationFileRequest +{ + [JsonPropertyName("thumbnail")] + public string? Thumbnail { get; set; } +} diff --git a/src/Infrastructure/BotSharp.OpenAPI/ViewModels/Conversations/View/ConversationViewModel.cs b/src/Infrastructure/BotSharp.OpenAPI/ViewModels/Conversations/View/ConversationViewModel.cs index e7110f44d..2f8998bb3 100644 --- a/src/Infrastructure/BotSharp.OpenAPI/ViewModels/Conversations/View/ConversationViewModel.cs +++ b/src/Infrastructure/BotSharp.OpenAPI/ViewModels/Conversations/View/ConversationViewModel.cs @@ -8,6 +8,10 @@ public class ConversationViewModel : ConversationDto [JsonPropertyName("is_realtime_enabled")] public bool IsRealtimeEnabled { get; set; } + [JsonPropertyName("thumbnail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Thumbnail { get; set; } + public static ConversationViewModel FromSession(Conversation sess) { return new ConversationViewModel diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationFileDocument.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationFileDocument.cs new file mode 100644 index 000000000..ed516ab23 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationFileDocument.cs @@ -0,0 +1,27 @@ +using BotSharp.Abstraction.Conversations.Models; + +namespace BotSharp.Plugin.MongoStorage.Collections; + +public class ConversationFileDocument : MongoBase +{ + public string ConversationId { get; set; } + public string? Thumbnail { get; set; } + + public static ConversationFile ToDomainModel(ConversationFileDocument model) + { + return new ConversationFile + { + ConversationId = model.ConversationId, + Thumbnail = model.Thumbnail + }; + } + + public static ConversationFileDocument ToMongoModel(ConversationFile model) + { + return new ConversationFileDocument + { + ConversationId = model.ConversationId, + Thumbnail = model.Thumbnail + }; + } +} diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/MongoDbContext.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/MongoDbContext.cs index af807f1d0..9836b571e 100644 --- a/src/Plugins/BotSharp.Plugin.MongoStorage/MongoDbContext.cs +++ b/src/Plugins/BotSharp.Plugin.MongoStorage/MongoDbContext.cs @@ -216,6 +216,9 @@ public IMongoCollection ConversationDialogs public IMongoCollection ConversationStates => GetCollection("ConversationStates"); + public IMongoCollection ConversationFiles + => GetCollection("ConversationFiles"); + public IMongoCollection LlmCompletionLogs => GetCollection("LlmCompletionLogs"); diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs index 300700a64..f8ae23c36 100644 --- a/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs +++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs @@ -1,5 +1,6 @@ using BotSharp.Abstraction.Conversations.Models; using BotSharp.Abstraction.Repositories.Filters; +using Microsoft.Extensions.Logging; using MongoDB.Driver.Linq; using System.Text.Json; @@ -74,6 +75,7 @@ public async Task DeleteConversations(IEnumerable conversationIds) var filterContentLog = Builders.Filter.In(x => x.ConversationId, conversationIds); var filterStateLog = Builders.Filter.In(x => x.ConversationId, conversationIds); var conbTabItems = Builders.Filter.In(x => x.ConversationId, conversationIds); + var filterConvFile = Builders.Filter.In(x => x.ConversationId, conversationIds); var promptLogDeleted = await _dc.LlmCompletionLogs.DeleteManyAsync(filterPromptLog); var contentLogDeleted = await _dc.ContentLogs.DeleteManyAsync(filterContentLog); @@ -81,25 +83,37 @@ public async Task DeleteConversations(IEnumerable conversationIds) var statesDeleted = await _dc.ConversationStates.DeleteManyAsync(filterSates); var dialogDeleted = await _dc.ConversationDialogs.DeleteManyAsync(filterDialog); var cronDeleted = await _dc.CrontabItems.DeleteManyAsync(conbTabItems); + var fileDeleted = await _dc.ConversationFiles.DeleteManyAsync(filterConvFile); var convDeleted = await _dc.Conversations.DeleteManyAsync(filterConv); return convDeleted.DeletedCount > 0 || dialogDeleted.DeletedCount > 0 || statesDeleted.DeletedCount > 0 || promptLogDeleted.DeletedCount > 0 || contentLogDeleted.DeletedCount > 0 - || stateLogDeleted.DeletedCount > 0 || convDeleted.DeletedCount > 0; + || stateLogDeleted.DeletedCount > 0 || fileDeleted.DeletedCount > 0 + || convDeleted.DeletedCount > 0; } [SideCar] - public async Task> GetConversationDialogs(string conversationId) + public async Task> GetConversationDialogs(string conversationId, ConversationDialogFilter? filter = null) { var dialogs = new List(); - if (string.IsNullOrEmpty(conversationId)) return dialogs; + if (string.IsNullOrEmpty(conversationId)) + { + return dialogs; + } - var filter = Builders.Filter.Eq(x => x.ConversationId, conversationId); - var foundDialog = await _dc.ConversationDialogs.Find(filter).FirstOrDefaultAsync(); - if (foundDialog == null) return dialogs; + var dialogFilter = Builders.Filter.Eq(x => x.ConversationId, conversationId); + var foundDialog = await _dc.ConversationDialogs.Find(dialogFilter).FirstOrDefaultAsync(); + if (foundDialog == null) + { + return dialogs; + } - var formattedDialog = foundDialog.Dialogs?.Select(x => DialogMongoElement.ToDomainElement(x))?.ToList(); - return formattedDialog ?? new List(); + var formattedDialog = foundDialog.Dialogs?.Select(x => DialogMongoElement.ToDomainElement(x))?.ToList() ?? []; + if (filter?.Order == "desc") + { + formattedDialog = formattedDialog.OrderByDescending(x => x.MetaData?.CreatedTime).ToList(); + } + return formattedDialog ?? []; } [SideCar] @@ -765,6 +779,81 @@ public async Task MigrateConvsersationLatestStates(string conversationId) return true; } + #region Files + public async Task> GetConversationFiles(ConversationFileFilter filter) + { + if (filter == null || filter.ConversationIds.IsNullOrEmpty()) + { + return []; + } + + var builder = Builders.Filter; + var fileFilter = builder.In(x => x.ConversationId, filter.ConversationIds); + + var fileDocs = await _dc.ConversationFiles.Find(fileFilter).ToListAsync(); + var files = fileDocs.Select(x => ConversationFileDocument.ToDomainModel(x)).ToList(); + return files; + } + + public async Task SaveConversationFiles(List files) + { + if (files.IsNullOrEmpty()) + { + return false; + } + + try + { + var builder = Builders.Filter; + var operations = files.Where(x => !string.IsNullOrEmpty(x.ConversationId)) + .Select(file => + { + var fileDoc = ConversationFileDocument.ToMongoModel(file); + var filter = builder.Eq(x => x.ConversationId, file.ConversationId); + return new ReplaceOneModel(filter, fileDoc) + { + IsUpsert = true + }; + }) + .ToList(); + + if (!operations.IsNullOrEmpty()) + { + await _dc.ConversationFiles.BulkWriteAsync(operations, new BulkWriteOptions { IsOrdered = false }); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error when saving conversation files."); + return false; + } + } + + public async Task DeleteConversationFiles(List conversationIds) + { + if (conversationIds.IsNullOrEmpty()) + { + return false; + } + + try + { + var builder = Builders.Filter; + var filter = builder.In(x => x.ConversationId, conversationIds); + var result = await _dc.ConversationFiles.DeleteManyAsync(filter); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error when deleting conversation files."); + return false; + } + } + #endregion + + #region Private methods private string ConvertSnakeCaseToPascalCase(string snakeCase) {