diff --git a/LifelogBb/Controllers/ChatController.cs b/LifelogBb/Controllers/ChatController.cs index b529917..01ee41c 100644 --- a/LifelogBb/Controllers/ChatController.cs +++ b/LifelogBb/Controllers/ChatController.cs @@ -2,14 +2,19 @@ using LifelogBb.Models; using LifelogBb.Models.Chat; using LifelogBb.Models.Entities; +using Microsoft.Data.Sqlite; using Microsoft.AspNetCore.Mvc; -using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Westwind.AspNetCore.Markdown; +using System.Data; namespace LifelogBb.Controllers { - public class ChatController : Controller + public partial class ChatController : Controller { + private const int DefaultSessionNameMaxLength = 50; + private const int MaxConversationMessages = 20; + private const int MaxSortOrderRetries = 3; private readonly LifelogBbContext _context; private readonly ChatService _chatService; @@ -19,12 +24,55 @@ public ChatController(LifelogBbContext context, ChatService chatService) _chatService = chatService; } - public IActionResult Index() + public async Task Index(long? id) { var config = Config.GetConfig(_context); + var sessions = await _context.ChatSessions + .OrderByDescending(s => s.UpdatedAt) + .Select(s => new ChatSessionListItem + { + Id = s.Id, + Name = s.Name, + UpdatedAt = s.UpdatedAt + }) + .ToListAsync(); + + ChatSession? activeSession = null; + if (id.HasValue) + { + activeSession = await _context.ChatSessions + .Include(s => s.Messages.OrderBy(m => m.SortOrder)) + .FirstOrDefaultAsync(s => s.Id == id.Value); + } + + if (!id.HasValue && activeSession == null && sessions.Count > 0) + { + activeSession = await _context.ChatSessions + .Include(s => s.Messages.OrderBy(m => m.SortOrder)) + .OrderByDescending(s => s.UpdatedAt) + .FirstOrDefaultAsync(); + } + + var messages = new List(); + if (activeSession != null) + { + foreach (var msg in activeSession.Messages) + { + messages.Add(new ChatMessageViewModel + { + Role = msg.Role, + Content = msg.Role == "assistant" ? Markdown.Parse(msg.Content) : msg.Content + }); + } + } + var model = new ChatViewModel { - IsConfigured = !string.IsNullOrWhiteSpace(config.ChatApiKey) + IsConfigured = !string.IsNullOrWhiteSpace(config.ChatApiKey), + Sessions = sessions, + ActiveSessionId = activeSession?.Id, + ActiveSessionName = activeSession?.Name, + Messages = messages }; return View(model); } @@ -40,10 +88,32 @@ public async Task Send([FromBody] ChatSendRequest request) var conversation = new List(); - // Add existing conversation history - if (request.History != null) + // Load existing messages from the session if we have one + ChatSession? session = null; + if (request.SessionId.HasValue) + { + session = await _context.ChatSessions + .FirstOrDefaultAsync(s => s.Id == request.SessionId.Value); + } + + if (request.SessionId.HasValue && session == null) + { + return Json(new { error = "Session not found." }); + } + + if (session != null) { - foreach (var msg in request.History) + var maxHistoryMessages = MaxConversationMessages - 1; + + // Build conversation from persisted messages, reserving one slot for the current user message + var historyMessages = await _context.ChatSessionMessages + .Where(m => m.ChatSessionId == session.Id) + .OrderByDescending(m => m.SortOrder) + .Take(maxHistoryMessages) + .OrderBy(m => m.SortOrder) + .ToListAsync(); + + foreach (var msg in historyMessages) { conversation.Add(new ChatMessage { @@ -59,17 +129,182 @@ public async Task Send([FromBody] ChatSendRequest request) Role = "user", Content = request.Message }); + if (conversation.Count > MaxConversationMessages) + { + conversation = conversation.TakeLast(MaxConversationMessages).ToList(); + } var response = await _chatService.SendAsync(conversation); var html = Markdown.Parse(response); - return Json(new { response = html }); + // Persist to database + var sessionId = session?.Id; + var newSessionName = request.Message.Length > DefaultSessionNameMaxLength + ? request.Message[..DefaultSessionNameMaxLength] + "..." + : request.Message; + + var persisted = false; + for (var attempt = 0; attempt < MaxSortOrderRetries && !persisted; attempt++) + { + try + { + await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable, HttpContext.RequestAborted); + + ChatSession sessionToPersist; + if (sessionId.HasValue) + { + var existingSession = await _context.ChatSessions + .FirstOrDefaultAsync(s => s.Id == sessionId.Value, HttpContext.RequestAborted); + + if (existingSession == null) + { + await transaction.RollbackAsync(HttpContext.RequestAborted); + return Json(new { error = "Session not found." }); + } + + sessionToPersist = existingSession; + } + else + { + sessionToPersist = new ChatSession + { + Name = newSessionName + }; + sessionToPersist.SetCreateFields(); + _context.ChatSessions.Add(sessionToPersist); + await _context.SaveChangesAsync(HttpContext.RequestAborted); + } + + var maxSortOrder = await _context.ChatSessionMessages + .Where(m => m.ChatSessionId == sessionToPersist.Id) + .MaxAsync(m => (int?)m.SortOrder, HttpContext.RequestAborted); + var nextSortOrder = (maxSortOrder ?? -1) + 1; + + var userMsg = new ChatSessionMessage + { + ChatSessionId = sessionToPersist.Id, + Role = "user", + Content = request.Message, + SortOrder = nextSortOrder + }; + userMsg.SetCreateFields(); + + var assistantMsg = new ChatSessionMessage + { + ChatSessionId = sessionToPersist.Id, + Role = "assistant", + Content = response, + SortOrder = nextSortOrder + 1 + }; + assistantMsg.SetCreateFields(); + + _context.ChatSessionMessages.AddRange(userMsg, assistantMsg); + sessionToPersist.SetUpdateFields(); + await _context.SaveChangesAsync(HttpContext.RequestAborted); + await transaction.CommitAsync(HttpContext.RequestAborted); + sessionId = sessionToPersist.Id; + session = sessionToPersist; + persisted = true; + } + catch (Exception ex) when (IsRetryableSqliteLockError(ex)) + { + _context.ChangeTracker.Clear(); + + if (attempt < MaxSortOrderRetries - 1) + { + await Task.Delay(50 * (attempt + 1), HttpContext.RequestAborted); + } + } + } + + if (!persisted) + { + return Json(new { error = "Could not save message. Please try again." }); + } + + return Json(new { response = html, sessionId = session!.Id, sessionName = session.Name }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task CreateSession() + { + var session = new ChatSession + { + Name = "New Chat" + }; + session.SetCreateFields(); + _context.ChatSessions.Add(session); + await _context.SaveChangesAsync(); + + return RedirectToAction(nameof(Index), new { id = session.Id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RenameSession([FromBody] RenameSessionRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Name)) + { + return Json(new { error = "Name is required." }); + } + + var session = await _context.ChatSessions.FindAsync(request.Id); + if (session == null) + { + return NotFound(); + } + + var maxLength = typeof(ChatSession) + .GetProperty(nameof(ChatSession.Name))! + .GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.StringLengthAttribute), false) + .Cast() + .FirstOrDefault()?.MaximumLength ?? 200; + session.Name = request.Name.Length > maxLength ? request.Name[..maxLength] : request.Name; + session.SetUpdateFields(); + await _context.SaveChangesAsync(); + + return Json(new { success = true, name = session.Name }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task DeleteSession(long id) + { + var session = await _context.ChatSessions.FindAsync(id); + if (session == null) + { + return NotFound(); + } + + _context.ChatSessions.Remove(session); + await _context.SaveChangesAsync(); + + return RedirectToAction(nameof(Index)); + } + + private static bool IsRetryableSqliteLockError(Exception exception) + { + var sqliteException = exception switch + { + DbUpdateException dbUpdateException when dbUpdateException.InnerException is SqliteException innerSqliteException => innerSqliteException, + SqliteException directSqliteException => directSqliteException, + _ => null + }; + + return sqliteException is { SqliteErrorCode: 5 or 6 or 517 }; } } public class ChatSendRequest { public string Message { get; set; } = ""; - public List? History { get; set; } + public long? SessionId { get; set; } + } + + public class RenameSessionRequest + { + public long Id { get; set; } + public string Name { get; set; } = ""; } } diff --git a/LifelogBb/Migrations/20260516065018_ChatPersistence.Designer.cs b/LifelogBb/Migrations/20260516065018_ChatPersistence.Designer.cs new file mode 100644 index 0000000..dfbb12a --- /dev/null +++ b/LifelogBb/Migrations/20260516065018_ChatPersistence.Designer.cs @@ -0,0 +1,557 @@ +// +using System; +using LifelogBb.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LifelogBb.Migrations +{ + [DbContext(typeof(LifelogBbContext))] + [Migration("20260516065018_ChatPersistence")] + partial class ChatPersistence + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("LifelogBb.Models.Entities.BucketList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageFileName") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BucketLists"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.ChatSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ChatSessions"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.ChatSessionMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatSessionId") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChatSessionId"); + + b.ToTable("ChatSessionMessages"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.Config", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BucketListPageSize") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(12); + + b.Property("ChatApiKey") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ChatEndpoint") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("https://api.openai.com/v1/chat/completions"); + + b.Property("ChatMaxToolRoundtrips") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(10); + + b.Property("ChatModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("gpt-4o"); + + b.Property("ChatSystemPrompt") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("You are a helpful life-tracking assistant for LifelogBB. You can query the user's data (weights, journals, todos, goals, habits, quotes, strength trainings, endurance trainings) using the available tools. Summarize and analyze the data to help the user understand their progress and habits."); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnduranceTrainingPageSize") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(20); + + b.Property("FeedTimeZone") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Europe/Berlin"); + + b.Property("FeedToken") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("ChangeMeInTheConfig"); + + b.Property("GoalPageSize") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(20); + + b.Property("HabitPageSize") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(20); + + b.Property("Height") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(170); + + b.Property("JournalPageSize") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(20); + + b.Property("QuotePageSize") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(20); + + b.Property("StartOfWeek") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("StrengthTrainingPageSize") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(20); + + b.Property("TodoPageSize") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(20); + + b.Property("UnitsType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeightPageSize") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(20); + + b.Property("WeightWarning") + .ValueGeneratedOnAdd() + .HasColumnType("REAL") + .HasDefaultValue(1.0); + + b.Property("WeightWarningText") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("You are gaining weight!"); + + b.HasKey("Id"); + + b.ToTable("Configs"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.EnduranceTraining", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Distance") + .HasColumnType("REAL"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Exercise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EnduranceTrainings"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentValue") + .HasColumnType("REAL"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("InitialValue") + .HasColumnType("REAL"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TargetValue") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Goals"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RecurrenceRules") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.Journal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date") + .IsUnique(); + + b.ToTable("Journals"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.Quote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.StrengthTraining", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Exercise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Reps") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Weight") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.ToTable("StrengthTrainings"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.Todo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("Completed") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DueDate") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("IsImportant") + .HasColumnType("INTEGER"); + + b.Property("Progress") + .HasColumnType("INTEGER"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Todos"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.Weight", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bmi") + .HasColumnType("REAL"); + + b.Property("BodyWeight") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Weights"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.ChatSessionMessage", b => + { + b.HasOne("LifelogBb.Models.Entities.ChatSession", "ChatSession") + .WithMany("Messages") + .HasForeignKey("ChatSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatSession"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.ChatSession", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LifelogBb/Migrations/20260516065018_ChatPersistence.cs b/LifelogBb/Migrations/20260516065018_ChatPersistence.cs new file mode 100644 index 0000000..e950f35 --- /dev/null +++ b/LifelogBb/Migrations/20260516065018_ChatPersistence.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LifelogBb.Migrations +{ + /// + public partial class ChatPersistence : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ChatSessions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatSessions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ChatSessionMessages", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ChatSessionId = table.Column(type: "INTEGER", nullable: false), + Role = table.Column(type: "TEXT", nullable: false), + Content = table.Column(type: "TEXT", nullable: false), + SortOrder = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatSessionMessages", x => x.Id); + table.ForeignKey( + name: "FK_ChatSessionMessages_ChatSessions_ChatSessionId", + column: x => x.ChatSessionId, + principalTable: "ChatSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessionMessages_ChatSessionId", + table: "ChatSessionMessages", + column: "ChatSessionId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChatSessionMessages"); + + migrationBuilder.DropTable( + name: "ChatSessions"); + } + } +} diff --git a/LifelogBb/Migrations/LifelogBbContextModelSnapshot.cs b/LifelogBb/Migrations/LifelogBbContextModelSnapshot.cs index 022d698..73f9f5f 100644 --- a/LifelogBb/Migrations/LifelogBbContextModelSnapshot.cs +++ b/LifelogBb/Migrations/LifelogBbContextModelSnapshot.cs @@ -56,6 +56,61 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BucketLists"); }); + modelBuilder.Entity("LifelogBb.Models.Entities.ChatSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ChatSessions"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.ChatSessionMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatSessionId") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChatSessionId"); + + b.ToTable("ChatSessionMessages"); + }); + modelBuilder.Entity("LifelogBb.Models.Entities.Config", b => { b.Property("Id") @@ -477,6 +532,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Weights"); }); + + modelBuilder.Entity("LifelogBb.Models.Entities.ChatSessionMessage", b => + { + b.HasOne("LifelogBb.Models.Entities.ChatSession", "ChatSession") + .WithMany("Messages") + .HasForeignKey("ChatSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatSession"); + }); + + modelBuilder.Entity("LifelogBb.Models.Entities.ChatSession", b => + { + b.Navigation("Messages"); + }); #pragma warning restore 612, 618 } } diff --git a/LifelogBb/Models/Chat/ChatSessionListItem.cs b/LifelogBb/Models/Chat/ChatSessionListItem.cs new file mode 100644 index 0000000..903e72e --- /dev/null +++ b/LifelogBb/Models/Chat/ChatSessionListItem.cs @@ -0,0 +1,9 @@ +namespace LifelogBb.Models.Chat +{ + public class ChatSessionListItem + { + public long Id { get; set; } + public string Name { get; set; } = ""; + public DateTime UpdatedAt { get; set; } + } +} diff --git a/LifelogBb/Models/Chat/ChatViewModel.cs b/LifelogBb/Models/Chat/ChatViewModel.cs index bfce277..cb5c60a 100644 --- a/LifelogBb/Models/Chat/ChatViewModel.cs +++ b/LifelogBb/Models/Chat/ChatViewModel.cs @@ -5,5 +5,8 @@ public class ChatViewModel public List Messages { get; set; } = new(); public string UserInput { get; set; } = ""; public bool IsConfigured { get; set; } + public List Sessions { get; set; } = new(); + public long? ActiveSessionId { get; set; } + public string? ActiveSessionName { get; set; } } } diff --git a/LifelogBb/Models/Entities/ChatSession.cs b/LifelogBb/Models/Entities/ChatSession.cs new file mode 100644 index 0000000..d2006d4 --- /dev/null +++ b/LifelogBb/Models/Entities/ChatSession.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace LifelogBb.Models.Entities +{ + public class ChatSession : BaseEntity + { + [Required] + [StringLength(200)] + public string Name { get; set; } = "New Chat"; + + public ICollection Messages { get; set; } = new List(); + } +} diff --git a/LifelogBb/Models/Entities/ChatSessionMessage.cs b/LifelogBb/Models/Entities/ChatSessionMessage.cs new file mode 100644 index 0000000..75008ec --- /dev/null +++ b/LifelogBb/Models/Entities/ChatSessionMessage.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace LifelogBb.Models.Entities +{ + public class ChatSessionMessage : BaseEntity + { + [Required] + public long ChatSessionId { get; set; } + + [Required] + public string Role { get; set; } = "user"; + + [Required] + public string Content { get; set; } = ""; + + public int SortOrder { get; set; } + + public ChatSession ChatSession { get; set; } = null!; + } +} diff --git a/LifelogBb/Models/LifelogBbContext.cs b/LifelogBb/Models/LifelogBbContext.cs index 87c1d58..b226cd5 100644 --- a/LifelogBb/Models/LifelogBbContext.cs +++ b/LifelogBb/Models/LifelogBbContext.cs @@ -18,6 +18,8 @@ public class LifelogBbContext : DbContext public DbSet Habits { get; set; } = null!; public DbSet Goals { get; set; } = null!; public DbSet Configs { get; set; } = null!; + public DbSet ChatSessions { get; set; } = null!; + public DbSet ChatSessionMessages { get; set; } = null!; private readonly IConfiguration _configuration; @@ -77,6 +79,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().Property(b => b.ChatSystemPrompt).HasDefaultValue("You are a helpful life-tracking assistant for LifelogBB. You can query the user's data (weights, journals, todos, goals, habits, quotes, strength trainings, endurance trainings) using the available tools. Summarize and analyze the data to help the user understand their progress and habits."); modelBuilder.Entity().Property(b => b.ChatMaxToolRoundtrips).HasDefaultValue(10); + modelBuilder.Entity() + .HasMany(s => s.Messages) + .WithOne(m => m.ChatSession) + .HasForeignKey(m => m.ChatSessionId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity().HasIndex(j => j.Date).IsUnique(); } diff --git a/LifelogBb/Views/Chat/Index.cshtml b/LifelogBb/Views/Chat/Index.cshtml index 1359587..17c58f8 100644 --- a/LifelogBb/Views/Chat/Index.cshtml +++ b/LifelogBb/Views/Chat/Index.cshtml @@ -7,9 +7,9 @@
@Html.AntiForgeryToken()
-
- @if (!Model.IsConfigured) - { + @if (!Model.IsConfigured) + { +
- } +
+ } +
+
+
+

Chats

+
+
+ +
+
+
+
+ @if (Model.Sessions.Count == 0) + { +
+ No chats yet +
+ } + @foreach (var session in Model.Sessions) + { +
+ + @session.Name + +
+ +
+ +
+
+
+ } +
+
+
+
-

Chat

+

+ + @(Model.ActiveSessionName ?? "Chat") +

-
-
- - Ask me anything about your life tracking data. -
+ @if (Model.Messages.Count == 0) + { +
+ + Ask me anything about your life tracking data. +
+ }