diff --git a/backend/Conversations/Conversation.cs b/backend/Conversations/Conversation.cs new file mode 100644 index 0000000..1789354 --- /dev/null +++ b/backend/Conversations/Conversation.cs @@ -0,0 +1,19 @@ +namespace backend.Conversations; + +public enum EndReason +{ + Unknown = 0, + Tool = 1, + Idle = 2, + Error = 3, +} + +public class Conversation +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid DeviceId { get; set; } + public DateTimeOffset StartedAt { get; set; } + public DateTimeOffset? EndedAt { get; set; } + public EndReason EndReason { get; set; } = EndReason.Unknown; + public List Turns { get; set; } = new(); +} diff --git a/backend/Conversations/Turn.cs b/backend/Conversations/Turn.cs new file mode 100644 index 0000000..8ed6414 --- /dev/null +++ b/backend/Conversations/Turn.cs @@ -0,0 +1,21 @@ +namespace backend.Conversations; + +public enum TurnRole +{ + User = 0, + Assistant = 1, + Tool = 2, +} + +public class Turn +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ConversationId { get; set; } + public int Index { get; set; } + public TurnRole Role { get; set; } + public string? Text { get; set; } + public string? ToolName { get; set; } + public string? ToolArgsJson { get; set; } + public string? ToolResultJson { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/Data/AppDbContext.cs b/backend/Data/AppDbContext.cs index 0676481..1533c26 100644 --- a/backend/Data/AppDbContext.cs +++ b/backend/Data/AppDbContext.cs @@ -1,4 +1,5 @@ using backend.Auth; +using backend.Conversations; using backend.Devices; using backend.Pairing; using backend.Settings; @@ -15,6 +16,8 @@ public class AppDbContext(DbContextOptions options) public DbSet DeviceConfigs => Set(); public DbSet SystemSettings => Set(); public DbSet PairingCodes => Set(); + public DbSet Conversations => Set(); + public DbSet Turns => Set(); protected override void OnModelCreating(ModelBuilder b) { @@ -36,5 +39,13 @@ public class AppDbContext(DbContextOptions options) b.Entity().Property(s => s.Id).ValueGeneratedNever(); b.Entity().HasKey(p => p.Code); b.Entity().HasIndex(p => p.ExpiresAt); + + b.Entity().HasIndex(c => c.DeviceId); + b.Entity() + .HasMany(c => c.Turns) + .WithOne() + .HasForeignKey(t => t.ConversationId) + .OnDelete(DeleteBehavior.Cascade); + b.Entity().HasIndex(t => new { t.ConversationId, t.Index }).IsUnique(); } } diff --git a/backend/Migrations/20260611210852_AddConversationsAndTurns.Designer.cs b/backend/Migrations/20260611210852_AddConversationsAndTurns.Designer.cs new file mode 100644 index 0000000..0104c6d --- /dev/null +++ b/backend/Migrations/20260611210852_AddConversationsAndTurns.Designer.cs @@ -0,0 +1,482 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using backend.Data; + +#nullable disable + +namespace backend.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260611210852_AddConversationsAndTurns")] + partial class AddConversationsAndTurns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.17"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("backend.Auth.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("backend.Conversations.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("EndReason") + .HasColumnType("INTEGER"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("backend.Conversations.Turn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Text") + .HasColumnType("TEXT"); + + b.Property("ToolArgsJson") + .HasColumnType("TEXT"); + + b.Property("ToolName") + .HasColumnType("TEXT"); + + b.Property("ToolResultJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Index") + .IsUnique(); + + b.ToTable("Turns"); + }); + + modelBuilder.Entity("backend.Devices.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("IsRevoked") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("PairedAt") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("backend.Devices.DeviceConfig", b => + { + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("EnabledToolsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IdleTimeoutSeconds") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SystemPrompt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Voice") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("DeviceId"); + + b.ToTable("DeviceConfigs"); + }); + + modelBuilder.Entity("backend.Pairing.PairingCode", b => + { + b.Property("Code") + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.HasKey("Code"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("PairingCodes"); + }); + + modelBuilder.Entity("backend.Settings.SystemSettings", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("DefaultIdleTimeoutSeconds") + .HasColumnType("INTEGER"); + + b.Property("DefaultModel") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultSystemPrompt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultVoice") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SystemSettings"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("backend.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("backend.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("backend.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("backend.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("backend.Conversations.Turn", b => + { + b.HasOne("backend.Conversations.Conversation", null) + .WithMany("Turns") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("backend.Devices.Device", b => + { + b.HasOne("backend.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("backend.Devices.DeviceConfig", b => + { + b.HasOne("backend.Devices.Device", null) + .WithOne("Config") + .HasForeignKey("backend.Devices.DeviceConfig", "DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("backend.Conversations.Conversation", b => + { + b.Navigation("Turns"); + }); + + modelBuilder.Entity("backend.Devices.Device", b => + { + b.Navigation("Config"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Migrations/20260611210852_AddConversationsAndTurns.cs b/backend/Migrations/20260611210852_AddConversationsAndTurns.cs new file mode 100644 index 0000000..de044a2 --- /dev/null +++ b/backend/Migrations/20260611210852_AddConversationsAndTurns.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace backend.Migrations +{ + /// + public partial class AddConversationsAndTurns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Conversations", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + DeviceId = table.Column(type: "TEXT", nullable: false), + StartedAt = table.Column(type: "TEXT", nullable: false), + EndedAt = table.Column(type: "TEXT", nullable: true), + EndReason = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Conversations", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Turns", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ConversationId = table.Column(type: "TEXT", nullable: false), + Index = table.Column(type: "INTEGER", nullable: false), + Role = table.Column(type: "INTEGER", nullable: false), + Text = table.Column(type: "TEXT", nullable: true), + ToolName = table.Column(type: "TEXT", nullable: true), + ToolArgsJson = table.Column(type: "TEXT", nullable: true), + ToolResultJson = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Turns", x => x.Id); + table.ForeignKey( + name: "FK_Turns_Conversations_ConversationId", + column: x => x.ConversationId, + principalTable: "Conversations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Conversations_DeviceId", + table: "Conversations", + column: "DeviceId"); + + migrationBuilder.CreateIndex( + name: "IX_Turns_ConversationId_Index", + table: "Turns", + columns: new[] { "ConversationId", "Index" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Turns"); + + migrationBuilder.DropTable( + name: "Conversations"); + } + } +} diff --git a/backend/Migrations/AppDbContextModelSnapshot.cs b/backend/Migrations/AppDbContextModelSnapshot.cs index 9839e75..621100f 100644 --- a/backend/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/Migrations/AppDbContextModelSnapshot.cs @@ -208,6 +208,69 @@ namespace backend.Migrations b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("backend.Conversations.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("EndReason") + .HasColumnType("INTEGER"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("backend.Conversations.Turn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Text") + .HasColumnType("TEXT"); + + b.Property("ToolArgsJson") + .HasColumnType("TEXT"); + + b.Property("ToolName") + .HasColumnType("TEXT"); + + b.Property("ToolResultJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Index") + .IsUnique(); + + b.ToTable("Turns"); + }); + modelBuilder.Entity("backend.Devices.Device", b => { b.Property("Id") @@ -374,6 +437,15 @@ namespace backend.Migrations .IsRequired(); }); + modelBuilder.Entity("backend.Conversations.Turn", b => + { + b.HasOne("backend.Conversations.Conversation", null) + .WithMany("Turns") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("backend.Devices.Device", b => { b.HasOne("backend.Auth.ApplicationUser", null) @@ -392,6 +464,11 @@ namespace backend.Migrations .IsRequired(); }); + modelBuilder.Entity("backend.Conversations.Conversation", b => + { + b.Navigation("Turns"); + }); + modelBuilder.Entity("backend.Devices.Device", b => { b.Navigation("Config");