diff --git a/.env.example b/.env.example index 6ade72b..7d3c28d 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,18 @@ Llm__Providers__OpenAI__Model=gpt-5.1 Llm__Providers__Claude__Model=claude-3-5-haiku-latest Llm__Providers__Claude__Version=2023-06-01 Llm__Providers__Gemini__Model=gemini-3.1-flash-lite +# LM Studio does not require auth by default; a harmless placeholder such as +# "lmstudio" is sufficient for the saved provider key when auth is off. +# If the backend runs in Docker, point this at a host-reachable LM Studio server, +# for example http://host.docker.internal:1234/v1. +Llm__Providers__LmStudio__BaseUrl=http://localhost:1234/v1 +Llm__Providers__LmStudio__Model=local-model + +# Built-in guest preview keys used once per logged-out browser session. +# These should be low-quota or dedicated guest credentials. +Llm__GuestPreview__OpenAI__ApiKey= +Llm__GuestPreview__OpenAI__Model=gpt-5.2 +Llm__GuestPreview__Claude__ApiKey= +Llm__GuestPreview__Claude__Model=claude-sonnet-4-20250514 +Llm__GuestPreview__Gemini__ApiKey= +Llm__GuestPreview__Gemini__Model=gemini-2.5-flash diff --git a/.gitignore b/.gitignore index deecb06..61aeabd 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 985e24a..2f1c824 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ docker compose up --build Coliee/ ├── .github/workflows/ CI/CD pipelines (GitHub Actions) ├── backend/Coliee.API/ .NET 8 Web API -├── frontend/ React frontend (Vite + Nginx) +├── frontend/ Unified React frontend (Vite + Nginx) ├── docker-compose.yml Multi-service orchestration ├── init.sql MySQL schema initialisation ├── .env.example Environment variable template diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 092bcae..f59216d 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -145,6 +145,12 @@ Go to your repo → **Settings** → **Secrets and variables** → **Actions** a | `SMTP_PORT` | `587` | | `SMTP_USER` | Your Gmail address | | `SMTP_PASSWORD` | [Google App Password](https://myaccount.google.com/apppasswords) | +| `Llm__GuestPreview__OpenAI__ApiKey` | Dedicated one-time guest preview key for OpenAI | +| `Llm__GuestPreview__OpenAI__Model` | Default built-in OpenAI guest model | +| `Llm__GuestPreview__Claude__ApiKey` | Dedicated one-time guest preview key for Claude | +| `Llm__GuestPreview__Claude__Model` | Default built-in Claude guest model | +| `Llm__GuestPreview__Gemini__ApiKey` | Dedicated one-time guest preview key for Gemini | +| `Llm__GuestPreview__Gemini__Model` | Default built-in Gemini guest model | --- diff --git a/README.md b/README.md index bbccdb7..a2f0e8d 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ - � **Note Generation** — synthesise notes from any combination of nodes - � **Export** — download notes as PDF or plain text - 🔒 **Secure Authentication** — JWT-based auth with BCrypt password hashing -- 🔑 **BYOK Provider Settings** — save and manage personal OpenAI, Claude, and Gemini keys -- 💬 **Main-Node Chat** — minimal chat surface on `/home` using the selected BYOK provider +- 🔑 **BYOK Provider Settings** — save and manage personal OpenAI, Claude, Gemini, and LM Studio keys +- 💬 **Canvas Workspace** — the primary frontend experience now lives in the unified canvas workspace on `/` --- @@ -27,7 +27,7 @@ | Frontend | React.js (functional components + hooks), React Flow | | Backend | ASP.NET Web API (.NET 8), ADO.NET | | Database | MySQL 8.0 hosted on Supabase | -| AI | OpenAI API (GPT-4 / GPT-3.5-turbo) | +| AI | OpenAI, Anthropic Claude, and Google Gemini APIs | | Proxy | Nginx reverse proxy | | DevOps | Docker, GitHub Actions CI/CD | | Hosting | Azure App Service (API) + Azure Static Web Apps (frontend) | @@ -56,7 +56,8 @@ Coliee/ - [Docker](https://docs.docker.com/get-docker/) & Docker Compose - [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) *(for backend development outside Docker)* - [Node.js 18+](https://nodejs.org/) *(for frontend development outside Docker)* -- A provider API key for OpenAI, Claude, or Gemini if you want to use the BYOK chat flow +- A provider API key for OpenAI, Claude, Gemini, or LM Studio if you want to use the BYOK chat flow +- Optional built-in guest preview API keys for OpenAI, Claude, and Gemini if you want logged-out users to get one real AI action before sign-in ### 1. Clone the repository @@ -81,6 +82,18 @@ MYSQL_ROOT_PASSWORD=your_root_password JWT_KEY=your_jwt_secret Llm__EncryptionKey=replace-with-base64-32-byte-key +Llm__GuestPreview__OpenAI__ApiKey= +Llm__GuestPreview__OpenAI__Model=gpt-5.2 +Llm__GuestPreview__Claude__ApiKey= +Llm__GuestPreview__Claude__Model=claude-sonnet-4-20250514 +Llm__GuestPreview__Gemini__ApiKey= +Llm__GuestPreview__Gemini__Model=gemini-2.5-flash +# LM Studio does not require auth by default; a placeholder such as +# "lmstudio" is enough for the saved provider key when auth is off. +# If the backend runs in Docker, point LM Studio at a host-reachable URL such as +# http://host.docker.internal:1234/v1. +Llm__Providers__LmStudio__BaseUrl=http://localhost:1234/v1 +Llm__Providers__LmStudio__Model=local-model # Backend API URL (used by frontend) VITE_API_URL=http://localhost:5000 @@ -162,7 +175,7 @@ The backend follows a **layered structure**: Controller → Service → Data Access (ADO.NET) → MySQL ``` -All LLM providers are abstracted behind an interface, and the current BYOK flow supports OpenAI, Claude, and Gemini using user-managed keys stored encrypted at rest. +All LLM providers are abstracted behind an interface, and the current BYOK flow supports OpenAI, Claude, Gemini, and LM Studio using user-managed keys stored encrypted at rest. Logged-out guest canvas actions can also use dedicated built-in preview keys for a single real AI action before sign-in is required. --- diff --git a/backend/Coliee.API.Tests/AuthControllerTests.cs b/backend/Coliee.API.Tests/AuthControllerTests.cs index 2377714..e0b9321 100644 --- a/backend/Coliee.API.Tests/AuthControllerTests.cs +++ b/backend/Coliee.API.Tests/AuthControllerTests.cs @@ -2,6 +2,7 @@ using Coliee.API.DTOs; using Coliee.API.Services; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Coliee.API.Tests; @@ -57,7 +58,14 @@ public async Task ResendPasswordResetOtp_ReturnsRateLimitPayload_OnBlock() } private static AuthController CreateController(IPasswordResetOtpService passwordResetOtpService) => - new(null!, null!, passwordResetOtpService, new StubEmailVerificationOtpService(), new StubPasswordPolicyService(), new StubGoogleAuthService()); + new( + null!, + null!, + passwordResetOtpService, + new StubEmailVerificationOtpService(), + new StubPasswordPolicyService(), + new StubGoogleAuthService(), + NullLogger.Instance); } internal sealed class StubPasswordResetOtpService : IPasswordResetOtpService diff --git a/backend/Coliee.API.Tests/Controllers/AuthControllerTests.cs b/backend/Coliee.API.Tests/Controllers/AuthControllerTests.cs index 39a6fd4..9084abc 100644 --- a/backend/Coliee.API.Tests/Controllers/AuthControllerTests.cs +++ b/backend/Coliee.API.Tests/Controllers/AuthControllerTests.cs @@ -5,6 +5,7 @@ using Coliee.API.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Coliee.API.Tests.Controllers; @@ -220,7 +221,8 @@ private static AuthController CreateController( new StubPasswordResetOtpService(), emailVerificationOtpService ?? new StubEmailVerificationOtpService(), new StubPasswordPolicyService(), - googleAuthService); + googleAuthService, + NullLogger.Instance); } private static TokenService CreateTokenService() diff --git a/backend/Coliee.API.Tests/Controllers/CanvasesControllerTests.cs b/backend/Coliee.API.Tests/Controllers/CanvasesControllerTests.cs index 2144c24..76e33f1 100644 --- a/backend/Coliee.API.Tests/Controllers/CanvasesControllerTests.cs +++ b/backend/Coliee.API.Tests/Controllers/CanvasesControllerTests.cs @@ -21,7 +21,7 @@ public async Task GetCanvases_ReturnsOrderedCanvasSummaries() new CanvasSummaryResponse { Id = 1, Title = "Older" } ] }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), service, new FakeCanvasGraphService()); var result = await controller.GetCanvases(); @@ -34,14 +34,16 @@ public async Task GetCanvases_ReturnsOrderedCanvasSummaries() public async Task CreateCanvas_ReturnsCreatedStatus() { var service = new FakeCanvasService + { }; + var creationService = new FakeCanvasCreationService { CreateResult = new CanvasSummaryResponse { Id = 10, - Title = "Launch checklist" + Title = "Launch Checklist" } }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(creationService, service, new FakeCanvasGraphService()); var result = await controller.CreateCanvas(new CreateCanvasRequest { FirstPrompt = "Launch checklist" }); @@ -49,17 +51,40 @@ public async Task CreateCanvas_ReturnsCreatedStatus() Assert.Equal(201, objectResult.StatusCode); var payload = Assert.IsType(objectResult.Value); Assert.Equal(10, payload.Id); - Assert.Equal("Launch checklist", payload.Title); + Assert.Equal("Launch Checklist", payload.Title); + } + + [Fact] + public async Task CreateCanvas_PassesGenerationModeToCreationService() + { + var creationService = new FakeCanvasCreationService + { + CreateResult = new CanvasSummaryResponse + { + Id = 11, + Title = "Launch Checklist" + } + }; + var controller = CreateController(creationService, new FakeCanvasService(), new FakeCanvasGraphService()); + + await controller.CreateCanvas(new CreateCanvasRequest + { + FirstPrompt = "Launch checklist", + GenerationMode = "rich" + }); + + Assert.Equal("rich", creationService.LastGenerationMode); } [Fact] public async Task CreateCanvas_WhenServiceRejectsPrompt_ReturnsBadRequest() { - var service = new FakeCanvasService + var service = new FakeCanvasService(); + var creationService = new FakeCanvasCreationService { ExceptionToThrow = new CanvasOperationException("A first prompt is required to create a canvas.", 400) }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(creationService, service, new FakeCanvasGraphService()); var result = await controller.CreateCanvas(new CreateCanvasRequest { FirstPrompt = " " }); @@ -78,7 +103,7 @@ public async Task RenameCanvas_ReturnsUpdatedCanvas() Title = "Renamed canvas" } }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), service, new FakeCanvasGraphService()); var result = await controller.RenameCanvas(10, new RenameCanvasRequest { Title = "Renamed canvas" }); @@ -94,7 +119,7 @@ public async Task RenameCanvas_WhenServiceRejectsTitle_ReturnsBadRequest() { ExceptionToThrow = new CanvasOperationException("A canvas title is required.", 400) }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), service, new FakeCanvasGraphService()); var result = await controller.RenameCanvas(10, new RenameCanvasRequest { Title = " " }); @@ -105,7 +130,7 @@ public async Task RenameCanvas_WhenServiceRejectsTitle_ReturnsBadRequest() [Fact] public async Task DeleteCanvas_ReturnsNoContent() { - var controller = CreateController(new FakeCanvasService(), new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), new FakeCanvasService(), new FakeCanvasGraphService()); var result = await controller.DeleteCanvas(10); @@ -119,7 +144,7 @@ public async Task DeleteCanvas_WhenForbidden_ReturnsForbidden() { DeleteExceptionToThrow = new CanvasOperationException("You do not have access to that canvas.", 403) }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), service, new FakeCanvasGraphService()); var result = await controller.DeleteCanvas(10); @@ -134,7 +159,7 @@ public async Task DeleteCanvas_WhenMissing_ReturnsNotFound() { DeleteExceptionToThrow = new CanvasOperationException("Canvas not found.", 404) }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), service, new FakeCanvasGraphService()); var result = await controller.DeleteCanvas(999); @@ -158,7 +183,7 @@ public async Task GetCanvasGraph_ReturnsGraphPayload() ] } }; - var controller = CreateController(new FakeCanvasService(), graphService); + var controller = CreateController(new FakeCanvasCreationService(), new FakeCanvasService(), graphService); var result = await controller.GetCanvasGraph(10); @@ -175,7 +200,7 @@ public async Task ExpandCanvasNode_WhenProviderMissing_ReturnsBadRequestWithCode { LlmExceptionToThrow = new LlmChatException("Missing provider", LlmProviderErrorCodes.MissingKey, 400) }; - var controller = CreateController(new FakeCanvasService(), graphService); + var controller = CreateController(new FakeCanvasCreationService(), new FakeCanvasService(), graphService); var result = await controller.ExpandCanvasNode(10, 42, new ExpandCanvasNodeRequest { Prompt = "Hello" }); @@ -183,9 +208,24 @@ public async Task ExpandCanvasNode_WhenProviderMissing_ReturnsBadRequestWithCode Assert.Equal(400, objectResult.StatusCode); } - private static CanvasesController CreateController(ICanvasService service, ICanvasGraphService graphService, int userId = 1) + [Fact] + public async Task ExpandCanvasNode_PassesGenerationModeToGraphService() + { + var graphService = new FakeCanvasGraphService(); + var controller = CreateController(new FakeCanvasCreationService(), new FakeCanvasService(), graphService); + + await controller.ExpandCanvasNode(10, 42, new ExpandCanvasNodeRequest + { + Prompt = "Hello", + GenerationMode = "rich" + }); + + Assert.Equal("rich", graphService.LastGenerationMode); + } + + private static CanvasesController CreateController(ICanvasCreationService creationService, ICanvasService service, ICanvasGraphService graphService, int userId = 1) { - var controller = new CanvasesController(service, graphService); + var controller = new CanvasesController(creationService, service, graphService); var claims = new List { new Claim(ClaimTypes.NameIdentifier, userId.ToString()), @@ -202,21 +242,28 @@ private static CanvasesController CreateController(ICanvasService service, ICanv return controller; } - private sealed class FakeCanvasService : ICanvasService + private sealed class FakeCanvasCreationService : ICanvasCreationService { - public IReadOnlyList Items { get; set; } = []; public CanvasSummaryResponse CreateResult { get; set; } = new(); - public CanvasSummaryResponse RenameResult { get; set; } = new(); public Exception? ExceptionToThrow { get; set; } - public Exception? DeleteExceptionToThrow { get; set; } + public string? LastGenerationMode { get; private set; } - public Task CreateCanvasAsync(int userId, string firstPrompt) + public Task CreateCanvasAsync(int userId, string firstPrompt, string? generationMode) { if (ExceptionToThrow != null) throw ExceptionToThrow; + LastGenerationMode = generationMode; return Task.FromResult(CreateResult); } + } + + private sealed class FakeCanvasService : ICanvasService + { + public IReadOnlyList Items { get; set; } = []; + public CanvasSummaryResponse RenameResult { get; set; } = new(); + public Exception? ExceptionToThrow { get; set; } + public Exception? DeleteExceptionToThrow { get; set; } public Task> GetCanvasesAsync(int userId) { @@ -246,6 +293,7 @@ private sealed class FakeCanvasGraphService : ICanvasGraphService public CanvasGraphResponse ExpandResult { get; set; } = new(); public Exception? ExceptionToThrow { get; set; } public Exception? LlmExceptionToThrow { get; set; } + public string? LastGenerationMode { get; private set; } public Task GetCanvasGraphAsync(int userId, int canvasId) { @@ -255,7 +303,7 @@ public Task GetCanvasGraphAsync(int userId, int canvasId) return Task.FromResult(GraphResult); } - public Task ExpandCanvasNodeAsync(int userId, int canvasId, int nodeId, string prompt, string? providerOverride) + public Task ExpandCanvasNodeAsync(int userId, int canvasId, int nodeId, string prompt, string? providerOverride, string? generationMode) { if (ExceptionToThrow != null) throw ExceptionToThrow; @@ -263,6 +311,7 @@ public Task ExpandCanvasNodeAsync(int userId, int canvasId, if (LlmExceptionToThrow != null) throw LlmExceptionToThrow; + LastGenerationMode = generationMode; return Task.FromResult(ExpandResult); } } diff --git a/backend/Coliee.API.Tests/Controllers/ChatGuestControllerTests.cs b/backend/Coliee.API.Tests/Controllers/ChatGuestControllerTests.cs new file mode 100644 index 0000000..100674a --- /dev/null +++ b/backend/Coliee.API.Tests/Controllers/ChatGuestControllerTests.cs @@ -0,0 +1,101 @@ +using Coliee.API.Controllers; +using Coliee.API.DTOs; +using Coliee.API.Models; +using Coliee.API.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Xunit; + +namespace Coliee.API.Tests.Controllers; + +public class ChatGuestControllerTests +{ + [Fact] + public async Task GuestPreview_AllowsOneRealActionThenRequiresSignIn() + { + var service = new FakeChatSessionService + { + Response = new GuestChatPreviewResponse + { + ActionType = "chat", + Message = "Real guest reply", + Provider = "gemini", + Model = "gemini-3.1-flash-lite-preview" + } + }; + var cache = new MemoryCache(new MemoryCacheOptions()); + var controller = CreateController(service, cache); + + var first = await controller.GuestPreview(new GuestChatPreviewRequest + { + ActionType = "chat", + Prompt = "Hello", + ModelId = "gemini-3.1-flash-lite-preview" + }); + + var firstResult = Assert.IsType(first); + var firstPayload = Assert.IsType(firstResult.Value); + Assert.Equal("Real guest reply", firstPayload.Message); + Assert.Equal(1, service.PreviewCallCount); + + var second = await controller.GuestPreview(new GuestChatPreviewRequest + { + ActionType = "chat", + Prompt = "Try again", + ModelId = "gemini-3.1-flash-lite-preview" + }); + + var unauthorized = Assert.IsType(second); + Assert.Equal(401, unauthorized.StatusCode); + Assert.Equal("auth_required", ReadAnonymousProperty(unauthorized.Value!, "code")); + Assert.Equal(1, service.PreviewCallCount); + } + + private static ChatGuestController CreateController(FakeChatSessionService service, IMemoryCache cache) + { + var controller = new ChatGuestController(service, cache); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + controller.ControllerContext.HttpContext!.Request.Headers["Cookie"] = "coliee_guest_preview_id=guest-123"; + return controller; + } + + private static T ReadAnonymousProperty(object value, string propertyName) + { + return (T)(value.GetType().GetProperty(propertyName)?.GetValue(value) + ?? throw new InvalidOperationException($"Property '{propertyName}' was not found.")); + } + + private sealed class FakeChatSessionService : IChatSessionService + { + public GuestChatPreviewResponse Response { get; set; } = new(); + public int PreviewCallCount { get; private set; } + + public Task PreviewGuestAsync(GuestChatPreviewRequest request) + { + PreviewCallCount++; + return Task.FromResult(Response); + } + + public Task> GetSessionsAsync(int userId) => throw new NotImplementedException(); + public Task CreateSessionAsync(int userId, string? title, string? content, string? providerOverride, string? modelOverride = null) => throw new NotImplementedException(); + public Task ImportGuestCanvasesAsync(int userId, ImportGuestCanvasesRequest request) => throw new NotImplementedException(); + public Task RenameSessionAsync(int userId, int sessionId, string title) => throw new NotImplementedException(); + public Task DeleteSessionAsync(int userId, int sessionId) => throw new NotImplementedException(); + public Task GetSessionAsync(int userId, int sessionId, int? activeNodeId = null) => throw new NotImplementedException(); + public Task GetNodeAsync(int userId, int sessionId, int nodeId) => throw new NotImplementedException(); + public Task CreateNodeAsync(int userId, int sessionId, CreateChatNodeRequest request) => throw new NotImplementedException(); + public Task UpdateNodeAsync(int userId, int sessionId, int nodeId, UpdateChatNodeRequest request) => throw new NotImplementedException(); + public Task SendNodeMessageAsync(int userId, int sessionId, int nodeId, string content, string? imageUrl, string? providerOverride, string? modelOverride = null) => throw new NotImplementedException(); + public Task UpdateMessageAsync(int userId, int sessionId, int nodeId, int messageId, UpdateChatCanvasMessageRequest request) => throw new NotImplementedException(); + public Task CreateBranchAsync(int userId, int sessionId, int nodeId, int branchFromMessageId, string content, string? providerOverride, string? modelOverride = null) => throw new NotImplementedException(); + public Task MoveMessagesToBranchAsync(int userId, int sessionId, int nodeId, IReadOnlyList messageIds, string? transferMode = null) => throw new NotImplementedException(); + public Task GenerateNoteAsync(int userId, int sessionId, int nodeId, string? providerOverride, string? modelOverride = null) => throw new NotImplementedException(); + public Task GenerateImageAsync(int userId, int sessionId, int nodeId, string prompt, string? providerOverride, string? modelOverride = null) => throw new NotImplementedException(); + public Task DeleteNodeAsync(int userId, int sessionId, int nodeId, int? newActiveNodeId = null) => throw new NotImplementedException(); + public Task SaveLayoutAsync(int userId, int sessionId, IReadOnlyList nodes, int? activeNodeId = null) => throw new NotImplementedException(); + } +} diff --git a/backend/Coliee.API.Tests/Controllers/ChatSessionsControllerTests.cs b/backend/Coliee.API.Tests/Controllers/ChatSessionsControllerTests.cs new file mode 100644 index 0000000..732e17e --- /dev/null +++ b/backend/Coliee.API.Tests/Controllers/ChatSessionsControllerTests.cs @@ -0,0 +1,493 @@ +using System.Security.Claims; +using Coliee.API.Controllers; +using Coliee.API.DTOs; +using Coliee.API.Models; +using Coliee.API.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Xunit; + +namespace Coliee.API.Tests.Controllers; + +public class ChatSessionsControllerTests +{ + [Fact] + public async Task GetSessions_ReturnsOrderedSessions() + { + var service = new FakeChatSessionService + { + Sessions = + [ + new ChatSessionSummaryResponse { Id = 2, Title = "Newest" }, + new ChatSessionSummaryResponse { Id = 1, Title = "Older" } + ] + }; + var controller = CreateController(service); + + var result = await controller.GetSessions(); + + var okResult = Assert.IsType(result); + var payload = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal(["Newest", "Older"], payload.Select(item => item.Title).ToArray()); + } + + [Fact] + public async Task CreateSession_ReturnsCreatedState() + { + var service = new FakeChatSessionService + { + State = new ChatSessionStateResponse + { + Graph = new ChatSessionGraphResponse + { + Session = new ChatSessionSummaryResponse { Id = 12, Title = "Launch plan" }, + RootNodeId = 42, + ActiveNodeId = 42 + }, + Node = new ChatCanvasNodeDetailResponse + { + SessionId = 12, + NodeId = 42, + Title = "Launch plan" + } + } + }; + var controller = CreateController(service); + + var result = await controller.CreateSession(new CreateChatSessionRequest + { + Title = "Launch plan", + Content = "Launch plan" + }); + + var objectResult = Assert.IsType(result); + Assert.Equal(201, objectResult.StatusCode); + var payload = Assert.IsType(objectResult.Value); + Assert.Equal("Launch plan", payload.Graph.Session.Title); + } + + [Fact] + public async Task SendNodeMessage_PassesRouteAndContent() + { + var service = new FakeChatSessionService + { + State = new ChatSessionStateResponse + { + Graph = new ChatSessionGraphResponse + { + Session = new ChatSessionSummaryResponse { Id = 12, Title = "Launch plan" }, + RootNodeId = 42, + ActiveNodeId = 42 + }, + Node = new ChatCanvasNodeDetailResponse + { + SessionId = 12, + NodeId = 42, + Title = "Launch plan" + } + } + }; + var controller = CreateController(service); + + var result = await controller.SendNodeMessage(12, 42, new SendChatNodeMessageRequest + { + Content = "Continue", + ProviderOverride = "gemini", + ModelOverride = "gemini-1.5-flash" + }); + + var okResult = Assert.IsType(result); + Assert.IsType(okResult.Value); + Assert.Equal(12, service.LastSessionId); + Assert.Equal(42, service.LastNodeId); + Assert.Equal("Continue", service.LastContent); + Assert.Equal("gemini", service.LastProviderOverride); + Assert.Equal("gemini-1.5-flash", service.LastModelOverride); + } + + [Fact] + public async Task UpdateMessage_PassesMessageRouteAndContent() + { + var service = new FakeChatSessionService + { + State = new ChatSessionStateResponse + { + Graph = new ChatSessionGraphResponse + { + Session = new ChatSessionSummaryResponse { Id = 12, Title = "Launch plan" }, + RootNodeId = 42, + ActiveNodeId = 42 + }, + Node = new ChatCanvasNodeDetailResponse + { + SessionId = 12, + NodeId = 42, + Title = "Launch plan" + } + } + }; + var controller = CreateController(service); + + var result = await controller.UpdateMessage(12, 42, 77, new UpdateChatCanvasMessageRequest + { + Content = "Edited response" + }); + + var okResult = Assert.IsType(result); + Assert.IsType(okResult.Value); + Assert.Equal(12, service.LastSessionId); + Assert.Equal(42, service.LastNodeId); + Assert.Equal(77, service.LastMessageId); + Assert.Equal("Edited response", service.LastContent); + } + + [Fact] + public async Task CreateBranch_ReturnsBadRequestWhenServiceRejectsBranch() + { + var service = new FakeChatSessionService + { + ExceptionToThrow = new ChatSessionOperationException("Branches can only start from assistant replies in the current node.", 400) + }; + var controller = CreateController(service); + + var result = await controller.CreateBranch(12, 42, new CreateChatBranchRequest + { + BranchFromMessageId = 9, + Content = "Explore another angle" + }); + + var objectResult = Assert.IsType(result); + Assert.Equal(400, objectResult.StatusCode); + Assert.Equal("Branches can only start from assistant replies in the current node.", ReadAnonymousProperty(objectResult.Value!, "message")); + } + + [Fact] + public async Task SaveLayout_MapsPayloadNodes() + { + var service = new FakeChatSessionService + { + Graph = new ChatSessionGraphResponse + { + Session = new ChatSessionSummaryResponse { Id = 12, Title = "Launch plan" }, + RootNodeId = 42, + ActiveNodeId = 77 + } + }; + var controller = CreateController(service); + + var result = await controller.SaveLayout(12, new SaveChatLayoutRequest + { + Nodes = + [ + new SaveChatLayoutNodeRequest { NodeId = 42, PositionX = 10, PositionY = 20 }, + new SaveChatLayoutNodeRequest { NodeId = 77, PositionX = 100, PositionY = 140 } + ] + }, 77); + + var okResult = Assert.IsType(result); + Assert.IsType(okResult.Value); + Assert.Equal(2, service.LastLayout.Count); + Assert.Equal(77, service.LastActiveNodeId); + } + + [Fact] + public async Task DeleteSession_ReturnsNoContent_WhenServiceSucceeds() + { + var service = new FakeChatSessionService(); + var controller = CreateController(service); + + var result = await controller.DeleteSession(55); + + Assert.IsType(result); + Assert.Equal(55, service.LastSessionId); + } + + [Fact] + public async Task GenerateNote_PassesRouteAndOverridesToService() + { + var service = new FakeChatSessionService + { + State = new ChatSessionStateResponse + { + Graph = new ChatSessionGraphResponse + { + Session = new ChatSessionSummaryResponse { Id = 12, Title = "Launch plan" }, + RootNodeId = 42, + ActiveNodeId = 42 + }, + Node = new ChatCanvasNodeDetailResponse + { + SessionId = 12, + NodeId = 99, + Title = "AI Summary" + } + } + }; + var controller = CreateController(service); + + var result = await controller.GenerateNote(12, 42, new GenerateChatNodeNoteRequest + { + ProviderOverride = "openai", + ModelOverride = "gpt-4o-mini" + }); + + var okResult = Assert.IsType(result); + Assert.IsType(okResult.Value); + Assert.Equal(12, service.LastSessionId); + Assert.Equal(42, service.LastNodeId); + Assert.Equal("openai", service.LastProviderOverride); + Assert.Equal("gpt-4o-mini", service.LastModelOverride); + } + + [Fact] + public async Task GenerateNote_ReturnsLlmErrorPayload_WhenServiceThrowsLlmException() + { + var service = new FakeChatSessionService + { + ExceptionToThrow = new LlmChatException("Provider key missing", "provider_key_missing", 422) + }; + var controller = CreateController(service); + + var result = await controller.GenerateNote(12, 42, new GenerateChatNodeNoteRequest()); + + var objectResult = Assert.IsType(result); + Assert.Equal(422, objectResult.StatusCode); + Assert.Equal("Provider key missing", ReadAnonymousProperty(objectResult.Value!, "message")); + Assert.Equal("provider_key_missing", ReadAnonymousProperty(objectResult.Value!, "code")); + } + + private static ChatSessionsController CreateController(IChatSessionService service, int userId = 1) + { + var controller = new ChatSessionsController(service); + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim("email", "test@example.com") + }; + var identity = new ClaimsIdentity(claims, "TestAuth"); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(identity) + } + }; + return controller; + } + + private static T ReadAnonymousProperty(object value, string propertyName) + { + return (T)(value.GetType().GetProperty(propertyName)?.GetValue(value) + ?? throw new InvalidOperationException($"Property '{propertyName}' was not found.")); + } + + private sealed class FakeChatSessionService : IChatSessionService + { + public IReadOnlyList Sessions { get; set; } = []; + public ChatSessionStateResponse State { get; set; } = new(); + public ChatSessionGraphResponse Graph { get; set; } = new(); + public Exception? ExceptionToThrow { get; set; } + public int LastSessionId { get; private set; } + public int LastNodeId { get; private set; } + public string? LastTitle { get; private set; } + public string? LastContent { get; private set; } + public string? LastProviderOverride { get; private set; } + public string? LastModelOverride { get; private set; } + public int LastMessageId { get; private set; } + public IReadOnlyList LastLayout { get; private set; } = []; + public int? LastActiveNodeId { get; private set; } + + public Task> GetSessionsAsync(int userId) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + return Task.FromResult(Sessions); + } + + public Task CreateSessionAsync(int userId, string? title, string? content, string? providerOverride, string? modelOverride = null) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastTitle = title; + LastContent = content; + LastProviderOverride = providerOverride; + LastModelOverride = modelOverride; + return Task.FromResult(State); + } + + public Task PreviewGuestAsync(GuestChatPreviewRequest request) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + return Task.FromResult(new GuestChatPreviewResponse()); + } + + public Task ImportGuestCanvasesAsync(int userId, ImportGuestCanvasesRequest request) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + return Task.FromResult(State); + } + + public Task RenameSessionAsync(int userId, int sessionId, string title) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastContent = title; + return Task.FromResult(Graph.Session); + } + + public Task DeleteSessionAsync(int userId, int sessionId) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + return Task.CompletedTask; + } + + public Task GetSessionAsync(int userId, int sessionId, int? activeNodeId = null) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastActiveNodeId = activeNodeId; + return Task.FromResult(Graph); + } + + public Task GetNodeAsync(int userId, int sessionId, int nodeId) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastNodeId = nodeId; + return Task.FromResult(State.Node); + } + + public Task CreateNodeAsync(int userId, int sessionId, CreateChatNodeRequest request) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastNodeId = request.ParentNodeId; + LastContent = request.Content; + return Task.FromResult(State); + } + + public Task UpdateNodeAsync(int userId, int sessionId, int nodeId, UpdateChatNodeRequest request) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastNodeId = nodeId; + LastContent = request.Content ?? request.Title; + return Task.FromResult(State); + } + + public Task SendNodeMessageAsync(int userId, int sessionId, int nodeId, string content, string? imageUrl, string? providerOverride, string? modelOverride = null) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastNodeId = nodeId; + LastContent = content; + LastProviderOverride = providerOverride; + LastModelOverride = modelOverride; + return Task.FromResult(State); + } + + public Task CreateBranchAsync(int userId, int sessionId, int nodeId, int branchFromMessageId, string content, string? providerOverride, string? modelOverride = null) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastNodeId = nodeId; + LastContent = content; + LastProviderOverride = providerOverride; + LastModelOverride = modelOverride; + return Task.FromResult(State); + } + + public Task MoveMessagesToBranchAsync(int userId, int sessionId, int nodeId, IReadOnlyList messageIds, string? transferMode = null) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastNodeId = nodeId; + LastMessageId = messageIds.FirstOrDefault(); + return Task.FromResult(State); + } + + public Task UpdateMessageAsync(int userId, int sessionId, int nodeId, int messageId, UpdateChatCanvasMessageRequest request) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastNodeId = nodeId; + LastMessageId = messageId; + LastContent = request.Content; + return Task.FromResult(State); + } + + public Task GenerateNoteAsync(int userId, int sessionId, int nodeId, string? providerOverride, string? modelOverride = null) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastNodeId = nodeId; + LastProviderOverride = providerOverride; + LastModelOverride = modelOverride; + return Task.FromResult(State); + } + + public Task GenerateImageAsync(int userId, int sessionId, int nodeId, string prompt, string? providerOverride, string? modelOverride = null) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastNodeId = nodeId; + LastContent = prompt; + LastProviderOverride = providerOverride; + LastModelOverride = modelOverride; + return Task.FromResult(State); + } + + public Task DeleteNodeAsync(int userId, int sessionId, int nodeId, int? newActiveNodeId = null) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastNodeId = nodeId; + LastActiveNodeId = newActiveNodeId; + return Task.FromResult(Graph); + } + + public Task SaveLayoutAsync(int userId, int sessionId, IReadOnlyList nodes, int? activeNodeId = null) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastSessionId = sessionId; + LastLayout = nodes; + LastActiveNodeId = activeNodeId; + return Task.FromResult(Graph); + } + } +} diff --git a/backend/Coliee.API.Tests/Controllers/LlmControllerTests.cs b/backend/Coliee.API.Tests/Controllers/LlmControllerTests.cs index f944b8b..3e145a7 100644 --- a/backend/Coliee.API.Tests/Controllers/LlmControllerTests.cs +++ b/backend/Coliee.API.Tests/Controllers/LlmControllerTests.cs @@ -10,6 +10,31 @@ namespace Coliee.API.Tests.Controllers; public class LlmControllerTests { + [Fact] + public async Task GetProviderModels_ReturnsPayload() + { + var service = new FakeLlmService + { + ProviderModels = new ProviderModelsResponse + { + Provider = "openai", + Models = + [ + new ProviderModelResponse { Id = "gpt-4.1-mini", DisplayName = "GPT-4.1 Mini" } + ] + } + }; + var controller = CreateController(service); + + var result = await controller.GetProviderModels("openai"); + + var okResult = Assert.IsType(result); + var payload = Assert.IsType(okResult.Value); + Assert.Equal("openai", payload.Provider); + Assert.Equal("openai", service.LastProvider); + Assert.Single(payload.Models); + } + [Fact] public async Task GetMainNodeChat_ReturnsChatPayload() { @@ -97,6 +122,44 @@ public async Task SendMainNodeMessage_ReturnsUpdatedChatPayload() Assert.Equal(2, payload.Messages.Count); } + [Fact] + public async Task SendStandaloneChatMessage_ReturnsAssistantReply() + { + var service = new FakeLlmService + { + StandaloneResponse = new StandaloneChatResponse + { + DefaultProvider = "gemini", + AvailableProviders = ["openai", "gemini"], + Message = new StandaloneChatMessageResponse + { + Role = "assistant", + Content = "Standalone reply", + Provider = "gemini", + Model = "gemini-1.5-flash" + } + } + }; + var controller = CreateController(service); + + var result = await controller.SendStandaloneChatMessage(new SendStandaloneChatMessageRequest + { + History = + [ + new StandaloneChatMessageRequest { Role = "user", Content = "Hello" } + ], + Content = "Continue", + ProviderOverride = "gemini" + }); + + var okResult = Assert.IsType(result); + var payload = Assert.IsType(okResult.Value); + Assert.Equal("Continue", service.LastContent); + Assert.Equal("gemini", service.LastProviderOverride); + Assert.Single(service.LastHistory); + Assert.Equal("Standalone reply", payload.Message.Content); + } + [Fact] public async Task SendMainNodeMessage_InvalidModelState_ReturnsBadRequest() { @@ -149,6 +212,19 @@ public async Task SendMainNodeMessage_InvalidClaims_ReturnsUnauthorized() Assert.Equal("Invalid token claims.", ReadAnonymousProperty(objectResult.Value!, "message")); } + [Fact] + public async Task SendStandaloneChatMessage_InvalidModelState_ReturnsBadRequest() + { + var service = new FakeLlmService(); + var controller = CreateController(service); + controller.ModelState.AddModelError("Content", "Required"); + + var result = await controller.SendStandaloneChatMessage(new SendStandaloneChatMessageRequest()); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Validation failed", ReadAnonymousProperty(badRequestResult.Value!, "message")); + } + private static LlmController CreateController(ILlmService service, int userId = 1) { var controller = new LlmController(service); @@ -190,17 +266,41 @@ private static T ReadAnonymousProperty(object value, string propertyName) private sealed class FakeLlmService : ILlmService { public MainNodeChatResponse ChatResponse { get; set; } = new(); + public ProviderModelsResponse ProviderModels { get; set; } = new(); + public StandaloneChatResponse StandaloneResponse { get; set; } = new(); public Exception? ExceptionToThrow { get; set; } public int LastCanvasId { get; private set; } + public IReadOnlyList LastHistory { get; private set; } = []; public string? LastContent { get; private set; } + public string? LastProvider { get; private set; } public string? LastProviderOverride { get; private set; } public Task GetProvidersAsync(int userId) => throw new NotImplementedException(); + public Task GetProviderModelsAsync(int userId, string provider) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastProvider = provider; + return Task.FromResult(ProviderModels); + } + public Task UpsertProviderKeyAsync(int userId, string provider, string apiKey) => throw new NotImplementedException(); public Task DeleteProviderKeyAsync(int userId, string provider) => throw new NotImplementedException(); public Task SetDefaultProviderAsync(int userId, string provider) => throw new NotImplementedException(); public Task TestProviderAsync(int userId, string provider) => throw new NotImplementedException(); + public Task SendStandaloneChatMessageAsync(int userId, IReadOnlyList history, string content, string? providerOverride) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastHistory = history; + LastContent = content; + LastProviderOverride = providerOverride; + return Task.FromResult(StandaloneResponse); + } + public Task GetMainNodeChatAsync(int userId, int canvasId) { if (ExceptionToThrow != null) diff --git a/backend/Coliee.API.Tests/Services/CanvasCreationServiceTests.cs b/backend/Coliee.API.Tests/Services/CanvasCreationServiceTests.cs new file mode 100644 index 0000000..09eceb2 --- /dev/null +++ b/backend/Coliee.API.Tests/Services/CanvasCreationServiceTests.cs @@ -0,0 +1,322 @@ +using Coliee.API.DTOs; +using Coliee.API.Data; +using Coliee.API.Models; +using Coliee.API.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Coliee.API.Tests.Services; + +public class CanvasCreationServiceTests +{ + [Fact] + public async Task CreateCanvas_GeneratesShortSemanticTitleAndSeedsGraph() + { + var repository = new InMemoryCanvasRepository(); + var graphService = new FakeCanvasGraphService(repository); + var providerResolver = new FakeCanvasProviderResolver(); + var service = new CanvasCreationService(repository, graphService, providerResolver, NullLogger.Instance); + + var response = await service.CreateCanvasAsync(1, " Plan launch\nchecklist for the Q2 release "); + + response.Title.Should().Be("Launch Checklist For Q2 Release"); + graphService.LastPrompt.Should().Be("Plan launch checklist for the Q2 release"); + graphService.LastGenerationMode.Should().Be(CanvasGenerationModes.Compact); + repository.GetChildNodeCount(response.Id).Should().BeGreaterThan(0); + } + + [Fact] + public async Task CreateCanvas_WithRichGenerationMode_PassesModeToGraphExpansion() + { + var repository = new InMemoryCanvasRepository(); + var graphService = new FakeCanvasGraphService(repository); + var providerResolver = new FakeCanvasProviderResolver(); + var service = new CanvasCreationService(repository, graphService, providerResolver, NullLogger.Instance); + + await service.CreateCanvasAsync(1, "Plan launch checklist", CanvasGenerationModes.Rich); + + graphService.LastGenerationMode.Should().Be(CanvasGenerationModes.Rich); + } + + [Fact] + public async Task CreateCanvas_WhitespaceOnlyPrompt_ThrowsBadRequest() + { + var repository = new InMemoryCanvasRepository(); + var service = new CanvasCreationService( + repository, + new FakeCanvasGraphService(repository), + new FakeCanvasProviderResolver(), + NullLogger.Instance); + + var action = async () => await service.CreateCanvasAsync(1, " "); + + var exception = await action.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(400); + } + + [Fact] + public async Task CreateCanvas_WhenNoProviderConfigured_ThrowsMissingKeyWithoutPersistingCanvas() + { + var repository = new InMemoryCanvasRepository(); + var service = new CanvasCreationService( + repository, + new FakeCanvasGraphService(repository), + new FakeCanvasProviderResolver + { + ExceptionToThrow = new LlmChatException("Add a provider API key in Settings before creating a canvas.", LlmProviderErrorCodes.MissingKey, 400) + }, + NullLogger.Instance); + + var action = async () => await service.CreateCanvasAsync(1, "Launch checklist"); + + var exception = await action.Should().ThrowAsync(); + exception.Which.Code.Should().Be(LlmProviderErrorCodes.MissingKey); + (await repository.GetCanvasesAsync(1)).Should().BeEmpty(); + } + + [Fact] + public async Task CreateCanvas_WhenInitialExpansionFails_RollsBackCanvas() + { + var repository = new InMemoryCanvasRepository(); + var service = new CanvasCreationService( + repository, + new FakeCanvasGraphService(repository) + { + ExceptionToThrow = new LlmChatException("Provider unavailable.", LlmProviderErrorCodes.ProviderError, 502) + }, + new FakeCanvasProviderResolver(), + NullLogger.Instance); + + var action = async () => await service.CreateCanvasAsync(1, "Launch checklist"); + + await action.Should().ThrowAsync(); + (await repository.GetCanvasesAsync(1)).Should().BeEmpty(); + } + + private sealed class FakeCanvasProviderResolver : ICanvasProviderResolver + { + public Exception? ExceptionToThrow { get; set; } + + public Task ResolveAsync(int userId, string? providerOverride) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + return Task.FromResult(new CanvasProviderResolution( + LlmProviderNames.OpenAi, + [new LlmProviderKeyRecord { Provider = LlmProviderNames.OpenAi, EncryptedApiKey = "enc", IsDefault = true }])); + } + } + + private sealed class FakeCanvasGraphService : ICanvasGraphService + { + private readonly ICanvasRepository _canvasRepository; + public Exception? ExceptionToThrow { get; set; } + public string? LastPrompt { get; private set; } + public string? LastGenerationMode { get; private set; } + + public FakeCanvasGraphService(ICanvasRepository canvasRepository) + { + _canvasRepository = canvasRepository; + } + + public Task GetCanvasGraphAsync(int userId, int canvasId) + { + throw new NotImplementedException(); + } + + public async Task ExpandCanvasNodeAsync(int userId, int canvasId, int nodeId, string prompt, string? providerOverride, string? generationMode) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastPrompt = prompt; + LastGenerationMode = generationMode; + await _canvasRepository.AddChildCanvasNodesAsync(userId, canvasId, nodeId, + [ + new CanvasNodeCreateInput + { + Kind = "response", + Title = "Launch Sequencing", + Content = "Test child", + SourcePrompt = prompt, + Depth = 1 + } + ]); + + return new CanvasGraphResponse + { + CanvasId = canvasId, + Mode = "graph", + RootNodeId = nodeId, + Nodes = + [ + new CanvasGraphNodeResponse + { + Id = nodeId, + ParentNodeId = null, + Kind = "main", + Title = "Main Node", + GenerationMode = "compact", + Content = string.Empty, + Depth = 0, + SiblingOrder = 0 + }, + new CanvasGraphNodeResponse + { + Id = nodeId + 1, + ParentNodeId = nodeId, + Kind = "response", + Title = "Launch Sequencing", + GenerationMode = generationMode ?? "compact", + Summary = "Test child", + Content = "Test child", + SourcePrompt = prompt, + Blocks = [], + Sources = [], + Depth = 1, + SiblingOrder = 0 + } + ] + }; + } + } + + private sealed class InMemoryCanvasRepository : ICanvasRepository + { + private readonly List _items = []; + private readonly List _nodes = []; + private int _nextId = 1; + private int _nextNodeId = 1; + + public Task CreateCanvasAsync(int userId, string title) + { + var now = DateTime.UtcNow.AddMinutes(_nextId); + var canvas = new Canvas + { + Id = _nextId++, + UserId = userId, + Title = title, + CreatedAt = now, + UpdatedAt = now + }; + _items.Add(canvas); + _nodes.Add(new CanvasNode + { + Id = _nextNodeId++, + CanvasId = canvas.Id, + ParentNodeId = null, + Kind = "main", + Title = "Main Node", + Content = string.Empty, + Depth = 0, + SiblingOrder = 0, + CreatedAt = now, + UpdatedAt = now + }); + return Task.FromResult(canvas); + } + + public Task> GetCanvasesAsync(int userId) + { + return Task.FromResult>(_items.Where(item => item.UserId == userId).ToList()); + } + + public Task GetCanvasAsync(int userId, int canvasId) + { + return Task.FromResult(_items.FirstOrDefault(item => item.UserId == userId && item.Id == canvasId)); + } + + public Task GetCanvasByIdAsync(int canvasId) + { + return Task.FromResult(_items.FirstOrDefault(item => item.Id == canvasId)); + } + + public Task RenameCanvasAsync(int userId, int canvasId, string title) + { + var canvas = _items.First(item => item.UserId == userId && item.Id == canvasId); + canvas.Title = title; + canvas.UpdatedAt = DateTime.UtcNow; + return Task.FromResult(canvas); + } + + public Task DeleteCanvasAsync(int userId, int canvasId) + { + _items.RemoveAll(item => item.UserId == userId && item.Id == canvasId); + _nodes.RemoveAll(item => item.CanvasId == canvasId); + return Task.CompletedTask; + } + + public Task HasLegacyMainNodeMessagesAsync(int userId, int canvasId) + { + return Task.FromResult(false); + } + + public Task EnsureRootCanvasNodeAsync(int userId, int canvasId) + { + return Task.FromResult(_nodes.First(item => item.CanvasId == canvasId && item.ParentNodeId == null)); + } + + public Task> GetCanvasNodesAsync(int userId, int canvasId) + { + return Task.FromResult>(_nodes.Where(item => item.CanvasId == canvasId).ToList()); + } + + public Task GetCanvasNodeAsync(int userId, int canvasId, int nodeId) + { + return Task.FromResult(_nodes.FirstOrDefault(item => item.CanvasId == canvasId && item.Id == nodeId)); + } + + public Task UpdateCanvasNodeAsync(int userId, int canvasId, int nodeId, CanvasNodeUpdateInput node) + { + var existing = _nodes.First(item => item.CanvasId == canvasId && item.Id == nodeId); + existing.GenerationMode = node.GenerationMode; + existing.Summary = node.Summary; + existing.Category = node.Category; + existing.Content = node.Content; + existing.SourcePrompt = node.SourcePrompt; + existing.ContinuationPrompt = node.ContinuationPrompt; + existing.Blocks = [.. node.Blocks]; + existing.Sources = [.. node.Sources]; + existing.UpdatedAt = DateTime.UtcNow; + + var canvas = _items.First(item => item.Id == canvasId && item.UserId == userId); + canvas.UpdatedAt = existing.UpdatedAt; + return Task.FromResult(existing); + } + + public Task> AddChildCanvasNodesAsync(int userId, int canvasId, int parentNodeId, IReadOnlyList nodes) + { + var now = DateTime.UtcNow; + var inserted = nodes.Select((node, index) => + { + var created = new CanvasNode + { + Id = _nextNodeId++, + CanvasId = canvasId, + ParentNodeId = parentNodeId, + Kind = node.Kind, + Title = node.Title, + Content = node.Content, + SourcePrompt = node.SourcePrompt, + Depth = node.Depth, + SiblingOrder = index, + CreatedAt = now, + UpdatedAt = now + }; + _nodes.Add(created); + return created; + }).ToList(); + + var canvas = _items.First(item => item.Id == canvasId); + canvas.UpdatedAt = now; + return Task.FromResult>(inserted); + } + + public int GetChildNodeCount(int canvasId) + { + return _nodes.Count(item => item.CanvasId == canvasId && item.ParentNodeId != null); + } + } +} diff --git a/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs b/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs index 3a73ae2..a7eb189 100644 --- a/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs +++ b/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs @@ -38,6 +38,50 @@ public async Task GetCanvasGraph_LegacyCanvas_ReturnsLegacyMode() response.RootNodeId.Should().BeNull(); } + [Fact] + public async Task GetCanvasGraph_EmptyRootWithFoundationalChild_PromotesFoundationIntoRootResponse() + { + var repository = new InMemoryGraphStore(); + var canvas = await repository.CreateCanvasAsync(1, "Graph canvas"); + var service = CreateService(repository, new StubProviderClient(LlmProviderNames.OpenAi)); + var rootNode = await repository.EnsureRootCanvasNodeAsync(1, canvas.Id); + + await repository.AddChildCanvasNodesAsync(1, canvas.Id, rootNode.Id, + [ + new CanvasNodeCreateInput + { + Kind = "response", + Title = "Define Mixed Fruit Drink", + GenerationMode = CanvasGenerationModes.Rich, + Summary = "Provides a clear definition and outlines typical fruit combinations in mixed fruit drinks.", + Category = "Foundational Knowledge", + Content = "Mixed fruit drinks are beverages made by blending multiple fruit juices or purees into one drink.", + SourcePrompt = "What is Mixed Fruit Drink", + Depth = 1 + }, + new CanvasNodeCreateInput + { + Kind = "response", + Title = "Nutritional Value", + GenerationMode = CanvasGenerationModes.Rich, + Summary = "Explores the health benefits and nutritional content, including vitamins, sugars, and fiber.", + Category = "Health & Wellness", + Content = "This branch focuses on nutrients, sugars, and health tradeoffs.", + SourcePrompt = "What is Mixed Fruit Drink", + Depth = 1 + } + ]); + + var response = await service.GetCanvasGraphAsync(1, canvas.Id); + + var responseRoot = response.Nodes.Single(node => node.Id == rootNode.Id); + responseRoot.Summary.Should().Be("Provides a clear definition and outlines typical fruit combinations in mixed fruit drinks."); + responseRoot.Content.Should().Be("Mixed fruit drinks are beverages made by blending multiple fruit juices or purees into one drink."); + responseRoot.SourcePrompt.Should().Be("What is Mixed Fruit Drink"); + response.Nodes.Should().NotContain(node => node.Title == "Define Mixed Fruit Drink"); + response.Nodes.Should().Contain(node => node.Title == "Nutritional Value"); + } + [Fact] public async Task ExpandCanvasNode_SplitsReplyIntoChildrenAndStoresPrompt() { @@ -45,7 +89,23 @@ public async Task ExpandCanvasNode_SplitsReplyIntoChildrenAndStoresPrompt() var canvas = await repository.CreateCanvasAsync(1, "Graph canvas"); var service = CreateService( repository, - new StubProviderClient(LlmProviderNames.OpenAi, "## Alpha\nA details\n\n## Beta\nB details", "gpt-test")); + new StubProviderClient( + LlmProviderNames.OpenAi, + """ + { + "branches": [ + { + "title": "Alpha angle", + "summary": "Alpha details explain the first meaningful angle to investigate." + }, + { + "title": "Beta angle", + "summary": "Beta details explain the second meaningful angle to investigate." + } + ] + } + """, + "gpt-test")); await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234"); var rootNode = await repository.EnsureRootCanvasNodeAsync(1, canvas.Id); @@ -55,6 +115,8 @@ public async Task ExpandCanvasNode_SplitsReplyIntoChildrenAndStoresPrompt() response.Mode.Should().Be("graph"); response.Nodes.Count(node => node.ParentNodeId == rootNode.Id).Should().Be(2); response.Nodes.Where(node => node.ParentNodeId == rootNode.Id).Select(node => node.SourcePrompt).Distinct().Should().ContainSingle("Break this down"); + response.Nodes.Should().Contain(node => node.Title == "Alpha angle"); + response.Nodes.Should().Contain(node => node.Title == "Beta angle"); } [Fact] @@ -82,9 +144,242 @@ public async Task ExpandCanvasNode_UsesAncestorPathContextOnly() await service.ExpandCanvasNodeAsync(1, canvas.Id, firstLevel[0].Id, "Go deeper", null); provider.LastMessages.Should().HaveCount(2); - provider.LastMessages[0].Content.Should().Contain("Main Node"); + provider.LastMessages[0].Content.Should().Contain("Existing canvas context:"); provider.LastMessages[0].Content.Should().Contain("Parent context"); - provider.LastMessages[1].Content.Should().Be("Go deeper"); + provider.LastMessages[1].Content.Should().Contain("Original user request:"); + provider.LastMessages[1].Content.Should().Contain("Go deeper"); + } + + [Fact] + public async Task ExpandCanvasNode_EmptyRoot_DoesNotSendPlaceholderMainNodeContext() + { + var repository = new InMemoryGraphStore(); + var canvas = await repository.CreateCanvasAsync(1, "Graph canvas"); + var provider = new StubProviderClient( + LlmProviderNames.OpenAi, + """ + { + "branches": [ + { + "title": "Financial performance", + "summary": "Compare earnings growth, margins, asset quality, and return metrics." + }, + { + "title": "Digital banking", + "summary": "Compare apps, online journeys, product access, and service responsiveness." + } + ] + } + """); + var service = CreateService(repository, provider); + + await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234"); + var rootNode = await repository.EnsureRootCanvasNodeAsync(1, canvas.Id); + + await service.ExpandCanvasNodeAsync(1, canvas.Id, rootNode.Id, "HNB vs Sampath Bank", null); + + provider.LastMessages.Should().HaveCount(1); + provider.LastMessages[0].Content.Should().NotContain("Existing canvas context:"); + provider.LastMessages[0].Content.Should().NotContain("Main Node"); + provider.LastMessages[0].Content.Should().Contain("Original user request:"); + provider.LastMessages[0].Content.Should().Contain("HNB vs Sampath Bank"); + } + + [Fact] + public async Task ExpandCanvasNode_RootMainNode_StoresFoundationOnRootInsteadOfCreatingDefinitionChild() + { + var repository = new InMemoryGraphStore(); + var canvas = await repository.CreateCanvasAsync(1, "Graph canvas"); + var service = CreateService( + repository, + new StubProviderClient( + LlmProviderNames.OpenAi, + """ + { + "mainNode": { + "summary": "Neural networks are layered function approximators that learn useful patterns from data.", + "content": "Neural networks are layered systems that transform inputs through learned weights so they can model complex patterns and make predictions.", + "blocks": [ + { + "type": "description", + "text": "They learn by adjusting weights to reduce error on training examples.", + "options": [] + }, + { + "type": "analysis", + "text": "The key idea is composing simple transformations into a more expressive model.", + "options": [] + } + ], + "sources": [] + }, + "branches": [ + { + "title": "Architectures", + "summary": "Compare feedforward, convolutional, recurrent, and transformer-style designs." + }, + { + "title": "Training dynamics", + "summary": "Understand loss functions, backpropagation, optimization, and regularization." + }, + { + "title": "Practical uses", + "summary": "Connect the concept to real product, research, and decision-making use cases." + } + ] + } + """)); + + await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234"); + var rootNode = await repository.EnsureRootCanvasNodeAsync(1, canvas.Id); + + var response = await service.ExpandCanvasNodeAsync(1, canvas.Id, rootNode.Id, "Teach me neural networks", null, CanvasGenerationModes.Compact); + + var updatedRoot = response.Nodes.Single(node => node.Id == rootNode.Id); + updatedRoot.GenerationMode.Should().Be(CanvasGenerationModes.Rich); + updatedRoot.Summary.Should().Be("Neural networks are layered function approximators that learn useful patterns from data."); + updatedRoot.Content.Should().Contain("Neural networks are layered systems"); + updatedRoot.SourcePrompt.Should().Be("Teach me neural networks"); + updatedRoot.Blocks.Should().HaveCount(2); + + var childNodes = response.Nodes.Where(node => node.ParentNodeId == rootNode.Id).ToList(); + childNodes.Should().HaveCount(3); + childNodes.Select(node => node.Title).Should().BeEquivalentTo(["Architectures", "Training dynamics", "Practical uses"]); + childNodes.Select(node => node.Title).Should().NotContain(title => title.Contains("definition", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ExpandCanvasNode_RootMainNode_SecondExpansion_AppendsContentAndUpdatesLatestMetadata() + { + var repository = new InMemoryGraphStore(); + var canvas = await repository.CreateCanvasAsync(1, "Graph canvas"); + var service = CreateService( + repository, + new StubProviderClient( + LlmProviderNames.OpenAi, + [ + """ + { + "mainNode": { + "summary": "A first foundation summary.", + "content": "First foundation content explains the core concept in a grounded way.", + "blocks": [], + "sources": [] + }, + "branches": [ + { + "title": "First branch", + "summary": "Explore the first meaningful path from the main topic." + }, + { + "title": "Second branch", + "summary": "Explore the second meaningful path from the main topic." + } + ] + } + """, + """ + { + "mainNode": { + "summary": "A newer foundation summary.", + "content": "Second foundation content adds a sharper explanation and a new angle.", + "blocks": [], + "sources": [] + }, + "branches": [ + { + "title": "Third branch", + "summary": "Explore a third path that becomes relevant after the update." + }, + { + "title": "Fourth branch", + "summary": "Explore a fourth path that broadens the workspace direction." + } + ] + } + """ + ])); + + await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234"); + var rootNode = await repository.EnsureRootCanvasNodeAsync(1, canvas.Id); + + await service.ExpandCanvasNodeAsync(1, canvas.Id, rootNode.Id, "First prompt", null, CanvasGenerationModes.Compact); + var secondResponse = await service.ExpandCanvasNodeAsync(1, canvas.Id, rootNode.Id, "Second prompt", null, CanvasGenerationModes.Compact); + + var updatedRoot = secondResponse.Nodes.Single(node => node.Id == rootNode.Id); + updatedRoot.Summary.Should().Be("A newer foundation summary."); + updatedRoot.SourcePrompt.Should().Be("Second prompt"); + updatedRoot.Content.Should().Contain("First foundation content explains the core concept in a grounded way."); + updatedRoot.Content.Should().Contain("## Update"); + updatedRoot.Content.Should().Contain("Second foundation content adds a sharper explanation and a new angle."); + } + + [Fact] + public async Task ExpandCanvasNode_RootMainNode_FallbackAbsorbsDefinitionLikeBranchIntoRoot() + { + var repository = new InMemoryGraphStore(); + var canvas = await repository.CreateCanvasAsync(1, "Graph canvas"); + var service = CreateService( + repository, + new StubProviderClient( + LlmProviderNames.OpenAi, + ["{ \"branches\": [] }"], + [ + """ + ## Core Definition + Event-driven architecture coordinates components by publishing events and letting interested services react asynchronously. + + ## Service boundaries + Break the system into clear domains so events represent useful business changes instead of low-level implementation noise. + + ## Reliability patterns + Use retries, dead-letter handling, and idempotency so asynchronous processing stays dependable under failure. + """ + ])); + + await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234"); + var rootNode = await repository.EnsureRootCanvasNodeAsync(1, canvas.Id); + + var response = await service.ExpandCanvasNodeAsync(1, canvas.Id, rootNode.Id, "Explain event-driven architecture", null, CanvasGenerationModes.Compact); + + var updatedRoot = response.Nodes.Single(node => node.Id == rootNode.Id); + updatedRoot.Content.Should().Contain("Event-driven architecture coordinates components"); + updatedRoot.SourcePrompt.Should().Be("Explain event-driven architecture"); + + var childNodes = response.Nodes.Where(node => node.ParentNodeId == rootNode.Id).ToList(); + childNodes.Should().HaveCount(2); + childNodes.Select(node => node.Title).Should().NotContain(title => title.Contains("definition", StringComparison.OrdinalIgnoreCase)); + childNodes.Select(node => node.Title).Should().Contain(["Service boundaries", "Reliability patterns"]); + } + + [Fact] + public async Task ExpandCanvasNode_FallbackFiltering_RemovesLowSignalFragments() + { + var repository = new InMemoryGraphStore(); + var canvas = await repository.CreateCanvasAsync(1, "Graph canvas"); + var service = CreateService( + repository, + new StubProviderClient( + LlmProviderNames.OpenAi, + """ + Let's break it down: + + Financial performance compares earnings quality, loan growth, margins, and balance-sheet resilience between HNB and Sampath Bank. + + In context, this node path means: + + Digital banking compares mobile app quality, online onboarding, and day-to-day customer experience across the two banks. + """)); + + await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234"); + var rootNode = await repository.EnsureRootCanvasNodeAsync(1, canvas.Id); + + var response = await service.ExpandCanvasNodeAsync(1, canvas.Id, rootNode.Id, "HNB vs Sampath Bank", null); + + var childNodes = response.Nodes.Where(node => node.ParentNodeId == rootNode.Id).ToList(); + childNodes.Should().HaveCount(2); + childNodes.Select(node => node.Title).Should().NotContain(title => title.Contains("break it down", StringComparison.OrdinalIgnoreCase)); + childNodes.Select(node => node.Title).Should().NotContain(title => title.Contains("node path", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -106,6 +401,7 @@ private static TestCanvasGraphService CreateService(InMemoryGraphStore repositor return new TestCanvasGraphService( repository, repository, + new CanvasProviderResolver(repository, NullLogger.Instance), new StubEncryptionService(), clients, NullLogger.Instance); @@ -118,10 +414,11 @@ private sealed class TestCanvasGraphService : CanvasGraphService public TestCanvasGraphService( ICanvasRepository canvasRepository, ILlmRepository llmRepository, + ICanvasProviderResolver canvasProviderResolver, ILlmEncryptionService encryptionService, IEnumerable providerClients, Microsoft.Extensions.Logging.ILogger logger) - : base(canvasRepository, llmRepository, encryptionService, providerClients, logger) + : base(canvasRepository, llmRepository, canvasProviderResolver, encryptionService, providerClients, logger) { _llmService = new LlmService( canvasRepository, @@ -147,27 +444,68 @@ private sealed class StubEncryptionService : ILlmEncryptionService private sealed class StubProviderClient : ILlmProviderClient { - private readonly string _content; + private readonly Queue _chatResponses; + private readonly Queue _structuredResponses; + private readonly string _defaultChatContent; + private readonly string _defaultStructuredContent; private readonly string _model; public StubProviderClient(string provider, string content = "Assistant reply", string model = "test-model") { Provider = provider; - _content = content; + _chatResponses = new Queue(); + _structuredResponses = new Queue(); + _defaultChatContent = content; + _defaultStructuredContent = content; + _model = model; + } + + public StubProviderClient( + string provider, + IEnumerable structuredResponses, + IEnumerable? chatResponses = null, + string model = "test-model") + { + Provider = provider; + _structuredResponses = new Queue(structuredResponses); + _chatResponses = new Queue(chatResponses ?? []); + _defaultStructuredContent = _structuredResponses.LastOrDefault() ?? "Assistant reply"; + _defaultChatContent = _chatResponses.LastOrDefault() ?? _defaultStructuredContent; _model = model; } public string Provider { get; } public List LastMessages { get; } = []; - public Task SendChatAsync(string apiKey, IReadOnlyList messages, CancellationToken cancellationToken = default) + public Task> GetModelsAsync(string apiKey, CancellationToken cancellationToken = default) + { + return Task.FromResult>([new LlmProviderModel { Id = _model, DisplayName = _model }]); + } + + public Task SendChatAsync( + string apiKey, + IReadOnlyList messages, + string? modelOverride = null, + string? systemPrompt = null, + CancellationToken cancellationToken = default) { LastMessages.Clear(); LastMessages.AddRange(messages); return Task.FromResult(new LlmProviderChatResult { - Content = _content, - Model = _model + Content = _chatResponses.Count > 0 ? _chatResponses.Dequeue() : _defaultChatContent, + Model = modelOverride ?? _model + }); + } + + public Task SendStructuredJsonAsync(string apiKey, LlmProviderStructuredRequest request, string? modelOverride = null, CancellationToken cancellationToken = default) + { + LastMessages.Clear(); + LastMessages.AddRange(request.Messages); + return Task.FromResult(new LlmProviderChatResult + { + Content = _structuredResponses.Count > 0 ? _structuredResponses.Dequeue() : _defaultStructuredContent, + Model = modelOverride ?? _model }); } @@ -209,6 +547,7 @@ public Task CreateCanvasAsync(int userId, string title) ParentNodeId = null, Kind = "main", Title = "Main Node", + GenerationMode = CanvasGenerationModes.Compact, Content = string.Empty, Depth = 0, SiblingOrder = 0, @@ -270,6 +609,24 @@ public Task> GetCanvasNodesAsync(int userId, int canva return Task.FromResult(_nodes.FirstOrDefault(item => item.CanvasId == canvasId && item.Id == nodeId)); } + public Task UpdateCanvasNodeAsync(int userId, int canvasId, int nodeId, CanvasNodeUpdateInput node) + { + var existing = _nodes.First(item => item.CanvasId == canvasId && item.Id == nodeId); + existing.GenerationMode = node.GenerationMode; + existing.Summary = node.Summary; + existing.Category = node.Category; + existing.Content = node.Content; + existing.SourcePrompt = node.SourcePrompt; + existing.ContinuationPrompt = node.ContinuationPrompt; + existing.Blocks = [.. node.Blocks]; + existing.Sources = [.. node.Sources]; + existing.UpdatedAt = DateTime.UtcNow; + + var canvas = _canvases.First(item => item.UserId == userId && item.Id == canvasId); + canvas.UpdatedAt = existing.UpdatedAt; + return Task.FromResult(existing); + } + public Task> AddChildCanvasNodesAsync(int userId, int canvasId, int parentNodeId, IReadOnlyList nodes) { var nextOrder = _nodes.Where(item => item.CanvasId == canvasId && item.ParentNodeId == parentNodeId).Select(item => item.SiblingOrder).DefaultIfEmpty(-1).Max() + 1; @@ -284,8 +641,14 @@ public Task> AddChildCanvasNodesAsync(int userId, int ParentNodeId = parentNodeId, Kind = node.Kind, Title = node.Title, + GenerationMode = node.GenerationMode, + Summary = node.Summary, + Category = node.Category, Content = node.Content, SourcePrompt = node.SourcePrompt, + ContinuationPrompt = node.ContinuationPrompt, + Blocks = node.Blocks, + Sources = node.Sources, Depth = node.Depth, SiblingOrder = nextOrder++, CreatedAt = DateTime.UtcNow, diff --git a/backend/Coliee.API.Tests/Services/CanvasServiceTests.cs b/backend/Coliee.API.Tests/Services/CanvasServiceTests.cs index 96d8948..612e292 100644 --- a/backend/Coliee.API.Tests/Services/CanvasServiceTests.cs +++ b/backend/Coliee.API.Tests/Services/CanvasServiceTests.cs @@ -9,30 +9,6 @@ namespace Coliee.API.Tests.Services; public class CanvasServiceTests { - [Fact] - public async Task CreateCanvas_TrimsAndGeneratesReadableTitle() - { - var repository = new InMemoryCanvasRepository(); - var service = new CanvasService(repository, NullLogger.Instance); - - var response = await service.CreateCanvasAsync(1, " Plan launch\nchecklist for the Q2 release "); - - response.Title.Should().Be("Plan launch checklist for the Q2 release"); - response.Id.Should().BeGreaterThan(0); - } - - [Fact] - public async Task CreateCanvas_WhitespaceOnlyPrompt_ThrowsBadRequest() - { - var repository = new InMemoryCanvasRepository(); - var service = new CanvasService(repository, NullLogger.Instance); - - var action = async () => await service.CreateCanvasAsync(1, " "); - - var exception = await action.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(400); - } - [Fact] public async Task GetCanvases_ReturnsItemsOrderedByMostRecentlyUpdated() { @@ -227,6 +203,21 @@ public Task> GetCanvasNodesAsync(int userId, int canva return Task.FromResult(_nodes.FirstOrDefault(item => item.CanvasId == canvasId && item.Id == nodeId)); } + public Task UpdateCanvasNodeAsync(int userId, int canvasId, int nodeId, CanvasNodeUpdateInput node) + { + var existing = _nodes.First(item => item.CanvasId == canvasId && item.Id == nodeId); + existing.GenerationMode = node.GenerationMode; + existing.Summary = node.Summary; + existing.Category = node.Category; + existing.Content = node.Content; + existing.SourcePrompt = node.SourcePrompt; + existing.ContinuationPrompt = node.ContinuationPrompt; + existing.Blocks = [.. node.Blocks]; + existing.Sources = [.. node.Sources]; + existing.UpdatedAt = DateTime.UtcNow; + return Task.FromResult(existing); + } + public Task> AddChildCanvasNodesAsync(int userId, int canvasId, int parentNodeId, IReadOnlyList nodes) { var nextOrder = _nodes.Where(item => item.CanvasId == canvasId && item.ParentNodeId == parentNodeId).Select(item => item.SiblingOrder).DefaultIfEmpty(-1).Max() + 1; diff --git a/backend/Coliee.API.Tests/Services/ChatSessionServiceTests.cs b/backend/Coliee.API.Tests/Services/ChatSessionServiceTests.cs new file mode 100644 index 0000000..e38bc6b --- /dev/null +++ b/backend/Coliee.API.Tests/Services/ChatSessionServiceTests.cs @@ -0,0 +1,973 @@ +using System.Text.Json; +using Coliee.API.Data; +using Coliee.API.DTOs; +using Coliee.API.Models; +using Coliee.API.Services; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Coliee.API.Tests.Services; + +public class ChatSessionServiceTests +{ + [Fact] + public async Task CreateSession_CreatesRootNodeAndFirstAssistantReply() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService(repository, new StubProviderClient(LlmProviderNames.Gemini, "First reply", "gemini-1.5-flash")); + await serviceDependencies(repository, "gemini"); + + var response = await service.CreateSessionAsync(1, null, "Plan the launch", null); + + response.Graph.Session.Title.Should().Be("Plan the launch"); + response.Graph.RootNodeId.Should().Be(response.Node.NodeId); + response.Node.LocalMessages.Select(item => item.Content).Should().ContainInOrder("Plan the launch", "First reply"); + + static async Task serviceDependencies(InMemoryChatSessionStore repository, string provider) + { + await repository.UpsertProviderKeyAsync(1, provider, $"enc::{provider}", $"****{provider[..4]}"); + await repository.SetDefaultProviderAsync(1, provider); + } + } + + [Fact] + public async Task SendNodeMessage_AppendsOnlyToSelectedNode() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService(repository, new QueueProviderClient(LlmProviderNames.Gemini, [ + new LlmProviderChatResult { Content = "First reply", Model = "gemini-1.5-flash" }, + new LlmProviderChatResult { Content = "Next reply", Model = "gemini-1.5-flash" } + ])); + await ConfigureGeminiAsync(repository); + + var created = await service.CreateSessionAsync(1, null, "Plan the launch", null); + var rootNodeId = created.Graph.RootNodeId; + + var response = await service.SendNodeMessageAsync(1, created.Graph.Session.Id, rootNodeId, "Give me milestones", null, null); + + response.Node.LocalMessages.Select(item => item.Content).Should().ContainInOrder( + "Plan the launch", + "First reply", + "Give me milestones", + "Next reply"); + } + + [Fact] + public async Task UpdateMessage_OnlyAllowsLatestUserPrompt() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService(repository, new QueueProviderClient(LlmProviderNames.Gemini, [ + new LlmProviderChatResult { Content = "First reply", Model = "gemini-1.5-flash" }, + new LlmProviderChatResult { Content = "Second reply", Model = "gemini-1.5-flash" } + ])); + await ConfigureGeminiAsync(repository); + + var created = await service.CreateSessionAsync(1, null, "Plan the launch", null); + await service.SendNodeMessageAsync(1, created.Graph.Session.Id, created.Graph.RootNodeId, "Give me milestones", null, null); + var firstUserMessageId = created.Node.LocalMessages.Single(item => item.Role == "user").Id; + + var action = async () => await service.UpdateMessageAsync(1, created.Graph.Session.Id, created.Graph.RootNodeId, firstUserMessageId, new UpdateChatCanvasMessageRequest + { + Content = "Edited first prompt" + }); + + var exception = await action.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(400); + } + + [Fact] + public async Task UpdateMessage_PersistsEditedPromptAndPreview() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService(repository, new QueueProviderClient(LlmProviderNames.Gemini, [ + new LlmProviderChatResult { Content = "Original reply", Model = "gemini-1.5-flash" } + ])); + await ConfigureGeminiAsync(repository); + + var created = await service.CreateSessionAsync(1, null, "Plan the launch", null); + var userMessageId = created.Node.LocalMessages.Single(item => item.Role == "user").Id; + + var updated = await service.UpdateMessageAsync(1, created.Graph.Session.Id, created.Graph.RootNodeId, userMessageId, new UpdateChatCanvasMessageRequest + { + Content = "Edited prompt with better detail" + }); + + updated.Node.LocalMessages.First(item => item.Role == "user").Content.Should().Be("Edited prompt with better detail"); + updated.Node.Preview.Should().Contain("Edited prompt with better detail"); + } + + [Fact] + public async Task SendNodeMessage_WithModelOverride_PassesSelectedModelToProvider() + { + var repository = new InMemoryChatSessionStore(); + var provider = new QueueProviderClient(LlmProviderNames.Gemini, [ + new LlmProviderChatResult { Content = "First reply", Model = "gemini-1.5-flash" }, + new LlmProviderChatResult { Content = "Next reply", Model = "gemini-2.0-flash" } + ]); + var service = CreateService(repository, provider); + await ConfigureGeminiAsync(repository); + + var created = await service.CreateSessionAsync(1, null, "Plan the launch", null); + + await service.SendNodeMessageAsync(1, created.Graph.Session.Id, created.Graph.RootNodeId, "Give me milestones", null, null, "gemini-2.0-flash"); + + provider.LastModelOverride.Should().Be("gemini-2.0-flash"); + } + + [Fact] + public async Task CreateBranch_CreatesChildNodeAndResolvedHistoryStopsAtBranchPoint() + { + var repository = new InMemoryChatSessionStore(); + var provider = new QueueProviderClient( + LlmProviderNames.Gemini, + [ + new LlmProviderChatResult { Content = "Root reply", Model = "gemini-1.5-flash" }, + new LlmProviderChatResult { Content = "Second root reply", Model = "gemini-1.5-flash" }, + new LlmProviderChatResult { Content = "Branch reply", Model = "gemini-1.5-flash" } + ], + [ + new LlmProviderChatResult + { + Content = """ + { + "facts": [ + "Plan the launch" + ], + "decisions": [ + "Keep the rollout staged" + ], + "openQuestions": [ + "What is the budget?" + ], + "carryForwardInstructions": [ + "Continue from the selected assistant reply." + ] + } + """, + Model = "gemini-1.5-flash" + } + ]); + var service = CreateService(repository, provider); + await ConfigureGeminiAsync(repository); + + var created = await service.CreateSessionAsync(1, null, "Plan the launch", null); + var rootNodeId = created.Graph.RootNodeId; + var continued = await service.SendNodeMessageAsync(1, created.Graph.Session.Id, rootNodeId, "Give me milestones", null, null); + var branchFromMessageId = continued.Node.LocalMessages + .Single(item => item.Content == "Root reply") + .Id; + + var branched = await service.CreateBranchAsync(1, created.Graph.Session.Id, rootNodeId, branchFromMessageId, "Explore budget risks", null); + + branched.Graph.Nodes.Should().HaveCount(2); + branched.Node.ParentNodeId.Should().Be(rootNodeId); + branched.Node.BranchFromMessageId.Should().Be(branchFromMessageId); + branched.Node.ResolvedMessages.Select(item => item.Content).Should().ContainInOrder( + "Plan the launch", + "Root reply", + "Explore budget risks", + "Branch reply"); + branched.Node.ResolvedMessages.Select(item => item.Content).Should().NotContain("Give me milestones"); + (await repository.GetNodeContextSnapshotAsync(1, created.Graph.Session.Id, branched.Node.NodeId))!.BoundaryMessageId.Should().Be(branchFromMessageId); + } + + [Fact] + public async Task CreateBranch_WhenMessageIsNotAssistant_ThrowsBadRequest() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService(repository, new StubProviderClient(LlmProviderNames.Gemini, "Root reply", "gemini-1.5-flash")); + await ConfigureGeminiAsync(repository); + + var created = await service.CreateSessionAsync(1, null, "Plan the launch", null); + var userMessageId = created.Node.LocalMessages.First(item => item.Role == "user").Id; + + var action = async () => await service.CreateBranchAsync( + 1, + created.Graph.Session.Id, + created.Graph.RootNodeId, + userMessageId, + "Explore budget risks", + null); + + var exception = await action.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(400); + } + + [Fact] + public async Task SaveLayout_PersistsNodePositions() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService(repository, new StubProviderClient(LlmProviderNames.Gemini, "Root reply", "gemini-1.5-flash")); + await ConfigureGeminiAsync(repository); + + var created = await service.CreateSessionAsync(1, null, "Plan the launch", null); + var graph = await service.SaveLayoutAsync(1, created.Graph.Session.Id, [ + new ChatNodeLayoutUpdateInput + { + NodeId = created.Graph.RootNodeId, + PositionX = 240, + PositionY = 96 + } + ]); + + graph.Nodes.Single(item => item.Id == created.Graph.RootNodeId).PositionX.Should().Be(240); + graph.Nodes.Single(item => item.Id == created.Graph.RootNodeId).PositionY.Should().Be(96); + } + + [Fact] + public async Task CreateBlankSession_UsesExplicitTitleWithoutProviderCall() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService(repository, new StubProviderClient(LlmProviderNames.Gemini, "Unused", "gemini-3.1-flash-lite-preview")); + + var response = await service.CreateSessionAsync(1, "New Canvas", null, null); + + response.Graph.Session.Title.Should().Be("New Canvas"); + response.Node.Title.Should().Be("Main Node"); + response.Node.LocalMessages.Should().BeEmpty(); + } + + [Fact] + public async Task DeleteSession_WhenOnlyOneCanvasRemains_ThrowsConflict() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService(repository, new StubProviderClient(LlmProviderNames.Gemini, "Unused", "gemini-1.5-flash")); + + var created = await service.CreateSessionAsync(1, "Untitled Canvas", null, null); + + var action = async () => await service.DeleteSessionAsync(1, created.Graph.Session.Id); + + var exception = await action.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(409); + } + + [Fact] + public async Task SendNodeMessage_AppendsFormattingInstructionToProviderInputForTextOnlyChats() + { + var repository = new InMemoryChatSessionStore(); + var provider = new CapturingProviderClient(LlmProviderNames.Gemini, "Formatted reply", "gemini-3.1-flash-lite-preview"); + var service = CreateService(repository, provider); + await ConfigureGeminiAsync(repository); + + var created = await service.CreateSessionAsync(1, null, "Plan the launch", null); + + await service.SendNodeMessageAsync(1, created.Graph.Session.Id, created.Graph.RootNodeId, "Give me milestones", null, null); + + provider.LastSystemPrompt.Should().Contain("Formatting instruction:"); + provider.LastMessages.Should().NotBeNull(); + provider.LastMessages!.Last().Content.Should().Be("Give me milestones"); + } + + [Fact] + public async Task SendNodeMessage_WhenContextExceedsBudget_CompactsHistoryIntoHiddenSnapshot() + { + var repository = new InMemoryChatSessionStore(); + var provider = new QueueProviderClient( + LlmProviderNames.Gemini, + [ + new LlmProviderChatResult { Content = "Root reply", Model = "gemini-1.5-flash" }, + new LlmProviderChatResult { Content = "Compacted reply", Model = "gemini-1.5-flash" } + ], + [ + new LlmProviderChatResult + { + Content = """ + { + "facts": [ + "Plan the launch" + ], + "decisions": [ + "Use a phased rollout" + ], + "openQuestions": [ + "How much budget is available?" + ], + "carryForwardInstructions": [ + "Keep the response focused on the latest prompt." + ] + } + """, + Model = "gemini-1.5-flash" + } + ]); + var service = CreateService( + repository, + provider, + new Dictionary + { + ["Llm:ContextCompaction:Default:MaxPromptChars"] = "50", + ["Llm:ContextCompaction:Default:ReserveChars"] = "0", + ["Llm:ContextCompaction:Default:FallbackWindowMessages"] = "2" + }); + await ConfigureGeminiAsync(repository); + + var created = await service.CreateSessionAsync(1, null, "Plan the launch", null); + var response = await service.SendNodeMessageAsync( + 1, + created.Graph.Session.Id, + created.Graph.RootNodeId, + "Give me a detailed breakdown of the risks, milestones, dependencies, and rollout considerations for this launch.", + null, + null); + + provider.LastSystemPrompt.Should().Contain("Compacted memory:"); + provider.LastMessages.Select(item => item.Content).Should().ContainSingle(); + response.Node.LocalMessages.Select(item => item.Content).Should().ContainInOrder( + "Plan the launch", + "Root reply", + "Give me a detailed breakdown of the risks, milestones, dependencies, and rollout considerations for this launch.", + "Compacted reply"); + + var snapshot = await repository.GetNodeContextSnapshotAsync(1, created.Graph.Session.Id, created.Graph.RootNodeId); + snapshot.Should().NotBeNull(); + snapshot!.BoundaryMessageId.Should().Be(response.Node.LocalMessages.First(item => item.Content == "Root reply").Id); + } + + [Fact] + public async Task PreviewGuestAsync_UsesRequestedModelWhenBuiltInGuestKeyExists() + { + var repository = new InMemoryChatSessionStore(); + var provider = new QueueProviderClient(LlmProviderNames.OpenAi, [ + new LlmProviderChatResult { Content = "Guest reply", Model = "gpt-5.2" } + ]); + var service = CreateService( + repository, + provider, + new Dictionary + { + ["Llm:GuestPreview:OpenAI:ApiKey"] = "guest-openai-key", + ["Llm:GuestPreview:OpenAI:Model"] = "gpt-5.1" + }); + + var response = await service.PreviewGuestAsync(new GuestChatPreviewRequest + { + ActionType = "chat", + Prompt = "Explain the roadmap", + ModelId = "gpt-5.2" + }); + + response.Provider.Should().Be(LlmProviderNames.OpenAi); + response.Model.Should().Be("gpt-5.2"); + provider.LastModelOverride.Should().Be("gpt-5.2"); + response.Message.Should().Be("Guest reply"); + } + + [Fact] + public async Task PreviewGuestAsync_WhenGuestCredentialsAreMissing_ReturnsFallbackPreview() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService( + repository, + new StubProviderClient(LlmProviderNames.Gemini, "Unused", "gemini-3.1-flash-lite-preview"), + new Dictionary()); + + var response = await service.PreviewGuestAsync(new GuestChatPreviewRequest + { + ActionType = "chat", + Prompt = "Outline the launch plan", + ModelId = "gemini-3.1-flash-lite-preview" + }); + + response.Provider.Should().Be(LlmProviderNames.Gemini); + response.Model.Should().Be("gemini-3.1-flash-lite-preview"); + response.Message.Should().Contain("guest preview interpreted your prompt"); + } + + private static async Task ConfigureGeminiAsync(InMemoryChatSessionStore repository) + { + await repository.UpsertProviderKeyAsync(1, LlmProviderNames.Gemini, "enc::AIza-gemini", "****9999"); + await repository.SetDefaultProviderAsync(1, LlmProviderNames.Gemini); + } + + private static ChatSessionService CreateService( + InMemoryChatSessionStore repository, + ILlmProviderClient providerClient, + IReadOnlyDictionary? configurationValues = null) + { + var configurationData = new Dictionary + { + ["Llm:GuestPreview:Provider"] = LlmProviderNames.Gemini, + ["Llm:GuestPreview:Model"] = "gemini-3.1-flash-lite-preview" + }; + + if (configurationValues != null) + { + foreach (var entry in configurationValues) + configurationData[entry.Key] = entry.Value; + } + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configurationData) + .Build(); + + return new ChatSessionService( + repository, + repository, + new CanvasProviderResolver(repository, NullLogger.Instance), + new StubEncryptionService(), + new ChatNodeContextCompactionService(configuration, NullLogger.Instance), + [providerClient], + configuration, + NullLogger.Instance); + } + + private sealed class StubEncryptionService : ILlmEncryptionService + { + public string Encrypt(string plaintext) => $"enc::{plaintext}"; + + public string Decrypt(string ciphertext) => ciphertext.Replace("enc::", string.Empty, StringComparison.Ordinal); + } + + private sealed class StubProviderClient : ILlmProviderClient + { + private readonly string _content; + private readonly string _model; + + public StubProviderClient(string provider, string content, string model) + { + Provider = provider; + _content = content; + _model = model; + } + + public string Provider { get; } + public string? LastSystemPrompt { get; private set; } + + public Task> GetModelsAsync(string apiKey, CancellationToken cancellationToken = default) + { + return Task.FromResult>([new LlmProviderModel { Id = _model, DisplayName = _model }]); + } + + public Task SendChatAsync( + string apiKey, + IReadOnlyList messages, + string? modelOverride = null, + string? systemPrompt = null, + CancellationToken cancellationToken = default) + { + LastSystemPrompt = systemPrompt; + return Task.FromResult(new LlmProviderChatResult + { + Content = _content, + Model = modelOverride ?? _model + }); + } + + public Task SendStructuredJsonAsync(string apiKey, LlmProviderStructuredRequest request, string? modelOverride = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new LlmProviderChatResult + { + Content = _content, + Model = modelOverride ?? _model + }); + } + + public Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class QueueProviderClient : ILlmProviderClient + { + private readonly Queue _chatResults; + private readonly Queue _structuredResults; + + public QueueProviderClient(string provider, IReadOnlyList results) + : this(provider, results, []) + { + } + + public QueueProviderClient( + string provider, + IReadOnlyList chatResults, + IReadOnlyList structuredResults) + { + Provider = provider; + _chatResults = new Queue(chatResults); + _structuredResults = new Queue(structuredResults); + } + + public string Provider { get; } + public string? LastModelOverride { get; private set; } + public string? LastSystemPrompt { get; private set; } + public IReadOnlyList LastMessages { get; private set; } = []; + + public Task> GetModelsAsync(string apiKey, CancellationToken cancellationToken = default) + { + var model = _chatResults.Count > 0 ? _chatResults.Peek().Model : (_structuredResults.Count > 0 ? _structuredResults.Peek().Model : "test-model"); + return Task.FromResult>([new LlmProviderModel { Id = model, DisplayName = model }]); + } + + public Task SendChatAsync( + string apiKey, + IReadOnlyList messages, + string? modelOverride = null, + string? systemPrompt = null, + CancellationToken cancellationToken = default) + { + LastModelOverride = modelOverride; + LastSystemPrompt = systemPrompt; + LastMessages = messages; + return Task.FromResult(_chatResults.Dequeue()); + } + + public Task SendStructuredJsonAsync(string apiKey, LlmProviderStructuredRequest request, string? modelOverride = null, CancellationToken cancellationToken = default) + { + LastModelOverride = modelOverride; + return Task.FromResult(_structuredResults.Count > 0 ? _structuredResults.Dequeue() : _chatResults.Dequeue()); + } + + public Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class CapturingProviderClient : ILlmProviderClient + { + private readonly string _content; + private readonly string _model; + + public CapturingProviderClient(string provider, string content, string model) + { + Provider = provider; + _content = content; + _model = model; + } + + public string Provider { get; } + public IReadOnlyList? LastMessages { get; private set; } + public string? LastModelOverride { get; private set; } + public string? LastSystemPrompt { get; private set; } + + public Task> GetModelsAsync(string apiKey, CancellationToken cancellationToken = default) + { + return Task.FromResult>([new LlmProviderModel { Id = _model, DisplayName = _model }]); + } + + public Task SendChatAsync( + string apiKey, + IReadOnlyList messages, + string? modelOverride = null, + string? systemPrompt = null, + CancellationToken cancellationToken = default) + { + LastMessages = messages; + LastModelOverride = modelOverride; + LastSystemPrompt = systemPrompt; + + return Task.FromResult(new LlmProviderChatResult + { + Content = _content, + Model = modelOverride ?? _model + }); + } + + public Task SendStructuredJsonAsync(string apiKey, LlmProviderStructuredRequest request, string? modelOverride = null, CancellationToken cancellationToken = default) + { + LastModelOverride = modelOverride; + + return Task.FromResult(new LlmProviderChatResult + { + Content = _content, + Model = modelOverride ?? _model + }); + } + + public Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class InMemoryChatSessionStore : IChatSessionRepository, ILlmRepository + { + private readonly List _sessions = []; + private readonly List _nodes = []; + private readonly List _messages = []; + private readonly List _providerKeys = []; + private readonly List _mainNodeMessages = []; + private readonly Dictionary _conversationIdsByCanvasId = []; + private int _nextSessionId = 1; + private int _nextNodeId = 1; + private int _nextMessageId = 1; + private int _nextConversationId = 1; + + public Task CreateSessionAsync(int userId, string title) + { + var session = new ChatSession + { + Id = _nextSessionId++, + UserId = userId, + Title = title, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + _sessions.Add(session); + return Task.FromResult(session); + } + + public Task> GetSessionsAsync(int userId) + { + return Task.FromResult>( + _sessions + .Where(item => item.UserId == userId) + .OrderByDescending(item => item.UpdatedAt) + .ThenByDescending(item => item.Id) + .ToList()); + } + + public Task GetSessionAsync(int userId, int sessionId) + { + return Task.FromResult(_sessions.FirstOrDefault(item => item.UserId == userId && item.Id == sessionId)); + } + + public Task RenameSessionAsync(int userId, int sessionId, string title) + { + var session = _sessions.First(item => item.UserId == userId && item.Id == sessionId); + session.Title = title; + session.UpdatedAt = DateTime.UtcNow; + return Task.FromResult(session); + } + + public Task DeleteSessionAsync(int userId, int sessionId) + { + _sessions.RemoveAll(item => item.UserId == userId && item.Id == sessionId); + var deletedNodeIds = _nodes.Where(item => item.SessionId == sessionId).Select(item => item.Id).ToHashSet(); + _nodes.RemoveAll(item => item.SessionId == sessionId); + _messages.RemoveAll(item => deletedNodeIds.Contains(item.NodeId)); + return Task.CompletedTask; + } + + public Task EnsureRootNodeAsync(int userId, int sessionId, string title) + { + var existing = _nodes.FirstOrDefault(item => item.SessionId == sessionId && item.ParentNodeId == null); + if (existing != null) + return Task.FromResult(existing); + + var root = new ChatNode + { + Id = _nextNodeId++, + SessionId = sessionId, + ParentNodeId = null, + BranchFromMessageId = null, + NodeType = ChatNodeTypes.Question, + Title = title, + Preview = string.Empty, + Content = string.Empty, + PositionX = 400, + PositionY = 300, + Width = 600, + Height = 520, + IsCollapsed = false, + ExpandedWidth = 600, + ExpandedHeight = 520, + PreferredProvider = null, + PreferredModel = null, + ContextBoundaryMessageId = null, + ContextSnapshotJson = null, + ContextSnapshotUpdatedAt = null, + SiblingOrder = 0, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + _nodes.Add(root); + return Task.FromResult(root); + } + + public Task> GetNodesAsync(int userId, int sessionId) + { + return Task.FromResult>( + _nodes + .Where(item => item.SessionId == sessionId) + .OrderBy(item => item.ParentNodeId) + .ThenBy(item => item.SiblingOrder) + .ThenBy(item => item.Id) + .ToList()); + } + + public Task GetNodeAsync(int userId, int sessionId, int nodeId) + { + return Task.FromResult(_nodes.FirstOrDefault(item => item.SessionId == sessionId && item.Id == nodeId)); + } + + public Task CreateNodeAsync(int userId, int sessionId, ChatNodeCreateInput node) + { + var siblingOrder = _nodes + .Where(item => item.SessionId == sessionId && item.ParentNodeId == node.ParentNodeId) + .Select(item => item.SiblingOrder) + .DefaultIfEmpty(-1) + .Max() + 1; + + var created = new ChatNode + { + Id = _nextNodeId++, + SessionId = sessionId, + ParentNodeId = node.ParentNodeId, + BranchFromMessageId = node.BranchFromMessageId, + NodeType = node.NodeType, + Title = node.Title, + Preview = node.Preview, + Content = node.Content, + PositionX = node.PositionX, + PositionY = node.PositionY, + Width = node.Width, + Height = node.Height, + IsCollapsed = node.IsCollapsed, + ExpandedWidth = node.ExpandedWidth, + ExpandedHeight = node.ExpandedHeight, + PreferredProvider = node.PreferredProvider, + PreferredModel = node.PreferredModel, + ContextBoundaryMessageId = null, + ContextSnapshotJson = null, + ContextSnapshotUpdatedAt = null, + SiblingOrder = siblingOrder, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + _nodes.Add(created); + return Task.FromResult(created); + } + + public Task UpdateNodeAsync(int userId, int sessionId, int nodeId, ChatNodeUpdateInput update) + { + var node = _nodes.First(item => item.SessionId == sessionId && item.Id == nodeId); + node.NodeType = update.NodeType; + node.Title = update.Title; + node.Preview = update.Preview; + node.Content = update.Content; + node.PositionX = update.PositionX; + node.PositionY = update.PositionY; + node.Width = update.Width; + node.Height = update.Height; + node.IsCollapsed = update.IsCollapsed; + node.ExpandedWidth = update.ExpandedWidth; + node.ExpandedHeight = update.ExpandedHeight; + node.PreferredProvider = update.PreferredProvider; + node.PreferredModel = update.PreferredModel; + node.UpdatedAt = DateTime.UtcNow; + var session = _sessions.First(item => item.Id == sessionId && item.UserId == userId); + session.UpdatedAt = DateTime.UtcNow; + return Task.FromResult(node); + } + + public Task GetNodeContextSnapshotAsync(int userId, int sessionId, int nodeId) + { + var node = _nodes.FirstOrDefault(item => item.SessionId == sessionId && item.Id == nodeId); + if (node == null || string.IsNullOrWhiteSpace(node.ContextSnapshotJson)) + return Task.FromResult(null); + + return Task.FromResult(JsonSerializer.Deserialize(node.ContextSnapshotJson)); + } + + public Task UpdateNodeContextSnapshotAsync(int userId, int sessionId, int nodeId, int boundaryMessageId, string snapshotJson) + { + var node = _nodes.First(item => item.SessionId == sessionId && item.Id == nodeId); + node.ContextBoundaryMessageId = boundaryMessageId; + node.ContextSnapshotJson = snapshotJson; + node.ContextSnapshotUpdatedAt = DateTime.UtcNow; + node.UpdatedAt = DateTime.UtcNow; + var session = _sessions.First(item => item.Id == sessionId && item.UserId == userId); + session.UpdatedAt = DateTime.UtcNow; + return Task.FromResult(node); + } + + public Task SaveNodeLayoutsAsync(int userId, int sessionId, IReadOnlyList nodes) + { + foreach (var update in nodes) + { + var node = _nodes.First(item => item.SessionId == sessionId && item.Id == update.NodeId); + node.PositionX = update.PositionX; + node.PositionY = update.PositionY; + } + + return Task.CompletedTask; + } + + public Task DeleteNodeAsync(int userId, int sessionId, int nodeId) + { + var target = _nodes.FirstOrDefault(item => item.SessionId == sessionId && item.Id == nodeId); + if (target == null || target.ParentNodeId == null) + return Task.FromResult(false); + + var toDelete = new HashSet { nodeId }; + var changed = true; + while (changed) + { + changed = false; + foreach (var child in _nodes.Where(item => item.SessionId == sessionId && item.ParentNodeId.HasValue && toDelete.Contains(item.ParentNodeId.Value)).ToList()) + { + if (toDelete.Add(child.Id)) + changed = true; + } + } + + _nodes.RemoveAll(item => item.SessionId == sessionId && toDelete.Contains(item.Id)); + _messages.RemoveAll(item => toDelete.Contains(item.NodeId)); + return Task.FromResult(true); + } + + public Task> GetNodeMessagesAsync(int userId, int sessionId, int nodeId) + { + return Task.FromResult>( + _messages.Where(item => item.NodeId == nodeId).OrderBy(item => item.Id).ToList()); + } + + public Task GetNodeMessageAsync(int userId, int sessionId, int nodeId, int messageId) + { + return Task.FromResult(_messages.FirstOrDefault(item => item.NodeId == nodeId && item.Id == messageId)); + } + + public Task AddMessageAsync(int userId, int sessionId, int nodeId, string role, string content, string? imageUrl, string? provider, string? model) + { + var message = new ChatMessage + { + Id = _nextMessageId++, + NodeId = nodeId, + Role = role, + Content = content, + ImageUrl = imageUrl, + Provider = provider, + Model = model, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + _messages.Add(message); + + var node = _nodes.First(item => item.SessionId == sessionId && item.Id == nodeId); + node.UpdatedAt = DateTime.UtcNow; + var session = _sessions.First(item => item.UserId == userId && item.Id == sessionId); + session.UpdatedAt = DateTime.UtcNow; + + return Task.FromResult(message); + } + + public Task UpdateMessageAsync(int userId, int sessionId, int nodeId, int messageId, string content) + { + var message = _messages.First(item => item.NodeId == nodeId && item.Id == messageId); + message.Content = content; + message.UpdatedAt = DateTime.UtcNow; + + var node = _nodes.First(item => item.SessionId == sessionId && item.Id == nodeId); + node.UpdatedAt = DateTime.UtcNow; + var session = _sessions.First(item => item.UserId == userId && item.Id == sessionId); + session.UpdatedAt = DateTime.UtcNow; + + return Task.FromResult(message); + } + + public Task ReassignMessagesAsync(int userId, int sessionId, int sourceNodeId, int targetNodeId, IReadOnlyList messageIds) + { + var normalizedIds = messageIds.ToHashSet(); + var sourceMessages = _messages + .Where(item => item.NodeId == sourceNodeId && normalizedIds.Contains(item.Id)) + .ToList(); + if (sourceMessages.Count != normalizedIds.Count) + throw new InvalidOperationException("Message reassignment was incomplete."); + + foreach (var message in sourceMessages) + { + message.NodeId = targetNodeId; + message.UpdatedAt = DateTime.UtcNow; + } + + var now = DateTime.UtcNow; + var sourceNode = _nodes.First(item => item.SessionId == sessionId && item.Id == sourceNodeId); + sourceNode.UpdatedAt = now; + var targetNode = _nodes.First(item => item.SessionId == sessionId && item.Id == targetNodeId); + targetNode.UpdatedAt = now; + var session = _sessions.First(item => item.UserId == userId && item.Id == sessionId); + session.UpdatedAt = now; + + return Task.CompletedTask; + } + + public Task> GetProviderKeysAsync(int userId) + { + return Task.FromResult>( + _providerKeys.Where(item => item.UserId == userId).ToList()); + } + + public Task GetProviderKeyAsync(int userId, string provider) + { + return Task.FromResult(_providerKeys.FirstOrDefault(item => item.UserId == userId && item.Provider == provider)); + } + + public Task UpsertProviderKeyAsync(int userId, string provider, string encryptedApiKey, string maskedLast4) + { + var existing = _providerKeys.FirstOrDefault(item => item.UserId == userId && item.Provider == provider); + if (existing == null) + { + _providerKeys.Add(new LlmProviderKeyRecord + { + UserId = userId, + Provider = provider, + EncryptedApiKey = encryptedApiKey, + MaskedLast4 = maskedLast4, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }); + } + else + { + existing.EncryptedApiKey = encryptedApiKey; + existing.MaskedLast4 = maskedLast4; + existing.UpdatedAt = DateTime.UtcNow; + } + + return Task.CompletedTask; + } + + public Task DeleteProviderKeyAsync(int userId, string provider) + { + _providerKeys.RemoveAll(item => item.UserId == userId && item.Provider == provider); + return Task.CompletedTask; + } + + public Task SetDefaultProviderAsync(int userId, string? provider) + { + foreach (var key in _providerKeys.Where(item => item.UserId == userId)) + key.IsDefault = key.Provider == provider; + + return Task.CompletedTask; + } + + public Task MarkProviderTestedAsync(int userId, string provider, DateTime testedAtUtc) + { + var existing = _providerKeys.First(item => item.UserId == userId && item.Provider == provider); + existing.LastTestedAt = testedAtUtc; + return Task.CompletedTask; + } + + public Task GetOrCreateMainNodeConversationAsync(int userId, int canvasId) + { + if (!_conversationIdsByCanvasId.TryGetValue(canvasId, out var conversationId)) + { + conversationId = _nextConversationId++; + _conversationIdsByCanvasId[canvasId] = conversationId; + } + + return Task.FromResult(conversationId); + } + + public Task GetMainNodeConversationIdAsync(int userId, int canvasId) + { + return Task.FromResult(_conversationIdsByCanvasId.GetValueOrDefault(canvasId)); + } + + public Task> GetMainNodeMessagesAsync(int userId, int canvasId) + { + return Task.FromResult>(_mainNodeMessages); + } + + public Task AddMainNodeMessageAsync(int userId, int canvasId, string role, string content, string? provider, string? model) + { + var nextId = _mainNodeMessages.Count + 1; + _mainNodeMessages.Add(new MainNodeMessage + { + Id = nextId, + ConversationId = canvasId, + Role = role, + Content = content, + Provider = provider, + Model = model, + CreatedAt = DateTime.UtcNow + }); + return Task.FromResult(nextId); + } + } +} diff --git a/backend/Coliee.API.Tests/Services/LlmProviderClientTests.cs b/backend/Coliee.API.Tests/Services/LlmProviderClientTests.cs index d7c9dd7..b37122b 100644 --- a/backend/Coliee.API.Tests/Services/LlmProviderClientTests.cs +++ b/backend/Coliee.API.Tests/Services/LlmProviderClientTests.cs @@ -2,6 +2,7 @@ using System.Text; using Coliee.API.Services; using FluentAssertions; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -61,6 +62,134 @@ public async Task GeminiClient_ParsesAssistantReply() response.Content.Should().Be("Gemini says hi"); } + [Fact] + public async Task GeminiClient_StructuredJson_UsesResponseJsonSchema() + { + string? requestBody = null; + var client = new GeminiProviderClient( + new HttpClient(new StubHttpMessageHandler(request => + { + requestBody = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"candidates":[{"content":{"parts":[{"text":"{\"branches\":[]}"}]}}]}""", Encoding.UTF8, "application/json") + }; + })), + CreateConfiguration(), + NullLogger.Instance); + + await client.SendStructuredJsonAsync( + "AIza-test", + new LlmProviderStructuredRequest + { + Instructions = "Return JSON.", + Messages = [new LlmProviderChatMessage { Role = "user", Content = "Hello" }], + SchemaName = "branch_plan", + SchemaJson = """{"type":"object","properties":{"branches":{"type":"array"}}}""", + MaxOutputTokens = 256 + }); + + requestBody.Should().NotBeNull(); + requestBody.Should().Contain("responseJsonSchema"); + requestBody.Should().NotContain("responseSchema"); + } + + [Fact] + public async Task LmStudioClient_ParsesAssistantReply() + { + var client = new LmStudioProviderClient( + new HttpClient(new StubHttpMessageHandler(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"choices":[{"message":{"content":"LM Studio says hi"}}]}""", Encoding.UTF8, "application/json") + })), + CreateConfiguration(), + NullLogger.Instance); + + var response = await client.SendChatAsync("lmstudio", [new LlmProviderChatMessage { Role = "user", Content = "Hello" }]); + + response.Content.Should().Be("LM Studio says hi"); + } + + [Fact] + public async Task LmStudioClient_GetModels_ReturnsAvailableModels() + { + var client = new LmStudioProviderClient( + new HttpClient(new StubHttpMessageHandler(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"data":[{"id":"local-model"},{"id":"qwen/qwen3-4b-2507"}]}""", Encoding.UTF8, "application/json") + })), + CreateConfiguration(), + NullLogger.Instance); + + var models = await client.GetModelsAsync("lmstudio"); + + models.Select(item => item.Id).Should().ContainInOrder("local-model", "qwen/qwen3-4b-2507"); + } + + [Fact] + public async Task GeminiClient_InvalidArgument_DoesNotMapToInvalidKey() + { + var client = new GeminiProviderClient( + new HttpClient(new StubHttpMessageHandler(_ => + new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent( + """{"error":{"code":400,"message":"Request contains an invalid argument.","status":"INVALID_ARGUMENT"}}""", + Encoding.UTF8, + "application/json") + })), + CreateConfiguration(), + NullLogger.Instance); + + var action = async () => await client.SendStructuredJsonAsync( + "AIza-test", + new LlmProviderStructuredRequest + { + Instructions = "Return JSON.", + Messages = [new LlmProviderChatMessage { Role = "user", Content = "Hello" }], + SchemaName = "branch_plan", + SchemaJson = """{"type":"object","properties":{"branches":{"type":"array"}}}""", + MaxOutputTokens = 256 + }); + + var exception = await action.Should().ThrowAsync(); + exception.Which.Code.Should().Be(LlmProviderErrorCodes.ProviderError); + exception.Which.Message.Should().Be("Request contains an invalid argument."); + } + + [Fact] + public async Task GeminiClient_ServiceUnavailable_Preserves503StatusCode() + { + var client = new GeminiProviderClient( + new HttpClient(new StubHttpMessageHandler(_ => + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent( + """{"error":{"code":503,"message":"This model is currently experiencing high demand.","status":"UNAVAILABLE"}}""", + Encoding.UTF8, + "application/json") + })), + CreateConfiguration(), + NullLogger.Instance); + + var action = async () => await client.SendStructuredJsonAsync( + "AIza-test", + new LlmProviderStructuredRequest + { + Instructions = "Return JSON.", + Messages = [new LlmProviderChatMessage { Role = "user", Content = "Hello" }], + SchemaName = "branch_plan", + SchemaJson = """{"type":"object","properties":{"branches":{"type":"array"}}}""", + MaxOutputTokens = 256 + }); + + var exception = await action.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + exception.Which.Code.Should().Be(LlmProviderErrorCodes.ProviderError); + } + [Fact] public async Task OpenAiClient_InvalidKey_MapsToLlmChatException() { @@ -87,6 +216,8 @@ private static IConfiguration CreateConfiguration() ["Llm:Providers:OpenAI:Model"] = "gpt-4.1-mini", ["Llm:Providers:Claude:Model"] = "claude-3-5-haiku-latest", ["Llm:Providers:Gemini:Model"] = "gemini-1.5-flash", + ["Llm:Providers:LmStudio:BaseUrl"] = "http://localhost:1234/v1", + ["Llm:Providers:LmStudio:Model"] = "local-model", }) .Build(); } diff --git a/backend/Coliee.API.Tests/Services/LlmServiceTests.cs b/backend/Coliee.API.Tests/Services/LlmServiceTests.cs index edf225d..899ba6c 100644 --- a/backend/Coliee.API.Tests/Services/LlmServiceTests.cs +++ b/backend/Coliee.API.Tests/Services/LlmServiceTests.cs @@ -1,4 +1,5 @@ using Coliee.API.Data; +using Coliee.API.DTOs; using Coliee.API.Models; using Coliee.API.Services; using FluentAssertions; @@ -35,6 +36,116 @@ public async Task DeleteProviderKey_DefaultProviderPromotesNextConfiguredProvide response.DefaultProvider.Should().Be("claude"); } + [Fact] + public async Task DeleteProviderKey_DefaultProviderPromotesLmStudioWhenItIsTheOnlyRemainingProvider() + { + var repository = new InMemoryDataStore(); + var service = CreateService( + repository, + new StubProviderClient(LlmProviderNames.OpenAi), + new StubProviderClient(LlmProviderNames.LmStudio)); + + await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234"); + await service.UpsertProviderKeyAsync(1, "lmstudio", "lmstudio"); + + var response = await service.DeleteProviderKeyAsync(1, "openai"); + + response.DefaultProvider.Should().Be("lmstudio"); + } + + [Fact] + public async Task GetProviderModels_ConfiguredProvider_ReturnsDiscoveredModels() + { + var repository = new InMemoryDataStore(); + var service = CreateService( + repository, + new StubProviderClient( + LlmProviderNames.OpenAi, + models: + [ + new LlmProviderModel { Id = "gpt-4.1-mini", DisplayName = "GPT-4.1 Mini" }, + new LlmProviderModel { Id = "gpt-4.1", DisplayName = "GPT-4.1" } + ])); + + await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234"); + + var response = await service.GetProviderModelsAsync(1, "openai"); + + response.Provider.Should().Be("openai"); + response.Models.Select(item => item.Id).Should().ContainInOrder("gpt-4.1-mini", "gpt-4.1"); + } + + [Fact] + public async Task GetProviders_IncludesLmStudioInConfiguredOrder() + { + var repository = new InMemoryDataStore(); + var service = CreateService( + repository, + new StubProviderClient(LlmProviderNames.OpenAi), + new StubProviderClient(LlmProviderNames.LmStudio)); + + await service.UpsertProviderKeyAsync(1, "lmstudio", "lmstudio"); + await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234"); + + var response = await service.GetProvidersAsync(1); + + response.Providers.Select(item => item.Provider).Should().ContainInOrder("openai", "claude", "gemini", "lmstudio"); + response.Providers.Single(item => item.Provider == "lmstudio").Configured.Should().BeTrue(); + } + + [Fact] + public async Task GetProviderModels_WithoutConfiguredProvider_ThrowsMissingKey() + { + var repository = new InMemoryDataStore(); + var service = CreateService(repository, new StubProviderClient(LlmProviderNames.OpenAi)); + + var action = async () => await service.GetProviderModelsAsync(1, "openai"); + + var exception = await action.Should().ThrowAsync(); + exception.Which.Code.Should().Be(LlmProviderErrorCodes.MissingKey); + exception.Which.StatusCode.Should().Be(400); + } + + [Fact] + public async Task SendStandaloneChatMessage_WithoutConfiguredProviders_ThrowsMissingKey() + { + var repository = new InMemoryDataStore(); + var service = CreateService(repository, new StubProviderClient(LlmProviderNames.OpenAi)); + + var action = async () => await service.SendStandaloneChatMessageAsync(1, [], "Hello", null); + + var exception = await action.Should().ThrowAsync(); + exception.Which.Code.Should().Be(LlmProviderErrorCodes.MissingKey); + } + + [Fact] + public async Task SendStandaloneChatMessage_UsesHistoryAndReturnsAssistantReply() + { + var repository = new InMemoryDataStore(); + var geminiClient = new StubProviderClient(LlmProviderNames.Gemini, "Standalone reply", "gemini-1.5-flash"); + var service = CreateService( + repository, + new StubProviderClient(LlmProviderNames.OpenAi), + geminiClient); + + await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234"); + await service.UpsertProviderKeyAsync(1, "gemini", "AIza-gemini-9999"); + + var response = await service.SendStandaloneChatMessageAsync( + 1, + [ + new StandaloneChatMessageRequest { Role = "user", Content = "Hello there" }, + new StandaloneChatMessageRequest { Role = "assistant", Content = "Hi" } + ], + "Can you expand on that?", + "gemini"); + + response.Message.Content.Should().Be("Standalone reply"); + response.Message.Provider.Should().Be("gemini"); + response.Message.Model.Should().Be("gemini-1.5-flash"); + geminiClient.LastMessages.Select(item => item.Content).Should().ContainInOrder("Hello there", "Hi", "Can you expand on that?"); + } + [Fact] public async Task SendMainNodeMessage_WithoutConfiguredProviders_ThrowsMissingKey() { @@ -147,22 +258,49 @@ private sealed class StubProviderClient : ILlmProviderClient { private readonly string _content; private readonly string _model; + private readonly IReadOnlyList _models; - public StubProviderClient(string provider, string content = "Assistant reply", string model = "test-model") + public StubProviderClient( + string provider, + string content = "Assistant reply", + string model = "test-model", + IReadOnlyList? models = null) { Provider = provider; _content = content; _model = model; + _models = models ?? [new LlmProviderModel { Id = model, DisplayName = model }]; } public string Provider { get; } + public IReadOnlyList LastMessages { get; private set; } = []; + + public Task> GetModelsAsync(string apiKey, CancellationToken cancellationToken = default) + { + return Task.FromResult(_models); + } - public Task SendChatAsync(string apiKey, IReadOnlyList messages, CancellationToken cancellationToken = default) + public Task SendChatAsync( + string apiKey, + IReadOnlyList messages, + string? modelOverride = null, + string? systemPrompt = null, + CancellationToken cancellationToken = default) { + LastMessages = messages.ToList(); return Task.FromResult(new LlmProviderChatResult { Content = _content, - Model = _model + Model = modelOverride ?? _model + }); + } + + public Task SendStructuredJsonAsync(string apiKey, LlmProviderStructuredRequest request, string? modelOverride = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new LlmProviderChatResult + { + Content = _content, + Model = modelOverride ?? _model }); } @@ -271,6 +409,21 @@ public Task> GetCanvasNodesAsync(int userId, int canva return Task.FromResult(_nodes.FirstOrDefault(item => item.CanvasId == canvasId && item.Id == nodeId)); } + public Task UpdateCanvasNodeAsync(int userId, int canvasId, int nodeId, CanvasNodeUpdateInput node) + { + var existing = _nodes.First(item => item.CanvasId == canvasId && item.Id == nodeId); + existing.GenerationMode = node.GenerationMode; + existing.Summary = node.Summary; + existing.Category = node.Category; + existing.Content = node.Content; + existing.SourcePrompt = node.SourcePrompt; + existing.ContinuationPrompt = node.ContinuationPrompt; + existing.Blocks = [.. node.Blocks]; + existing.Sources = [.. node.Sources]; + existing.UpdatedAt = DateTime.UtcNow; + return Task.FromResult(existing); + } + public Task> AddChildCanvasNodesAsync(int userId, int canvasId, int parentNodeId, IReadOnlyList nodes) { var nextOrder = _nodes.Where(item => item.CanvasId == canvasId && item.ParentNodeId == parentNodeId).Select(item => item.SiblingOrder).DefaultIfEmpty(-1).Max() + 1; diff --git a/backend/Coliee.API/Controllers/AuthController.cs b/backend/Coliee.API/Controllers/AuthController.cs index 5f12c55..6d13065 100644 --- a/backend/Coliee.API/Controllers/AuthController.cs +++ b/backend/Coliee.API/Controllers/AuthController.cs @@ -4,6 +4,7 @@ using Coliee.API.DTOs; using Coliee.API.Services; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Logging; namespace Coliee.API.Controllers; @@ -17,6 +18,7 @@ public class AuthController : ControllerBase private readonly IEmailVerificationOtpService _emailVerificationOtpService; private readonly IPasswordPolicyService _passwordPolicyService; private readonly IGoogleAuthService _googleAuthService; + private readonly ILogger _logger; public AuthController( IUserRepository userRepo, @@ -24,7 +26,8 @@ public AuthController( IPasswordResetOtpService passwordResetOtpService, IEmailVerificationOtpService emailVerificationOtpService, IPasswordPolicyService passwordPolicyService, - IGoogleAuthService googleAuthService) + IGoogleAuthService googleAuthService, + ILogger logger) { _userRepo = userRepo; _tokenService = tokenService; @@ -32,6 +35,7 @@ public AuthController( _emailVerificationOtpService = emailVerificationOtpService; _passwordPolicyService = passwordPolicyService; _googleAuthService = googleAuthService; + _logger = logger; } // POST api/auth/register @@ -264,6 +268,9 @@ public async Task VerifyEmail([FromBody] VerifyEmailRequest req) [EnableRateLimiting("AuthEndpoints")] public async Task GoogleLogin([FromBody] GoogleLoginRequest req) { + if (string.IsNullOrWhiteSpace(req.IdToken)) + return BadRequest(new { message = "Google ID token is required" }); + Google.Apis.Auth.GoogleJsonWebSignature.Payload? payload; try { @@ -277,48 +284,65 @@ public async Task GoogleLogin([FromBody] GoogleLoginRequest req) if (payload == null) return Unauthorized(new { message = "Invalid Google token" }); - var user = await _userRepo.GetByEmailAsync(payload.Email); - - if (user == null) + if (string.IsNullOrWhiteSpace(payload.Email)) { - await _userRepo.CreateGoogleUserAsync(payload.Email, payload.Name); - user = await _userRepo.GetByEmailAsync(payload.Email); - if (user == null) - return StatusCode(500, new { message = "Failed to create user account" }); + _logger.LogWarning("Google token validation succeeded but email claim was missing."); + return Unauthorized(new { message = "Google account email was not available." }); } - else + + try { - // If user exists but is locked out - if (user.LockedUntil.HasValue && user.LockedUntil.Value > DateTime.UtcNow) - return StatusCode(429, new + var user = await _userRepo.GetByEmailAsync(payload.Email); + + if (user == null) + { + await _userRepo.CreateGoogleUserAsync(payload.Email, payload.Name ?? payload.Email); + user = await _userRepo.GetByEmailAsync(payload.Email); + if (user == null) { - message = "Account locked due to too many failed attempts", - lockedUntil = user.LockedUntil.Value - }); - - // Google login resets failed login attempts - if (user.FailedLoginAttempts > 0) - await _userRepo.ResetFailedLoginAsync(user.Id); - - // If user registered with email/password but never verified, mark as verified now - if (!user.IsEmailVerified) + _logger.LogError("Google login could not load the newly-created user for email {Email}.", payload.Email); + return StatusCode(500, new { message = "Failed to create user account" }); + } + } + else { - await _userRepo.MarkEmailVerifiedAsync(user.Id); - user.IsEmailVerified = true; + // If user exists but is locked out + if (user.LockedUntil.HasValue && user.LockedUntil.Value > DateTime.UtcNow) + return StatusCode(429, new + { + message = "Account locked due to too many failed attempts", + lockedUntil = user.LockedUntil.Value + }); + + // Google login resets failed login attempts + if (user.FailedLoginAttempts > 0) + await _userRepo.ResetFailedLoginAsync(user.Id); + + // If user registered with email/password but never verified, mark as verified now + if (!user.IsEmailVerified) + { + await _userRepo.MarkEmailVerifiedAsync(user.Id); + user.IsEmailVerified = true; + } } - } - var token = _tokenService.GenerateToken(user); - var refreshToken = Guid.NewGuid().ToString(); - await _userRepo.StoreRefreshTokenAsync(user.Id, refreshToken, DateTime.UtcNow.AddDays(7)); - - return Ok(new AuthResponse + var token = _tokenService.GenerateToken(user); + var refreshToken = Guid.NewGuid().ToString(); + await _userRepo.StoreRefreshTokenAsync(user.Id, refreshToken, DateTime.UtcNow.AddDays(7)); + + return Ok(new AuthResponse + { + Token = token, + Email = user.Email, + Expiry = DateTime.UtcNow.AddMinutes(60), + RefreshToken = refreshToken + }); + } + catch (Exception ex) { - Token = token, - Email = user.Email, - Expiry = DateTime.UtcNow.AddMinutes(60), - RefreshToken = refreshToken - }); + _logger.LogError(ex, "Google login failed for email {Email}.", payload.Email); + return StatusCode(500, new { message = "Google sign-in failed while linking your account." }); + } } [HttpPost("resend-email-verification-otp")] diff --git a/backend/Coliee.API/Controllers/CanvasesController.cs b/backend/Coliee.API/Controllers/CanvasesController.cs index 1c79c90..37a7e98 100644 --- a/backend/Coliee.API/Controllers/CanvasesController.cs +++ b/backend/Coliee.API/Controllers/CanvasesController.cs @@ -11,11 +11,13 @@ namespace Coliee.API.Controllers; [Authorize] public class CanvasesController : ControllerBase { + private readonly ICanvasCreationService _canvasCreationService; private readonly ICanvasService _canvasService; private readonly ICanvasGraphService _canvasGraphService; - public CanvasesController(ICanvasService canvasService, ICanvasGraphService canvasGraphService) + public CanvasesController(ICanvasCreationService canvasCreationService, ICanvasService canvasService, ICanvasGraphService canvasGraphService) { + _canvasCreationService = canvasCreationService; _canvasService = canvasService; _canvasGraphService = canvasGraphService; } @@ -34,13 +36,17 @@ public async Task CreateCanvas([FromBody] CreateCanvasRequest req try { - var canvas = await _canvasService.CreateCanvasAsync(GetUserId(), request.FirstPrompt); + var canvas = await _canvasCreationService.CreateCanvasAsync(GetUserId(), request.FirstPrompt, request.GenerationMode); return StatusCode(StatusCodes.Status201Created, canvas); } catch (CanvasOperationException ex) { return StatusCode(ex.StatusCode, new { message = ex.Message }); } + catch (LlmChatException ex) + { + return StatusCode(ex.StatusCode, new { message = ex.Message, code = ex.Code }); + } } [HttpPatch("{canvasId:int}")] @@ -80,7 +86,13 @@ public async Task ExpandCanvasNode(int canvasId, int nodeId, [Fro try { - return Ok(await _canvasGraphService.ExpandCanvasNodeAsync(GetUserId(), canvasId, nodeId, request.Prompt, request.ProviderOverride)); + return Ok(await _canvasGraphService.ExpandCanvasNodeAsync( + GetUserId(), + canvasId, + nodeId, + request.Prompt, + request.ProviderOverride, + request.GenerationMode)); } catch (CanvasOperationException ex) { diff --git a/backend/Coliee.API/Controllers/ChatGuestController.cs b/backend/Coliee.API/Controllers/ChatGuestController.cs new file mode 100644 index 0000000..b0fbcff --- /dev/null +++ b/backend/Coliee.API/Controllers/ChatGuestController.cs @@ -0,0 +1,98 @@ +using System.Security.Claims; +using Coliee.API.DTOs; +using Coliee.API.Models; +using Coliee.API.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace Coliee.API.Controllers; + +[ApiController] +[Route("api/chat")] +public class ChatGuestController : ControllerBase +{ + private const string GuestPreviewCookieName = "coliee_guest_preview_id"; + private readonly IChatSessionService _chatSessionService; + private readonly IMemoryCache _memoryCache; + + public ChatGuestController(IChatSessionService chatSessionService, IMemoryCache memoryCache) + { + _chatSessionService = chatSessionService; + _memoryCache = memoryCache; + } + + [HttpPost("guest-preview")] + [AllowAnonymous] + public async Task GuestPreview([FromBody] GuestChatPreviewRequest request) + { + var guestId = EnsureGuestIdCookie(); + var cacheKey = $"guest-preview:{guestId}"; + if (_memoryCache.TryGetValue(cacheKey, out _)) + { + return StatusCode(StatusCodes.Status401Unauthorized, new + { + message = "Sign in to continue with AI actions.", + code = "auth_required" + }); + } + + try + { + var response = await _chatSessionService.PreviewGuestAsync(request); + _memoryCache.Set(cacheKey, true, TimeSpan.FromHours(12)); + return Ok(response); + } + catch (ChatSessionOperationException ex) + { + return StatusCode(ex.StatusCode, new { message = ex.Message }); + } + catch (LlmChatException ex) + { + return StatusCode(ex.StatusCode, new { message = ex.Message, code = ex.Code }); + } + } + + [HttpPost("import-guest")] + [Authorize] + public async Task ImportGuestCanvases([FromBody] ImportGuestCanvasesRequest request) + { + try + { + return Ok(await _chatSessionService.ImportGuestCanvasesAsync(GetUserId(), request)); + } + catch (ChatSessionOperationException ex) + { + return StatusCode(ex.StatusCode, new { message = ex.Message }); + } + catch (LlmChatException ex) + { + return StatusCode(ex.StatusCode, new { message = ex.Message, code = ex.Code }); + } + } + + private string EnsureGuestIdCookie() + { + if (Request.Cookies.TryGetValue(GuestPreviewCookieName, out var existing) && !string.IsNullOrWhiteSpace(existing)) + return existing; + + var guestId = Guid.NewGuid().ToString("N"); + Response.Cookies.Append(GuestPreviewCookieName, guestId, new CookieOptions + { + HttpOnly = true, + IsEssential = true, + SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.AddDays(7) + }); + return guestId; + } + + private int GetUserId() + { + var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userIdStr == null || !int.TryParse(userIdStr, out var userId)) + throw new ChatSessionOperationException("Invalid token claims.", StatusCodes.Status401Unauthorized); + + return userId; + } +} diff --git a/backend/Coliee.API/Controllers/ChatSessionsController.cs b/backend/Coliee.API/Controllers/ChatSessionsController.cs new file mode 100644 index 0000000..c845676 --- /dev/null +++ b/backend/Coliee.API/Controllers/ChatSessionsController.cs @@ -0,0 +1,218 @@ +using System.Security.Claims; +using Coliee.API.DTOs; +using Coliee.API.Models; +using Coliee.API.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Coliee.API.Controllers; + +[ApiController] +[Route("api/chat-sessions")] +[Authorize] +public class ChatSessionsController : ControllerBase +{ + private readonly IChatSessionService _chatSessionService; + + public ChatSessionsController(IChatSessionService chatSessionService) + { + _chatSessionService = chatSessionService; + } + + [HttpGet] + public async Task GetSessions() + { + return await ExecuteAsync(() => _chatSessionService.GetSessionsAsync(GetUserId())); + } + + [HttpPost] + public async Task CreateSession([FromBody] CreateChatSessionRequest request) + { + try + { + var result = await _chatSessionService.CreateSessionAsync(GetUserId(), request.Title, request.Content, request.ProviderOverride, request.ModelOverride); + return StatusCode(StatusCodes.Status201Created, result); + } + catch (ChatSessionOperationException ex) + { + return StatusCode(ex.StatusCode, new { message = ex.Message }); + } + catch (LlmChatException ex) + { + return StatusCode(ex.StatusCode, new { message = ex.Message, code = ex.Code }); + } + } + + [HttpPatch("{sessionId:int}")] + public async Task RenameSession(int sessionId, [FromBody] RenameChatSessionRequest request) + { + if (!ModelState.IsValid) + return BadRequest(new { message = "Validation failed" }); + + return await ExecuteAsync(() => _chatSessionService.RenameSessionAsync(GetUserId(), sessionId, request.Title)); + } + + [HttpDelete("{sessionId:int}")] + public async Task DeleteSession(int sessionId) + { + try + { + await _chatSessionService.DeleteSessionAsync(GetUserId(), sessionId); + return NoContent(); + } + catch (ChatSessionOperationException ex) + { + return StatusCode(ex.StatusCode, new { message = ex.Message }); + } + } + + [HttpGet("{sessionId:int}")] + public async Task GetSession(int sessionId, [FromQuery] int? activeNodeId = null) + { + return await ExecuteAsync(() => _chatSessionService.GetSessionAsync(GetUserId(), sessionId, activeNodeId)); + } + + [HttpGet("{sessionId:int}/nodes/{nodeId:int}")] + public async Task GetNode(int sessionId, int nodeId) + { + return await ExecuteAsync(() => _chatSessionService.GetNodeAsync(GetUserId(), sessionId, nodeId)); + } + + [HttpPost("{sessionId:int}/nodes")] + public async Task CreateNode(int sessionId, [FromBody] CreateChatNodeRequest request) + { + if (!ModelState.IsValid) + return BadRequest(new { message = "Validation failed" }); + + return await ExecuteAsync(() => _chatSessionService.CreateNodeAsync(GetUserId(), sessionId, request)); + } + + [HttpPatch("{sessionId:int}/nodes/{nodeId:int}")] + public async Task UpdateNode(int sessionId, int nodeId, [FromBody] UpdateChatNodeRequest request) + { + return await ExecuteAsync(() => _chatSessionService.UpdateNodeAsync(GetUserId(), sessionId, nodeId, request)); + } + + [HttpPost("{sessionId:int}/nodes/{nodeId:int}/messages")] + public async Task SendNodeMessage(int sessionId, int nodeId, [FromBody] SendChatNodeMessageRequest request) + { + if (!ModelState.IsValid) + return BadRequest(new { message = "Validation failed" }); + + return await ExecuteAsync(() => _chatSessionService.SendNodeMessageAsync(GetUserId(), sessionId, nodeId, request.Content, request.ImageUrl, request.ProviderOverride, request.ModelOverride)); + } + + [HttpPatch("{sessionId:int}/nodes/{nodeId:int}/messages/{messageId:int}")] + public async Task UpdateMessage(int sessionId, int nodeId, int messageId, [FromBody] UpdateChatCanvasMessageRequest request) + { + if (!ModelState.IsValid) + return BadRequest(new { message = "Validation failed" }); + + return await ExecuteAsync(() => _chatSessionService.UpdateMessageAsync(GetUserId(), sessionId, nodeId, messageId, request)); + } + + [HttpPost("{sessionId:int}/nodes/{nodeId:int}/branches")] + public async Task CreateBranch(int sessionId, int nodeId, [FromBody] CreateChatBranchRequest request) + { + if (!ModelState.IsValid) + return BadRequest(new { message = "Validation failed" }); + + return await ExecuteAsync(() => _chatSessionService.CreateBranchAsync( + GetUserId(), + sessionId, + nodeId, + request.BranchFromMessageId, + request.Content, + request.ProviderOverride, + request.ModelOverride)); + } + + [HttpPost("{sessionId:int}/nodes/{nodeId:int}/message-selections/branches")] + public async Task MoveMessagesToBranch(int sessionId, int nodeId, [FromBody] MoveChatNodeMessagesRequest request) + { + if (!ModelState.IsValid) + return BadRequest(new { message = "Validation failed" }); + + return await ExecuteAsync(() => _chatSessionService.MoveMessagesToBranchAsync( + GetUserId(), + sessionId, + nodeId, + request.MessageIds, + request.TransferMode)); + } + + [HttpPost("{sessionId:int}/nodes/{nodeId:int}/notes")] + public async Task GenerateNote(int sessionId, int nodeId, [FromBody] GenerateChatNodeNoteRequest request) + { + return await ExecuteAsync(() => _chatSessionService.GenerateNoteAsync( + GetUserId(), + sessionId, + nodeId, + request.ProviderOverride, + request.ModelOverride)); + } + + [HttpPost("{sessionId:int}/nodes/{nodeId:int}/image-prompts")] + public async Task GenerateImage(int sessionId, int nodeId, [FromBody] GenerateChatNodeImageRequest request) + { + if (!ModelState.IsValid) + return BadRequest(new { message = "Validation failed" }); + + return await ExecuteAsync(() => _chatSessionService.GenerateImageAsync( + GetUserId(), + sessionId, + nodeId, + request.Prompt, + request.ProviderOverride, + request.ModelOverride)); + } + + [HttpDelete("{sessionId:int}/nodes/{nodeId:int}")] + public async Task DeleteNode(int sessionId, int nodeId, [FromQuery] int? newActiveNodeId = null) + { + return await ExecuteAsync(() => _chatSessionService.DeleteNodeAsync(GetUserId(), sessionId, nodeId, newActiveNodeId)); + } + + [HttpPatch("{sessionId:int}/layout")] + public async Task SaveLayout(int sessionId, [FromBody] SaveChatLayoutRequest request, [FromQuery] int? activeNodeId = null) + { + if (!ModelState.IsValid) + return BadRequest(new { message = "Validation failed" }); + + var updates = request.Nodes + .Select(node => new ChatNodeLayoutUpdateInput + { + NodeId = node.NodeId, + PositionX = node.PositionX, + PositionY = node.PositionY + }) + .ToList(); + + return await ExecuteAsync(() => _chatSessionService.SaveLayoutAsync(GetUserId(), sessionId, updates, activeNodeId)); + } + + private int GetUserId() + { + var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userIdStr == null || !int.TryParse(userIdStr, out var userId)) + throw new ChatSessionOperationException("Invalid token claims.", StatusCodes.Status401Unauthorized); + + return userId; + } + + private async Task ExecuteAsync(Func> action) + { + try + { + return Ok(await action()); + } + catch (ChatSessionOperationException ex) + { + return StatusCode(ex.StatusCode, new { message = ex.Message }); + } + catch (LlmChatException ex) + { + return StatusCode(ex.StatusCode, new { message = ex.Message, code = ex.Code }); + } + } +} diff --git a/backend/Coliee.API/Controllers/LlmController.cs b/backend/Coliee.API/Controllers/LlmController.cs index cb10d91..6d59f09 100644 --- a/backend/Coliee.API/Controllers/LlmController.cs +++ b/backend/Coliee.API/Controllers/LlmController.cs @@ -24,6 +24,12 @@ public async Task GetProviders() return await ExecuteAsync(() => _llmService.GetProvidersAsync(GetUserId())); } + [HttpGet("providers/{provider}/models")] + public async Task GetProviderModels(string provider) + { + return await ExecuteAsync(() => _llmService.GetProviderModelsAsync(GetUserId(), provider)); + } + [HttpPut("providers/{provider}")] public async Task UpsertProvider(string provider, [FromBody] UpsertProviderKeyRequest request) { @@ -60,6 +66,15 @@ public async Task GetMainNodeChat([FromQuery] int canvasId) return await ExecuteAsync(() => _llmService.GetMainNodeChatAsync(GetUserId(), canvasId)); } + [HttpPost("chat/session/messages")] + public async Task SendStandaloneChatMessage([FromBody] SendStandaloneChatMessageRequest request) + { + if (!ModelState.IsValid) + return BadRequest(new { message = "Validation failed" }); + + return await ExecuteAsync(() => _llmService.SendStandaloneChatMessageAsync(GetUserId(), request.History, request.Content, request.ProviderOverride)); + } + [HttpPost("chat/main-node/messages")] public async Task SendMainNodeMessage([FromBody] SendMainNodeMessageRequest request) { diff --git a/backend/Coliee.API/DTOs/CanvasGraphNodeBlockResponse.cs b/backend/Coliee.API/DTOs/CanvasGraphNodeBlockResponse.cs new file mode 100644 index 0000000..7db79d4 --- /dev/null +++ b/backend/Coliee.API/DTOs/CanvasGraphNodeBlockResponse.cs @@ -0,0 +1,8 @@ +namespace Coliee.API.DTOs; + +public class CanvasGraphNodeBlockResponse +{ + public string Type { get; set; } = string.Empty; + public string Text { get; set; } = string.Empty; + public List Options { get; set; } = []; +} diff --git a/backend/Coliee.API/DTOs/CanvasGraphNodeResponse.cs b/backend/Coliee.API/DTOs/CanvasGraphNodeResponse.cs index 0c6fda8..2949308 100644 --- a/backend/Coliee.API/DTOs/CanvasGraphNodeResponse.cs +++ b/backend/Coliee.API/DTOs/CanvasGraphNodeResponse.cs @@ -6,8 +6,14 @@ public class CanvasGraphNodeResponse public int? ParentNodeId { get; set; } public string Kind { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; + public string GenerationMode { get; set; } = "compact"; + public string? Summary { get; set; } + public string? Category { get; set; } public string Content { get; set; } = string.Empty; public string? SourcePrompt { get; set; } + public string? ContinuationPrompt { get; set; } + public List Blocks { get; set; } = []; + public List Sources { get; set; } = []; public int Depth { get; set; } public int SiblingOrder { get; set; } public DateTime CreatedAt { get; set; } diff --git a/backend/Coliee.API/DTOs/CanvasGraphNodeSourceResponse.cs b/backend/Coliee.API/DTOs/CanvasGraphNodeSourceResponse.cs new file mode 100644 index 0000000..86c911d --- /dev/null +++ b/backend/Coliee.API/DTOs/CanvasGraphNodeSourceResponse.cs @@ -0,0 +1,7 @@ +namespace Coliee.API.DTOs; + +public class CanvasGraphNodeSourceResponse +{ + public string Label { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; +} diff --git a/backend/Coliee.API/DTOs/CanvasGraphResponse.cs b/backend/Coliee.API/DTOs/CanvasGraphResponse.cs index 9427eda..db38fa6 100644 --- a/backend/Coliee.API/DTOs/CanvasGraphResponse.cs +++ b/backend/Coliee.API/DTOs/CanvasGraphResponse.cs @@ -3,6 +3,7 @@ namespace Coliee.API.DTOs; public class CanvasGraphResponse { public int CanvasId { get; set; } + public string? CanvasTitle { get; set; } public string Mode { get; set; } = "graph"; public string? DefaultProvider { get; set; } public List AvailableProviders { get; set; } = []; diff --git a/backend/Coliee.API/DTOs/ChatCanvasMessageResponse.cs b/backend/Coliee.API/DTOs/ChatCanvasMessageResponse.cs new file mode 100644 index 0000000..670c03b --- /dev/null +++ b/backend/Coliee.API/DTOs/ChatCanvasMessageResponse.cs @@ -0,0 +1,15 @@ +namespace Coliee.API.DTOs; + +public class ChatCanvasMessageResponse +{ + public int Id { get; set; } + public int NodeId { get; set; } + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string? ImageUrl { get; set; } + public string? Provider { get; set; } + public string? Model { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public bool CanBranch { get; set; } +} diff --git a/backend/Coliee.API/DTOs/ChatCanvasNodeDetailResponse.cs b/backend/Coliee.API/DTOs/ChatCanvasNodeDetailResponse.cs new file mode 100644 index 0000000..dbb7649 --- /dev/null +++ b/backend/Coliee.API/DTOs/ChatCanvasNodeDetailResponse.cs @@ -0,0 +1,26 @@ +namespace Coliee.API.DTOs; + +public class ChatCanvasNodeDetailResponse +{ + public int SessionId { get; set; } + public int NodeId { get; set; } + public int? ParentNodeId { get; set; } + public int? BranchFromMessageId { get; set; } + public string NodeType { get; set; } = "question"; + public string Title { get; set; } = string.Empty; + public string Preview { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public double PositionX { get; set; } + public double PositionY { get; set; } + public double Width { get; set; } + public double Height { get; set; } + public bool IsCollapsed { get; set; } + public double? ExpandedWidth { get; set; } + public double? ExpandedHeight { get; set; } + public string? PreferredProvider { get; set; } + public string? PreferredModel { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List LocalMessages { get; set; } = []; + public List ResolvedMessages { get; set; } = []; +} diff --git a/backend/Coliee.API/DTOs/ChatCanvasNodeSummaryResponse.cs b/backend/Coliee.API/DTOs/ChatCanvasNodeSummaryResponse.cs new file mode 100644 index 0000000..cfe009b --- /dev/null +++ b/backend/Coliee.API/DTOs/ChatCanvasNodeSummaryResponse.cs @@ -0,0 +1,23 @@ +namespace Coliee.API.DTOs; + +public class ChatCanvasNodeSummaryResponse +{ + public int Id { get; set; } + public int? ParentNodeId { get; set; } + public int? BranchFromMessageId { get; set; } + public string NodeType { get; set; } = "question"; + public string Title { get; set; } = string.Empty; + public string Preview { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public double PositionX { get; set; } + public double PositionY { get; set; } + public double Width { get; set; } + public double Height { get; set; } + public bool IsCollapsed { get; set; } + public double? ExpandedWidth { get; set; } + public double? ExpandedHeight { get; set; } + public string? PreferredProvider { get; set; } + public string? PreferredModel { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/backend/Coliee.API/DTOs/ChatSessionGraphResponse.cs b/backend/Coliee.API/DTOs/ChatSessionGraphResponse.cs new file mode 100644 index 0000000..3cdb255 --- /dev/null +++ b/backend/Coliee.API/DTOs/ChatSessionGraphResponse.cs @@ -0,0 +1,11 @@ +namespace Coliee.API.DTOs; + +public class ChatSessionGraphResponse +{ + public ChatSessionSummaryResponse Session { get; set; } = new(); + public string? DefaultProvider { get; set; } + public List AvailableProviders { get; set; } = []; + public int RootNodeId { get; set; } + public int ActiveNodeId { get; set; } + public List Nodes { get; set; } = []; +} diff --git a/backend/Coliee.API/DTOs/ChatSessionStateResponse.cs b/backend/Coliee.API/DTOs/ChatSessionStateResponse.cs new file mode 100644 index 0000000..7891485 --- /dev/null +++ b/backend/Coliee.API/DTOs/ChatSessionStateResponse.cs @@ -0,0 +1,7 @@ +namespace Coliee.API.DTOs; + +public class ChatSessionStateResponse +{ + public ChatSessionGraphResponse Graph { get; set; } = new(); + public ChatCanvasNodeDetailResponse Node { get; set; } = new(); +} diff --git a/backend/Coliee.API/DTOs/ChatSessionSummaryResponse.cs b/backend/Coliee.API/DTOs/ChatSessionSummaryResponse.cs new file mode 100644 index 0000000..4b01cc1 --- /dev/null +++ b/backend/Coliee.API/DTOs/ChatSessionSummaryResponse.cs @@ -0,0 +1,9 @@ +namespace Coliee.API.DTOs; + +public class ChatSessionSummaryResponse +{ + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/backend/Coliee.API/DTOs/CreateCanvasRequest.cs b/backend/Coliee.API/DTOs/CreateCanvasRequest.cs index c3e6768..555abc0 100644 --- a/backend/Coliee.API/DTOs/CreateCanvasRequest.cs +++ b/backend/Coliee.API/DTOs/CreateCanvasRequest.cs @@ -8,4 +8,7 @@ public class CreateCanvasRequest [MinLength(1)] [MaxLength(4000)] public string FirstPrompt { get; set; } = string.Empty; + + [MaxLength(16)] + public string? GenerationMode { get; set; } } diff --git a/backend/Coliee.API/DTOs/CreateChatBranchRequest.cs b/backend/Coliee.API/DTOs/CreateChatBranchRequest.cs new file mode 100644 index 0000000..3195356 --- /dev/null +++ b/backend/Coliee.API/DTOs/CreateChatBranchRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Coliee.API.DTOs; + +public class CreateChatBranchRequest +{ + [Required] + public int BranchFromMessageId { get; set; } + + [Required] + public string Content { get; set; } = string.Empty; + + public string? ProviderOverride { get; set; } + public string? ModelOverride { get; set; } +} diff --git a/backend/Coliee.API/DTOs/CreateChatNodeRequest.cs b/backend/Coliee.API/DTOs/CreateChatNodeRequest.cs new file mode 100644 index 0000000..04403cf --- /dev/null +++ b/backend/Coliee.API/DTOs/CreateChatNodeRequest.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Coliee.API.DTOs; + +public class CreateChatNodeRequest +{ + [Required] + public int ParentNodeId { get; set; } + + [Required] + public string NodeType { get; set; } = "question"; + + public string? Title { get; set; } + public string? Content { get; set; } + public double? PositionX { get; set; } + public double? PositionY { get; set; } + public double? Width { get; set; } + public double? Height { get; set; } + public bool? IsCollapsed { get; set; } + public double? ExpandedWidth { get; set; } + public double? ExpandedHeight { get; set; } + public string? PreferredProvider { get; set; } + public string? PreferredModel { get; set; } +} diff --git a/backend/Coliee.API/DTOs/CreateChatSessionRequest.cs b/backend/Coliee.API/DTOs/CreateChatSessionRequest.cs new file mode 100644 index 0000000..96dea21 --- /dev/null +++ b/backend/Coliee.API/DTOs/CreateChatSessionRequest.cs @@ -0,0 +1,10 @@ +namespace Coliee.API.DTOs; + +public class CreateChatSessionRequest +{ + public string? Title { get; set; } + public string? Content { get; set; } + + public string? ProviderOverride { get; set; } + public string? ModelOverride { get; set; } +} diff --git a/backend/Coliee.API/DTOs/ExpandCanvasNodeRequest.cs b/backend/Coliee.API/DTOs/ExpandCanvasNodeRequest.cs index bb6d9be..990460c 100644 --- a/backend/Coliee.API/DTOs/ExpandCanvasNodeRequest.cs +++ b/backend/Coliee.API/DTOs/ExpandCanvasNodeRequest.cs @@ -11,4 +11,7 @@ public class ExpandCanvasNodeRequest [MaxLength(40)] public string? ProviderOverride { get; set; } + + [MaxLength(16)] + public string? GenerationMode { get; set; } } diff --git a/backend/Coliee.API/DTOs/GenerateChatNodeImageRequest.cs b/backend/Coliee.API/DTOs/GenerateChatNodeImageRequest.cs new file mode 100644 index 0000000..351464a --- /dev/null +++ b/backend/Coliee.API/DTOs/GenerateChatNodeImageRequest.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Coliee.API.DTOs; + +public class GenerateChatNodeImageRequest +{ + [Required] + public string Prompt { get; set; } = string.Empty; + + public string? ProviderOverride { get; set; } + public string? ModelOverride { get; set; } +} diff --git a/backend/Coliee.API/DTOs/GenerateChatNodeNoteRequest.cs b/backend/Coliee.API/DTOs/GenerateChatNodeNoteRequest.cs new file mode 100644 index 0000000..5720b9c --- /dev/null +++ b/backend/Coliee.API/DTOs/GenerateChatNodeNoteRequest.cs @@ -0,0 +1,7 @@ +namespace Coliee.API.DTOs; + +public class GenerateChatNodeNoteRequest +{ + public string? ProviderOverride { get; set; } + public string? ModelOverride { get; set; } +} diff --git a/backend/Coliee.API/DTOs/GuestChatPreviewMessageRequest.cs b/backend/Coliee.API/DTOs/GuestChatPreviewMessageRequest.cs new file mode 100644 index 0000000..a0e1b35 --- /dev/null +++ b/backend/Coliee.API/DTOs/GuestChatPreviewMessageRequest.cs @@ -0,0 +1,8 @@ +namespace Coliee.API.DTOs; + +public class GuestChatPreviewMessageRequest +{ + public string Role { get; set; } = "user"; + public string Content { get; set; } = string.Empty; + public string? ImageUrl { get; set; } +} diff --git a/backend/Coliee.API/DTOs/GuestChatPreviewRequest.cs b/backend/Coliee.API/DTOs/GuestChatPreviewRequest.cs new file mode 100644 index 0000000..1753c81 --- /dev/null +++ b/backend/Coliee.API/DTOs/GuestChatPreviewRequest.cs @@ -0,0 +1,11 @@ +namespace Coliee.API.DTOs; + +public class GuestChatPreviewRequest +{ + public string ActionType { get; set; } = "chat"; + public string? Prompt { get; set; } + public string? Content { get; set; } + public string? ImageUrl { get; set; } + public string? ModelId { get; set; } + public List Messages { get; set; } = []; +} diff --git a/backend/Coliee.API/DTOs/GuestChatPreviewResponse.cs b/backend/Coliee.API/DTOs/GuestChatPreviewResponse.cs new file mode 100644 index 0000000..c1b57dd --- /dev/null +++ b/backend/Coliee.API/DTOs/GuestChatPreviewResponse.cs @@ -0,0 +1,12 @@ +namespace Coliee.API.DTOs; + +public class GuestChatPreviewResponse +{ + public string ActionType { get; set; } = "chat"; + public string? Message { get; set; } + public string? ImageUrl { get; set; } + public string? NoteTitle { get; set; } + public string? NoteContent { get; set; } + public string? Provider { get; set; } + public string? Model { get; set; } +} diff --git a/backend/Coliee.API/DTOs/ImportGuestCanvasMessageRequest.cs b/backend/Coliee.API/DTOs/ImportGuestCanvasMessageRequest.cs new file mode 100644 index 0000000..e97fd39 --- /dev/null +++ b/backend/Coliee.API/DTOs/ImportGuestCanvasMessageRequest.cs @@ -0,0 +1,10 @@ +namespace Coliee.API.DTOs; + +public class ImportGuestCanvasMessageRequest +{ + public string Role { get; set; } = "user"; + public string Content { get; set; } = string.Empty; + public string? ImageUrl { get; set; } + public string? Provider { get; set; } + public string? Model { get; set; } +} diff --git a/backend/Coliee.API/DTOs/ImportGuestCanvasNodeRequest.cs b/backend/Coliee.API/DTOs/ImportGuestCanvasNodeRequest.cs new file mode 100644 index 0000000..9fbbe55 --- /dev/null +++ b/backend/Coliee.API/DTOs/ImportGuestCanvasNodeRequest.cs @@ -0,0 +1,20 @@ +namespace Coliee.API.DTOs; + +public class ImportGuestCanvasNodeRequest +{ + public string TempId { get; set; } = string.Empty; + public string? ParentTempId { get; set; } + public string NodeType { get; set; } = "question"; + public string? Title { get; set; } + public string? Content { get; set; } + public double PositionX { get; set; } + public double PositionY { get; set; } + public double Width { get; set; } + public double Height { get; set; } + public bool? IsCollapsed { get; set; } + public double? ExpandedWidth { get; set; } + public double? ExpandedHeight { get; set; } + public string? PreferredProvider { get; set; } + public string? PreferredModel { get; set; } + public List Messages { get; set; } = []; +} diff --git a/backend/Coliee.API/DTOs/ImportGuestCanvasRequest.cs b/backend/Coliee.API/DTOs/ImportGuestCanvasRequest.cs new file mode 100644 index 0000000..cf82267 --- /dev/null +++ b/backend/Coliee.API/DTOs/ImportGuestCanvasRequest.cs @@ -0,0 +1,8 @@ +namespace Coliee.API.DTOs; + +public class ImportGuestCanvasRequest +{ + public string TempId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public List Nodes { get; set; } = []; +} diff --git a/backend/Coliee.API/DTOs/ImportGuestCanvasesRequest.cs b/backend/Coliee.API/DTOs/ImportGuestCanvasesRequest.cs new file mode 100644 index 0000000..6380e37 --- /dev/null +++ b/backend/Coliee.API/DTOs/ImportGuestCanvasesRequest.cs @@ -0,0 +1,7 @@ +namespace Coliee.API.DTOs; + +public class ImportGuestCanvasesRequest +{ + public string? ActiveCanvasId { get; set; } + public List Canvases { get; set; } = []; +} diff --git a/backend/Coliee.API/DTOs/MoveChatNodeMessagesRequest.cs b/backend/Coliee.API/DTOs/MoveChatNodeMessagesRequest.cs new file mode 100644 index 0000000..d11bf7b --- /dev/null +++ b/backend/Coliee.API/DTOs/MoveChatNodeMessagesRequest.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Coliee.API.DTOs; + +public class MoveChatNodeMessagesRequest +{ + [Required] + [MinLength(1)] + public List MessageIds { get; set; } = []; + + public string? TransferMode { get; set; } +} diff --git a/backend/Coliee.API/DTOs/ProviderModelResponse.cs b/backend/Coliee.API/DTOs/ProviderModelResponse.cs new file mode 100644 index 0000000..dc52d68 --- /dev/null +++ b/backend/Coliee.API/DTOs/ProviderModelResponse.cs @@ -0,0 +1,7 @@ +namespace Coliee.API.DTOs; + +public class ProviderModelResponse +{ + public string Id { get; set; } = string.Empty; + public string? DisplayName { get; set; } +} diff --git a/backend/Coliee.API/DTOs/ProviderModelsResponse.cs b/backend/Coliee.API/DTOs/ProviderModelsResponse.cs new file mode 100644 index 0000000..939d60e --- /dev/null +++ b/backend/Coliee.API/DTOs/ProviderModelsResponse.cs @@ -0,0 +1,7 @@ +namespace Coliee.API.DTOs; + +public class ProviderModelsResponse +{ + public string Provider { get; set; } = string.Empty; + public List Models { get; set; } = []; +} diff --git a/backend/Coliee.API/DTOs/RenameChatSessionRequest.cs b/backend/Coliee.API/DTOs/RenameChatSessionRequest.cs new file mode 100644 index 0000000..f98d3ba --- /dev/null +++ b/backend/Coliee.API/DTOs/RenameChatSessionRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Coliee.API.DTOs; + +public class RenameChatSessionRequest +{ + [Required] + public string Title { get; set; } = string.Empty; +} diff --git a/backend/Coliee.API/DTOs/SaveChatLayoutNodeRequest.cs b/backend/Coliee.API/DTOs/SaveChatLayoutNodeRequest.cs new file mode 100644 index 0000000..ecfbfe2 --- /dev/null +++ b/backend/Coliee.API/DTOs/SaveChatLayoutNodeRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Coliee.API.DTOs; + +public class SaveChatLayoutNodeRequest +{ + [Required] + public int NodeId { get; set; } + + [Required] + public double PositionX { get; set; } + + [Required] + public double PositionY { get; set; } +} diff --git a/backend/Coliee.API/DTOs/SaveChatLayoutRequest.cs b/backend/Coliee.API/DTOs/SaveChatLayoutRequest.cs new file mode 100644 index 0000000..38ff4cd --- /dev/null +++ b/backend/Coliee.API/DTOs/SaveChatLayoutRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Coliee.API.DTOs; + +public class SaveChatLayoutRequest +{ + [Required] + [MinLength(1)] + public List Nodes { get; set; } = []; +} diff --git a/backend/Coliee.API/DTOs/SendChatNodeMessageRequest.cs b/backend/Coliee.API/DTOs/SendChatNodeMessageRequest.cs new file mode 100644 index 0000000..bbf697f --- /dev/null +++ b/backend/Coliee.API/DTOs/SendChatNodeMessageRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Coliee.API.DTOs; + +public class SendChatNodeMessageRequest +{ + [Required] + public string Content { get; set; } = string.Empty; + + public string? ImageUrl { get; set; } + public string? ProviderOverride { get; set; } + public string? ModelOverride { get; set; } +} diff --git a/backend/Coliee.API/DTOs/SendStandaloneChatMessageRequest.cs b/backend/Coliee.API/DTOs/SendStandaloneChatMessageRequest.cs new file mode 100644 index 0000000..4ba9ba2 --- /dev/null +++ b/backend/Coliee.API/DTOs/SendStandaloneChatMessageRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Coliee.API.DTOs; + +public class SendStandaloneChatMessageRequest +{ + public List History { get; set; } = []; + + [Required] + [MinLength(1)] + [MaxLength(4000)] + public string Content { get; set; } = string.Empty; + + public string? ProviderOverride { get; set; } +} diff --git a/backend/Coliee.API/DTOs/StandaloneChatMessageRequest.cs b/backend/Coliee.API/DTOs/StandaloneChatMessageRequest.cs new file mode 100644 index 0000000..49cc5b4 --- /dev/null +++ b/backend/Coliee.API/DTOs/StandaloneChatMessageRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Coliee.API.DTOs; + +public class StandaloneChatMessageRequest +{ + [Required] + [RegularExpression("^(user|assistant)$")] + public string Role { get; set; } = string.Empty; + + [Required] + [MinLength(1)] + [MaxLength(4000)] + public string Content { get; set; } = string.Empty; +} diff --git a/backend/Coliee.API/DTOs/StandaloneChatMessageResponse.cs b/backend/Coliee.API/DTOs/StandaloneChatMessageResponse.cs new file mode 100644 index 0000000..bb8b014 --- /dev/null +++ b/backend/Coliee.API/DTOs/StandaloneChatMessageResponse.cs @@ -0,0 +1,10 @@ +namespace Coliee.API.DTOs; + +public class StandaloneChatMessageResponse +{ + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string? Provider { get; set; } + public string? Model { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/backend/Coliee.API/DTOs/StandaloneChatResponse.cs b/backend/Coliee.API/DTOs/StandaloneChatResponse.cs new file mode 100644 index 0000000..8dcda33 --- /dev/null +++ b/backend/Coliee.API/DTOs/StandaloneChatResponse.cs @@ -0,0 +1,8 @@ +namespace Coliee.API.DTOs; + +public class StandaloneChatResponse +{ + public string? DefaultProvider { get; set; } + public List AvailableProviders { get; set; } = []; + public StandaloneChatMessageResponse Message { get; set; } = new(); +} diff --git a/backend/Coliee.API/DTOs/UpdateChatCanvasMessageRequest.cs b/backend/Coliee.API/DTOs/UpdateChatCanvasMessageRequest.cs new file mode 100644 index 0000000..5c744f4 --- /dev/null +++ b/backend/Coliee.API/DTOs/UpdateChatCanvasMessageRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Coliee.API.DTOs; + +public class UpdateChatCanvasMessageRequest +{ + [Required] + public string Content { get; set; } = string.Empty; +} diff --git a/backend/Coliee.API/DTOs/UpdateChatNodeRequest.cs b/backend/Coliee.API/DTOs/UpdateChatNodeRequest.cs new file mode 100644 index 0000000..8319030 --- /dev/null +++ b/backend/Coliee.API/DTOs/UpdateChatNodeRequest.cs @@ -0,0 +1,16 @@ +namespace Coliee.API.DTOs; + +public class UpdateChatNodeRequest +{ + public string? Title { get; set; } + public string? Content { get; set; } + public double? PositionX { get; set; } + public double? PositionY { get; set; } + public double? Width { get; set; } + public double? Height { get; set; } + public bool? IsCollapsed { get; set; } + public double? ExpandedWidth { get; set; } + public double? ExpandedHeight { get; set; } + public string? PreferredProvider { get; set; } + public string? PreferredModel { get; set; } +} diff --git a/backend/Coliee.API/Data/CanvasRepository.cs b/backend/Coliee.API/Data/CanvasRepository.cs index 80908ef..0d2dbf0 100644 --- a/backend/Coliee.API/Data/CanvasRepository.cs +++ b/backend/Coliee.API/Data/CanvasRepository.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Coliee.API.Models; using MySql.Data.MySqlClient; @@ -46,12 +47,13 @@ INSERT INTO main_node_conversations (canvas_id, created_at, updated_at) using var insertRootNode = new MySqlCommand( """ - INSERT INTO canvas_nodes (canvas_id, parent_node_id, kind, title, content, source_prompt, depth, sibling_order, created_at, updated_at) - VALUES (@canvasId, NULL, 'main', 'Main Node', '', NULL, 0, 0, UTC_TIMESTAMP(), UTC_TIMESTAMP()) + INSERT INTO canvas_nodes (canvas_id, parent_node_id, kind, title, generation_mode, summary, category, content, source_prompt, continuation_prompt, details_json, depth, sibling_order, created_at, updated_at) + VALUES (@canvasId, NULL, 'main', 'Main Node', @generationMode, NULL, NULL, '', NULL, NULL, NULL, 0, 0, UTC_TIMESTAMP(), UTC_TIMESTAMP()) """, conn, (MySqlTransaction)tx); insertRootNode.Parameters.AddWithValue("@canvasId", canvasId); + insertRootNode.Parameters.AddWithValue("@generationMode", CanvasGenerationModes.Compact); await insertRootNode.ExecuteNonQueryAsync(); await tx.CommitAsync(); @@ -211,14 +213,15 @@ public async Task EnsureRootCanvasNodeAsync(int userId, int canvasId await conn.OpenAsync(); using var cmd = new MySqlCommand( """ - INSERT INTO canvas_nodes (canvas_id, parent_node_id, kind, title, content, source_prompt, depth, sibling_order, created_at, updated_at) - SELECT canvas.id, NULL, 'main', 'Main Node', '', NULL, 0, 0, UTC_TIMESTAMP(), UTC_TIMESTAMP() + INSERT INTO canvas_nodes (canvas_id, parent_node_id, kind, title, generation_mode, summary, category, content, source_prompt, continuation_prompt, details_json, depth, sibling_order, created_at, updated_at) + SELECT canvas.id, NULL, 'main', 'Main Node', @generationMode, NULL, NULL, '', NULL, NULL, NULL, 0, 0, UTC_TIMESTAMP(), UTC_TIMESTAMP() FROM canvases canvas WHERE canvas.user_id = @userId AND canvas.id = @canvasId """, conn); cmd.Parameters.AddWithValue("@userId", userId); cmd.Parameters.AddWithValue("@canvasId", canvasId); + cmd.Parameters.AddWithValue("@generationMode", CanvasGenerationModes.Compact); var rows = await cmd.ExecuteNonQueryAsync(); if (rows == 0) @@ -237,7 +240,7 @@ public async Task> GetCanvasNodesAsync(int userId, int await EnsureCanvasNodesSchemaAsync(conn); using var cmd = new MySqlCommand( """ - SELECT node.id, node.canvas_id, node.parent_node_id, node.kind, node.title, node.content, node.source_prompt, node.depth, node.sibling_order, node.created_at, node.updated_at + SELECT node.id, node.canvas_id, node.parent_node_id, node.kind, node.title, node.generation_mode, node.summary, node.category, node.content, node.source_prompt, node.continuation_prompt, node.details_json, node.depth, node.sibling_order, node.created_at, node.updated_at FROM canvas_nodes node INNER JOIN canvases canvas ON canvas.id = node.canvas_id WHERE canvas.user_id = @userId AND canvas.id = @canvasId @@ -261,7 +264,7 @@ FROM canvas_nodes node await EnsureCanvasNodesSchemaAsync(conn); using var cmd = new MySqlCommand( """ - SELECT node.id, node.canvas_id, node.parent_node_id, node.kind, node.title, node.content, node.source_prompt, node.depth, node.sibling_order, node.created_at, node.updated_at + SELECT node.id, node.canvas_id, node.parent_node_id, node.kind, node.title, node.generation_mode, node.summary, node.category, node.content, node.source_prompt, node.continuation_prompt, node.details_json, node.depth, node.sibling_order, node.created_at, node.updated_at FROM canvas_nodes node INNER JOIN canvases canvas ON canvas.id = node.canvas_id WHERE canvas.user_id = @userId AND canvas.id = @canvasId AND node.id = @nodeId @@ -279,6 +282,72 @@ LIMIT 1 return null; } + public async Task UpdateCanvasNodeAsync(int userId, int canvasId, int nodeId, CanvasNodeUpdateInput node) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureCanvasNodesSchemaAsync(conn); + using var tx = await conn.BeginTransactionAsync(); + + try + { + using var updateNode = new MySqlCommand( + """ + UPDATE canvas_nodes node + INNER JOIN canvases canvas ON canvas.id = node.canvas_id + SET node.generation_mode = @generationMode, + node.summary = @summary, + node.category = @category, + node.content = @content, + node.source_prompt = @sourcePrompt, + node.continuation_prompt = @continuationPrompt, + node.details_json = @detailsJson, + node.updated_at = UTC_TIMESTAMP() + WHERE canvas.user_id = @userId + AND canvas.id = @canvasId + AND node.id = @nodeId + """, + conn, + (MySqlTransaction)tx); + updateNode.Parameters.AddWithValue("@generationMode", CanvasGenerationModes.Normalize(node.GenerationMode)); + updateNode.Parameters.AddWithValue("@summary", (object?)node.Summary ?? DBNull.Value); + updateNode.Parameters.AddWithValue("@category", (object?)node.Category ?? DBNull.Value); + updateNode.Parameters.AddWithValue("@content", node.Content); + updateNode.Parameters.AddWithValue("@sourcePrompt", (object?)node.SourcePrompt ?? DBNull.Value); + updateNode.Parameters.AddWithValue("@continuationPrompt", (object?)node.ContinuationPrompt ?? DBNull.Value); + updateNode.Parameters.AddWithValue("@detailsJson", (object?)SerializeDetailsJson(node.Blocks, node.Sources) ?? DBNull.Value); + updateNode.Parameters.AddWithValue("@userId", userId); + updateNode.Parameters.AddWithValue("@canvasId", canvasId); + updateNode.Parameters.AddWithValue("@nodeId", nodeId); + + var rows = await updateNode.ExecuteNonQueryAsync(); + if (rows == 0) + throw new InvalidOperationException("Canvas node update did not persist."); + + using var touchCanvas = new MySqlCommand( + """ + UPDATE canvases + SET updated_at = UTC_TIMESTAMP() + WHERE id = @canvasId AND user_id = @userId + """, + conn, + (MySqlTransaction)tx); + touchCanvas.Parameters.AddWithValue("@canvasId", canvasId); + touchCanvas.Parameters.AddWithValue("@userId", userId); + await touchCanvas.ExecuteNonQueryAsync(); + + await tx.CommitAsync(); + } + catch + { + await tx.RollbackAsync(); + throw; + } + + return await GetCanvasNodeAsync(userId, canvasId, nodeId) + ?? throw new InvalidOperationException("Updated canvas node could not be reloaded."); + } + public async Task> AddChildCanvasNodesAsync(int userId, int canvasId, int parentNodeId, IReadOnlyList nodes) { if (nodes.Count == 0) @@ -310,8 +379,8 @@ FROM canvas_nodes node { using var insert = new MySqlCommand( """ - INSERT INTO canvas_nodes (canvas_id, parent_node_id, kind, title, content, source_prompt, depth, sibling_order, created_at, updated_at) - VALUES (@canvasId, @parentNodeId, @kind, @title, @content, @sourcePrompt, @depth, @siblingOrder, UTC_TIMESTAMP(), UTC_TIMESTAMP()) + INSERT INTO canvas_nodes (canvas_id, parent_node_id, kind, title, generation_mode, summary, category, content, source_prompt, continuation_prompt, details_json, depth, sibling_order, created_at, updated_at) + VALUES (@canvasId, @parentNodeId, @kind, @title, @generationMode, @summary, @category, @content, @sourcePrompt, @continuationPrompt, @detailsJson, @depth, @siblingOrder, UTC_TIMESTAMP(), UTC_TIMESTAMP()) """, conn, (MySqlTransaction)tx); @@ -319,8 +388,13 @@ INSERT INTO canvas_nodes (canvas_id, parent_node_id, kind, title, content, sourc insert.Parameters.AddWithValue("@parentNodeId", parentNodeId); insert.Parameters.AddWithValue("@kind", node.Kind); insert.Parameters.AddWithValue("@title", node.Title); + insert.Parameters.AddWithValue("@generationMode", CanvasGenerationModes.Normalize(node.GenerationMode)); + insert.Parameters.AddWithValue("@summary", (object?)node.Summary ?? DBNull.Value); + insert.Parameters.AddWithValue("@category", (object?)node.Category ?? DBNull.Value); insert.Parameters.AddWithValue("@content", node.Content); insert.Parameters.AddWithValue("@sourcePrompt", (object?)node.SourcePrompt ?? DBNull.Value); + insert.Parameters.AddWithValue("@continuationPrompt", (object?)node.ContinuationPrompt ?? DBNull.Value); + insert.Parameters.AddWithValue("@detailsJson", (object?)SerializeDetailsJson(node.Blocks, node.Sources) ?? DBNull.Value); insert.Parameters.AddWithValue("@depth", node.Depth); insert.Parameters.AddWithValue("@siblingOrder", nextSiblingOrder); await insert.ExecuteNonQueryAsync(); @@ -333,8 +407,14 @@ INSERT INTO canvas_nodes (canvas_id, parent_node_id, kind, title, content, sourc ParentNodeId = parentNodeId, Kind = node.Kind, Title = node.Title, + GenerationMode = CanvasGenerationModes.Normalize(node.GenerationMode), + Summary = node.Summary, + Category = node.Category, Content = node.Content, SourcePrompt = node.SourcePrompt, + ContinuationPrompt = node.ContinuationPrompt, + Blocks = [.. node.Blocks], + Sources = [.. node.Sources], Depth = node.Depth, SiblingOrder = nextSiblingOrder, CreatedAt = now, @@ -385,7 +465,7 @@ private static Canvas MapCanvas(MySqlDataReader reader) await EnsureCanvasNodesSchemaAsync(conn); using var cmd = new MySqlCommand( """ - SELECT node.id, node.canvas_id, node.parent_node_id, node.kind, node.title, node.content, node.source_prompt, node.depth, node.sibling_order, node.created_at, node.updated_at + SELECT node.id, node.canvas_id, node.parent_node_id, node.kind, node.title, node.generation_mode, node.summary, node.category, node.content, node.source_prompt, node.continuation_prompt, node.details_json, node.depth, node.sibling_order, node.created_at, node.updated_at FROM canvas_nodes node INNER JOIN canvases canvas ON canvas.id = node.canvas_id WHERE canvas.user_id = @userId @@ -408,7 +488,13 @@ LIMIT 1 private static CanvasNode MapCanvasNode(MySqlDataReader reader) { var parentNodeOrdinal = reader.GetOrdinal("parent_node_id"); + var generationModeOrdinal = reader.GetOrdinal("generation_mode"); + var summaryOrdinal = reader.GetOrdinal("summary"); + var categoryOrdinal = reader.GetOrdinal("category"); var sourcePromptOrdinal = reader.GetOrdinal("source_prompt"); + var continuationPromptOrdinal = reader.GetOrdinal("continuation_prompt"); + var detailsJsonOrdinal = reader.GetOrdinal("details_json"); + var details = ParseDetailsJson(reader.IsDBNull(detailsJsonOrdinal) ? null : reader.GetString(detailsJsonOrdinal)); return new CanvasNode { @@ -417,8 +503,14 @@ private static CanvasNode MapCanvasNode(MySqlDataReader reader) ParentNodeId = reader.IsDBNull(parentNodeOrdinal) ? null : reader.GetInt32(parentNodeOrdinal), Kind = reader.GetString("kind"), Title = reader.GetString("title"), + GenerationMode = CanvasGenerationModes.Normalize(reader.IsDBNull(generationModeOrdinal) ? null : reader.GetString(generationModeOrdinal)), + Summary = reader.IsDBNull(summaryOrdinal) ? null : reader.GetString(summaryOrdinal), + Category = reader.IsDBNull(categoryOrdinal) ? null : reader.GetString(categoryOrdinal), Content = reader.GetString("content"), SourcePrompt = reader.IsDBNull(sourcePromptOrdinal) ? null : reader.GetString(sourcePromptOrdinal), + ContinuationPrompt = reader.IsDBNull(continuationPromptOrdinal) ? null : reader.GetString(continuationPromptOrdinal), + Blocks = details.Blocks, + Sources = details.Sources, Depth = reader.GetInt32("depth"), SiblingOrder = reader.GetInt32("sibling_order"), CreatedAt = reader.GetDateTime("created_at"), @@ -436,8 +528,13 @@ CREATE TABLE IF NOT EXISTS canvas_nodes ( parent_node_id INT NULL, kind VARCHAR(32) NOT NULL, title VARCHAR(160) NOT NULL, + generation_mode VARCHAR(16) NOT NULL DEFAULT 'compact', + summary MEDIUMTEXT NULL, + category VARCHAR(80) NULL, content MEDIUMTEXT NOT NULL, source_prompt TEXT NULL, + continuation_prompt TEXT NULL, + details_json JSON NULL, depth INT NOT NULL, sibling_order INT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -449,5 +546,74 @@ INDEX idx_canvas_nodes_canvas_parent (canvas_id, parent_node_id, sibling_order, """, conn); await cmd.ExecuteNonQueryAsync(); + + await EnsureColumnExistsAsync(conn, "canvas_nodes", "generation_mode", "VARCHAR(16) NOT NULL DEFAULT 'compact'", "title"); + await EnsureColumnExistsAsync(conn, "canvas_nodes", "summary", "MEDIUMTEXT NULL", "generation_mode"); + await EnsureColumnExistsAsync(conn, "canvas_nodes", "category", "VARCHAR(80) NULL", "summary"); + await EnsureColumnExistsAsync(conn, "canvas_nodes", "continuation_prompt", "TEXT NULL", "source_prompt"); + await EnsureColumnExistsAsync(conn, "canvas_nodes", "details_json", "JSON NULL", "continuation_prompt"); + } + + private static async Task EnsureColumnExistsAsync( + MySqlConnection conn, + string tableName, + string columnName, + string columnDefinition, + string? afterColumn = null) + { + using var existsCmd = new MySqlCommand( + """ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @tableName + AND COLUMN_NAME = @columnName + """, + conn); + existsCmd.Parameters.AddWithValue("@tableName", tableName); + existsCmd.Parameters.AddWithValue("@columnName", columnName); + + var exists = Convert.ToInt32(await existsCmd.ExecuteScalarAsync()) > 0; + if (exists) + return; + + var placement = string.IsNullOrWhiteSpace(afterColumn) ? string.Empty : $" AFTER {afterColumn}"; + using var alterCmd = new MySqlCommand( + $"ALTER TABLE {tableName} ADD COLUMN {columnName} {columnDefinition}{placement};", + conn); + await alterCmd.ExecuteNonQueryAsync(); + } + + private static string? SerializeDetailsJson(IReadOnlyList blocks, IReadOnlyList sources) + { + if (blocks.Count == 0 && sources.Count == 0) + return null; + + return JsonSerializer.Serialize(new CanvasNodeDetailsPayload + { + Blocks = [.. blocks], + Sources = [.. sources] + }); + } + + private static CanvasNodeDetailsPayload ParseDetailsJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return new CanvasNodeDetailsPayload(); + + try + { + return JsonSerializer.Deserialize(json) ?? new CanvasNodeDetailsPayload(); + } + catch (JsonException) + { + return new CanvasNodeDetailsPayload(); + } + } + + private sealed class CanvasNodeDetailsPayload + { + public List Blocks { get; set; } = []; + public List Sources { get; set; } = []; } } diff --git a/backend/Coliee.API/Data/ChatSessionRepository.cs b/backend/Coliee.API/Data/ChatSessionRepository.cs new file mode 100644 index 0000000..24ba1a1 --- /dev/null +++ b/backend/Coliee.API/Data/ChatSessionRepository.cs @@ -0,0 +1,1174 @@ +using System.Text.Json; +using Coliee.API.Models; +using Coliee.API.Services; +using MySql.Data.MySqlClient; + +namespace Coliee.API.Data; + +public class ChatSessionRepository : IChatSessionRepository +{ + private const string RootNodeType = "question"; + private const double RootPositionX = 400; + private const double RootPositionY = 300; + private const double DefaultNodeWidth = 600; + private const double DefaultNodeHeight = 520; + private readonly string _conn; + + public ChatSessionRepository(IConfiguration config) + { + _conn = config.GetConnectionString("Default") ?? string.Empty; + } + + public async Task CreateSessionAsync(int userId, string title) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var cmd = new MySqlCommand( + """ + INSERT INTO chat_sessions (user_id, title, created_at, updated_at) + VALUES (@userId, @title, UTC_TIMESTAMP(), UTC_TIMESTAMP()) + """, + conn); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@title", title); + await cmd.ExecuteNonQueryAsync(); + + return new ChatSession + { + Id = Convert.ToInt32(cmd.LastInsertedId), + UserId = userId, + Title = title, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + } + + public async Task> GetSessionsAsync(int userId) + { + var items = new List(); + + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var cmd = new MySqlCommand( + """ + SELECT id, user_id, title, created_at, updated_at + FROM chat_sessions + WHERE user_id = @userId + ORDER BY updated_at DESC, id DESC + """, + conn); + cmd.Parameters.AddWithValue("@userId", userId); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + items.Add(MapSession((MySqlDataReader)reader)); + + return items; + } + + public async Task GetSessionAsync(int userId, int sessionId) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var cmd = new MySqlCommand( + """ + SELECT id, user_id, title, created_at, updated_at + FROM chat_sessions + WHERE user_id = @userId AND id = @sessionId + LIMIT 1 + """, + conn); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + return MapSession((MySqlDataReader)reader); + + return null; + } + + public async Task RenameSessionAsync(int userId, int sessionId, string title) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var cmd = new MySqlCommand( + """ + UPDATE chat_sessions + SET title = @title, + updated_at = UTC_TIMESTAMP() + WHERE user_id = @userId AND id = @sessionId + """, + conn); + cmd.Parameters.AddWithValue("@title", title); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + await cmd.ExecuteNonQueryAsync(); + + return await GetSessionAsync(userId, sessionId) + ?? throw new InvalidOperationException("Chat session rename did not persist."); + } + + public async Task DeleteSessionAsync(int userId, int sessionId) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var cmd = new MySqlCommand( + """ + DELETE FROM chat_sessions + WHERE user_id = @userId AND id = @sessionId + """, + conn); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + await cmd.ExecuteNonQueryAsync(); + } + + public async Task EnsureRootNodeAsync(int userId, int sessionId, string title) + { + var existing = await GetRootNodeAsync(userId, sessionId); + if (existing != null) + return existing; + + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var cmd = new MySqlCommand( + """ + INSERT INTO chat_nodes (session_id, parent_node_id, branch_from_message_id, node_type, title, preview, content, position_x, position_y, width, height, preferred_provider, preferred_model, sibling_order, created_at, updated_at) + SELECT session.id, NULL, NULL, @nodeType, @title, '', '', @positionX, @positionY, @width, @height, NULL, NULL, 0, UTC_TIMESTAMP(), UTC_TIMESTAMP() + FROM chat_sessions session + WHERE session.user_id = @userId AND session.id = @sessionId + """, + conn); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + cmd.Parameters.AddWithValue("@title", title); + cmd.Parameters.AddWithValue("@nodeType", RootNodeType); + cmd.Parameters.AddWithValue("@positionX", RootPositionX); + cmd.Parameters.AddWithValue("@positionY", RootPositionY); + cmd.Parameters.AddWithValue("@width", DefaultNodeWidth); + cmd.Parameters.AddWithValue("@height", DefaultNodeHeight); + var rows = await cmd.ExecuteNonQueryAsync(); + + if (rows == 0) + throw new InvalidOperationException("Chat session not found for root node creation."); + + return await GetRootNodeAsync(userId, sessionId) + ?? throw new InvalidOperationException("Root chat node creation did not persist."); + } + + public async Task> GetNodesAsync(int userId, int sessionId) + { + var items = new List(); + + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var cmd = new MySqlCommand( + """ + SELECT node.id, node.session_id, node.parent_node_id, node.branch_from_message_id, node.node_type, node.title, node.preview, node.content, node.position_x, node.position_y, node.width, node.height, node.is_collapsed, node.expanded_width, node.expanded_height, node.preferred_provider, node.preferred_model, node.context_boundary_message_id, node.context_snapshot_json, node.context_snapshot_updated_at, node.sibling_order, node.created_at, node.updated_at + FROM chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + WHERE session.user_id = @userId AND session.id = @sessionId + ORDER BY node.parent_node_id, node.sibling_order, node.id + """, + conn); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + items.Add(MapNode((MySqlDataReader)reader)); + + return items; + } + + public async Task GetNodeAsync(int userId, int sessionId, int nodeId) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var cmd = new MySqlCommand( + """ + SELECT node.id, node.session_id, node.parent_node_id, node.branch_from_message_id, node.node_type, node.title, node.preview, node.content, node.position_x, node.position_y, node.width, node.height, node.is_collapsed, node.expanded_width, node.expanded_height, node.preferred_provider, node.preferred_model, node.context_boundary_message_id, node.context_snapshot_json, node.context_snapshot_updated_at, node.sibling_order, node.created_at, node.updated_at + FROM chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + WHERE session.user_id = @userId AND session.id = @sessionId AND node.id = @nodeId + LIMIT 1 + """, + conn); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + cmd.Parameters.AddWithValue("@nodeId", nodeId); + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + return MapNode((MySqlDataReader)reader); + + return null; + } + + public async Task CreateNodeAsync(int userId, int sessionId, ChatNodeCreateInput node) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var tx = await conn.BeginTransactionAsync(); + + try + { + var siblingOrder = 0; + if (node.ParentNodeId.HasValue) + { + using var orderCmd = new MySqlCommand( + """ + SELECT COALESCE(MAX(child.sibling_order), -1) + FROM chat_nodes child + INNER JOIN chat_sessions session ON session.id = child.session_id + WHERE session.user_id = @userId + AND session.id = @sessionId + AND child.parent_node_id = @parentNodeId + """, + conn, + (MySqlTransaction)tx); + orderCmd.Parameters.AddWithValue("@userId", userId); + orderCmd.Parameters.AddWithValue("@sessionId", sessionId); + orderCmd.Parameters.AddWithValue("@parentNodeId", node.ParentNodeId.Value); + siblingOrder = Convert.ToInt32(await orderCmd.ExecuteScalarAsync()) + 1; + } + + using var cmd = new MySqlCommand( + """ + INSERT INTO chat_nodes (session_id, parent_node_id, branch_from_message_id, node_type, title, preview, content, position_x, position_y, width, height, is_collapsed, expanded_width, expanded_height, preferred_provider, preferred_model, sibling_order, created_at, updated_at) + SELECT session.id, @parentNodeId, @branchFromMessageId, @nodeType, @title, @preview, @content, @positionX, @positionY, @width, @height, @isCollapsed, @expandedWidth, @expandedHeight, @preferredProvider, @preferredModel, @siblingOrder, UTC_TIMESTAMP(), UTC_TIMESTAMP() + FROM chat_sessions session + WHERE session.user_id = @userId AND session.id = @sessionId + """, + conn, + (MySqlTransaction)tx); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + cmd.Parameters.AddWithValue("@parentNodeId", (object?)node.ParentNodeId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@branchFromMessageId", (object?)node.BranchFromMessageId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@nodeType", node.NodeType); + cmd.Parameters.AddWithValue("@title", node.Title); + cmd.Parameters.AddWithValue("@preview", node.Preview); + cmd.Parameters.AddWithValue("@content", node.Content); + cmd.Parameters.AddWithValue("@positionX", node.PositionX); + cmd.Parameters.AddWithValue("@positionY", node.PositionY); + cmd.Parameters.AddWithValue("@width", node.Width); + cmd.Parameters.AddWithValue("@height", node.Height); + cmd.Parameters.AddWithValue("@isCollapsed", node.IsCollapsed); + cmd.Parameters.AddWithValue("@expandedWidth", (object?)node.ExpandedWidth ?? DBNull.Value); + cmd.Parameters.AddWithValue("@expandedHeight", (object?)node.ExpandedHeight ?? DBNull.Value); + cmd.Parameters.AddWithValue("@preferredProvider", (object?)node.PreferredProvider ?? DBNull.Value); + cmd.Parameters.AddWithValue("@preferredModel", (object?)node.PreferredModel ?? DBNull.Value); + cmd.Parameters.AddWithValue("@siblingOrder", siblingOrder); + + var rows = await cmd.ExecuteNonQueryAsync(); + if (rows == 0) + throw new InvalidOperationException("Chat node creation did not persist."); + + using var touchSession = new MySqlCommand( + """ + UPDATE chat_sessions + SET updated_at = UTC_TIMESTAMP() + WHERE user_id = @userId AND id = @sessionId + """, + conn, + (MySqlTransaction)tx); + touchSession.Parameters.AddWithValue("@userId", userId); + touchSession.Parameters.AddWithValue("@sessionId", sessionId); + await touchSession.ExecuteNonQueryAsync(); + + await tx.CommitAsync(); + var createdNode = await GetNodeAsync(userId, sessionId, Convert.ToInt32(cmd.LastInsertedId)); + return createdNode ?? throw new InvalidOperationException("Created chat node could not be reloaded."); + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + public async Task DeleteNodeAsync(int userId, int sessionId, int nodeId) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + + // Prevent deletion of root nodes (nodes without a parent) + using var checkCmd = new MySqlCommand( + """ + SELECT COUNT(*) + FROM chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @nodeId + AND node.parent_node_id IS NULL + """, + conn); + checkCmd.Parameters.AddWithValue("@userId", userId); + checkCmd.Parameters.AddWithValue("@sessionId", sessionId); + checkCmd.Parameters.AddWithValue("@nodeId", nodeId); + var rootCount = Convert.ToInt32(await checkCmd.ExecuteScalarAsync()); + if (rootCount > 0) + return false; // cannot delete root + + using var cmd = new MySqlCommand( + """ + DELETE node FROM chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @nodeId + """, + conn); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + cmd.Parameters.AddWithValue("@nodeId", nodeId); + var rows = await cmd.ExecuteNonQueryAsync(); + return rows > 0; + } + + public async Task UpdateNodeAsync(int userId, int sessionId, int nodeId, ChatNodeUpdateInput node) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var tx = await conn.BeginTransactionAsync(); + + try + { + using var cmd = new MySqlCommand( + """ + UPDATE chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + SET node.node_type = @nodeType, + node.title = @title, + node.preview = @preview, + node.content = @content, + node.position_x = @positionX, + node.position_y = @positionY, + node.width = @width, + node.height = @height, + node.is_collapsed = @isCollapsed, + node.expanded_width = @expandedWidth, + node.expanded_height = @expandedHeight, + node.preferred_provider = @preferredProvider, + node.preferred_model = @preferredModel, + node.updated_at = UTC_TIMESTAMP() + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @nodeId + """, + conn, + (MySqlTransaction)tx); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + cmd.Parameters.AddWithValue("@nodeId", nodeId); + cmd.Parameters.AddWithValue("@nodeType", node.NodeType); + cmd.Parameters.AddWithValue("@title", node.Title); + cmd.Parameters.AddWithValue("@preview", node.Preview); + cmd.Parameters.AddWithValue("@content", node.Content); + cmd.Parameters.AddWithValue("@positionX", node.PositionX); + cmd.Parameters.AddWithValue("@positionY", node.PositionY); + cmd.Parameters.AddWithValue("@width", node.Width); + cmd.Parameters.AddWithValue("@height", node.Height); + cmd.Parameters.AddWithValue("@isCollapsed", node.IsCollapsed); + cmd.Parameters.AddWithValue("@expandedWidth", (object?)node.ExpandedWidth ?? DBNull.Value); + cmd.Parameters.AddWithValue("@expandedHeight", (object?)node.ExpandedHeight ?? DBNull.Value); + cmd.Parameters.AddWithValue("@preferredProvider", (object?)node.PreferredProvider ?? DBNull.Value); + cmd.Parameters.AddWithValue("@preferredModel", (object?)node.PreferredModel ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(); + + using var touchSession = new MySqlCommand( + """ + UPDATE chat_sessions + SET updated_at = UTC_TIMESTAMP() + WHERE user_id = @userId AND id = @sessionId + """, + conn, + (MySqlTransaction)tx); + touchSession.Parameters.AddWithValue("@userId", userId); + touchSession.Parameters.AddWithValue("@sessionId", sessionId); + await touchSession.ExecuteNonQueryAsync(); + + await tx.CommitAsync(); + return await GetNodeAsync(userId, sessionId, nodeId) + ?? throw new InvalidOperationException("Updated chat node could not be reloaded."); + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + public async Task GetNodeContextSnapshotAsync(int userId, int sessionId, int nodeId) + { + var node = await GetNodeAsync(userId, sessionId, nodeId); + if (node == null || string.IsNullOrWhiteSpace(node.ContextSnapshotJson)) + return null; + + return JsonSerializer.Deserialize(node.ContextSnapshotJson); + } + + public async Task UpdateNodeContextSnapshotAsync(int userId, int sessionId, int nodeId, int boundaryMessageId, string snapshotJson) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var tx = await conn.BeginTransactionAsync(); + + try + { + using var cmd = new MySqlCommand( + """ + UPDATE chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + SET node.context_boundary_message_id = @boundaryMessageId, + node.context_snapshot_json = @snapshotJson, + node.context_snapshot_updated_at = UTC_TIMESTAMP(), + node.updated_at = UTC_TIMESTAMP() + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @nodeId + """, + conn, + (MySqlTransaction)tx); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + cmd.Parameters.AddWithValue("@nodeId", nodeId); + cmd.Parameters.AddWithValue("@boundaryMessageId", boundaryMessageId); + cmd.Parameters.AddWithValue("@snapshotJson", snapshotJson); + await cmd.ExecuteNonQueryAsync(); + + using var touchSession = new MySqlCommand( + """ + UPDATE chat_sessions + SET updated_at = UTC_TIMESTAMP() + WHERE user_id = @userId AND id = @sessionId + """, + conn, + (MySqlTransaction)tx); + touchSession.Parameters.AddWithValue("@userId", userId); + touchSession.Parameters.AddWithValue("@sessionId", sessionId); + await touchSession.ExecuteNonQueryAsync(); + + await tx.CommitAsync(); + return await GetNodeAsync(userId, sessionId, nodeId) + ?? throw new InvalidOperationException("Updated chat node snapshot could not be reloaded."); + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + public async Task SaveNodeLayoutsAsync(int userId, int sessionId, IReadOnlyList nodes) + { + if (nodes.Count == 0) + return; + + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var tx = await conn.BeginTransactionAsync(); + + try + { + foreach (var node in nodes) + { + using var cmd = new MySqlCommand( + """ + UPDATE chat_nodes current_node + INNER JOIN chat_sessions session ON session.id = current_node.session_id + SET current_node.position_x = @positionX, + current_node.position_y = @positionY + WHERE session.user_id = @userId + AND session.id = @sessionId + AND current_node.id = @nodeId + """, + conn, + (MySqlTransaction)tx); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + cmd.Parameters.AddWithValue("@nodeId", node.NodeId); + cmd.Parameters.AddWithValue("@positionX", node.PositionX); + cmd.Parameters.AddWithValue("@positionY", node.PositionY); + await cmd.ExecuteNonQueryAsync(); + } + + await tx.CommitAsync(); + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + public async Task> GetNodeMessagesAsync(int userId, int sessionId, int nodeId) + { + var items = new List(); + + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var cmd = new MySqlCommand( + """ + SELECT message.id, message.node_id, message.role, message.content, message.image_url, message.provider, message.model, message.created_at, message.updated_at + FROM chat_messages message + INNER JOIN chat_nodes node ON node.id = message.node_id + INNER JOIN chat_sessions session ON session.id = node.session_id + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @nodeId + ORDER BY message.id + """, + conn); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + cmd.Parameters.AddWithValue("@nodeId", nodeId); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + items.Add(MapMessage((MySqlDataReader)reader)); + + return items; + } + + public async Task GetNodeMessageAsync(int userId, int sessionId, int nodeId, int messageId) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var cmd = new MySqlCommand( + """ + SELECT message.id, message.node_id, message.role, message.content, message.image_url, message.provider, message.model, message.created_at, message.updated_at + FROM chat_messages message + INNER JOIN chat_nodes node ON node.id = message.node_id + INNER JOIN chat_sessions session ON session.id = node.session_id + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @nodeId + AND message.id = @messageId + LIMIT 1 + """, + conn); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + cmd.Parameters.AddWithValue("@nodeId", nodeId); + cmd.Parameters.AddWithValue("@messageId", messageId); + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + return MapMessage((MySqlDataReader)reader); + + return null; + } + + public async Task AddMessageAsync(int userId, int sessionId, int nodeId, string role, string content, string? imageUrl, string? provider, string? model) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var tx = await conn.BeginTransactionAsync(); + + try + { + using var insert = new MySqlCommand( + """ + INSERT INTO chat_messages (node_id, role, content, image_url, provider, model, created_at, updated_at) + SELECT node.id, @role, @content, @imageUrl, @provider, @model, UTC_TIMESTAMP(), UTC_TIMESTAMP() + FROM chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @nodeId + """, + conn, + (MySqlTransaction)tx); + insert.Parameters.AddWithValue("@userId", userId); + insert.Parameters.AddWithValue("@sessionId", sessionId); + insert.Parameters.AddWithValue("@nodeId", nodeId); + insert.Parameters.AddWithValue("@role", role); + insert.Parameters.AddWithValue("@content", content); + insert.Parameters.AddWithValue("@imageUrl", (object?)imageUrl ?? DBNull.Value); + insert.Parameters.AddWithValue("@provider", (object?)provider ?? DBNull.Value); + insert.Parameters.AddWithValue("@model", (object?)model ?? DBNull.Value); + var rows = await insert.ExecuteNonQueryAsync(); + if (rows == 0) + throw new InvalidOperationException("Chat message creation did not persist."); + + using var touchNode = new MySqlCommand( + """ + UPDATE chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + SET node.updated_at = UTC_TIMESTAMP() + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @nodeId + """, + conn, + (MySqlTransaction)tx); + touchNode.Parameters.AddWithValue("@userId", userId); + touchNode.Parameters.AddWithValue("@sessionId", sessionId); + touchNode.Parameters.AddWithValue("@nodeId", nodeId); + await touchNode.ExecuteNonQueryAsync(); + + using var touchSession = new MySqlCommand( + """ + UPDATE chat_sessions + SET updated_at = UTC_TIMESTAMP() + WHERE user_id = @userId AND id = @sessionId + """, + conn, + (MySqlTransaction)tx); + touchSession.Parameters.AddWithValue("@userId", userId); + touchSession.Parameters.AddWithValue("@sessionId", sessionId); + await touchSession.ExecuteNonQueryAsync(); + + await tx.CommitAsync(); + return (await GetNodeMessageAsync(userId, sessionId, nodeId, Convert.ToInt32(insert.LastInsertedId))) + ?? throw new InvalidOperationException("Created chat message could not be reloaded."); + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + public async Task UpdateMessageAsync(int userId, int sessionId, int nodeId, int messageId, string content) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var tx = await conn.BeginTransactionAsync(); + + try + { + using var update = new MySqlCommand( + """ + UPDATE chat_messages message + INNER JOIN chat_nodes node ON node.id = message.node_id + INNER JOIN chat_sessions session ON session.id = node.session_id + SET message.content = @content, + message.updated_at = UTC_TIMESTAMP() + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @nodeId + AND message.id = @messageId + """, + conn, + (MySqlTransaction)tx); + update.Parameters.AddWithValue("@userId", userId); + update.Parameters.AddWithValue("@sessionId", sessionId); + update.Parameters.AddWithValue("@nodeId", nodeId); + update.Parameters.AddWithValue("@messageId", messageId); + update.Parameters.AddWithValue("@content", content); + + var rows = await update.ExecuteNonQueryAsync(); + if (rows == 0) + throw new InvalidOperationException("Chat message update did not persist."); + + using var touchNode = new MySqlCommand( + """ + UPDATE chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + SET node.updated_at = UTC_TIMESTAMP() + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @nodeId + """, + conn, + (MySqlTransaction)tx); + touchNode.Parameters.AddWithValue("@userId", userId); + touchNode.Parameters.AddWithValue("@sessionId", sessionId); + touchNode.Parameters.AddWithValue("@nodeId", nodeId); + await touchNode.ExecuteNonQueryAsync(); + + using var touchSession = new MySqlCommand( + """ + UPDATE chat_sessions + SET updated_at = UTC_TIMESTAMP() + WHERE user_id = @userId AND id = @sessionId + """, + conn, + (MySqlTransaction)tx); + touchSession.Parameters.AddWithValue("@userId", userId); + touchSession.Parameters.AddWithValue("@sessionId", sessionId); + await touchSession.ExecuteNonQueryAsync(); + + await tx.CommitAsync(); + return await GetNodeMessageAsync(userId, sessionId, nodeId, messageId) + ?? throw new InvalidOperationException("Updated chat message could not be reloaded."); + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + public async Task ReassignMessagesAsync(int userId, int sessionId, int sourceNodeId, int targetNodeId, IReadOnlyList messageIds) + { + if (messageIds.Count == 0) + return; + + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var tx = await conn.BeginTransactionAsync(); + + try + { + var placeholders = new List(messageIds.Count); + using var update = new MySqlCommand + { + Connection = conn, + Transaction = (MySqlTransaction)tx + }; + + for (var index = 0; index < messageIds.Count; index++) + { + var parameterName = $"@messageId{index}"; + placeholders.Add(parameterName); + update.Parameters.AddWithValue(parameterName, messageIds[index]); + } + + update.CommandText = + $""" + UPDATE chat_messages message + INNER JOIN chat_nodes source_node ON source_node.id = message.node_id + INNER JOIN chat_sessions session ON session.id = source_node.session_id + INNER JOIN chat_nodes target_node ON target_node.id = @targetNodeId AND target_node.session_id = session.id + SET message.node_id = @targetNodeId, + message.updated_at = UTC_TIMESTAMP() + WHERE session.user_id = @userId + AND session.id = @sessionId + AND source_node.id = @sourceNodeId + AND message.id IN ({string.Join(", ", placeholders)}) + """; + update.Parameters.AddWithValue("@userId", userId); + update.Parameters.AddWithValue("@sessionId", sessionId); + update.Parameters.AddWithValue("@sourceNodeId", sourceNodeId); + update.Parameters.AddWithValue("@targetNodeId", targetNodeId); + + var movedRows = await update.ExecuteNonQueryAsync(); + if (movedRows != messageIds.Count) + throw new InvalidOperationException("Message reassignment was incomplete."); + + using var touchSourceNode = new MySqlCommand( + """ + UPDATE chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + SET node.updated_at = UTC_TIMESTAMP() + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @sourceNodeId + """, + conn, + (MySqlTransaction)tx); + touchSourceNode.Parameters.AddWithValue("@userId", userId); + touchSourceNode.Parameters.AddWithValue("@sessionId", sessionId); + touchSourceNode.Parameters.AddWithValue("@sourceNodeId", sourceNodeId); + await touchSourceNode.ExecuteNonQueryAsync(); + + using var touchTargetNode = new MySqlCommand( + """ + UPDATE chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + SET node.updated_at = UTC_TIMESTAMP() + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.id = @targetNodeId + """, + conn, + (MySqlTransaction)tx); + touchTargetNode.Parameters.AddWithValue("@userId", userId); + touchTargetNode.Parameters.AddWithValue("@sessionId", sessionId); + touchTargetNode.Parameters.AddWithValue("@targetNodeId", targetNodeId); + await touchTargetNode.ExecuteNonQueryAsync(); + + using var touchSession = new MySqlCommand( + """ + UPDATE chat_sessions + SET updated_at = UTC_TIMESTAMP() + WHERE user_id = @userId AND id = @sessionId + """, + conn, + (MySqlTransaction)tx); + touchSession.Parameters.AddWithValue("@userId", userId); + touchSession.Parameters.AddWithValue("@sessionId", sessionId); + await touchSession.ExecuteNonQueryAsync(); + + await tx.CommitAsync(); + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + private async Task GetRootNodeAsync(int userId, int sessionId) + { + using var conn = new MySqlConnection(_conn); + await conn.OpenAsync(); + await EnsureSchemaAsync(conn); + using var cmd = new MySqlCommand( + """ + SELECT node.id, node.session_id, node.parent_node_id, node.branch_from_message_id, node.node_type, node.title, node.preview, node.content, node.position_x, node.position_y, node.width, node.height, node.is_collapsed, node.expanded_width, node.expanded_height, node.preferred_provider, node.preferred_model, node.context_boundary_message_id, node.context_snapshot_json, node.context_snapshot_updated_at, node.sibling_order, node.created_at, node.updated_at + FROM chat_nodes node + INNER JOIN chat_sessions session ON session.id = node.session_id + WHERE session.user_id = @userId + AND session.id = @sessionId + AND node.parent_node_id IS NULL + LIMIT 1 + """, + conn); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@sessionId", sessionId); + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + return MapNode((MySqlDataReader)reader); + + return null; + } + + private static ChatSession MapSession(MySqlDataReader reader) + { + return new ChatSession + { + Id = reader.GetInt32("id"), + UserId = reader.GetInt32("user_id"), + Title = reader.GetString("title"), + CreatedAt = reader.GetDateTime("created_at"), + UpdatedAt = reader.GetDateTime("updated_at") + }; + } + + private static ChatNode MapNode(MySqlDataReader reader) + { + var parentNodeOrdinal = reader.GetOrdinal("parent_node_id"); + var branchFromMessageOrdinal = reader.GetOrdinal("branch_from_message_id"); + var isCollapsedOrdinal = reader.GetOrdinal("is_collapsed"); + var expandedWidthOrdinal = reader.GetOrdinal("expanded_width"); + var expandedHeightOrdinal = reader.GetOrdinal("expanded_height"); + var preferredProviderOrdinal = reader.GetOrdinal("preferred_provider"); + var preferredModelOrdinal = reader.GetOrdinal("preferred_model"); + var contextBoundaryOrdinal = reader.GetOrdinal("context_boundary_message_id"); + var contextSnapshotOrdinal = reader.GetOrdinal("context_snapshot_json"); + var contextSnapshotUpdatedOrdinal = reader.GetOrdinal("context_snapshot_updated_at"); + + return new ChatNode + { + Id = reader.GetInt32("id"), + SessionId = reader.GetInt32("session_id"), + ParentNodeId = reader.IsDBNull(parentNodeOrdinal) ? null : reader.GetInt32(parentNodeOrdinal), + BranchFromMessageId = reader.IsDBNull(branchFromMessageOrdinal) ? null : reader.GetInt32(branchFromMessageOrdinal), + NodeType = reader.GetString("node_type"), + Title = reader.GetString("title"), + Preview = reader.GetString("preview"), + Content = reader.GetString("content"), + PositionX = reader.GetDouble("position_x"), + PositionY = reader.GetDouble("position_y"), + Width = reader.GetDouble("width"), + Height = reader.GetDouble("height"), + IsCollapsed = reader.GetBoolean(isCollapsedOrdinal), + ExpandedWidth = reader.IsDBNull(expandedWidthOrdinal) ? null : reader.GetDouble(expandedWidthOrdinal), + ExpandedHeight = reader.IsDBNull(expandedHeightOrdinal) ? null : reader.GetDouble(expandedHeightOrdinal), + PreferredProvider = reader.IsDBNull(preferredProviderOrdinal) ? null : reader.GetString(preferredProviderOrdinal), + PreferredModel = reader.IsDBNull(preferredModelOrdinal) ? null : reader.GetString(preferredModelOrdinal), + ContextBoundaryMessageId = reader.IsDBNull(contextBoundaryOrdinal) ? null : reader.GetInt32(contextBoundaryOrdinal), + ContextSnapshotJson = reader.IsDBNull(contextSnapshotOrdinal) ? null : reader.GetString(contextSnapshotOrdinal), + ContextSnapshotUpdatedAt = reader.IsDBNull(contextSnapshotUpdatedOrdinal) ? null : reader.GetDateTime(contextSnapshotUpdatedOrdinal), + SiblingOrder = reader.GetInt32("sibling_order"), + CreatedAt = reader.GetDateTime("created_at"), + UpdatedAt = reader.GetDateTime("updated_at") + }; + } + + private static ChatMessage MapMessage(MySqlDataReader reader) + { + var imageUrlOrdinal = reader.GetOrdinal("image_url"); + var providerOrdinal = reader.GetOrdinal("provider"); + var modelOrdinal = reader.GetOrdinal("model"); + + return new ChatMessage + { + Id = reader.GetInt32("id"), + NodeId = reader.GetInt32("node_id"), + Role = reader.GetString("role"), + Content = reader.GetString("content"), + ImageUrl = reader.IsDBNull(imageUrlOrdinal) ? null : reader.GetString(imageUrlOrdinal), + Provider = reader.IsDBNull(providerOrdinal) ? null : reader.GetString(providerOrdinal), + Model = reader.IsDBNull(modelOrdinal) ? null : reader.GetString(modelOrdinal), + CreatedAt = reader.GetDateTime("created_at"), + UpdatedAt = reader.GetDateTime("updated_at") + }; + } + + private static async Task EnsureSchemaAsync(MySqlConnection conn) + { + using var sessionsCmd = new MySqlCommand( + """ + CREATE TABLE IF NOT EXISTS chat_sessions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + title VARCHAR(120) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_chat_sessions_user_updated (user_id, updated_at, id) + ) + """, + conn); + await sessionsCmd.ExecuteNonQueryAsync(); + + using var nodesCmd = new MySqlCommand( + """ + CREATE TABLE IF NOT EXISTS chat_nodes ( + id INT AUTO_INCREMENT PRIMARY KEY, + session_id INT NOT NULL, + parent_node_id INT NULL, + branch_from_message_id INT NULL, + node_type VARCHAR(24) NOT NULL DEFAULT 'question', + title VARCHAR(160) NOT NULL, + preview VARCHAR(280) NOT NULL DEFAULT '', + content MEDIUMTEXT NOT NULL, + position_x DOUBLE NOT NULL DEFAULT 0, + position_y DOUBLE NOT NULL DEFAULT 0, + width DOUBLE NOT NULL DEFAULT 600, + height DOUBLE NOT NULL DEFAULT 520, + is_collapsed TINYINT(1) NOT NULL DEFAULT 0, + expanded_width DOUBLE NULL, + expanded_height DOUBLE NULL, + preferred_provider VARCHAR(32) NULL, + preferred_model VARCHAR(120) NULL, + context_boundary_message_id INT NULL, + context_snapshot_json MEDIUMTEXT NULL, + context_snapshot_updated_at TIMESTAMP NULL, + sibling_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (parent_node_id) REFERENCES chat_nodes(id) ON DELETE CASCADE, + INDEX idx_chat_nodes_session_parent (session_id, parent_node_id, sibling_order, id), + INDEX idx_chat_nodes_branch_message (branch_from_message_id) + ) + """, + conn); + await nodesCmd.ExecuteNonQueryAsync(); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "node_type", + """ + ALTER TABLE chat_nodes + ADD COLUMN node_type VARCHAR(24) NOT NULL DEFAULT 'question' AFTER branch_from_message_id + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "content", + """ + ALTER TABLE chat_nodes + ADD COLUMN content MEDIUMTEXT NOT NULL DEFAULT '' AFTER preview + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "width", + """ + ALTER TABLE chat_nodes + ADD COLUMN width DOUBLE NOT NULL DEFAULT 600 AFTER position_y + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "height", + """ + ALTER TABLE chat_nodes + ADD COLUMN height DOUBLE NOT NULL DEFAULT 520 AFTER width + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "is_collapsed", + """ + ALTER TABLE chat_nodes + ADD COLUMN is_collapsed TINYINT(1) NOT NULL DEFAULT 0 AFTER height + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "expanded_width", + """ + ALTER TABLE chat_nodes + ADD COLUMN expanded_width DOUBLE NULL AFTER is_collapsed + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "expanded_height", + """ + ALTER TABLE chat_nodes + ADD COLUMN expanded_height DOUBLE NULL AFTER expanded_width + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "preferred_provider", + """ + ALTER TABLE chat_nodes + ADD COLUMN preferred_provider VARCHAR(32) NULL AFTER expanded_height + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "preferred_model", + """ + ALTER TABLE chat_nodes + ADD COLUMN preferred_model VARCHAR(120) NULL AFTER preferred_provider + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "context_boundary_message_id", + """ + ALTER TABLE chat_nodes + ADD COLUMN context_boundary_message_id INT NULL AFTER preferred_model + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "context_snapshot_json", + """ + ALTER TABLE chat_nodes + ADD COLUMN context_snapshot_json MEDIUMTEXT NULL AFTER context_boundary_message_id + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "context_snapshot_updated_at", + """ + ALTER TABLE chat_nodes + ADD COLUMN context_snapshot_updated_at TIMESTAMP NULL AFTER context_snapshot_json + """); + + using var backfillNodeTypesCmd = new MySqlCommand( + """ + UPDATE chat_nodes + SET node_type = CASE + WHEN parent_node_id IS NULL THEN 'question' + WHEN branch_from_message_id IS NOT NULL THEN 'response' + ELSE node_type + END + WHERE parent_node_id IS NULL + OR branch_from_message_id IS NOT NULL + """, + conn); + await backfillNodeTypesCmd.ExecuteNonQueryAsync(); + + using var backfillNoteExpansionCmd = new MySqlCommand( + """ + UPDATE chat_nodes + SET is_collapsed = COALESCE(is_collapsed, 0), + expanded_width = COALESCE(expanded_width, width), + expanded_height = COALESCE(expanded_height, height) + WHERE node_type = 'note' + """, + conn); + await backfillNoteExpansionCmd.ExecuteNonQueryAsync(); + + using var messagesCmd = new MySqlCommand( + """ + CREATE TABLE IF NOT EXISTS chat_messages ( + id INT AUTO_INCREMENT PRIMARY KEY, + node_id INT NOT NULL, + role VARCHAR(16) NOT NULL, + content MEDIUMTEXT NOT NULL, + image_url MEDIUMTEXT NULL, + provider VARCHAR(32) NULL, + model VARCHAR(120) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (node_id) REFERENCES chat_nodes(id) ON DELETE CASCADE, + INDEX idx_chat_messages_node_order (node_id, id) + ) + """, + conn); + await messagesCmd.ExecuteNonQueryAsync(); + + await EnsureColumnAsync( + conn, + "chat_messages", + "image_url", + """ + ALTER TABLE chat_messages + ADD COLUMN image_url MEDIUMTEXT NULL AFTER content + """); + + await EnsureColumnAsync( + conn, + "chat_messages", + "updated_at", + """ + ALTER TABLE chat_messages + ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER created_at + """); + } + + private static async Task EnsureColumnAsync(MySqlConnection conn, string tableName, string columnName, string alterStatement) + { + if (await ColumnExistsAsync(conn, tableName, columnName)) + return; + + using var alterCmd = new MySqlCommand(alterStatement, conn); + await alterCmd.ExecuteNonQueryAsync(); + } + + private static async Task ColumnExistsAsync(MySqlConnection conn, string tableName, string columnName) + { + using var cmd = new MySqlCommand( + """ + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = @tableName + AND column_name = @columnName + """, + conn); + cmd.Parameters.AddWithValue("@tableName", tableName); + cmd.Parameters.AddWithValue("@columnName", columnName); + + var count = Convert.ToInt32(await cmd.ExecuteScalarAsync()); + return count > 0; + } +} diff --git a/backend/Coliee.API/Data/ICanvasRepository.cs b/backend/Coliee.API/Data/ICanvasRepository.cs index 827806a..909d642 100644 --- a/backend/Coliee.API/Data/ICanvasRepository.cs +++ b/backend/Coliee.API/Data/ICanvasRepository.cs @@ -14,5 +14,6 @@ public interface ICanvasRepository Task EnsureRootCanvasNodeAsync(int userId, int canvasId); Task> GetCanvasNodesAsync(int userId, int canvasId); Task GetCanvasNodeAsync(int userId, int canvasId, int nodeId); + Task UpdateCanvasNodeAsync(int userId, int canvasId, int nodeId, CanvasNodeUpdateInput node); Task> AddChildCanvasNodesAsync(int userId, int canvasId, int parentNodeId, IReadOnlyList nodes); } diff --git a/backend/Coliee.API/Data/IChatSessionRepository.cs b/backend/Coliee.API/Data/IChatSessionRepository.cs new file mode 100644 index 0000000..b1b552b --- /dev/null +++ b/backend/Coliee.API/Data/IChatSessionRepository.cs @@ -0,0 +1,27 @@ +using Coliee.API.Models; +using Coliee.API.Services; + +namespace Coliee.API.Data; + +public interface IChatSessionRepository +{ + Task CreateSessionAsync(int userId, string title); + Task> GetSessionsAsync(int userId); + Task GetSessionAsync(int userId, int sessionId); + Task RenameSessionAsync(int userId, int sessionId, string title); + Task DeleteSessionAsync(int userId, int sessionId); + Task EnsureRootNodeAsync(int userId, int sessionId, string title); + Task> GetNodesAsync(int userId, int sessionId); + Task GetNodeAsync(int userId, int sessionId, int nodeId); + Task CreateNodeAsync(int userId, int sessionId, ChatNodeCreateInput node); + Task UpdateNodeAsync(int userId, int sessionId, int nodeId, ChatNodeUpdateInput node); + Task GetNodeContextSnapshotAsync(int userId, int sessionId, int nodeId); + Task UpdateNodeContextSnapshotAsync(int userId, int sessionId, int nodeId, int boundaryMessageId, string snapshotJson); + Task SaveNodeLayoutsAsync(int userId, int sessionId, IReadOnlyList nodes); + Task DeleteNodeAsync(int userId, int sessionId, int nodeId); + Task> GetNodeMessagesAsync(int userId, int sessionId, int nodeId); + Task GetNodeMessageAsync(int userId, int sessionId, int nodeId, int messageId); + Task AddMessageAsync(int userId, int sessionId, int nodeId, string role, string content, string? imageUrl, string? provider, string? model); + Task UpdateMessageAsync(int userId, int sessionId, int nodeId, int messageId, string content); + Task ReassignMessagesAsync(int userId, int sessionId, int sourceNodeId, int targetNodeId, IReadOnlyList messageIds); +} diff --git a/backend/Coliee.API/Data/LlmRepository.cs b/backend/Coliee.API/Data/LlmRepository.cs index bdeb9a3..c5fa90e 100644 --- a/backend/Coliee.API/Data/LlmRepository.cs +++ b/backend/Coliee.API/Data/LlmRepository.cs @@ -18,12 +18,13 @@ public async Task> GetProviderKeysAsync(int using var conn = new MySqlConnection(_conn); await conn.OpenAsync(); + await EnsureSchemaAsync(conn); using var cmd = new MySqlCommand( """ SELECT user_id, provider, encrypted_api_key, masked_last4, is_default, last_tested_at, created_at, updated_at FROM llm_provider_keys WHERE user_id = @userId - ORDER BY FIELD(provider, 'openai', 'claude', 'gemini') + ORDER BY FIELD(provider, 'openai', 'claude', 'gemini', 'lmstudio') """, conn); cmd.Parameters.AddWithValue("@userId", userId); @@ -39,6 +40,7 @@ ORDER BY FIELD(provider, 'openai', 'claude', 'gemini') { using var conn = new MySqlConnection(_conn); await conn.OpenAsync(); + await EnsureSchemaAsync(conn); using var cmd = new MySqlCommand( """ SELECT user_id, provider, encrypted_api_key, masked_last4, is_default, last_tested_at, created_at, updated_at @@ -60,6 +62,7 @@ public async Task UpsertProviderKeyAsync(int userId, string provider, string enc { using var conn = new MySqlConnection(_conn); await conn.OpenAsync(); + await EnsureSchemaAsync(conn); using var cmd = new MySqlCommand( """ INSERT INTO llm_provider_keys (user_id, provider, encrypted_api_key, masked_last4, is_default, created_at, updated_at) @@ -82,6 +85,7 @@ public async Task DeleteProviderKeyAsync(int userId, string provider) { using var conn = new MySqlConnection(_conn); await conn.OpenAsync(); + await EnsureSchemaAsync(conn); using var cmd = new MySqlCommand( "DELETE FROM llm_provider_keys WHERE user_id = @userId AND provider = @provider", conn); @@ -94,6 +98,7 @@ public async Task SetDefaultProviderAsync(int userId, string? provider) { using var conn = new MySqlConnection(_conn); await conn.OpenAsync(); + await EnsureSchemaAsync(conn); using var cmd = new MySqlCommand( """ UPDATE llm_provider_keys @@ -114,6 +119,7 @@ public async Task MarkProviderTestedAsync(int userId, string provider, DateTime { using var conn = new MySqlConnection(_conn); await conn.OpenAsync(); + await EnsureSchemaAsync(conn); using var cmd = new MySqlCommand( """ UPDATE llm_provider_keys @@ -135,6 +141,7 @@ public async Task GetOrCreateMainNodeConversationAsync(int userId, int canv using var conn = new MySqlConnection(_conn); await conn.OpenAsync(); + await EnsureSchemaAsync(conn); using var cmd = new MySqlCommand( """ INSERT INTO main_node_conversations (canvas_id, created_at, updated_at) @@ -168,6 +175,7 @@ FROM canvases { using var conn = new MySqlConnection(_conn); await conn.OpenAsync(); + await EnsureSchemaAsync(conn); using var cmd = new MySqlCommand( """ SELECT conversation.id @@ -190,6 +198,7 @@ public async Task> GetMainNodeMessagesAsync(int u using var conn = new MySqlConnection(_conn); await conn.OpenAsync(); + await EnsureSchemaAsync(conn); using var cmd = new MySqlCommand( """ SELECT m.id, m.conversation_id, m.role, m.content, m.provider, m.model, m.created_at @@ -216,6 +225,7 @@ public async Task AddMainNodeMessageAsync(int userId, int canvasId, string using var conn = new MySqlConnection(_conn); await conn.OpenAsync(); + await EnsureSchemaAsync(conn); using var tx = await conn.BeginTransactionAsync(); try @@ -302,4 +312,104 @@ private static MainNodeMessage MapMessage(MySqlDataReader reader) CreatedAt = reader.GetDateTime("created_at") }; } + + private static async Task EnsureSchemaAsync(MySqlConnection conn) + { + using var providerKeysCmd = new MySqlCommand( + """ + CREATE TABLE IF NOT EXISTS llm_provider_keys ( + user_id INT NOT NULL, + provider VARCHAR(32) NOT NULL, + encrypted_api_key MEDIUMTEXT NOT NULL, + masked_last4 VARCHAR(16) NOT NULL, + is_default TINYINT(1) NOT NULL DEFAULT 0, + last_tested_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, provider), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """, + conn); + await providerKeysCmd.ExecuteNonQueryAsync(); + + await EnsureColumnExistsAsync(conn, "llm_provider_keys", "encrypted_api_key", "MEDIUMTEXT NOT NULL", "provider"); + await EnsureColumnExistsAsync(conn, "llm_provider_keys", "masked_last4", "VARCHAR(16) NOT NULL", "encrypted_api_key"); + await EnsureColumnExistsAsync(conn, "llm_provider_keys", "is_default", "TINYINT(1) NOT NULL DEFAULT 0", "masked_last4"); + await EnsureColumnExistsAsync(conn, "llm_provider_keys", "last_tested_at", "TIMESTAMP NULL DEFAULT NULL", "is_default"); + await EnsureColumnExistsAsync(conn, "llm_provider_keys", "created_at", "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", "last_tested_at"); + await EnsureColumnExistsAsync(conn, "llm_provider_keys", "updated_at", "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", "created_at"); + + using var conversationsCmd = new MySqlCommand( + """ + CREATE TABLE IF NOT EXISTS main_node_conversations ( + id INT AUTO_INCREMENT PRIMARY KEY, + canvas_id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_main_node_conversations_canvas (canvas_id), + FOREIGN KEY (canvas_id) REFERENCES canvases(id) ON DELETE CASCADE + ) + """, + conn); + await conversationsCmd.ExecuteNonQueryAsync(); + + await EnsureColumnExistsAsync(conn, "main_node_conversations", "canvas_id", "INT NOT NULL", "id"); + await EnsureColumnExistsAsync(conn, "main_node_conversations", "created_at", "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", "canvas_id"); + await EnsureColumnExistsAsync(conn, "main_node_conversations", "updated_at", "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", "created_at"); + + using var messagesCmd = new MySqlCommand( + """ + CREATE TABLE IF NOT EXISTS main_node_messages ( + id INT AUTO_INCREMENT PRIMARY KEY, + conversation_id INT NOT NULL, + role VARCHAR(16) NOT NULL, + content MEDIUMTEXT NOT NULL, + provider VARCHAR(32) NULL, + model VARCHAR(120) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (conversation_id) REFERENCES main_node_conversations(id) ON DELETE CASCADE, + INDEX idx_main_node_messages_conversation_order (conversation_id, id) + ) + """, + conn); + await messagesCmd.ExecuteNonQueryAsync(); + + await EnsureColumnExistsAsync(conn, "main_node_messages", "conversation_id", "INT NOT NULL", "id"); + await EnsureColumnExistsAsync(conn, "main_node_messages", "role", "VARCHAR(16) NOT NULL", "conversation_id"); + await EnsureColumnExistsAsync(conn, "main_node_messages", "content", "MEDIUMTEXT NOT NULL", "role"); + await EnsureColumnExistsAsync(conn, "main_node_messages", "provider", "VARCHAR(32) NULL", "content"); + await EnsureColumnExistsAsync(conn, "main_node_messages", "model", "VARCHAR(120) NULL", "provider"); + await EnsureColumnExistsAsync(conn, "main_node_messages", "created_at", "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", "model"); + } + + private static async Task EnsureColumnExistsAsync( + MySqlConnection conn, + string tableName, + string columnName, + string columnDefinition, + string? afterColumn = null) + { + using var existsCmd = new MySqlCommand( + """ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @tableName + AND COLUMN_NAME = @columnName + """, + conn); + existsCmd.Parameters.AddWithValue("@tableName", tableName); + existsCmd.Parameters.AddWithValue("@columnName", columnName); + + var exists = Convert.ToInt32(await existsCmd.ExecuteScalarAsync()) > 0; + if (exists) + return; + + var placement = string.IsNullOrWhiteSpace(afterColumn) ? string.Empty : $" AFTER {afterColumn}"; + using var alterCmd = new MySqlCommand( + $"ALTER TABLE {tableName} ADD COLUMN {columnName} {columnDefinition}{placement};", + conn); + await alterCmd.ExecuteNonQueryAsync(); + } } diff --git a/backend/Coliee.API/Migrations/006_canvas_node_generation_modes.sql b/backend/Coliee.API/Migrations/006_canvas_node_generation_modes.sql new file mode 100644 index 0000000..c301097 --- /dev/null +++ b/backend/Coliee.API/Migrations/006_canvas_node_generation_modes.sql @@ -0,0 +1,84 @@ +SET @ddl = ( + SELECT IF( + EXISTS( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'canvas_nodes' + AND COLUMN_NAME = 'generation_mode' + ), + 'SELECT 1', + 'ALTER TABLE canvas_nodes ADD COLUMN generation_mode VARCHAR(16) NOT NULL DEFAULT ''compact'' AFTER title' + ) +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl = ( + SELECT IF( + EXISTS( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'canvas_nodes' + AND COLUMN_NAME = 'summary' + ), + 'SELECT 1', + 'ALTER TABLE canvas_nodes ADD COLUMN summary MEDIUMTEXT NULL AFTER generation_mode' + ) +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl = ( + SELECT IF( + EXISTS( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'canvas_nodes' + AND COLUMN_NAME = 'category' + ), + 'SELECT 1', + 'ALTER TABLE canvas_nodes ADD COLUMN category VARCHAR(80) NULL AFTER summary' + ) +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl = ( + SELECT IF( + EXISTS( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'canvas_nodes' + AND COLUMN_NAME = 'continuation_prompt' + ), + 'SELECT 1', + 'ALTER TABLE canvas_nodes ADD COLUMN continuation_prompt TEXT NULL AFTER source_prompt' + ) +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl = ( + SELECT IF( + EXISTS( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'canvas_nodes' + AND COLUMN_NAME = 'details_json' + ), + 'SELECT 1', + 'ALTER TABLE canvas_nodes ADD COLUMN details_json JSON NULL AFTER continuation_prompt' + ) +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/Coliee.API/Migrations/007_chat_sessions.sql b/backend/Coliee.API/Migrations/007_chat_sessions.sql new file mode 100644 index 0000000..f9fff1b --- /dev/null +++ b/backend/Coliee.API/Migrations/007_chat_sessions.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS chat_sessions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + title VARCHAR(120) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_chat_sessions_user_updated (user_id, updated_at, id) +); + +CREATE TABLE IF NOT EXISTS chat_nodes ( + id INT AUTO_INCREMENT PRIMARY KEY, + session_id INT NOT NULL, + parent_node_id INT NULL, + branch_from_message_id INT NULL, + title VARCHAR(160) NOT NULL, + preview VARCHAR(280) NOT NULL DEFAULT '', + position_x DOUBLE NOT NULL DEFAULT 0, + position_y DOUBLE NOT NULL DEFAULT 0, + sibling_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (parent_node_id) REFERENCES chat_nodes(id) ON DELETE CASCADE, + INDEX idx_chat_nodes_session_parent (session_id, parent_node_id, sibling_order, id), + INDEX idx_chat_nodes_branch_message (branch_from_message_id) +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id INT AUTO_INCREMENT PRIMARY KEY, + node_id INT NOT NULL, + role VARCHAR(16) NOT NULL, + content MEDIUMTEXT NOT NULL, + provider VARCHAR(32) NULL, + model VARCHAR(120) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (node_id) REFERENCES chat_nodes(id) ON DELETE CASCADE, + INDEX idx_chat_messages_node_order (node_id, id) +); diff --git a/backend/Coliee.API/Models/CanvasGenerationModes.cs b/backend/Coliee.API/Models/CanvasGenerationModes.cs new file mode 100644 index 0000000..ebfda43 --- /dev/null +++ b/backend/Coliee.API/Models/CanvasGenerationModes.cs @@ -0,0 +1,18 @@ +namespace Coliee.API.Models; + +public static class CanvasGenerationModes +{ + public const string Compact = "compact"; + public const string Rich = "rich"; + + public static readonly IReadOnlyList Ordered = [Compact, Rich]; + + public static string Normalize(string? mode) + { + if (string.IsNullOrWhiteSpace(mode)) + return Compact; + + var normalized = mode.Trim().ToLowerInvariant(); + return Ordered.Contains(normalized, StringComparer.Ordinal) ? normalized : Compact; + } +} diff --git a/backend/Coliee.API/Models/CanvasNode.cs b/backend/Coliee.API/Models/CanvasNode.cs index 1da8d3f..f6deb42 100644 --- a/backend/Coliee.API/Models/CanvasNode.cs +++ b/backend/Coliee.API/Models/CanvasNode.cs @@ -7,8 +7,14 @@ public class CanvasNode public int? ParentNodeId { get; set; } public string Kind { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; + public string GenerationMode { get; set; } = CanvasGenerationModes.Compact; + public string? Summary { get; set; } + public string? Category { get; set; } public string Content { get; set; } = string.Empty; public string? SourcePrompt { get; set; } + public string? ContinuationPrompt { get; set; } + public List Blocks { get; set; } = []; + public List Sources { get; set; } = []; public int Depth { get; set; } public int SiblingOrder { get; set; } public DateTime CreatedAt { get; set; } diff --git a/backend/Coliee.API/Models/CanvasNodeBlock.cs b/backend/Coliee.API/Models/CanvasNodeBlock.cs new file mode 100644 index 0000000..2640570 --- /dev/null +++ b/backend/Coliee.API/Models/CanvasNodeBlock.cs @@ -0,0 +1,8 @@ +namespace Coliee.API.Models; + +public class CanvasNodeBlock +{ + public string Type { get; set; } = string.Empty; + public string Text { get; set; } = string.Empty; + public List Options { get; set; } = []; +} diff --git a/backend/Coliee.API/Models/CanvasNodeCreateInput.cs b/backend/Coliee.API/Models/CanvasNodeCreateInput.cs index 0b2d6e2..4c05e20 100644 --- a/backend/Coliee.API/Models/CanvasNodeCreateInput.cs +++ b/backend/Coliee.API/Models/CanvasNodeCreateInput.cs @@ -4,7 +4,13 @@ public class CanvasNodeCreateInput { public string Kind { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; + public string GenerationMode { get; set; } = CanvasGenerationModes.Compact; + public string? Summary { get; set; } + public string? Category { get; set; } public string Content { get; set; } = string.Empty; public string? SourcePrompt { get; set; } + public string? ContinuationPrompt { get; set; } + public List Blocks { get; set; } = []; + public List Sources { get; set; } = []; public int Depth { get; set; } } diff --git a/backend/Coliee.API/Models/CanvasNodeSource.cs b/backend/Coliee.API/Models/CanvasNodeSource.cs new file mode 100644 index 0000000..4be1af3 --- /dev/null +++ b/backend/Coliee.API/Models/CanvasNodeSource.cs @@ -0,0 +1,7 @@ +namespace Coliee.API.Models; + +public class CanvasNodeSource +{ + public string Label { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; +} diff --git a/backend/Coliee.API/Models/CanvasNodeUpdateInput.cs b/backend/Coliee.API/Models/CanvasNodeUpdateInput.cs new file mode 100644 index 0000000..28f9083 --- /dev/null +++ b/backend/Coliee.API/Models/CanvasNodeUpdateInput.cs @@ -0,0 +1,13 @@ +namespace Coliee.API.Models; + +public class CanvasNodeUpdateInput +{ + public string GenerationMode { get; set; } = CanvasGenerationModes.Compact; + public string? Summary { get; set; } + public string? Category { get; set; } + public string Content { get; set; } = string.Empty; + public string? SourcePrompt { get; set; } + public string? ContinuationPrompt { get; set; } + public List Blocks { get; set; } = []; + public List Sources { get; set; } = []; +} diff --git a/backend/Coliee.API/Models/ChatMessage.cs b/backend/Coliee.API/Models/ChatMessage.cs new file mode 100644 index 0000000..e848568 --- /dev/null +++ b/backend/Coliee.API/Models/ChatMessage.cs @@ -0,0 +1,14 @@ +namespace Coliee.API.Models; + +public class ChatMessage +{ + public int Id { get; set; } + public int NodeId { get; set; } + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string? ImageUrl { get; set; } + public string? Provider { get; set; } + public string? Model { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/backend/Coliee.API/Models/ChatNode.cs b/backend/Coliee.API/Models/ChatNode.cs new file mode 100644 index 0000000..46a92e7 --- /dev/null +++ b/backend/Coliee.API/Models/ChatNode.cs @@ -0,0 +1,28 @@ +namespace Coliee.API.Models; + +public class ChatNode +{ + public int Id { get; set; } + public int SessionId { get; set; } + public int? ParentNodeId { get; set; } + public int? BranchFromMessageId { get; set; } + public string NodeType { get; set; } = "question"; + public string Title { get; set; } = string.Empty; + public string Preview { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public double PositionX { get; set; } + public double PositionY { get; set; } + public double Width { get; set; } + public double Height { get; set; } + public bool IsCollapsed { get; set; } + public double? ExpandedWidth { get; set; } + public double? ExpandedHeight { get; set; } + public string? PreferredProvider { get; set; } + public string? PreferredModel { get; set; } + public int? ContextBoundaryMessageId { get; set; } + public string? ContextSnapshotJson { get; set; } + public DateTime? ContextSnapshotUpdatedAt { get; set; } + public int SiblingOrder { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/backend/Coliee.API/Models/ChatNodeCreateInput.cs b/backend/Coliee.API/Models/ChatNodeCreateInput.cs new file mode 100644 index 0000000..9fd5865 --- /dev/null +++ b/backend/Coliee.API/Models/ChatNodeCreateInput.cs @@ -0,0 +1,20 @@ +namespace Coliee.API.Models; + +public class ChatNodeCreateInput +{ + public int? ParentNodeId { get; set; } + public int? BranchFromMessageId { get; set; } + public string NodeType { get; set; } = "question"; + public string Title { get; set; } = string.Empty; + public string Preview { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public double PositionX { get; set; } + public double PositionY { get; set; } + public double Width { get; set; } + public double Height { get; set; } + public bool IsCollapsed { get; set; } + public double? ExpandedWidth { get; set; } + public double? ExpandedHeight { get; set; } + public string? PreferredProvider { get; set; } + public string? PreferredModel { get; set; } +} diff --git a/backend/Coliee.API/Models/ChatNodeLayoutUpdateInput.cs b/backend/Coliee.API/Models/ChatNodeLayoutUpdateInput.cs new file mode 100644 index 0000000..7b9eba5 --- /dev/null +++ b/backend/Coliee.API/Models/ChatNodeLayoutUpdateInput.cs @@ -0,0 +1,8 @@ +namespace Coliee.API.Models; + +public class ChatNodeLayoutUpdateInput +{ + public int NodeId { get; set; } + public double PositionX { get; set; } + public double PositionY { get; set; } +} diff --git a/backend/Coliee.API/Models/ChatNodeTypes.cs b/backend/Coliee.API/Models/ChatNodeTypes.cs new file mode 100644 index 0000000..0b5f8f9 --- /dev/null +++ b/backend/Coliee.API/Models/ChatNodeTypes.cs @@ -0,0 +1,19 @@ +namespace Coliee.API.Models; + +public static class ChatNodeTypes +{ + public const string Question = "question"; + public const string Response = "response"; + public const string Note = "note"; + + public static string Normalize(string? nodeType) + { + var normalized = nodeType?.Trim().ToLowerInvariant(); + return normalized switch + { + Response => Response, + Note => Note, + _ => Question + }; + } +} diff --git a/backend/Coliee.API/Models/ChatNodeUpdateInput.cs b/backend/Coliee.API/Models/ChatNodeUpdateInput.cs new file mode 100644 index 0000000..9f29ac7 --- /dev/null +++ b/backend/Coliee.API/Models/ChatNodeUpdateInput.cs @@ -0,0 +1,18 @@ +namespace Coliee.API.Models; + +public class ChatNodeUpdateInput +{ + public string NodeType { get; set; } = "question"; + public string Title { get; set; } = string.Empty; + public string Preview { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public double PositionX { get; set; } + public double PositionY { get; set; } + public double Width { get; set; } + public double Height { get; set; } + public bool IsCollapsed { get; set; } + public double? ExpandedWidth { get; set; } + public double? ExpandedHeight { get; set; } + public string? PreferredProvider { get; set; } + public string? PreferredModel { get; set; } +} diff --git a/backend/Coliee.API/Models/ChatSession.cs b/backend/Coliee.API/Models/ChatSession.cs new file mode 100644 index 0000000..65d4aca --- /dev/null +++ b/backend/Coliee.API/Models/ChatSession.cs @@ -0,0 +1,10 @@ +namespace Coliee.API.Models; + +public class ChatSession +{ + public int Id { get; set; } + public int UserId { get; set; } + public string Title { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/backend/Coliee.API/Program.cs b/backend/Coliee.API/Program.cs index 48ea546..c9e443e 100644 --- a/backend/Coliee.API/Program.cs +++ b/backend/Coliee.API/Program.cs @@ -11,6 +11,7 @@ builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddMemoryCache(); // Register repositories and services builder.Services.AddScoped(); @@ -18,9 +19,14 @@ builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -35,6 +41,8 @@ builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddHttpClient(); builder.Services.AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddSingleton(TimeProvider.System); // Rate limiting configuration diff --git a/backend/Coliee.API/Services/CanvasConversationMessage.cs b/backend/Coliee.API/Services/CanvasConversationMessage.cs new file mode 100644 index 0000000..fc58235 --- /dev/null +++ b/backend/Coliee.API/Services/CanvasConversationMessage.cs @@ -0,0 +1,3 @@ +namespace Coliee.API.Services; + +public sealed record CanvasConversationMessage(string Role, string Content, string? ImageUrl = null); diff --git a/backend/Coliee.API/Services/CanvasCreationService.cs b/backend/Coliee.API/Services/CanvasCreationService.cs new file mode 100644 index 0000000..3f6ebe3 --- /dev/null +++ b/backend/Coliee.API/Services/CanvasCreationService.cs @@ -0,0 +1,168 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Coliee.API.Data; +using Coliee.API.DTOs; +using Coliee.API.Models; +using Microsoft.AspNetCore.Http; + +namespace Coliee.API.Services; + +public partial class CanvasCreationService : ICanvasCreationService +{ + private const int TitleMaxLength = 80; + private readonly ICanvasRepository _canvasRepository; + private readonly ICanvasGraphService _canvasGraphService; + private readonly ICanvasProviderResolver _canvasProviderResolver; + private readonly ILogger _logger; + + public CanvasCreationService( + ICanvasRepository canvasRepository, + ICanvasGraphService canvasGraphService, + ICanvasProviderResolver canvasProviderResolver, + ILogger logger) + { + _canvasRepository = canvasRepository; + _canvasGraphService = canvasGraphService; + _canvasProviderResolver = canvasProviderResolver; + _logger = logger; + } + + public async Task CreateCanvasAsync(int userId, string firstPrompt, string? generationMode = null) + { + var normalizedPrompt = NormalizePrompt(firstPrompt); + if (string.IsNullOrWhiteSpace(normalizedPrompt)) + throw new CanvasOperationException("A first prompt is required to create a canvas.", StatusCodes.Status400BadRequest); + + await _canvasProviderResolver.ResolveAsync(userId, null); + + var title = BuildTitle(normalizedPrompt); + var canvas = await _canvasRepository.CreateCanvasAsync(userId, title); + + try + { + var rootNode = await _canvasRepository.EnsureRootCanvasNodeAsync(userId, canvas.Id); + await _canvasGraphService.ExpandCanvasNodeAsync( + userId, + canvas.Id, + rootNode.Id, + normalizedPrompt, + null, + CanvasGenerationModes.Normalize(generationMode)); + + var persistedCanvas = await _canvasRepository.GetCanvasAsync(userId, canvas.Id) ?? canvas; + _logger.LogInformation("Created and seeded canvas {CanvasId} for user {UserId}.", canvas.Id, userId); + return MapCanvas(persistedCanvas); + } + catch (Exception ex) when (ex is CanvasOperationException or LlmChatException) + { + try + { + await _canvasRepository.DeleteCanvasAsync(userId, canvas.Id); + } + catch (Exception rollbackEx) + { + _logger.LogError(rollbackEx, "Failed to rollback canvas {CanvasId} for user {UserId} after seeded create failed.", canvas.Id, userId); + } + + throw; + } + } + + private static CanvasSummaryResponse MapCanvas(Models.Canvas canvas) + { + return new CanvasSummaryResponse + { + Id = canvas.Id, + Title = canvas.Title, + CreatedAt = canvas.CreatedAt, + UpdatedAt = canvas.UpdatedAt + }; + } + + private static string NormalizePrompt(string firstPrompt) + { + return WhitespaceRegex().Replace(firstPrompt.Trim(), " "); + } + + private static string BuildTitle(string normalizedPrompt) + { + var stripped = StripLeadingLowSignalText(normalizedPrompt); + var words = TokenRegex().Matches(stripped).Select(match => match.Value).ToList(); + if (words.Count == 0) + words = TokenRegex().Matches(normalizedPrompt).Select(match => match.Value).ToList(); + + var selectedWords = new List(); + foreach (var word in words) + { + var lower = word.ToLowerInvariant(); + if (selectedWords.Count == 0 && LeadingStopWords().Contains(lower)) + continue; + + if (AlwaysIgnoredWords().Contains(lower)) + continue; + + selectedWords.Add(FormatTitleWord(word)); + if (selectedWords.Count == 6) + break; + } + + if (selectedWords.Count == 0) + return "New Canvas"; + + var title = string.Join(" ", selectedWords).Trim().TrimEnd('.', '!', '?', ',', ';', ':'); + if (title.Length <= TitleMaxLength) + return title; + + var truncated = title[..TitleMaxLength].TrimEnd(); + var splitIndex = truncated.LastIndexOf(' '); + if (splitIndex >= TitleMaxLength / 2) + truncated = truncated[..splitIndex]; + + return truncated.TrimEnd('.', '!', '?', ',', ';', ':'); + } + + private static string StripLeadingLowSignalText(string prompt) + { + var stripped = prompt.Trim(); + foreach (var pattern in LeadingPhrasePatterns()) + stripped = pattern.Replace(stripped, string.Empty, 1).Trim(); + + return stripped; + } + + private static string FormatTitleWord(string word) + { + if (word.All(char.IsUpper)) + return word; + + return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(word.ToLowerInvariant()); + } + + private static HashSet LeadingStopWords() + { + return ["a", "an", "the", "my", "our", "this", "that", "these", "those"]; + } + + private static HashSet AlwaysIgnoredWords() + { + return ["a", "an", "the"]; + } + + private static Regex[] LeadingPhrasePatterns() + { + return + [ + new Regex(@"^(help me|can you|could you|would you|please)\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), + new Regex(@"^(i need to|i want to|i'm trying to|im trying to)\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), + new Regex(@"^(create|make|build|draft|outline|brainstorm|plan|write)\s+(a|an|the)?\s*canvas\s+(for|about|on)\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), + new Regex(@"^(create|make|build|draft|outline|brainstorm|plan|write)\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), + new Regex(@"^(how should we|how do we|what should we|what do we)\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant) + ]; + } + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); + + [GeneratedRegex(@"[A-Za-z0-9][A-Za-z0-9'&/-]*")] + private static partial Regex TokenRegex(); +} diff --git a/backend/Coliee.API/Services/CanvasGraphService.cs b/backend/Coliee.API/Services/CanvasGraphService.cs index 4a1c697..be697c5 100644 --- a/backend/Coliee.API/Services/CanvasGraphService.cs +++ b/backend/Coliee.API/Services/CanvasGraphService.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using Coliee.API.Data; using Coliee.API.DTOs; @@ -10,8 +11,14 @@ namespace Coliee.API.Services; public partial class CanvasGraphService : ICanvasGraphService { private const int NodeTitleMaxLength = 60; + private const int MinimumStructuredBranchCount = 2; + private const int CompactMaxContextItems = 6; + private const int CompactMaxContextChars = 2000; + private const int RichMaxContextItems = 8; + private const int RichMaxContextChars = 4000; private readonly ICanvasRepository _canvasRepository; private readonly ILlmRepository _llmRepository; + private readonly ICanvasProviderResolver _canvasProviderResolver; private readonly ILlmEncryptionService _encryptionService; private readonly IReadOnlyDictionary _providerClients; private readonly ILogger _logger; @@ -19,12 +26,14 @@ public partial class CanvasGraphService : ICanvasGraphService public CanvasGraphService( ICanvasRepository canvasRepository, ILlmRepository llmRepository, + ICanvasProviderResolver canvasProviderResolver, ILlmEncryptionService encryptionService, IEnumerable providerClients, ILogger logger) { _canvasRepository = canvasRepository; _llmRepository = llmRepository; + _canvasProviderResolver = canvasProviderResolver; _encryptionService = encryptionService; _providerClients = providerClients.ToDictionary(client => client.Provider, StringComparer.Ordinal); _logger = logger; @@ -37,7 +46,13 @@ public async Task GetCanvasGraphAsync(int userId, int canva return await BuildGraphResponseAsync(userId, canvasId, providers); } - public async Task ExpandCanvasNodeAsync(int userId, int canvasId, int nodeId, string prompt, string? providerOverride) + public async Task ExpandCanvasNodeAsync( + int userId, + int canvasId, + int nodeId, + string prompt, + string? providerOverride, + string? generationMode = null) { await EnsureCanvasExistsAsync(userId, canvasId); if (nodeId <= 0) @@ -50,165 +65,1251 @@ public async Task ExpandCanvasNodeAsync(int userId, int can if (string.IsNullOrWhiteSpace(trimmedPrompt)) throw new LlmChatException("Prompt content is required.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status400BadRequest); - var providers = await _llmRepository.GetProviderKeysAsync(userId); - var selectedProvider = ResolveProvider(providers, providerOverride); + var normalizedGenerationMode = CanvasGenerationModes.Normalize(generationMode); + var resolution = await _canvasProviderResolver.ResolveAsync(userId, providerOverride); + var providers = resolution.Providers; + var selectedProvider = resolution.Provider; var providerRecord = providers.First(item => item.Provider == selectedProvider); var decryptedApiKey = _encryptionService.Decrypt(providerRecord.EncryptedApiKey); + var providerClient = GetProviderClient(selectedProvider); var nodes = await _canvasRepository.GetCanvasNodesAsync(userId, canvasId); var selectedNode = nodes.FirstOrDefault(item => item.Id == nodeId); if (selectedNode == null) throw new CanvasOperationException("Node not found.", StatusCodes.Status404NotFound); - var providerMessages = BuildProviderMessages(nodes, selectedNode, trimmedPrompt); - var providerClient = GetProviderClient(selectedProvider); - var providerReply = await providerClient.SendChatAsync(decryptedApiKey, providerMessages); + if (IsRootMainNode(selectedNode)) + { + var rootExpansion = await GenerateRootNodeExpansionAsync( + providerClient, + decryptedApiKey, + nodes, + selectedNode, + trimmedPrompt, + normalizedGenerationMode); - var childNodes = ChunkResponse(providerReply.Content) - .Select(chunk => new CanvasNodeCreateInput + if (rootExpansion.MainNode != null) { - Kind = "response", - Title = BuildNodeTitle(chunk), - Content = chunk, - SourcePrompt = trimmedPrompt, - Depth = selectedNode.Depth + 1 - }) - .ToList(); + await _canvasRepository.UpdateCanvasNodeAsync( + userId, + canvasId, + selectedNode.Id, + rootExpansion.MainNode); + } + + if (rootExpansion.ChildNodes.Count > 0) + await _canvasRepository.AddChildCanvasNodesAsync(userId, canvasId, selectedNode.Id, rootExpansion.ChildNodes); + } + else + { + var childNodes = await GenerateChildNodesAsync( + providerClient, + decryptedApiKey, + nodes, + selectedNode, + trimmedPrompt, + normalizedGenerationMode); + + await _canvasRepository.AddChildCanvasNodesAsync(userId, canvasId, selectedNode.Id, childNodes); + } - await _canvasRepository.AddChildCanvasNodesAsync(userId, canvasId, selectedNode.Id, childNodes); _logger.LogInformation( - "Expanded canvas node {NodeId} for user {UserId} on canvas {CanvasId} with provider {Provider}.", + "Expanded canvas node {NodeId} for user {UserId} on canvas {CanvasId} with provider {Provider} in {GenerationMode} mode.", nodeId, userId, canvasId, - selectedProvider); + selectedProvider, + normalizedGenerationMode); return await BuildGraphResponseAsync(userId, canvasId, providers); } - private async Task BuildGraphResponseAsync(int userId, int canvasId, IReadOnlyList providers) + private async Task> GenerateChildNodesAsync( + ILlmProviderClient providerClient, + string apiKey, + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt, + string generationMode) + { + return generationMode == CanvasGenerationModes.Rich + ? await GenerateRichChildNodesAsync(providerClient, apiKey, allNodes, selectedNode, prompt) + : await GenerateCompactChildNodesAsync(providerClient, apiKey, allNodes, selectedNode, prompt); + } + + private static bool IsRootMainNode(CanvasNode node) + { + return node.Kind == "main" && node.ParentNodeId == null; + } + + private async Task GenerateRootNodeExpansionAsync( + ILlmProviderClient providerClient, + string apiKey, + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt, + string generationMode) + { + return generationMode == CanvasGenerationModes.Rich + ? await GenerateRichRootNodeExpansionAsync(providerClient, apiKey, allNodes, selectedNode, prompt) + : await GenerateCompactRootNodeExpansionAsync(providerClient, apiKey, allNodes, selectedNode, prompt); + } + + private async Task GenerateCompactRootNodeExpansionAsync( + ILlmProviderClient providerClient, + string apiKey, + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt) + { + var plannerResponse = await GenerateStructuredPlanAsync( + providerClient, + apiKey, + BuildRootPlannerRequest(allNodes, selectedNode, prompt, CanvasGenerationModes.Compact), + ValidateCompactRootPlannerResponse, + "compact root"); + + if (plannerResponse?.MainNode != null) + { + var childNodes = plannerResponse.Branches + .Where(branch => !IsFoundationLikeBranch(branch.Title)) + .Select(branch => + { + var normalizedSummary = NormalizeChunk(branch.Summary); + return new CanvasNodeCreateInput + { + Kind = "response", + Title = NormalizeBranchTitle(branch.Title), + GenerationMode = CanvasGenerationModes.Compact, + Summary = normalizedSummary, + Content = normalizedSummary, + SourcePrompt = prompt, + ContinuationPrompt = BuildContinuationPrompt(prompt, branch.Title, branch.Summary), + Depth = selectedNode.Depth + 1 + }; + }) + .Where(node => !IsLowSignalBranch(node.Title, node.Summary ?? node.Content)) + .Distinct(CanvasNodeCreateInputComparer.Instance) + .ToList(); + + if (childNodes.Count >= MinimumStructuredBranchCount) + { + return new RootNodeExpansionResult + { + MainNode = BuildRootNodeUpdate(selectedNode, prompt, plannerResponse.MainNode), + ChildNodes = childNodes + }; + } + } + + return await GenerateFallbackRootNodeExpansionAsync( + providerClient, + apiKey, + allNodes, + selectedNode, + prompt, + CanvasGenerationModes.Compact); + } + + private async Task GenerateRichRootNodeExpansionAsync( + ILlmProviderClient providerClient, + string apiKey, + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt) + { + var plannerResponse = await GenerateStructuredPlanAsync( + providerClient, + apiKey, + BuildRootPlannerRequest(allNodes, selectedNode, prompt, CanvasGenerationModes.Rich), + ValidateRichRootPlannerResponse, + "rich root"); + + if (plannerResponse?.MainNode != null) + { + var childNodes = new List(); + foreach (var branch in plannerResponse.Branches.Where(branch => !IsFoundationLikeBranch(branch.Title))) + { + if (IsLowSignalBranch(branch.Title, branch.Summary)) + continue; + + var detailResponse = await GenerateStructuredPlanAsync( + providerClient, + apiKey, + BuildRichDetailRequest(allNodes, selectedNode, prompt, branch), + ValidateRichDetailResponse, + "rich detail"); + + var normalizedSummary = NormalizeChunk(branch.Summary); + var detailContent = !string.IsNullOrWhiteSpace(detailResponse?.Content) + ? NormalizeChunk(detailResponse.Content) + : normalizedSummary; + + childNodes.Add(new CanvasNodeCreateInput + { + Kind = "response", + Title = NormalizeBranchTitle(branch.Title), + GenerationMode = CanvasGenerationModes.Rich, + Summary = normalizedSummary, + Category = NormalizeCategory(branch.Category), + Content = detailContent, + SourcePrompt = prompt, + ContinuationPrompt = NormalizeChunk(branch.ContinuationPrompt), + Blocks = detailResponse?.Blocks? + .Where(block => !string.IsNullOrWhiteSpace(block.Type) && !string.IsNullOrWhiteSpace(block.Text)) + .Select(block => new CanvasNodeBlock + { + Type = NormalizeBlockType(block.Type), + Text = NormalizeChunk(block.Text), + Options = block.Options?.Where(option => !string.IsNullOrWhiteSpace(option)).Select(NormalizeChunk).ToList() ?? [] + }) + .Take(4) + .ToList() ?? [], + Sources = detailResponse?.Sources? + .Where(source => !string.IsNullOrWhiteSpace(source.Label) && !string.IsNullOrWhiteSpace(source.Url)) + .Select(source => new CanvasNodeSource + { + Label = NormalizeChunk(source.Label), + Url = NormalizeChunk(source.Url) + }) + .ToList() ?? [], + Depth = selectedNode.Depth + 1 + }); + } + + var distinctChildNodes = childNodes.Distinct(CanvasNodeCreateInputComparer.Instance).ToList(); + if (distinctChildNodes.Count >= MinimumStructuredBranchCount) + { + return new RootNodeExpansionResult + { + MainNode = BuildRootNodeUpdate(selectedNode, prompt, plannerResponse.MainNode), + ChildNodes = distinctChildNodes + }; + } + } + + return await GenerateFallbackRootNodeExpansionAsync( + providerClient, + apiKey, + allNodes, + selectedNode, + prompt, + CanvasGenerationModes.Rich); + } + + private async Task GenerateFallbackRootNodeExpansionAsync( + ILlmProviderClient providerClient, + string apiKey, + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt, + string generationMode) + { + var fallbackReply = await providerClient.SendChatAsync(apiKey, BuildRootFallbackMessages(allNodes, selectedNode, prompt, generationMode)); + var fallbackBranches = BuildFallbackBranches(fallbackReply.Content); + var foundationBranch = fallbackBranches.FirstOrDefault(branch => IsFoundationLikeBranch(branch.Title)); + var childBranches = fallbackBranches + .Where(branch => !ReferenceEquals(branch, foundationBranch)) + .ToList(); + + CanvasNodeUpdateInput? mainNode = null; + if (foundationBranch != null) + { + var normalizedFoundation = NormalizeMarkdownBody(foundationBranch.Content); + mainNode = new CanvasNodeUpdateInput + { + GenerationMode = CanvasGenerationModes.Rich, + Summary = NormalizeChunk(foundationBranch.Content), + Content = AppendRootContent(selectedNode.Content, normalizedFoundation), + SourcePrompt = prompt, + Blocks = [], + Sources = [] + }; + } + + var childNodes = childBranches + .Select(branch => + { + var normalizedSummary = NormalizeChunk(branch.Content); + return new CanvasNodeCreateInput + { + Kind = "response", + Title = NormalizeBranchTitle(branch.Title), + GenerationMode = generationMode, + Summary = normalizedSummary, + Content = normalizedSummary, + SourcePrompt = prompt, + ContinuationPrompt = BuildContinuationPrompt(prompt, branch.Title, branch.Content), + Depth = selectedNode.Depth + 1 + }; + }) + .Distinct(CanvasNodeCreateInputComparer.Instance) + .ToList(); + + return new RootNodeExpansionResult + { + MainNode = mainNode, + ChildNodes = childNodes + }; + } + + private static CanvasNodeUpdateInput BuildRootNodeUpdate(CanvasNode selectedNode, string prompt, RootMainNodeResponse foundation) + { + var normalizedContent = NormalizeMarkdownBody(foundation.Content); + return new CanvasNodeUpdateInput + { + GenerationMode = CanvasGenerationModes.Rich, + Summary = NormalizeChunk(foundation.Summary), + Content = AppendRootContent(selectedNode.Content, normalizedContent), + SourcePrompt = prompt, + Blocks = foundation.Blocks + .Where(block => !string.IsNullOrWhiteSpace(block.Type) && !string.IsNullOrWhiteSpace(block.Text)) + .Select(block => new CanvasNodeBlock + { + Type = NormalizeBlockType(block.Type), + Text = NormalizeChunk(block.Text), + Options = block.Options?.Where(option => !string.IsNullOrWhiteSpace(option)).Select(NormalizeChunk).ToList() ?? [] + }) + .Take(4) + .ToList(), + Sources = foundation.Sources + .Where(source => !string.IsNullOrWhiteSpace(source.Label) && !string.IsNullOrWhiteSpace(source.Url)) + .Select(source => new CanvasNodeSource + { + Label = NormalizeChunk(source.Label), + Url = NormalizeChunk(source.Url) + }) + .ToList() + }; + } + + private async Task> GenerateCompactChildNodesAsync( + ILlmProviderClient providerClient, + string apiKey, + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt) + { + var plannerResponse = await GenerateStructuredPlanAsync( + providerClient, + apiKey, + BuildPlannerRequest(allNodes, selectedNode, prompt, CanvasGenerationModes.Compact), + ValidateCompactPlannerResponse, + "compact"); + + if (plannerResponse?.Branches?.Count >= MinimumStructuredBranchCount) + { + return plannerResponse.Branches + .Select(branch => + { + var normalizedSummary = NormalizeChunk(branch.Summary); + return new CanvasNodeCreateInput + { + Kind = "response", + Title = NormalizeBranchTitle(branch.Title), + GenerationMode = CanvasGenerationModes.Compact, + Summary = normalizedSummary, + Content = normalizedSummary, + SourcePrompt = prompt, + ContinuationPrompt = BuildContinuationPrompt(prompt, branch.Title, branch.Summary), + Depth = selectedNode.Depth + 1 + }; + }) + .Where(node => !IsLowSignalBranch(node.Title, node.Summary ?? node.Content)) + .Distinct(CanvasNodeCreateInputComparer.Instance) + .ToList(); + } + + return await GenerateFallbackChildNodesAsync( + providerClient, + apiKey, + allNodes, + selectedNode, + prompt, + CanvasGenerationModes.Compact); + } + + private async Task> GenerateRichChildNodesAsync( + ILlmProviderClient providerClient, + string apiKey, + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt) + { + var plannerResponse = await GenerateStructuredPlanAsync( + providerClient, + apiKey, + BuildPlannerRequest(allNodes, selectedNode, prompt, CanvasGenerationModes.Rich), + ValidateRichPlannerResponse, + "rich planner"); + + var plannerBranches = plannerResponse?.Branches; + if (plannerBranches is null || plannerBranches.Count < MinimumStructuredBranchCount) + { + return await GenerateFallbackChildNodesAsync( + providerClient, + apiKey, + allNodes, + selectedNode, + prompt, + CanvasGenerationModes.Rich); + } + + var childNodes = new List(); + foreach (var branch in plannerBranches) + { + if (IsLowSignalBranch(branch.Title, branch.Summary)) + continue; + + var detailResponse = await GenerateStructuredPlanAsync( + providerClient, + apiKey, + BuildRichDetailRequest(allNodes, selectedNode, prompt, branch), + ValidateRichDetailResponse, + "rich detail"); + + var normalizedSummary = NormalizeChunk(branch.Summary); + var detailContent = !string.IsNullOrWhiteSpace(detailResponse?.Content) + ? NormalizeChunk(detailResponse.Content) + : normalizedSummary; + + childNodes.Add(new CanvasNodeCreateInput + { + Kind = "response", + Title = NormalizeBranchTitle(branch.Title), + GenerationMode = CanvasGenerationModes.Rich, + Summary = normalizedSummary, + Category = NormalizeCategory(branch.Category), + Content = detailContent, + SourcePrompt = prompt, + ContinuationPrompt = NormalizeChunk(branch.ContinuationPrompt), + Blocks = detailResponse?.Blocks? + .Where(block => !string.IsNullOrWhiteSpace(block.Type) && !string.IsNullOrWhiteSpace(block.Text)) + .Select(block => new CanvasNodeBlock + { + Type = NormalizeBlockType(block.Type), + Text = NormalizeChunk(block.Text), + Options = block.Options?.Where(option => !string.IsNullOrWhiteSpace(option)).Select(NormalizeChunk).ToList() ?? [] + }) + .Take(4) + .ToList() ?? [], + Sources = detailResponse?.Sources? + .Where(source => !string.IsNullOrWhiteSpace(source.Label) && !string.IsNullOrWhiteSpace(source.Url)) + .Select(source => new CanvasNodeSource + { + Label = NormalizeChunk(source.Label), + Url = NormalizeChunk(source.Url) + }) + .ToList() ?? [], + Depth = selectedNode.Depth + 1 + }); + } + + return childNodes.Count >= MinimumStructuredBranchCount + ? childNodes.Distinct(CanvasNodeCreateInputComparer.Instance).ToList() + : await GenerateFallbackChildNodesAsync( + providerClient, + apiKey, + allNodes, + selectedNode, + prompt, + CanvasGenerationModes.Rich); + } + + private async Task> GenerateFallbackChildNodesAsync( + ILlmProviderClient providerClient, + string apiKey, + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt, + string generationMode) + { + var fallbackReply = await providerClient.SendChatAsync(apiKey, BuildFallbackMessages(allNodes, selectedNode, prompt, generationMode)); + var fallbackBranches = BuildFallbackBranches(fallbackReply.Content); + if (fallbackBranches.Count == 0) + { + var normalized = NormalizeChunk(fallbackReply.Content); + fallbackBranches = + [ + new GeneratedBranch(BuildNodeTitle(normalized), normalized) + ]; + } + + return fallbackBranches + .Select(branch => + { + var normalizedSummary = NormalizeChunk(branch.Content); + return new CanvasNodeCreateInput + { + Kind = "response", + Title = NormalizeBranchTitle(branch.Title), + GenerationMode = generationMode, + Summary = normalizedSummary, + Content = normalizedSummary, + SourcePrompt = prompt, + ContinuationPrompt = BuildContinuationPrompt(prompt, branch.Title, branch.Content), + Depth = selectedNode.Depth + 1 + }; + }) + .Distinct(CanvasNodeCreateInputComparer.Instance) + .ToList(); + } + + private async Task GenerateStructuredPlanAsync( + ILlmProviderClient providerClient, + string apiKey, + LlmProviderStructuredRequest request, + Func> validator, + string operationLabel) + where T : class + { + var validationErrors = new List(); + for (var attempt = 0; attempt < 2; attempt += 1) + { + var attemptRequest = attempt == 0 + ? request + : new LlmProviderStructuredRequest + { + Instructions = request.Instructions + "\n\nPrevious response errors:\n- " + string.Join("\n- ", validationErrors) + "\nReturn corrected JSON only.", + Messages = request.Messages, + SchemaJson = request.SchemaJson, + SchemaName = request.SchemaName, + MaxOutputTokens = request.MaxOutputTokens + }; + + try + { + var providerReply = await providerClient.SendStructuredJsonAsync(apiKey, attemptRequest); + var candidateJson = ExtractJsonCandidate(providerReply.Content) ?? providerReply.Content; + var model = JsonSerializer.Deserialize(candidateJson, JsonSerializerOptions); + if (model == null) + { + validationErrors = ["Structured response could not be deserialized."]; + continue; + } + + validationErrors = [.. validator(model)]; + if (validationErrors.Count == 0) + return model; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse {OperationLabel} structured JSON.", operationLabel); + validationErrors = ["Structured JSON could not be parsed."]; + } + catch (LlmChatException) + { + throw; + } + } + + if (validationErrors.Count > 0) + { + _logger.LogWarning( + "Structured generation for {OperationLabel} failed validation after retry: {Errors}", + operationLabel, + string.Join("; ", validationErrors)); + } + + return null; + } + + private LlmProviderStructuredRequest BuildRootPlannerRequest( + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt, + string generationMode) + { + var promptType = DetectPromptType(prompt); + var contextMessage = BuildContextMessage( + allNodes, + selectedNode, + generationMode == CanvasGenerationModes.Rich ? RichMaxContextItems : CompactMaxContextItems, + generationMode == CanvasGenerationModes.Rich ? RichMaxContextChars : CompactMaxContextChars); + + var instructions = generationMode == CanvasGenerationModes.Rich + ? BuildRichRootPlannerInstructions(promptType) + : BuildCompactRootPlannerInstructions(promptType); + + var schema = generationMode == CanvasGenerationModes.Rich ? RichRootPlannerSchema : CompactRootPlannerSchema; + var schemaName = generationMode == CanvasGenerationModes.Rich ? "rich_root_branch_plan" : "compact_root_branch_plan"; + var maxOutputTokens = generationMode == CanvasGenerationModes.Rich ? 2200 : 1500; + + var messages = new List(); + if (!string.IsNullOrWhiteSpace(contextMessage)) + { + messages.Add(new LlmProviderChatMessage + { + Role = "user", + Content = contextMessage + }); + } + + messages.Add(new LlmProviderChatMessage + { + Role = "user", + Content = + """ + Original user request: + """ + "\n" + prompt + }); + + return new LlmProviderStructuredRequest + { + Instructions = instructions, + Messages = messages, + SchemaName = schemaName, + SchemaJson = schema, + MaxOutputTokens = maxOutputTokens + }; + } + + private LlmProviderStructuredRequest BuildPlannerRequest( + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt, + string generationMode) + { + var promptType = DetectPromptType(prompt); + var contextMessage = BuildContextMessage( + allNodes, + selectedNode, + generationMode == CanvasGenerationModes.Rich ? RichMaxContextItems : CompactMaxContextItems, + generationMode == CanvasGenerationModes.Rich ? RichMaxContextChars : CompactMaxContextChars); + + var instructions = generationMode == CanvasGenerationModes.Rich + ? BuildRichPlannerInstructions(promptType) + : BuildCompactPlannerInstructions(promptType); + + var schema = generationMode == CanvasGenerationModes.Rich ? RichPlannerSchema : CompactPlannerSchema; + var schemaName = generationMode == CanvasGenerationModes.Rich ? "rich_branch_plan" : "compact_branch_plan"; + var maxOutputTokens = generationMode == CanvasGenerationModes.Rich ? 1400 : 900; + + var messages = new List(); + if (!string.IsNullOrWhiteSpace(contextMessage)) + { + messages.Add(new LlmProviderChatMessage + { + Role = "user", + Content = contextMessage + }); + } + + messages.Add(new LlmProviderChatMessage + { + Role = "user", + Content = + """ + Original user request: + """ + "\n" + prompt + }); + + return new LlmProviderStructuredRequest + { + Instructions = instructions, + Messages = messages, + SchemaName = schemaName, + SchemaJson = schema, + MaxOutputTokens = maxOutputTokens + }; + } + + private LlmProviderStructuredRequest BuildRichDetailRequest( + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt, + RichPlannerBranch branch) + { + var contextMessage = BuildContextMessage(allNodes, selectedNode, RichMaxContextItems, RichMaxContextChars); + var messages = new List(); + if (!string.IsNullOrWhiteSpace(contextMessage)) + { + messages.Add(new LlmProviderChatMessage + { + Role = "user", + Content = contextMessage + }); + } + + messages.Add(new LlmProviderChatMessage + { + Role = "user", + Content = + """ + Original user request: + """ + "\n" + prompt + "\n\n" + + """ + Selected branch: + """ + "\n" + + $"Title: {NormalizeBranchTitle(branch.Title)}\n" + + $"Category: {NormalizeCategory(branch.Category) ?? "focused workstream"}\n" + + $"Summary: {NormalizeChunk(branch.Summary)}\n" + + $"Continuation prompt: {NormalizeChunk(branch.ContinuationPrompt)}" + }); + + return new LlmProviderStructuredRequest + { + Instructions = RichDetailInstructions, + Messages = messages, + SchemaName = "rich_branch_detail", + SchemaJson = RichDetailSchema, + MaxOutputTokens = 1800 + }; + } + + private async Task BuildGraphResponseAsync(int userId, int canvasId, IReadOnlyList providers) + { + var canvas = await _canvasRepository.GetCanvasAsync(userId, canvasId) + ?? throw new CanvasOperationException("Canvas not found.", StatusCodes.Status404NotFound); + + if (await _canvasRepository.HasLegacyMainNodeMessagesAsync(userId, canvasId)) + { + return new CanvasGraphResponse + { + CanvasId = canvasId, + CanvasTitle = canvas.Title, + Mode = "legacy", + DefaultProvider = providers.FirstOrDefault(item => item.IsDefault)?.Provider, + AvailableProviders = providers.Select(item => item.Provider).ToList() + }; + } + + var rootNode = await _canvasRepository.EnsureRootCanvasNodeAsync(userId, canvasId); + var nodes = NormalizeGraphNodes(await _canvasRepository.GetCanvasNodesAsync(userId, canvasId)); + + return new CanvasGraphResponse + { + CanvasId = canvasId, + CanvasTitle = canvas.Title, + Mode = "graph", + DefaultProvider = providers.FirstOrDefault(item => item.IsDefault)?.Provider, + AvailableProviders = providers.Select(item => item.Provider).ToList(), + RootNodeId = rootNode.Id, + Nodes = nodes.Select(MapNode).ToList() + }; + } + + private static IReadOnlyList NormalizeGraphNodes(IReadOnlyList nodes) + { + var rootNode = nodes.FirstOrDefault(IsRootMainNode); + if (rootNode == null || !string.IsNullOrWhiteSpace(rootNode.Summary) || !string.IsNullOrWhiteSpace(rootNode.Content)) + return nodes; + + var foundationChild = nodes + .Where(node => node.ParentNodeId == rootNode.Id && node.Kind == "response") + .OrderBy(GetFoundationCandidatePriority) + .ThenBy(node => node.SiblingOrder) + .ThenBy(node => node.Id) + .FirstOrDefault(IsFoundationGraphChild); + + if (foundationChild == null) + return nodes; + + var normalizedRoot = new CanvasNode + { + Id = rootNode.Id, + CanvasId = rootNode.CanvasId, + ParentNodeId = rootNode.ParentNodeId, + Kind = rootNode.Kind, + Title = rootNode.Title, + GenerationMode = CanvasGenerationModes.Normalize(foundationChild.GenerationMode), + Summary = string.IsNullOrWhiteSpace(foundationChild.Summary) ? foundationChild.Content : foundationChild.Summary, + Category = null, + Content = foundationChild.Content, + SourcePrompt = foundationChild.SourcePrompt, + ContinuationPrompt = null, + Blocks = [.. foundationChild.Blocks], + Sources = [.. foundationChild.Sources], + Depth = rootNode.Depth, + SiblingOrder = rootNode.SiblingOrder, + CreatedAt = rootNode.CreatedAt, + UpdatedAt = foundationChild.UpdatedAt > rootNode.UpdatedAt ? foundationChild.UpdatedAt : rootNode.UpdatedAt + }; + + return nodes + .Where(node => node.Id != foundationChild.Id) + .Select(node => node.Id == rootNode.Id ? normalizedRoot : node) + .ToList(); + } + + private async Task EnsureCanvasExistsAsync(int userId, int canvasId) + { + if (canvasId <= 0) + throw new CanvasOperationException("A valid canvas is required.", StatusCodes.Status400BadRequest); + + var canvas = await _canvasRepository.GetCanvasAsync(userId, canvasId); + if (canvas == null) + throw new CanvasOperationException("Canvas not found.", StatusCodes.Status404NotFound); + } + + private ILlmProviderClient GetProviderClient(string provider) + { + if (_providerClients.TryGetValue(provider, out var client)) + return client; + + throw new InvalidOperationException($"No provider client is registered for '{provider}'."); + } + + private static string BuildContextMessage( + IReadOnlyList allNodes, + CanvasNode selectedNode, + int maxItems, + int maxChars) + { + var nodesById = allNodes.ToDictionary(item => item.Id); + var path = new Stack(); + var current = selectedNode; + + while (true) + { + path.Push(current); + if (!current.ParentNodeId.HasValue || !nodesById.TryGetValue(current.ParentNodeId.Value, out current!)) + break; + } + + var sections = path + .Select(FormatNodeContext) + .Where(section => !string.IsNullOrWhiteSpace(section)) + .TakeLast(maxItems) + .ToList(); + + if (sections.Count == 0) + return string.Empty; + + var builder = new StringBuilder(); + builder.AppendLine("Existing canvas context:"); + builder.AppendLine("Use the following nodes as background context."); + builder.AppendLine("Do not explain the canvas structure unless explicitly asked."); + builder.AppendLine(); + + var remainingChars = maxChars; + foreach (var section in sections) + { + var clipped = ClipToLength(section, Math.Min(remainingChars, maxChars)); + if (string.IsNullOrWhiteSpace(clipped)) + continue; + + if (builder.Length > 0 && builder[^1] != '\n') + builder.AppendLine(); + + builder.AppendLine(clipped); + builder.AppendLine(); + remainingChars -= clipped.Length; + if (remainingChars <= 0) + break; + } + + return builder.ToString().Trim(); + } + + private static string FormatNodeContext(CanvasNode node) + { + if (node.Kind == "main" && string.IsNullOrWhiteSpace(node.Content) && string.Equals(node.Title, "Main Node", StringComparison.Ordinal)) + return string.Empty; + + if (node.Kind == "main" && string.IsNullOrWhiteSpace(node.Summary) && string.IsNullOrWhiteSpace(node.Content)) + return $"Canvas topic: {node.Title}"; + + var summary = string.IsNullOrWhiteSpace(node.Summary) ? node.Content : node.Summary; + if (string.IsNullOrWhiteSpace(summary)) + return node.Title; + + return $"{node.Title}\n{ClipToLength(NormalizeChunk(summary), 320)}"; + } + + private static string DetectPromptType(string prompt) + { + var normalized = prompt.Trim().ToLowerInvariant(); + + if (normalized.Contains(" vs ") || normalized.Contains("compare") || normalized.Contains("tradeoff") || normalized.Contains("choose") || normalized.Contains("decision")) + return "comparison"; + + if (normalized.Contains("debug") || normalized.Contains("bug") || normalized.Contains("error") || normalized.Contains("issue") || normalized.Contains("failing")) + return "debugging"; + + if (normalized.Contains("learn") || normalized.Contains("understand") || normalized.Contains("explain") || normalized.Contains("teach")) + return "learning"; + + if (normalized.Contains("research") || normalized.Contains("analyze") || normalized.Contains("analysis") || normalized.Contains("investigate")) + return "analysis"; + + if (normalized.Contains("brainstorm") || normalized.Contains("ideate") || normalized.Contains("idea")) + return "creative"; + + if (normalized.Contains("plan") || normalized.Contains("roadmap") || normalized.Contains("launch") || normalized.Contains("strategy")) + return "planning"; + + if (normalized.Contains("build") || normalized.Contains("create") || normalized.Contains("implement") || normalized.Contains("design")) + return "building"; + + return "general"; + } + + private static string BuildCompactPlannerInstructions(string promptType) + { + return + """ + You are a branch planner for a node-based workspace. + Generate 3 to 5 distinct next branches, targeting 4 when the prompt is broad enough. + Optimize for token efficiency while keeping branches useful, concrete, and non-overlapping. + Use the inferred prompt type to choose a natural structure instead of a fixed template. + Titles must be concise and easy to scan. + Summaries must be one short sentence each. + Do not answer the user's full request. + Do not include filler such as overview, miscellaneous, or more details unless clearly necessary. + Return only JSON matching the schema. + """ + "\n" + $"Prompt type: {promptType}."; + } + + private static string BuildRichPlannerInstructions(string promptType) + { + return + """ + You are a branch planner for a node-based workspace. + Generate 4 to 5 distinct next branches for the user's request. + Titles must be concise and easy to scan. + Categories must reflect the branch role clearly. + Summaries must explain why the branch matters in one concise sentence. + Continuation prompts must be directly usable for going deeper on that branch. + Do not answer the user's full request. + Do not include filler or overlapping branches. + Return only JSON matching the schema. + """ + "\n" + $"Prompt type: {promptType}."; + } + + private static string BuildCompactRootPlannerInstructions(string promptType) + { + return + """ + You are planning the root main node of a node-based workspace. + Put the foundational framing, definition, or concept setup into mainNode. + mainNode should explain the core idea directly instead of turning that material into a child branch. + Generate 3 to 5 distinct child branches, targeting 4 when the prompt is broad enough. + Child branches must start at the next meaningful exploration paths and must exclude the foundation. + Child branch titles must be concise and easy to scan. + Child branch summaries must be one short, useful sentence each. + Do not answer the user's full request as a final solution. + Do not include filler or overlapping branches. + Return only JSON matching the schema. + """ + "\n" + $"Prompt type: {promptType}."; + } + + private static string BuildRichRootPlannerInstructions(string promptType) + { + return + """ + You are planning the root main node of a node-based workspace. + Put the foundational framing, definition, or concept setup into mainNode. + mainNode should explain the core idea directly instead of turning that material into a child branch. + Generate 4 to 5 distinct child branches. + Child branches must start at the next meaningful exploration paths and must exclude the foundation. + Child branch titles must be concise and easy to scan. + Child branch categories must reflect the branch role clearly. + Child branch summaries must explain why the branch matters in one concise sentence. + Child continuation prompts must be directly usable for going deeper on that branch. + Do not answer the user's full request as a final solution. + Do not include filler or overlapping branches. + Return only JSON matching the schema. + """ + "\n" + $"Prompt type: {promptType}."; + } + + private static string BuildContinuationPrompt(string prompt, string title, string summary) + { + return NormalizeChunk($"Go deeper on '{NormalizeBranchTitle(title)}' for the user's goal '{prompt}'. Focus on: {summary}"); + } + + private static string NormalizeCategory(string? category) + { + if (string.IsNullOrWhiteSpace(category)) + return null!; + + return NormalizeChunk(category); + } + + private static string NormalizeBlockType(string? type) + { + var normalized = type?.Trim().ToLowerInvariant(); + return normalized switch + { + "analysis" or "description" or "recommendation" or "question" or "user-input" => normalized, + _ => "description" + }; + } + + private static IReadOnlyList BuildFallbackMessages( + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt, + string generationMode) + { + var contextMessage = BuildContextMessage( + allNodes, + selectedNode, + generationMode == CanvasGenerationModes.Rich ? RichMaxContextItems : CompactMaxContextItems, + generationMode == CanvasGenerationModes.Rich ? RichMaxContextChars : CompactMaxContextChars); + + var messages = new List(); + if (!string.IsNullOrWhiteSpace(contextMessage)) + { + messages.Add(new LlmProviderChatMessage + { + Role = "user", + Content = contextMessage + }); + } + + messages.Add(new LlmProviderChatMessage + { + Role = "user", + Content = + """ + Generate child branches for this canvas node. + Return plain text with one branch per paragraph or heading. + Make branches distinct, specific, and directly useful. + Avoid filler and do not explain the node path or canvas structure. + """ + "\n\nOriginal user request:\n" + prompt + }); + + return messages; + } + + private static IReadOnlyList BuildRootFallbackMessages( + IReadOnlyList allNodes, + CanvasNode selectedNode, + string prompt, + string generationMode) { - if (await _canvasRepository.HasLegacyMainNodeMessagesAsync(userId, canvasId)) + var contextMessage = BuildContextMessage( + allNodes, + selectedNode, + generationMode == CanvasGenerationModes.Rich ? RichMaxContextItems : CompactMaxContextItems, + generationMode == CanvasGenerationModes.Rich ? RichMaxContextChars : CompactMaxContextChars); + + var messages = new List(); + if (!string.IsNullOrWhiteSpace(contextMessage)) { - return new CanvasGraphResponse + messages.Add(new LlmProviderChatMessage { - CanvasId = canvasId, - Mode = "legacy", - DefaultProvider = providers.FirstOrDefault(item => item.IsDefault)?.Provider, - AvailableProviders = providers.Select(item => item.Provider).ToList() - }; + Role = "user", + Content = contextMessage + }); } - var rootNode = await _canvasRepository.EnsureRootCanvasNodeAsync(userId, canvasId); - var nodes = await _canvasRepository.GetCanvasNodesAsync(userId, canvasId); - - return new CanvasGraphResponse + messages.Add(new LlmProviderChatMessage { - CanvasId = canvasId, - Mode = "graph", - DefaultProvider = providers.FirstOrDefault(item => item.IsDefault)?.Provider, - AvailableProviders = providers.Select(item => item.Provider).ToList(), - RootNodeId = rootNode.Id, - Nodes = nodes.Select(MapNode).ToList() - }; + Role = "user", + Content = + """ + Generate content for the root main node plus child branches. + Start with one foundation section for the main node using a heading such as Core Definition, Definition, Overview, Concept, What Is, or Fundamentals when appropriate. + Then provide 3 to 5 distinct child branches as separate headings or paragraphs. + The foundation section must not be repeated as a child branch. + Make child branches concrete, distinct, and directly useful. + Avoid filler and do not explain canvas structure. + """ + "\n\nOriginal user request:\n" + prompt + }); + + return messages; } - private async Task EnsureCanvasExistsAsync(int userId, int canvasId) + private static IReadOnlyList ValidateCompactPlannerResponse(CompactPlannerResponse response) { - if (canvasId <= 0) - throw new CanvasOperationException("A valid canvas is required.", StatusCodes.Status400BadRequest); + var errors = new List(); + if (response.Branches is null || response.Branches.Count < MinimumStructuredBranchCount || response.Branches.Count > 5) + errors.Add("The response must contain 2 to 5 branches."); - var canvas = await _canvasRepository.GetCanvasAsync(userId, canvasId); - if (canvas == null) - throw new CanvasOperationException("Canvas not found.", StatusCodes.Status404NotFound); + var seenTitles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var branch in response.Branches ?? []) + { + if (string.IsNullOrWhiteSpace(branch.Title) || string.IsNullOrWhiteSpace(branch.Summary)) + errors.Add("Every compact branch must include title and summary."); + + var normalizedTitle = NormalizeBranchTitle(branch.Title); + if (!seenTitles.Add(normalizedTitle)) + errors.Add("Compact branch titles must be unique."); + + if (IsLowSignalBranch(normalizedTitle, branch.Summary)) + errors.Add("Compact branch summaries must be substantive and non-filler."); + } + + return errors; } - private ILlmProviderClient GetProviderClient(string provider) + private static IReadOnlyList ValidateCompactRootPlannerResponse(CompactRootPlannerResponse response) { - if (_providerClients.TryGetValue(provider, out var client)) - return client; + var errors = new List(); + errors.AddRange(ValidateRootMainNodeResponse(response.MainNode)); - throw new InvalidOperationException($"No provider client is registered for '{provider}'."); + if (response.Branches is null || response.Branches.Count < MinimumStructuredBranchCount || response.Branches.Count > 5) + errors.Add("The root planner response must contain 2 to 5 child branches."); + + var seenTitles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var branch in response.Branches ?? []) + { + if (string.IsNullOrWhiteSpace(branch.Title) || string.IsNullOrWhiteSpace(branch.Summary)) + errors.Add("Every compact root branch must include title and summary."); + + var normalizedTitle = NormalizeBranchTitle(branch.Title); + if (!seenTitles.Add(normalizedTitle)) + errors.Add("Compact root branch titles must be unique."); + + if (IsFoundationLikeBranch(normalizedTitle)) + errors.Add("Root child branches must exclude foundation or definition branches."); + + if (IsLowSignalBranch(normalizedTitle, branch.Summary)) + errors.Add("Compact root branch summaries must be substantive and non-filler."); + } + + return errors; } - private string ResolveProvider(IReadOnlyList providers, string? providerOverride) + private static IReadOnlyList ValidateRichPlannerResponse(RichPlannerResponse response) { - if (providers.Count == 0) + var errors = new List(); + if (response.Branches is null || response.Branches.Count < MinimumStructuredBranchCount || response.Branches.Count > 5) + errors.Add("The rich planner response must contain 2 to 5 branches."); + + var seenTitles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var branch in response.Branches ?? []) { - _logger.LogWarning("Rejected graph node expansion because no provider keys are configured."); - throw new LlmChatException( - "Add a provider API key in Settings before sending a message.", - LlmProviderErrorCodes.MissingKey, - StatusCodes.Status400BadRequest); + if (string.IsNullOrWhiteSpace(branch.Title) + || string.IsNullOrWhiteSpace(branch.Summary) + || string.IsNullOrWhiteSpace(branch.Category) + || string.IsNullOrWhiteSpace(branch.ContinuationPrompt)) + { + errors.Add("Every rich branch must include title, category, summary, and continuationPrompt."); + } + + var normalizedTitle = NormalizeBranchTitle(branch.Title); + if (!seenTitles.Add(normalizedTitle)) + errors.Add("Rich branch titles must be unique."); + + if (IsLowSignalBranch(normalizedTitle, branch.Summary)) + errors.Add("Rich branch summaries must be substantive and non-filler."); } - if (!string.IsNullOrWhiteSpace(providerOverride)) + return errors; + } + + private static IReadOnlyList ValidateRichRootPlannerResponse(RichRootPlannerResponse response) + { + var errors = new List(); + errors.AddRange(ValidateRootMainNodeResponse(response.MainNode)); + + if (response.Branches is null || response.Branches.Count < MinimumStructuredBranchCount || response.Branches.Count > 5) + errors.Add("The rich root planner response must contain 2 to 5 child branches."); + + var seenTitles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var branch in response.Branches ?? []) { - var normalizedOverride = NormalizeProvider(providerOverride); - if (!providers.Any(item => item.Provider == normalizedOverride)) + if (string.IsNullOrWhiteSpace(branch.Title) + || string.IsNullOrWhiteSpace(branch.Summary) + || string.IsNullOrWhiteSpace(branch.Category) + || string.IsNullOrWhiteSpace(branch.ContinuationPrompt)) { - throw new LlmChatException( - "That provider is not configured yet.", - LlmProviderErrorCodes.MissingKey, - StatusCodes.Status400BadRequest); + errors.Add("Every rich root branch must include title, category, summary, and continuationPrompt."); } - return normalizedOverride; + var normalizedTitle = NormalizeBranchTitle(branch.Title); + if (!seenTitles.Add(normalizedTitle)) + errors.Add("Rich root branch titles must be unique."); + + if (IsFoundationLikeBranch(normalizedTitle)) + errors.Add("Root child branches must exclude foundation or definition branches."); + + if (IsLowSignalBranch(normalizedTitle, branch.Summary)) + errors.Add("Rich root branch summaries must be substantive and non-filler."); } - return providers.FirstOrDefault(item => item.IsDefault)?.Provider - ?? providers.First().Provider; + return errors; } - private static IReadOnlyList BuildProviderMessages( - IReadOnlyList allNodes, - CanvasNode selectedNode, - string prompt) + private static IReadOnlyList ValidateRichDetailResponse(RichDetailResponse response) { - var nodesById = allNodes.ToDictionary(item => item.Id); - var path = new Stack(); - var current = selectedNode; + var errors = new List(); + if (string.IsNullOrWhiteSpace(response.Content)) + errors.Add("Rich detail content is required."); - while (true) + if (response.Blocks is null || response.Blocks.Count < 2 || response.Blocks.Count > 4) + errors.Add("Rich detail responses must include 2 to 4 blocks."); + + foreach (var block in response.Blocks ?? []) { - path.Push(current); - if (!current.ParentNodeId.HasValue || !nodesById.TryGetValue(current.ParentNodeId.Value, out current!)) - break; + if (string.IsNullOrWhiteSpace(block.Type) || string.IsNullOrWhiteSpace(block.Text)) + errors.Add("Each detail block must include type and text."); } - var contextSections = path - .Select(node => FormatNodeContext(node)) - .Where(section => !string.IsNullOrWhiteSpace(section)) - .ToList(); + return errors; + } - var messages = new List(); - if (contextSections.Count > 0) + private static IReadOnlyList ValidateRootMainNodeResponse(RootMainNodeResponse response) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(response.Summary)) + errors.Add("Root mainNode summary is required."); + + if (string.IsNullOrWhiteSpace(response.Content)) + errors.Add("Root mainNode content is required."); + + if (response.Blocks?.Count > 4) + errors.Add("Root mainNode may include at most 4 blocks."); + + foreach (var block in response.Blocks ?? []) { - messages.Add(new LlmProviderChatMessage - { - Role = "user", - Content = $"Use this node path as context:\n\n{string.Join("\n\n", contextSections)}" - }); + if (string.IsNullOrWhiteSpace(block.Type) || string.IsNullOrWhiteSpace(block.Text)) + errors.Add("Each root mainNode block must include type and text."); } - messages.Add(new LlmProviderChatMessage + foreach (var source in response.Sources ?? []) { - Role = "user", - Content = prompt - }); + if (string.IsNullOrWhiteSpace(source.Label) || string.IsNullOrWhiteSpace(source.Url)) + errors.Add("Each root mainNode source must include label and url."); + } - return messages; + if (IsLowSignalBranch("main node", response.Content)) + errors.Add("Root mainNode content must be substantive."); + + return errors; } - private static string FormatNodeContext(CanvasNode node) + private static List BuildFallbackBranches(string content) { - if (node.Kind == "main") - return "Main Node"; + var jsonBranches = TryBuildFallbackBranchesFromJson(content); + if (jsonBranches.Count > 0) + return jsonBranches; - if (string.IsNullOrWhiteSpace(node.Content)) - return node.Title; + return ChunkResponse(content) + .Select(chunk => + { + var normalizedChunk = NormalizeChunk(chunk); + return new GeneratedBranch(BuildNodeTitle(chunk), normalizedChunk); + }) + .Where(branch => !IsLowSignalBranch(branch.Title, branch.Content)) + .Distinct(GeneratedBranchComparer.Instance) + .ToList(); + } + + private static string? ExtractJsonCandidate(string content) + { + var normalized = content.Replace("\r\n", "\n").Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + return null; + + var fencedJsonMatch = JsonFenceRegex().Match(normalized); + if (fencedJsonMatch.Success) + return fencedJsonMatch.Groups["json"].Value.Trim(); + + var firstBrace = normalized.IndexOf('{'); + var lastBrace = normalized.LastIndexOf('}'); + if (firstBrace >= 0 && lastBrace > firstBrace) + return normalized[firstBrace..(lastBrace + 1)]; + + var firstBracket = normalized.IndexOf('['); + var lastBracket = normalized.LastIndexOf(']'); + if (firstBracket >= 0 && lastBracket > firstBracket) + return normalized[firstBracket..(lastBracket + 1)]; - return $"{node.Title}\n{node.Content}"; + return null; } private static IReadOnlyList ChunkResponse(string content) @@ -318,19 +1419,171 @@ private static string BuildNodeTitle(string chunk) .FirstOrDefault(line => !string.IsNullOrWhiteSpace(line)) ?? "Response Node"; + firstLine = NormalizeBranchTitle(firstLine); + if (firstLine.Length <= NodeTitleMaxLength) return firstLine; return $"{firstLine[..(NodeTitleMaxLength - 3)].TrimEnd()}..."; } - private string NormalizeProvider(string provider) + private static List TryBuildFallbackBranchesFromJson(string content) + { + var candidateJson = ExtractJsonCandidate(content); + if (string.IsNullOrWhiteSpace(candidateJson)) + return []; + + try + { + using var document = JsonDocument.Parse(candidateJson); + if (!document.RootElement.TryGetProperty("branches", out var branchesElement) + || branchesElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + return branchesElement + .EnumerateArray() + .Select(branch => + { + var title = branch.TryGetProperty("title", out var titleElement) + ? NormalizeBranchTitle(titleElement.GetString() ?? string.Empty) + : string.Empty; + var summary = branch.TryGetProperty("summary", out var summaryElement) + ? NormalizeChunk(summaryElement.GetString() ?? string.Empty) + : string.Empty; + + return new GeneratedBranch(title, summary); + }) + .Where(branch => !IsLowSignalBranch(branch.Title, branch.Content)) + .Distinct(GeneratedBranchComparer.Instance) + .ToList(); + } + catch (JsonException) + { + return []; + } + } + + private static string NormalizeBranchTitle(string title) + { + var normalized = StripMarkdownPrefix(title).Trim(); + normalized = normalized.Trim('"', '\'', '`', '*', '-', '•', ':'); + normalized = WhitespaceRegex().Replace(normalized, " ").Trim(); + return string.IsNullOrWhiteSpace(normalized) ? "Response Node" : normalized; + } + + private static bool IsFoundationLikeBranch(string? title) + { + var normalized = NormalizeBranchTitle(title ?? string.Empty).ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(normalized)) + return false; + + return normalized is "overview" or "definition" or "core definition" or "concept" or "fundamentals" or "basics" + || normalized.StartsWith("what is ", StringComparison.Ordinal) + || normalized.Contains(" definition", StringComparison.Ordinal) + || normalized.Contains("overview", StringComparison.Ordinal) + || normalized.Contains("concept", StringComparison.Ordinal) + || normalized.Contains("fundamental", StringComparison.Ordinal) + || normalized.Contains("basics", StringComparison.Ordinal) + || normalized.Contains("introduction", StringComparison.Ordinal); + } + + private static bool IsFoundationGraphChild(CanvasNode node) + { + if (IsFoundationLikeBranch(node.Title)) + return true; + + var normalizedCategory = NormalizeCategory(node.Category); + if (string.IsNullOrWhiteSpace(normalizedCategory)) + return false; + + var lowerCategory = normalizedCategory.ToLowerInvariant(); + return lowerCategory.Contains("foundation", StringComparison.Ordinal) + || lowerCategory.Contains("foundational", StringComparison.Ordinal) + || lowerCategory.Contains("core concept", StringComparison.Ordinal) + || lowerCategory.Contains("concept", StringComparison.Ordinal) + || lowerCategory.Contains("overview", StringComparison.Ordinal); + } + + private static int GetFoundationCandidatePriority(CanvasNode node) + { + var normalizedCategory = NormalizeCategory(node.Category)?.ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(normalizedCategory)) + { + if (normalizedCategory.Contains("foundational", StringComparison.Ordinal) + || normalizedCategory.Contains("foundation", StringComparison.Ordinal)) + { + return 0; + } + + if (normalizedCategory.Contains("concept", StringComparison.Ordinal) + || normalizedCategory.Contains("overview", StringComparison.Ordinal)) + { + return 1; + } + } + + return IsFoundationLikeBranch(node.Title) ? 2 : 3; + } + + private static string NormalizeChunk(string content) + { + var normalized = content.Replace("\r\n", "\n").Trim(); + return WhitespaceRegex().Replace(normalized, " ").Trim(); + } + + private static string NormalizeMarkdownBody(string content) + { + var normalized = content.Replace("\r\n", "\n").Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + return string.Empty; + + normalized = Regex.Replace(normalized, @"[ \t]+\n", "\n"); + normalized = Regex.Replace(normalized, @"\n{3,}", "\n\n"); + return normalized.Trim(); + } + + private static string AppendRootContent(string existingContent, string latestContent) + { + var normalizedLatest = NormalizeMarkdownBody(latestContent); + if (string.IsNullOrWhiteSpace(normalizedLatest)) + return NormalizeMarkdownBody(existingContent); + + var normalizedExisting = NormalizeMarkdownBody(existingContent); + if (string.IsNullOrWhiteSpace(normalizedExisting)) + return normalizedLatest; + + return $"{normalizedExisting}\n\n---\n\n## Update\n\n{normalizedLatest}"; + } + + private static bool IsLowSignalBranch(string? title, string? content) { - var normalizedProvider = LlmProviderNames.Normalize(provider); - if (!LlmProviderNames.IsSupported(normalizedProvider)) - throw new LlmChatException("Unsupported provider.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status400BadRequest); + var normalizedTitle = NormalizeBranchTitle(title ?? string.Empty).Trim().ToLowerInvariant(); + var normalizedContent = NormalizeChunk(content ?? string.Empty).Trim().ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(normalizedTitle) || string.IsNullOrWhiteSpace(normalizedContent)) + return true; + + if (normalizedContent.Length < 30) + return true; + + if (normalizedTitle.EndsWith(':') || normalizedContent.EndsWith(':')) + return true; - return normalizedProvider; + if (normalizedContent == normalizedTitle) + return true; + + if (normalizedTitle is "let's break it down" or "overview" or "miscellaneous" or "other ideas") + return true; + + if (normalizedContent.Contains("this node path") + || normalizedContent.Contains("what a node path means") + || normalizedContent.Contains("greater than symbol") + || normalizedContent.Contains("child-parent relationship")) + return true; + + return false; } private static string StripMarkdownPrefix(string line) @@ -338,6 +1591,22 @@ private static string StripMarkdownPrefix(string line) return MarkdownPrefixRegex().Replace(line, string.Empty).Trim(); } + private static string ClipToLength(string content, int maxLength) + { + if (maxLength <= 0 || string.IsNullOrWhiteSpace(content)) + return string.Empty; + + if (content.Length <= maxLength) + return content; + + var clipped = content[..maxLength].TrimEnd(); + var lastSpace = clipped.LastIndexOf(' '); + if (lastSpace >= maxLength / 2) + clipped = clipped[..lastSpace]; + + return clipped.TrimEnd() + "..."; + } + private static CanvasGraphNodeResponse MapNode(CanvasNode node) { return new CanvasGraphNodeResponse @@ -346,8 +1615,23 @@ private static CanvasGraphNodeResponse MapNode(CanvasNode node) ParentNodeId = node.ParentNodeId, Kind = node.Kind, Title = node.Title, + GenerationMode = CanvasGenerationModes.Normalize(node.GenerationMode), + Summary = string.IsNullOrWhiteSpace(node.Summary) ? (string.IsNullOrWhiteSpace(node.Content) ? null : node.Content) : node.Summary, + Category = node.Category, Content = node.Content, SourcePrompt = node.SourcePrompt, + ContinuationPrompt = node.ContinuationPrompt, + Blocks = node.Blocks.Select(block => new CanvasGraphNodeBlockResponse + { + Type = block.Type, + Text = block.Text, + Options = [.. block.Options] + }).ToList(), + Sources = node.Sources.Select(source => new CanvasGraphNodeSourceResponse + { + Label = source.Label, + Url = source.Url + }).ToList(), Depth = node.Depth, SiblingOrder = node.SiblingOrder, CreatedAt = node.CreatedAt, @@ -355,6 +1639,246 @@ private static CanvasGraphNodeResponse MapNode(CanvasNode node) }; } + private static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private const string CompactPlannerSchema = + """ + { + "type": "object", + "additionalProperties": false, + "properties": { + "branches": { + "type": "array", + "minItems": 2, + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { "type": "string" }, + "summary": { "type": "string" } + }, + "required": ["title", "summary"] + } + } + }, + "required": ["branches"] + } + """; + + private const string CompactRootPlannerSchema = + """ + { + "type": "object", + "additionalProperties": false, + "properties": { + "mainNode": { + "type": "object", + "additionalProperties": false, + "properties": { + "summary": { "type": "string" }, + "content": { "type": "string" }, + "blocks": { + "type": "array", + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "type": "string" }, + "text": { "type": "string" }, + "options": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["type", "text", "options"] + } + }, + "sources": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { "type": "string" }, + "url": { "type": "string" } + }, + "required": ["label", "url"] + } + } + }, + "required": ["summary", "content"] + }, + "branches": { + "type": "array", + "minItems": 2, + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { "type": "string" }, + "summary": { "type": "string" } + }, + "required": ["title", "summary"] + } + } + }, + "required": ["mainNode", "branches"] + } + """; + + private const string RichPlannerSchema = + """ + { + "type": "object", + "additionalProperties": false, + "properties": { + "branches": { + "type": "array", + "minItems": 2, + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { "type": "string" }, + "category": { "type": "string" }, + "summary": { "type": "string" }, + "continuationPrompt": { "type": "string" } + }, + "required": ["title", "category", "summary", "continuationPrompt"] + } + } + }, + "required": ["branches"] + } + """; + + private const string RichRootPlannerSchema = + """ + { + "type": "object", + "additionalProperties": false, + "properties": { + "mainNode": { + "type": "object", + "additionalProperties": false, + "properties": { + "summary": { "type": "string" }, + "content": { "type": "string" }, + "blocks": { + "type": "array", + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "type": "string" }, + "text": { "type": "string" }, + "options": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["type", "text", "options"] + } + }, + "sources": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { "type": "string" }, + "url": { "type": "string" } + }, + "required": ["label", "url"] + } + } + }, + "required": ["summary", "content"] + }, + "branches": { + "type": "array", + "minItems": 2, + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { "type": "string" }, + "category": { "type": "string" }, + "summary": { "type": "string" }, + "continuationPrompt": { "type": "string" } + }, + "required": ["title", "category", "summary", "continuationPrompt"] + } + } + }, + "required": ["mainNode", "branches"] + } + """; + + private const string RichDetailSchema = + """ + { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { "type": "string" }, + "blocks": { + "type": "array", + "minItems": 2, + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "type": "string" }, + "text": { "type": "string" }, + "options": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["type", "text", "options"] + } + }, + "sources": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { "type": "string" }, + "url": { "type": "string" } + }, + "required": ["label", "url"] + } + } + }, + "required": ["content", "blocks", "sources"] + } + """; + + private const string RichDetailInstructions = + """ + You are enriching one branch in a node workspace. + Expand only the selected branch. + Content must be substantive, concrete, and directly useful. + Include 2 to 4 short blocks using only relevant block types. + Include sources only when they are genuinely useful and public. + Return only JSON matching the schema. + """; + + [GeneratedRegex(@"```(?:json)?\s*(?[\s\S]*?)```", RegexOptions.IgnoreCase)] + private static partial Regex JsonFenceRegex(); + [GeneratedRegex(@"^#{1,6}\s+")] private static partial Regex HeadingRegex(); @@ -369,4 +1893,123 @@ private static CanvasGraphNodeResponse MapNode(CanvasNode node) [GeneratedRegex(@"^(#{1,6}\s+|(\*|-|\+|\d+\.)\s+)")] private static partial Regex MarkdownPrefixRegex(); + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); + + private sealed class CompactPlannerResponse + { + public List Branches { get; set; } = []; + } + + private sealed class CompactRootPlannerResponse + { + public RootMainNodeResponse MainNode { get; set; } = new(); + public List Branches { get; set; } = []; + } + + private sealed class CompactPlannerBranch + { + public string Title { get; set; } = string.Empty; + public string Summary { get; set; } = string.Empty; + } + + private sealed class RichPlannerResponse + { + public List Branches { get; set; } = []; + } + + private sealed class RichRootPlannerResponse + { + public RootMainNodeResponse MainNode { get; set; } = new(); + public List Branches { get; set; } = []; + } + + private sealed class RichPlannerBranch + { + public string Title { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string Summary { get; set; } = string.Empty; + public string ContinuationPrompt { get; set; } = string.Empty; + } + + private sealed class RootMainNodeResponse + { + public string Summary { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public List Blocks { get; set; } = []; + public List Sources { get; set; } = []; + } + + private sealed class RichDetailResponse + { + public string Content { get; set; } = string.Empty; + public List Blocks { get; set; } = []; + public List Sources { get; set; } = []; + } + + private sealed class RichDetailBlock + { + public string Type { get; set; } = string.Empty; + public string Text { get; set; } = string.Empty; + public List Options { get; set; } = []; + } + + private sealed class RichDetailSource + { + public string Label { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + } + + private sealed class RootNodeExpansionResult + { + public CanvasNodeUpdateInput? MainNode { get; set; } + public IReadOnlyList ChildNodes { get; set; } = []; + } + + private sealed record GeneratedBranch(string Title, string Content); + + private sealed class GeneratedBranchComparer : IEqualityComparer + { + public static GeneratedBranchComparer Instance { get; } = new(); + + public bool Equals(GeneratedBranch? x, GeneratedBranch? y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + + return string.Equals(x.Title, y.Title, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.Content, y.Content, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode(GeneratedBranch obj) + { + return HashCode.Combine( + obj.Title.ToLowerInvariant(), + obj.Content.ToLowerInvariant()); + } + } + + private sealed class CanvasNodeCreateInputComparer : IEqualityComparer + { + public static CanvasNodeCreateInputComparer Instance { get; } = new(); + + public bool Equals(CanvasNodeCreateInput? x, CanvasNodeCreateInput? y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + + return string.Equals(x.Title, y.Title, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.Summary ?? x.Content, y.Summary ?? y.Content, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.GenerationMode, y.GenerationMode, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode(CanvasNodeCreateInput obj) + { + return HashCode.Combine( + obj.Title.ToLowerInvariant(), + (obj.Summary ?? obj.Content).ToLowerInvariant(), + obj.GenerationMode.ToLowerInvariant()); + } + } } diff --git a/backend/Coliee.API/Services/CanvasProviderResolution.cs b/backend/Coliee.API/Services/CanvasProviderResolution.cs new file mode 100644 index 0000000..49cbd81 --- /dev/null +++ b/backend/Coliee.API/Services/CanvasProviderResolution.cs @@ -0,0 +1,7 @@ +using Coliee.API.Models; + +namespace Coliee.API.Services; + +public sealed record CanvasProviderResolution( + string Provider, + IReadOnlyList Providers); diff --git a/backend/Coliee.API/Services/CanvasProviderResolver.cs b/backend/Coliee.API/Services/CanvasProviderResolver.cs new file mode 100644 index 0000000..8444c2b --- /dev/null +++ b/backend/Coliee.API/Services/CanvasProviderResolver.cs @@ -0,0 +1,47 @@ +using Coliee.API.Data; +using Microsoft.AspNetCore.Http; + +namespace Coliee.API.Services; + +public class CanvasProviderResolver : ICanvasProviderResolver +{ + private readonly ILlmRepository _llmRepository; + private readonly ILogger _logger; + + public CanvasProviderResolver(ILlmRepository llmRepository, ILogger logger) + { + _llmRepository = llmRepository; + _logger = logger; + } + + public async Task ResolveAsync(int userId, string? providerOverride) + { + var providers = await _llmRepository.GetProviderKeysAsync(userId); + if (providers.Count == 0) + { + _logger.LogWarning("Rejected canvas generation because no provider keys are configured for user {UserId}.", userId); + throw new LlmChatException( + "Add a provider API key in Settings before creating a canvas.", + LlmProviderErrorCodes.MissingKey, + StatusCodes.Status400BadRequest); + } + + if (!string.IsNullOrWhiteSpace(providerOverride)) + { + var normalizedOverride = providerOverride.Trim().ToLowerInvariant(); + if (!providers.Any(item => item.Provider == normalizedOverride)) + { + throw new LlmChatException( + "That provider is not configured yet.", + LlmProviderErrorCodes.MissingKey, + StatusCodes.Status400BadRequest); + } + + return new CanvasProviderResolution(normalizedOverride, providers); + } + + return new CanvasProviderResolution( + providers.FirstOrDefault(item => item.IsDefault)?.Provider ?? providers.First().Provider, + providers); + } +} diff --git a/backend/Coliee.API/Services/CanvasService.cs b/backend/Coliee.API/Services/CanvasService.cs index 456aaaa..e8d33dd 100644 --- a/backend/Coliee.API/Services/CanvasService.cs +++ b/backend/Coliee.API/Services/CanvasService.cs @@ -1,8 +1,6 @@ using System.Text.RegularExpressions; using Coliee.API.Data; using Coliee.API.DTOs; -using Microsoft.AspNetCore.Http; - namespace Coliee.API.Services; public partial class CanvasService : ICanvasService @@ -17,19 +15,6 @@ public CanvasService(ICanvasRepository canvasRepository, ILogger _logger = logger; } - public async Task CreateCanvasAsync(int userId, string firstPrompt) - { - var normalizedPrompt = NormalizePrompt(firstPrompt); - if (string.IsNullOrWhiteSpace(normalizedPrompt)) - throw new CanvasOperationException("A first prompt is required to create a canvas.", StatusCodes.Status400BadRequest); - - var title = BuildTitle(normalizedPrompt); - var canvas = await _canvasRepository.CreateCanvasAsync(userId, title); - _logger.LogInformation("Created canvas {CanvasId} for user {UserId}.", canvas.Id, userId); - - return MapCanvas(canvas); - } - public async Task> GetCanvasesAsync(int userId) { var canvases = await _canvasRepository.GetCanvasesAsync(userId); @@ -70,11 +55,6 @@ private static CanvasSummaryResponse MapCanvas(Models.Canvas canvas) }; } - private static string NormalizePrompt(string firstPrompt) - { - return WhitespaceRegex().Replace(firstPrompt.Trim(), " "); - } - private static string NormalizeTitle(string title) { return WhitespaceRegex().Replace(title.Trim(), " "); @@ -93,19 +73,6 @@ private async Task EnsureCanvasAccessAsync(int userId, int canvasId) throw new CanvasOperationException("You do not have access to that canvas.", StatusCodes.Status403Forbidden); } - private static string BuildTitle(string normalizedPrompt) - { - if (normalizedPrompt.Length <= TitleMaxLength) - return normalizedPrompt; - - var truncated = normalizedPrompt[..TitleMaxLength].TrimEnd(); - var splitIndex = truncated.LastIndexOf(' '); - if (splitIndex >= TitleMaxLength / 2) - truncated = truncated[..splitIndex]; - - return $"{truncated.TrimEnd()}..."; - } - [GeneratedRegex(@"\s+")] private static partial Regex WhitespaceRegex(); } diff --git a/backend/Coliee.API/Services/ChatNodeContextCompactionService.cs b/backend/Coliee.API/Services/ChatNodeContextCompactionService.cs new file mode 100644 index 0000000..85d2e79 --- /dev/null +++ b/backend/Coliee.API/Services/ChatNodeContextCompactionService.cs @@ -0,0 +1,426 @@ +using System.Text; +using System.Text.Json; +using Coliee.API.Models; + +namespace Coliee.API.Services; + +public class ChatNodeContextCompactionService : IChatNodeContextCompactionService +{ + private static readonly JsonSerializerOptions SnapshotJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + private const int DefaultMaxPromptChars = 24000; + private const int DefaultReserveChars = 4000; + private const int DefaultFallbackWindowMessages = 12; + private const int CompactionMaxOutputTokens = 512; + private const int MessageOverheadChars = 120; + private const int ImageOverheadChars = 1200; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public ChatNodeContextCompactionService( + IConfiguration configuration, + ILogger logger) + { + _configuration = configuration; + _logger = logger; + } + + public async Task BuildPromptContextAsync( + int userId, + int sessionId, + ChatNode node, + IReadOnlyList history, + CanvasConversationMessage nextMessage, + ILlmProviderClient providerClient, + string apiKey, + string? modelOverride, + bool includeFormattingInstruction, + bool compactAllHistory = false, + ChatNodeContextSnapshot? initialSnapshot = null, + CancellationToken cancellationToken = default) + { + _ = userId; + _ = sessionId; + + var limit = ResolveContextLimit(providerClient.Provider, modelOverride); + var snapshot = initialSnapshot == null ? null : CloneSnapshot(initialSnapshot); + var rawHistory = history + .Where(message => snapshot == null || message.Id > snapshot.BoundaryMessageId) + .OrderBy(message => message.Id) + .ToList(); + + var snapshotChanged = false; + if (compactAllHistory && rawHistory.Count > 0) + { + snapshot = await CompactHistoryAsync( + providerClient, + apiKey, + modelOverride, + snapshot, + rawHistory, + cancellationToken, + boundaryMessageId: rawHistory.Last().Id); + snapshotChanged = true; + rawHistory.Clear(); + } + + while (EstimateRequestChars( + BuildSystemPrompt(node, snapshot, includeFormattingInstruction), + rawHistory, + nextMessage) > limit.PromptBudgetChars) + { + var chunk = TakeOldestCompactableChunk(rawHistory); + if (chunk.Count == 0) + { + return BuildFallbackContext( + node, + snapshot, + rawHistory, + nextMessage, + includeFormattingInstruction, + limit, + snapshotChanged || compactAllHistory); + } + + snapshot = await CompactHistoryAsync( + providerClient, + apiKey, + modelOverride, + snapshot, + chunk, + cancellationToken, + boundaryMessageId: chunk.Last().Id); + snapshotChanged = true; + rawHistory = rawHistory.Skip(chunk.Count).ToList(); + } + + var systemPrompt = BuildSystemPrompt(node, snapshot, includeFormattingInstruction); + var messages = rawHistory + .Select(message => new CanvasConversationMessage(message.Role, message.Content, message.ImageUrl)) + .ToList(); + messages.Add(nextMessage); + + return new ChatNodePromptContextResult + { + SystemPrompt = systemPrompt, + Messages = messages, + Snapshot = snapshotChanged || compactAllHistory ? snapshot : null + }; + } + + private async Task CompactHistoryAsync( + ILlmProviderClient providerClient, + string apiKey, + string? modelOverride, + ChatNodeContextSnapshot? existingSnapshot, + IReadOnlyList chunk, + CancellationToken cancellationToken, + int boundaryMessageId) + { + if (chunk.Count == 0) + { + var empty = existingSnapshot != null ? CloneSnapshot(existingSnapshot) : new ChatNodeContextSnapshot(); + empty.BoundaryMessageId = boundaryMessageId; + return empty; + } + + var request = new LlmProviderStructuredRequest + { + Instructions = BuildCompactionInstructions(existingSnapshot), + SchemaName = "chat_node_context_snapshot", + SchemaJson = """ + { + "type": "object", + "properties": { + "facts": { "type": "array", "items": { "type": "string" } }, + "decisions": { "type": "array", "items": { "type": "string" } }, + "openQuestions": { "type": "array", "items": { "type": "string" } }, + "carryForwardInstructions": { "type": "array", "items": { "type": "string" } } + }, + "required": ["facts", "decisions", "openQuestions", "carryForwardInstructions"], + "additionalProperties": false + } + """, + Messages = + [ + new LlmProviderChatMessage + { + Role = "user", + Content = BuildCompactionTranscript(existingSnapshot, chunk) + } + ], + MaxOutputTokens = CompactionMaxOutputTokens + }; + + try + { + var result = await providerClient.SendStructuredJsonAsync( + apiKey, + request, + modelOverride, + cancellationToken); + + var incoming = DeserializeSnapshot(result.Content, boundaryMessageId); + return MergeSnapshot(existingSnapshot, incoming, boundaryMessageId); + } + catch (Exception ex) when (ex is LlmChatException or JsonException or InvalidOperationException) + { + _logger.LogWarning(ex, "Context compaction failed; retaining the existing snapshot."); + var fallback = existingSnapshot != null ? CloneSnapshot(existingSnapshot) : new ChatNodeContextSnapshot(); + fallback.BoundaryMessageId = boundaryMessageId; + return fallback; + } + } + + private ChatNodePromptContextResult BuildFallbackContext( + ChatNode node, + ChatNodeContextSnapshot? snapshot, + IReadOnlyList rawHistory, + CanvasConversationMessage nextMessage, + bool includeFormattingInstruction, + ContextLimit limit, + bool snapshotChanged) + { + var retained = TrimToRecentWindow(rawHistory, limit.FallbackWindowMessages); + while (EstimateRequestChars(BuildSystemPrompt(node, snapshot, includeFormattingInstruction), retained, nextMessage) > limit.PromptBudgetChars + && retained.Count > 1) + { + retained.RemoveAt(0); + } + + return new ChatNodePromptContextResult + { + SystemPrompt = BuildSystemPrompt(node, snapshot, includeFormattingInstruction), + Messages = retained + .Select(message => new CanvasConversationMessage(message.Role, message.Content, message.ImageUrl)) + .Append(nextMessage) + .ToList(), + Snapshot = snapshotChanged ? snapshot : null + }; + } + + private static string BuildCompactionInstructions(ChatNodeContextSnapshot? existingSnapshot) + { + var builder = new StringBuilder(); + builder.AppendLine("You are compressing canvas chat history into durable memory."); + builder.AppendLine("Return only JSON that matches the schema."); + builder.AppendLine("Preserve durable information, not exact wording."); + builder.AppendLine("Deduplicate repeated items and keep entries concise."); + if (existingSnapshot != null) + builder.AppendLine("Merge the new history with the existing snapshot."); + + return builder.ToString().Trim(); + } + + private static string BuildCompactionTranscript(ChatNodeContextSnapshot? existingSnapshot, IReadOnlyList chunk) + { + var builder = new StringBuilder(); + if (existingSnapshot != null) + { + builder.AppendLine("Existing snapshot:"); + builder.AppendLine(JsonSerializer.Serialize(existingSnapshot)); + builder.AppendLine(); + } + + builder.AppendLine("Conversation chunk:"); + foreach (var message in chunk) + { + builder.Append('[') + .Append(message.Id) + .Append("] ") + .Append(message.Role) + .Append(": ") + .AppendLine(message.Content); + + if (!string.IsNullOrWhiteSpace(message.ImageUrl)) + builder.AppendLine($"image: {message.ImageUrl}"); + } + + return builder.ToString().Trim(); + } + + private static ChatNodeContextSnapshot DeserializeSnapshot(string json, int boundaryMessageId) + { + var snapshot = JsonSerializer.Deserialize(json, SnapshotJsonOptions) ?? new ChatNodeContextSnapshot(); + snapshot.BoundaryMessageId = boundaryMessageId; + snapshot.Facts = NormalizeItems(snapshot.Facts); + snapshot.Decisions = NormalizeItems(snapshot.Decisions); + snapshot.OpenQuestions = NormalizeItems(snapshot.OpenQuestions); + snapshot.CarryForwardInstructions = NormalizeItems(snapshot.CarryForwardInstructions); + return snapshot; + } + + private static ChatNodeContextSnapshot MergeSnapshot( + ChatNodeContextSnapshot? existingSnapshot, + ChatNodeContextSnapshot incomingSnapshot, + int boundaryMessageId) + { + var merged = existingSnapshot != null ? CloneSnapshot(existingSnapshot) : new ChatNodeContextSnapshot(); + merged.BoundaryMessageId = boundaryMessageId; + merged.Facts = MergeItems(merged.Facts, incomingSnapshot.Facts); + merged.Decisions = MergeItems(merged.Decisions, incomingSnapshot.Decisions); + merged.OpenQuestions = MergeItems(merged.OpenQuestions, incomingSnapshot.OpenQuestions); + merged.CarryForwardInstructions = MergeItems(merged.CarryForwardInstructions, incomingSnapshot.CarryForwardInstructions); + return merged; + } + + private static ChatNodeContextSnapshot CloneSnapshot(ChatNodeContextSnapshot snapshot) + { + return new ChatNodeContextSnapshot + { + BoundaryMessageId = snapshot.BoundaryMessageId, + Facts = snapshot.Facts.ToList(), + Decisions = snapshot.Decisions.ToList(), + OpenQuestions = snapshot.OpenQuestions.ToList(), + CarryForwardInstructions = snapshot.CarryForwardInstructions.ToList() + }; + } + + private static List MergeItems(IReadOnlyList existing, IReadOnlyList incoming) + { + return existing + .Concat(incoming) + .Select(item => item.Trim()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static List NormalizeItems(IReadOnlyList? items) + { + if (items == null) + return []; + + return items + .Select(item => item.Trim()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string? BuildSystemPrompt( + ChatNode node, + ChatNodeContextSnapshot? snapshot, + bool includeFormattingInstruction) + { + var builder = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(node.Content)) + { + builder.AppendLine("Node context:"); + builder.AppendLine(node.Content.Trim()); + builder.AppendLine(); + } + + if (snapshot != null && ( + snapshot.Facts.Count > 0 + || snapshot.Decisions.Count > 0 + || snapshot.OpenQuestions.Count > 0 + || snapshot.CarryForwardInstructions.Count > 0)) + { + builder.AppendLine("Compacted memory:"); + AppendSection(builder, "Facts", snapshot.Facts); + AppendSection(builder, "Decisions", snapshot.Decisions); + AppendSection(builder, "Open questions", snapshot.OpenQuestions); + AppendSection(builder, "Carry-forward instructions", snapshot.CarryForwardInstructions); + builder.AppendLine(); + } + + if (includeFormattingInstruction) + { + builder.AppendLine("Formatting instruction:"); + builder.AppendLine("Always format your response as a professional Rich Document. Use # and ## headers, emojis as visual anchors, and --- (horizontal lines) to separate major ideas. Structure your breakdown with lists and bold highlights."); + } + + var systemPrompt = builder.ToString().Trim(); + return string.IsNullOrWhiteSpace(systemPrompt) ? null : systemPrompt; + } + + private static void AppendSection(StringBuilder builder, string title, IReadOnlyList items) + { + if (items.Count == 0) + return; + + builder.AppendLine(title + ":"); + foreach (var item in items) + builder.AppendLine($"- {item}"); + + builder.AppendLine(); + } + + private ContextLimit ResolveContextLimit(string provider, string? modelOverride) + { + var normalizedProvider = provider.Trim().ToLowerInvariant(); + var normalizedModel = string.IsNullOrWhiteSpace(modelOverride) ? null : modelOverride.Trim(); + var providerPath = $"Llm:ContextCompaction:Providers:{normalizedProvider}"; + var modelPath = normalizedModel == null ? null : $"{providerPath}:Models:{normalizedModel}"; + + var maxPromptChars = + GetInt(modelPath is null ? null : $"{modelPath}:MaxPromptChars") + ?? GetInt($"{providerPath}:MaxPromptChars") + ?? GetInt("Llm:ContextCompaction:Default:MaxPromptChars") + ?? DefaultMaxPromptChars; + var reserveChars = + GetInt(modelPath is null ? null : $"{modelPath}:ReserveChars") + ?? GetInt($"{providerPath}:ReserveChars") + ?? GetInt("Llm:ContextCompaction:Default:ReserveChars") + ?? DefaultReserveChars; + var fallbackWindowMessages = + GetInt(modelPath is null ? null : $"{modelPath}:FallbackWindowMessages") + ?? GetInt($"{providerPath}:FallbackWindowMessages") + ?? GetInt("Llm:ContextCompaction:Default:FallbackWindowMessages") + ?? DefaultFallbackWindowMessages; + + return new ContextLimit(maxPromptChars, reserveChars, fallbackWindowMessages); + } + + private int? GetInt(string? key) + { + if (string.IsNullOrWhiteSpace(key)) + return null; + + return int.TryParse(_configuration[key], out var value) ? value : null; + } + + private static int EstimateRequestChars(string? systemPrompt, IReadOnlyList history, CanvasConversationMessage nextMessage) + { + var total = string.IsNullOrWhiteSpace(systemPrompt) ? 0 : systemPrompt.Length; + foreach (var message in history) + { + total += message.Content.Length + MessageOverheadChars; + if (!string.IsNullOrWhiteSpace(message.ImageUrl)) + total += ImageOverheadChars + message.ImageUrl!.Length; + } + + total += nextMessage.Content.Length + MessageOverheadChars; + if (!string.IsNullOrWhiteSpace(nextMessage.ImageUrl)) + total += ImageOverheadChars + nextMessage.ImageUrl!.Length; + + return total; + } + + private static List TakeOldestCompactableChunk(IReadOnlyList messages) + { + var assistantIndex = messages + .Select((message, index) => new { message, index }) + .FirstOrDefault(item => item.message.Role == "assistant"); + + return assistantIndex == null + ? [] + : messages.Take(assistantIndex.index + 1).ToList(); + } + + private static List TrimToRecentWindow(IReadOnlyList history, int maxMessages) + { + if (history.Count <= maxMessages) + return history.ToList(); + + return history.Skip(history.Count - maxMessages).ToList(); + } + + private sealed record ContextLimit(int MaxPromptChars, int ReserveChars, int FallbackWindowMessages) + { + public int PromptBudgetChars => Math.Max(0, MaxPromptChars - ReserveChars); + } +} diff --git a/backend/Coliee.API/Services/ChatNodeContextSnapshot.cs b/backend/Coliee.API/Services/ChatNodeContextSnapshot.cs new file mode 100644 index 0000000..c268e55 --- /dev/null +++ b/backend/Coliee.API/Services/ChatNodeContextSnapshot.cs @@ -0,0 +1,10 @@ +namespace Coliee.API.Services; + +public sealed class ChatNodeContextSnapshot +{ + public int BoundaryMessageId { get; set; } + public List Facts { get; set; } = []; + public List Decisions { get; set; } = []; + public List OpenQuestions { get; set; } = []; + public List CarryForwardInstructions { get; set; } = []; +} diff --git a/backend/Coliee.API/Services/ChatNodePromptContextResult.cs b/backend/Coliee.API/Services/ChatNodePromptContextResult.cs new file mode 100644 index 0000000..80ebc1e --- /dev/null +++ b/backend/Coliee.API/Services/ChatNodePromptContextResult.cs @@ -0,0 +1,8 @@ +namespace Coliee.API.Services; + +public sealed class ChatNodePromptContextResult +{ + public string? SystemPrompt { get; set; } + public IReadOnlyList Messages { get; set; } = []; + public ChatNodeContextSnapshot? Snapshot { get; set; } +} diff --git a/backend/Coliee.API/Services/ChatSessionOperationException.cs b/backend/Coliee.API/Services/ChatSessionOperationException.cs new file mode 100644 index 0000000..c40285a --- /dev/null +++ b/backend/Coliee.API/Services/ChatSessionOperationException.cs @@ -0,0 +1,12 @@ +namespace Coliee.API.Services; + +public class ChatSessionOperationException : Exception +{ + public int StatusCode { get; } + + public ChatSessionOperationException(string message, int statusCode) + : base(message) + { + StatusCode = statusCode; + } +} diff --git a/backend/Coliee.API/Services/ChatSessionService.cs b/backend/Coliee.API/Services/ChatSessionService.cs new file mode 100644 index 0000000..49bef9d --- /dev/null +++ b/backend/Coliee.API/Services/ChatSessionService.cs @@ -0,0 +1,2008 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; +using Coliee.API.Data; +using Coliee.API.DTOs; +using Coliee.API.Models; +using Microsoft.AspNetCore.Http; + +namespace Coliee.API.Services; + +public partial class ChatSessionService : IChatSessionService +{ + private const int SessionTitleMaxLength = 80; + private const int NodeTitleMaxLength = 64; + private const int PreviewMaxLength = 180; + private const string BlankSessionTitle = "Untitled Canvas"; + private const string RichDocumentInstruction = + "Always format your response as a professional Rich Document. Use # and ## headers, emojis as visual anchors, and --- (horizontal lines) to separate major ideas. Structure your breakdown with lists and bold highlights."; + private const string RootNodeTitle = "Main Node"; + private const string GeneratedNoteTitle = "AI Summary"; + private const double RootPositionX = 400; + private const double RootPositionY = 300; + private const double DefaultNodeWidth = 600; + private const double DefaultNodeHeight = 520; + private const double DefaultNoteWidth = 400; + private const double DefaultNoteHeight = 400; + private readonly IChatSessionRepository _chatSessionRepository; + private readonly ILlmRepository _llmRepository; + private readonly ICanvasProviderResolver _providerResolver; + private readonly ILlmEncryptionService _encryptionService; + private readonly IChatNodeContextCompactionService _contextCompactionService; + private readonly IReadOnlyDictionary _providerClients; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public ChatSessionService( + IChatSessionRepository chatSessionRepository, + ILlmRepository llmRepository, + ICanvasProviderResolver providerResolver, + ILlmEncryptionService encryptionService, + IChatNodeContextCompactionService contextCompactionService, + IEnumerable providerClients, + IConfiguration configuration, + ILogger logger) + { + _chatSessionRepository = chatSessionRepository; + _llmRepository = llmRepository; + _providerResolver = providerResolver; + _encryptionService = encryptionService; + _contextCompactionService = contextCompactionService; + _providerClients = providerClients.ToDictionary(client => client.Provider, StringComparer.Ordinal); + _configuration = configuration; + _logger = logger; + } + + public async Task> GetSessionsAsync(int userId) + { + var sessions = await _chatSessionRepository.GetSessionsAsync(userId); + return sessions.Select(MapSession).ToList(); + } + + public async Task CreateSessionAsync(int userId, string? title, string? content, string? providerOverride, string? modelOverride = null) + { + var normalizedTitle = NormalizeSessionTitle(title); + var trimmedContent = NormalizeOptionalContent(content); + var sessionTitle = normalizedTitle + ?? (trimmedContent is null + ? BlankSessionTitle + : BuildDisplayTitle(trimmedContent, SessionTitleMaxLength)); + + var session = await _chatSessionRepository.CreateSessionAsync(userId, sessionTitle); + + try + { + var rootNode = await _chatSessionRepository.EnsureRootNodeAsync(userId, session.Id, RootNodeTitle); + if (trimmedContent is null) + return await BuildStateResponseAsync(userId, session.Id, rootNode.Id); + + var resolution = await ResolveProviderAsync(userId, null, providerOverride); + var selectedProvider = resolution.Provider; + var providerRecord = resolution.Providers.First(item => item.Provider == selectedProvider); + var providerClient = GetProviderClient(selectedProvider); + var apiKey = _encryptionService.Decrypt(providerRecord.EncryptedApiKey); + var normalizedModelOverride = NormalizeModelOverride(selectedProvider, modelOverride); + + var promptContext = await _contextCompactionService.BuildPromptContextAsync( + userId, + session.Id, + rootNode, + [], + new CanvasConversationMessage("user", trimmedContent), + providerClient, + apiKey, + normalizedModelOverride, + includeFormattingInstruction: true); + + await _chatSessionRepository.AddMessageAsync(userId, session.Id, rootNode.Id, "user", trimmedContent, null, null, null); + + if (promptContext.Snapshot != null) + { + await PersistNodeContextSnapshotAsync(userId, session.Id, rootNode.Id, promptContext.Snapshot); + } + + var providerReply = await SendConversationAsync( + selectedProvider, + providerClient, + apiKey, + promptContext.Messages, + normalizedModelOverride, + promptContext.SystemPrompt); + + await _chatSessionRepository.AddMessageAsync( + userId, + session.Id, + rootNode.Id, + "assistant", + providerReply.Content, + null, + selectedProvider, + providerReply.Model); + + await _chatSessionRepository.UpdateNodeAsync( + userId, + session.Id, + rootNode.Id, + BuildUpdatedNodeInput(rootNode, preview: BuildPreview(providerReply.Content))); + + _logger.LogInformation("Created chat session {SessionId} for user {UserId}.", session.Id, userId); + return await BuildStateResponseAsync(userId, session.Id, rootNode.Id, resolution.Providers); + } + catch (Exception ex) when (ex is ChatSessionOperationException or LlmChatException) + { + try + { + await _chatSessionRepository.DeleteSessionAsync(userId, session.Id); + } + catch (Exception rollbackEx) + { + _logger.LogError(rollbackEx, "Failed to rollback chat session {SessionId} for user {UserId}.", session.Id, userId); + } + + throw; + } + } + + public async Task PreviewGuestAsync(GuestChatPreviewRequest request) + { + var actionType = NormalizeGuestActionType(request.ActionType); + var modelId = NormalizeNullable(request.ModelId) ?? "gemini-3.1-flash-lite-preview"; + var provider = InferProviderFromModel(modelId); + var normalizedModel = NormalizeModelOverride(provider, modelId) ?? modelId; + var normalizedPrompt = NormalizeOptionalContent(request.Prompt); + var normalizedContent = NormalizeOptionalContent(request.Content); + var normalizedImageUrl = NormalizeNullable(request.ImageUrl); + var messages = request.Messages + .Select(message => new ConversationMessage( + message.Role == "assistant" || message.Role == "model" ? "assistant" : "user", + NormalizeOptionalContent(message.Content) ?? string.Empty, + NormalizeNullable(message.ImageUrl))) + .ToList(); + + var guestApiKey = ResolveGuestPreviewApiKey(provider); + var providerClient = !string.IsNullOrWhiteSpace(guestApiKey) + ? GetProviderClient(provider) + : null; + var guestModelOverride = ResolveGuestPreviewModel(provider, normalizedModel); + + return actionType switch + { + "note" => await PreviewGuestNoteAsync(messages, normalizedContent, provider, providerClient, guestApiKey, guestModelOverride), + "image" => await PreviewGuestImageAsync(normalizedPrompt ?? normalizedContent, provider, guestApiKey, guestModelOverride), + _ => await PreviewGuestChatAsync(messages, normalizedPrompt ?? normalizedContent, normalizedImageUrl, provider, providerClient, guestApiKey, guestModelOverride) + }; + } + + public async Task ImportGuestCanvasesAsync(int userId, ImportGuestCanvasesRequest request) + { + if (request.Canvases.Count == 0) + throw new ChatSessionOperationException("At least one guest canvas is required.", StatusCodes.Status400BadRequest); + + ChatSessionStateResponse? activeState = null; + + foreach (var guestCanvas in request.Canvases) + { + var normalizedTitle = NormalizeSessionTitle(guestCanvas.Name) ?? BlankSessionTitle; + var createdSession = await _chatSessionRepository.CreateSessionAsync(userId, normalizedTitle); + var rootSource = guestCanvas.Nodes.FirstOrDefault(node => string.IsNullOrWhiteSpace(node.ParentTempId)) + ?? guestCanvas.Nodes.FirstOrDefault() + ?? throw new ChatSessionOperationException("Each imported canvas requires at least one node.", StatusCodes.Status400BadRequest); + + var rootNode = await _chatSessionRepository.EnsureRootNodeAsync(userId, createdSession.Id, NormalizeNodeTitle(rootSource.Title, RootNodeTitle, NodeTitleMaxLength)); + var rootType = ChatNodeTypes.Normalize(rootSource.NodeType); + var rootTitle = NormalizeNodeTitle(rootSource.Title, RootNodeTitle, NodeTitleMaxLength); + var rootContent = rootSource.Content?.Trim() ?? string.Empty; + var rootWidth = rootSource.Width > 0 ? rootSource.Width : DefaultNodeWidth; + var rootHeight = rootSource.Height > 0 ? rootSource.Height : DefaultNodeHeight; + + await _chatSessionRepository.UpdateNodeAsync( + userId, + createdSession.Id, + rootNode.Id, + new ChatNodeUpdateInput + { + NodeType = rootType, + Title = rootTitle, + Preview = BuildPreview(rootContent), + Content = rootContent, + PositionX = rootSource.PositionX, + PositionY = rootSource.PositionY, + Width = rootWidth, + Height = rootHeight, + IsCollapsed = rootSource.IsCollapsed ?? false, + ExpandedWidth = rootSource.ExpandedWidth ?? rootWidth, + ExpandedHeight = rootSource.ExpandedHeight ?? rootHeight, + PreferredProvider = NormalizeNullable(rootSource.PreferredProvider), + PreferredModel = NormalizeNullable(rootSource.PreferredModel) + }); + + foreach (var message in rootSource.Messages) + { + await _chatSessionRepository.AddMessageAsync( + userId, + createdSession.Id, + rootNode.Id, + NormalizeImportedRole(message.Role), + message.Content?.Trim() ?? string.Empty, + NormalizeNullable(message.ImageUrl), + NormalizeNullable(message.Provider), + NormalizeNullable(message.Model)); + } + + var createdNodeIds = new Dictionary(StringComparer.Ordinal) + { + [rootSource.TempId] = rootNode.Id + }; + + var pendingNodes = guestCanvas.Nodes.Where(node => node.TempId != rootSource.TempId).ToList(); + while (pendingNodes.Count > 0) + { + var progressed = false; + + foreach (var guestNode in pendingNodes.ToList()) + { + if (string.IsNullOrWhiteSpace(guestNode.ParentTempId) || !createdNodeIds.TryGetValue(guestNode.ParentTempId, out var parentNodeId)) + continue; + + var nodeType = ChatNodeTypes.Normalize(guestNode.NodeType); + var createdNode = await _chatSessionRepository.CreateNodeAsync(userId, createdSession.Id, new ChatNodeCreateInput + { + ParentNodeId = parentNodeId, + BranchFromMessageId = null, + NodeType = nodeType, + Title = NormalizeNodeTitle(guestNode.Title, GetDefaultNodeTitle(nodeType), NodeTitleMaxLength), + Preview = BuildPreview(guestNode.Content?.Trim() ?? string.Empty), + Content = guestNode.Content?.Trim() ?? string.Empty, + PositionX = guestNode.PositionX, + PositionY = guestNode.PositionY, + Width = guestNode.Width > 0 ? guestNode.Width : (nodeType == ChatNodeTypes.Note ? DefaultNoteWidth : DefaultNodeWidth), + Height = guestNode.Height > 0 ? guestNode.Height : (nodeType == ChatNodeTypes.Note ? DefaultNoteHeight : DefaultNodeHeight), + IsCollapsed = guestNode.IsCollapsed ?? false, + ExpandedWidth = guestNode.ExpandedWidth ?? (guestNode.Width > 0 ? guestNode.Width : (nodeType == ChatNodeTypes.Note ? DefaultNoteWidth : DefaultNodeWidth)), + ExpandedHeight = guestNode.ExpandedHeight ?? (guestNode.Height > 0 ? guestNode.Height : (nodeType == ChatNodeTypes.Note ? DefaultNoteHeight : DefaultNodeHeight)), + PreferredProvider = NormalizeNullable(guestNode.PreferredProvider), + PreferredModel = NormalizeNullable(guestNode.PreferredModel) + }); + + foreach (var message in guestNode.Messages) + { + await _chatSessionRepository.AddMessageAsync( + userId, + createdSession.Id, + createdNode.Id, + NormalizeImportedRole(message.Role), + message.Content?.Trim() ?? string.Empty, + NormalizeNullable(message.ImageUrl), + NormalizeNullable(message.Provider), + NormalizeNullable(message.Model)); + } + + createdNodeIds[guestNode.TempId] = createdNode.Id; + pendingNodes.Remove(guestNode); + progressed = true; + } + + if (!progressed) + { + throw new ChatSessionOperationException( + "Guest canvas import contains invalid parent references.", + StatusCodes.Status400BadRequest); + } + } + + if (activeState == null || string.Equals(request.ActiveCanvasId, guestCanvas.TempId, StringComparison.Ordinal)) + activeState = await BuildStateResponseAsync(userId, createdSession.Id, rootNode.Id); + } + + return activeState + ?? throw new ChatSessionOperationException("Guest canvas import failed.", StatusCodes.Status500InternalServerError); + } + + public async Task RenameSessionAsync(int userId, int sessionId, string title) + { + await EnsureSessionExistsAsync(userId, sessionId); + var normalizedTitle = NormalizeTitle(title, "Canvas title is required.", SessionTitleMaxLength); + var session = await _chatSessionRepository.RenameSessionAsync(userId, sessionId, normalizedTitle); + return MapSession(session); + } + + public async Task DeleteSessionAsync(int userId, int sessionId) + { + await EnsureSessionExistsAsync(userId, sessionId); + var sessions = await _chatSessionRepository.GetSessionsAsync(userId); + if (sessions.Count <= 1) + { + throw new ChatSessionOperationException( + "At least one canvas must remain.", + StatusCodes.Status409Conflict); + } + + await _chatSessionRepository.DeleteSessionAsync(userId, sessionId); + } + + public async Task GetSessionAsync(int userId, int sessionId, int? activeNodeId = null) + { + await EnsureSessionExistsAsync(userId, sessionId); + return await BuildGraphResponseAsync(userId, sessionId, activeNodeId); + } + + public async Task GetNodeAsync(int userId, int sessionId, int nodeId) + { + await EnsureSessionExistsAsync(userId, sessionId); + var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); + return await BuildNodeDetailAsync(userId, sessionId, node); + } + + public async Task CreateNodeAsync(int userId, int sessionId, CreateChatNodeRequest request) + { + await EnsureSessionExistsAsync(userId, sessionId); + if (request.ParentNodeId <= 0) + throw new ChatSessionOperationException("A valid parent node is required.", StatusCodes.Status400BadRequest); + + var parentNode = await GetRequiredNodeAsync(userId, sessionId, request.ParentNodeId); + var nodes = await _chatSessionRepository.GetNodesAsync(userId, sessionId); + var nodeType = ChatNodeTypes.Normalize(request.NodeType); + var (width, height) = ResolveNodeSize(nodeType, request.Width, request.Height); + var (positionX, positionY) = ResolveNodePosition(parentNode, nodeType, nodes, request.PositionX, request.PositionY); + var content = request.Content?.Trim() ?? string.Empty; + var preferredProvider = NormalizeNullable(request.PreferredProvider); + var preferredModel = NormalizeNullable(request.PreferredModel); + + var createdNode = await _chatSessionRepository.CreateNodeAsync(userId, sessionId, new ChatNodeCreateInput + { + ParentNodeId = parentNode.Id, + BranchFromMessageId = null, + NodeType = nodeType, + Title = NormalizeNodeTitle(request.Title, GetDefaultNodeTitle(nodeType), NodeTitleMaxLength), + Preview = BuildPreview(content), + Content = content, + PositionX = positionX, + PositionY = positionY, + Width = width, + Height = height, + IsCollapsed = request.IsCollapsed ?? false, + ExpandedWidth = request.ExpandedWidth ?? width, + ExpandedHeight = request.ExpandedHeight ?? height, + PreferredProvider = preferredProvider, + PreferredModel = preferredModel + }); + + return await BuildStateResponseAsync(userId, sessionId, createdNode.Id); + } + + public async Task UpdateNodeAsync(int userId, int sessionId, int nodeId, UpdateChatNodeRequest request) + { + await EnsureSessionExistsAsync(userId, sessionId); + var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); + var nodeType = node.ParentNodeId == null ? ChatNodeTypes.Question : node.NodeType; + var title = request.Title is null + ? node.Title + : NormalizeNodeTitle(request.Title, GetDefaultNodeTitle(nodeType), NodeTitleMaxLength); + var content = request.Content is null ? node.Content : request.Content.Trim(); + var preview = request.Content is null ? node.Preview : BuildPreview(content); + var preferredProvider = request.PreferredProvider is null + ? node.PreferredProvider + : NormalizeNullable(request.PreferredProvider); + var preferredModel = request.PreferredModel is null + ? node.PreferredModel + : NormalizeNullable(request.PreferredModel); + + await _chatSessionRepository.UpdateNodeAsync( + userId, + sessionId, + nodeId, + new ChatNodeUpdateInput + { + NodeType = nodeType, + Title = title, + Preview = preview, + Content = content, + PositionX = request.PositionX ?? node.PositionX, + PositionY = request.PositionY ?? node.PositionY, + Width = request.Width ?? node.Width, + Height = request.Height ?? node.Height, + IsCollapsed = request.IsCollapsed ?? node.IsCollapsed, + ExpandedWidth = request.ExpandedWidth ?? node.ExpandedWidth ?? node.Width, + ExpandedHeight = request.ExpandedHeight ?? node.ExpandedHeight ?? node.Height, + PreferredProvider = preferredProvider, + PreferredModel = preferredModel + }); + + return await BuildStateResponseAsync(userId, sessionId, nodeId); + } + + public async Task SendNodeMessageAsync( + int userId, + int sessionId, + int nodeId, + string content, + string? imageUrl, + string? providerOverride, + string? modelOverride = null) + { + var session = await EnsureSessionExistsAsync(userId, sessionId); + var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); + if (node.NodeType == ChatNodeTypes.Note) + { + throw new ChatSessionOperationException( + "Notes do not accept chat messages.", + StatusCodes.Status400BadRequest); + } + + var normalizedImageUrl = NormalizeNullable(imageUrl); + var normalizedContent = NormalizeOptionalContent(content) ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalizedContent) && string.IsNullOrWhiteSpace(normalizedImageUrl)) + { + throw new ChatSessionOperationException( + "Message content is required.", + StatusCodes.Status400BadRequest); + } + + var resolution = await ResolveProviderAsync(userId, node.PreferredProvider, providerOverride); + var selectedProvider = resolution.Provider; + var providerRecord = resolution.Providers.First(item => item.Provider == selectedProvider); + var providerClient = GetProviderClient(selectedProvider); + var apiKey = _encryptionService.Decrypt(providerRecord.EncryptedApiKey); + var normalizedModelOverride = NormalizeModelOverride(selectedProvider, modelOverride) ?? NormalizeModelOverride(selectedProvider, node.PreferredModel); + + var localHistory = await _chatSessionRepository.GetNodeMessagesAsync(userId, sessionId, node.Id); + var initialSnapshot = await _chatSessionRepository.GetNodeContextSnapshotAsync(userId, sessionId, node.Id); + var promptContext = await _contextCompactionService.BuildPromptContextAsync( + userId, + sessionId, + node, + localHistory, + new CanvasConversationMessage("user", normalizedContent, normalizedImageUrl), + providerClient, + apiKey, + normalizedModelOverride, + includeFormattingInstruction: normalizedImageUrl is null, + initialSnapshot: initialSnapshot); + + await _chatSessionRepository.AddMessageAsync(userId, session.Id, node.Id, "user", normalizedContent, normalizedImageUrl, null, null); + + if (promptContext.Snapshot != null) + { + await PersistNodeContextSnapshotAsync(userId, session.Id, node.Id, promptContext.Snapshot); + } + + var providerReply = await SendConversationAsync( + selectedProvider, + providerClient, + apiKey, + promptContext.Messages, + normalizedModelOverride, + promptContext.SystemPrompt); + + await _chatSessionRepository.AddMessageAsync( + userId, + session.Id, + node.Id, + "assistant", + providerReply.Content, + null, + selectedProvider, + providerReply.Model); + + await _chatSessionRepository.UpdateNodeAsync( + userId, + session.Id, + node.Id, + BuildUpdatedNodeInput(node, preview: BuildPreview(providerReply.Content))); + + return await BuildStateResponseAsync(userId, session.Id, node.Id, resolution.Providers); + } + + public async Task UpdateMessageAsync(int userId, int sessionId, int nodeId, int messageId, UpdateChatCanvasMessageRequest request) + { + await EnsureSessionExistsAsync(userId, sessionId); + var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); + var messages = await _chatSessionRepository.GetNodeMessagesAsync(userId, sessionId, nodeId); + var latestUserMessage = messages.LastOrDefault(message => message.Role == "user"); + + if (latestUserMessage == null || latestUserMessage.Id != messageId) + { + throw new ChatSessionOperationException( + "Only the latest user prompt can be edited.", + StatusCodes.Status400BadRequest); + } + + var normalizedContent = NormalizeRichContent(request.Content) ?? throw new ChatSessionOperationException( + "Message content is required.", + StatusCodes.Status400BadRequest); + + await _chatSessionRepository.UpdateMessageAsync(userId, sessionId, nodeId, messageId, normalizedContent); + await _chatSessionRepository.UpdateNodeAsync( + userId, + sessionId, + nodeId, + BuildUpdatedNodeInput(node, content: normalizedContent, preview: BuildPreview(normalizedContent))); + + return await BuildStateResponseAsync(userId, sessionId, nodeId); + } + + public async Task CreateBranchAsync(int userId, int sessionId, int nodeId, int branchFromMessageId, string content, string? providerOverride, string? modelOverride = null) + { + var session = await EnsureSessionExistsAsync(userId, sessionId); + var parentNode = await GetRequiredNodeAsync(userId, sessionId, nodeId); + var branchFromMessage = await _chatSessionRepository.GetNodeMessageAsync(userId, sessionId, nodeId, branchFromMessageId); + if (branchFromMessage == null || branchFromMessage.Role != "assistant") + { + throw new ChatSessionOperationException( + "Branches can only start from assistant replies in the current node.", + StatusCodes.Status400BadRequest); + } + + var trimmedContent = NormalizeTitle(content, "Message content is required.", 2000); + var resolution = await ResolveProviderAsync(userId, parentNode.PreferredProvider, providerOverride); + var selectedProvider = resolution.Provider; + var providerRecord = resolution.Providers.First(item => item.Provider == selectedProvider); + var providerClient = GetProviderClient(selectedProvider); + var apiKey = _encryptionService.Decrypt(providerRecord.EncryptedApiKey); + var normalizedModelOverride = NormalizeModelOverride(selectedProvider, modelOverride) ?? NormalizeModelOverride(selectedProvider, parentNode.PreferredModel); + + var parentResolvedHistory = await BuildResolvedMessagesAsync(userId, sessionId, parentNode); + var truncatedHistory = TakeHistoryUntil(parentResolvedHistory, branchFromMessageId); + var parentSnapshot = await _chatSessionRepository.GetNodeContextSnapshotAsync(userId, sessionId, nodeId); + var branchSeedSnapshot = parentSnapshot != null && parentSnapshot.BoundaryMessageId < branchFromMessageId + ? parentSnapshot + : null; + var promptContext = await _contextCompactionService.BuildPromptContextAsync( + userId, + sessionId, + parentNode, + truncatedHistory, + new CanvasConversationMessage("user", trimmedContent), + providerClient, + apiKey, + normalizedModelOverride, + includeFormattingInstruction: true, + compactAllHistory: true, + initialSnapshot: branchSeedSnapshot); + + var providerReply = await SendConversationAsync( + selectedProvider, + providerClient, + apiKey, + promptContext.Messages, + normalizedModelOverride, + promptContext.SystemPrompt); + + var nodes = await _chatSessionRepository.GetNodesAsync(userId, sessionId); + var (positionX, positionY) = ResolveNodePosition(parentNode, ChatNodeTypes.Response, nodes, null, null); + var childNode = await _chatSessionRepository.CreateNodeAsync(userId, sessionId, new ChatNodeCreateInput + { + ParentNodeId = parentNode.Id, + BranchFromMessageId = branchFromMessageId, + NodeType = ChatNodeTypes.Response, + Title = NormalizeNodeTitle(null, BuildDisplayTitle(trimmedContent, NodeTitleMaxLength), NodeTitleMaxLength), + Preview = BuildPreview(providerReply.Content), + Content = string.Empty, + PositionX = positionX, + PositionY = positionY, + Width = DefaultNodeWidth, + Height = DefaultNodeHeight, + PreferredProvider = parentNode.PreferredProvider, + PreferredModel = parentNode.PreferredModel + }); + + await _chatSessionRepository.AddMessageAsync(userId, session.Id, childNode.Id, "user", trimmedContent, null, null, null); + await _chatSessionRepository.AddMessageAsync(userId, session.Id, childNode.Id, "assistant", providerReply.Content, null, selectedProvider, providerReply.Model); + + if (promptContext.Snapshot != null) + { + await PersistNodeContextSnapshotAsync(userId, session.Id, childNode.Id, promptContext.Snapshot); + } + + return await BuildStateResponseAsync(userId, session.Id, childNode.Id, resolution.Providers); + } + + public async Task MoveMessagesToBranchAsync( + int userId, + int sessionId, + int nodeId, + IReadOnlyList messageIds, + string? transferMode = null) + { + var session = await EnsureSessionExistsAsync(userId, sessionId); + var sourceNode = await GetRequiredNodeAsync(userId, sessionId, nodeId); + if (sourceNode.NodeType == ChatNodeTypes.Note) + { + throw new ChatSessionOperationException( + "Notes do not contain transferable chat messages.", + StatusCodes.Status400BadRequest); + } + + var requestedIds = messageIds + .Where(id => id > 0) + .Distinct() + .ToList(); + if (requestedIds.Count == 0) + { + throw new ChatSessionOperationException( + "Select at least one message to move.", + StatusCodes.Status400BadRequest); + } + var normalizedTransferMode = NormalizeNullable(transferMode)?.ToLowerInvariant(); + var shouldMove = normalizedTransferMode switch + { + null or "" or "move" => true, + "copy" => false, + _ => throw new ChatSessionOperationException( + "Transfer mode must be either 'move' or 'copy'.", + StatusCodes.Status400BadRequest) + }; + + var localMessages = (await _chatSessionRepository.GetNodeMessagesAsync(userId, sessionId, nodeId)).ToList(); + var messageById = localMessages.ToDictionary(message => message.Id); + if (requestedIds.Any(id => !messageById.ContainsKey(id))) + { + throw new ChatSessionOperationException( + "Only messages from the current node can be moved.", + StatusCodes.Status400BadRequest); + } + + var selectedMessages = localMessages + .Where(message => requestedIds.Contains(message.Id)) + .ToList(); + var selectedIndices = selectedMessages + .Select(message => localMessages.FindIndex(item => item.Id == message.Id)) + .OrderBy(index => index) + .ToList(); + var firstSelectedIndex = selectedIndices.First(); + var lastSelectedIndex = selectedIndices.Last(); + var expectedCount = lastSelectedIndex - firstSelectedIndex + 1; + if (expectedCount != selectedIndices.Count) + { + throw new ChatSessionOperationException( + "Select a continuous message range before moving to a new node.", + StatusCodes.Status400BadRequest); + } + + var childNodes = await _chatSessionRepository.GetNodesAsync(userId, sessionId); + if (shouldMove) + { + var blockedAnchors = childNodes + .Where(node => node.ParentNodeId == sourceNode.Id && node.BranchFromMessageId.HasValue) + .Select(node => node.BranchFromMessageId!.Value) + .ToHashSet(); + var selectedAssistantIds = selectedMessages + .Where(message => message.Role == "assistant") + .Select(message => message.Id); + if (selectedAssistantIds.Any(blockedAnchors.Contains)) + { + throw new ChatSessionOperationException( + "Some selected assistant replies already anchor other branches. Remove those branches first.", + StatusCodes.Status409Conflict); + } + } + + var branchFromMessageId = firstSelectedIndex > 0 + ? localMessages[firstSelectedIndex - 1].Id + : sourceNode.BranchFromMessageId; + var seedTitle = selectedMessages.FirstOrDefault(message => message.Role == "user")?.Content + ?? selectedMessages[0].Content; + var remainingMessages = localMessages + .Where(message => !requestedIds.Contains(message.Id)) + .ToList(); + var finalMovedMessage = selectedMessages.Last(); + var finalRemainingMessage = remainingMessages.LastOrDefault(); + var (positionX, positionY) = ResolveNodePosition(sourceNode, ChatNodeTypes.Response, childNodes, null, null); + + var childNode = await _chatSessionRepository.CreateNodeAsync(userId, sessionId, new ChatNodeCreateInput + { + ParentNodeId = sourceNode.Id, + BranchFromMessageId = branchFromMessageId, + NodeType = ChatNodeTypes.Response, + Title = NormalizeNodeTitle(null, BuildDisplayTitle(seedTitle, NodeTitleMaxLength), NodeTitleMaxLength), + Preview = BuildPreview(finalMovedMessage.Content), + Content = string.Empty, + PositionX = positionX, + PositionY = positionY, + Width = DefaultNodeWidth, + Height = DefaultNodeHeight, + PreferredProvider = sourceNode.PreferredProvider, + PreferredModel = sourceNode.PreferredModel + }); + + if (shouldMove) + { + await _chatSessionRepository.ReassignMessagesAsync( + userId, + session.Id, + sourceNode.Id, + childNode.Id, + requestedIds); + + await _chatSessionRepository.UpdateNodeAsync( + userId, + session.Id, + sourceNode.Id, + BuildUpdatedNodeInput( + sourceNode, + preview: finalRemainingMessage == null ? string.Empty : BuildPreview(finalRemainingMessage.Content))); + } + else + { + foreach (var message in selectedMessages) + { + await _chatSessionRepository.AddMessageAsync( + userId, + session.Id, + childNode.Id, + message.Role, + message.Content, + message.ImageUrl, + message.Provider, + message.Model); + } + } + + await _chatSessionRepository.UpdateNodeAsync( + userId, + session.Id, + childNode.Id, + BuildUpdatedNodeInput(childNode, preview: BuildPreview(finalMovedMessage.Content))); + + return await BuildStateResponseAsync(userId, session.Id, childNode.Id); + } + + public async Task GenerateNoteAsync(int userId, int sessionId, int nodeId, string? providerOverride, string? modelOverride = null) + { + await EnsureSessionExistsAsync(userId, sessionId); + var sourceNode = await GetRequiredNodeAsync(userId, sessionId, nodeId); + var localMessages = (await _chatSessionRepository.GetNodeMessagesAsync(userId, sessionId, nodeId)).ToList(); + if (localMessages.Count == 0 && string.IsNullOrWhiteSpace(sourceNode.Content)) + { + throw new ChatSessionOperationException( + "Add some content or messages before generating a note.", + StatusCodes.Status400BadRequest); + } + + var resolution = await ResolveProviderAsync(userId, sourceNode.PreferredProvider, providerOverride); + var selectedProvider = resolution.Provider; + var providerRecord = resolution.Providers.First(item => item.Provider == selectedProvider); + var providerClient = GetProviderClient(selectedProvider); + var apiKey = _encryptionService.Decrypt(providerRecord.EncryptedApiKey); + var normalizedModelOverride = NormalizeModelOverride(selectedProvider, modelOverride) ?? NormalizeModelOverride(selectedProvider, sourceNode.PreferredModel); + + var prompt = BuildNotePrompt(sourceNode, localMessages); + var providerReply = await providerClient.SendChatAsync( + apiKey, + [new LlmProviderChatMessage { Role = "user", Content = prompt }], + normalizedModelOverride); + + var nodes = await _chatSessionRepository.GetNodesAsync(userId, sessionId); + var (positionX, positionY) = ResolveNodePosition(sourceNode, ChatNodeTypes.Note, nodes, null, null); + var (_, noteHeight) = CalculateGeneratedNoteSize(providerReply.Content); + var noteNode = await _chatSessionRepository.CreateNodeAsync(userId, sessionId, new ChatNodeCreateInput + { + ParentNodeId = sourceNode.Id, + BranchFromMessageId = null, + NodeType = ChatNodeTypes.Note, + Title = GeneratedNoteTitle, + Preview = BuildPreview(providerReply.Content), + Content = providerReply.Content, + PositionX = positionX, + PositionY = positionY, + Width = DefaultNodeWidth, + Height = noteHeight, + IsCollapsed = false, + ExpandedWidth = DefaultNodeWidth, + ExpandedHeight = noteHeight, + PreferredProvider = sourceNode.PreferredProvider, + PreferredModel = sourceNode.PreferredModel + }); + + return await BuildStateResponseAsync(userId, sessionId, noteNode.Id, resolution.Providers); + } + + public async Task GenerateImageAsync(int userId, int sessionId, int nodeId, string prompt, string? providerOverride, string? modelOverride = null) + { + var session = await EnsureSessionExistsAsync(userId, sessionId); + var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); + if (node.NodeType == ChatNodeTypes.Note) + { + throw new ChatSessionOperationException( + "Notes do not support image generation.", + StatusCodes.Status400BadRequest); + } + + var trimmedPrompt = NormalizeTitle(prompt, "Image prompt is required.", 4000); + var resolution = await ResolveProviderAsync(userId, node.PreferredProvider, providerOverride); + var selectedProvider = resolution.Provider; + var providerRecord = resolution.Providers.First(item => item.Provider == selectedProvider); + var apiKey = _encryptionService.Decrypt(providerRecord.EncryptedApiKey); + var preferredModel = NormalizeModelOverride(selectedProvider, modelOverride) ?? NormalizeModelOverride(selectedProvider, node.PreferredModel); + var imageModel = ResolveImageModel(selectedProvider, preferredModel); + var imageUrl = await GenerateImageForProviderAsync(selectedProvider, apiKey, trimmedPrompt, imageModel); + var assistantMessage = $"Here is the visual manifestation for: **{trimmedPrompt}**"; + + await _chatSessionRepository.AddMessageAsync(session.UserId, session.Id, node.Id, "user", $"[GENERATE IMAGE]: {trimmedPrompt}", null, null, null); + await _chatSessionRepository.AddMessageAsync(session.UserId, session.Id, node.Id, "assistant", assistantMessage, imageUrl, selectedProvider, imageModel); + + await _chatSessionRepository.UpdateNodeAsync( + userId, + session.Id, + node.Id, + BuildUpdatedNodeInput(node, preview: BuildPreview(assistantMessage))); + + return await BuildStateResponseAsync(userId, session.Id, node.Id, resolution.Providers); + } + + public async Task DeleteNodeAsync(int userId, int sessionId, int nodeId, int? newActiveNodeId = null) + { + await EnsureSessionExistsAsync(userId, sessionId); + var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); + + if (node.ParentNodeId == null) + { + throw new ChatSessionOperationException( + "The root node cannot be deleted. Delete the entire session instead.", + StatusCodes.Status400BadRequest); + } + + var deleted = await _chatSessionRepository.DeleteNodeAsync(userId, sessionId, nodeId); + if (!deleted) + { + throw new ChatSessionOperationException( + "Chat node could not be deleted.", + StatusCodes.Status500InternalServerError); + } + + _logger.LogInformation("Deleted sub-node {NodeId} from session {SessionId} for user {UserId}.", nodeId, sessionId, userId); + return await BuildGraphResponseAsync(userId, sessionId, newActiveNodeId); + } + + public async Task SaveLayoutAsync(int userId, int sessionId, IReadOnlyList nodes, int? activeNodeId = null) + { + await EnsureSessionExistsAsync(userId, sessionId); + if (nodes.Any(node => node.NodeId <= 0)) + { + throw new ChatSessionOperationException("A valid node is required for layout updates.", StatusCodes.Status400BadRequest); + } + + await _chatSessionRepository.SaveNodeLayoutsAsync(userId, sessionId, nodes); + return await BuildGraphResponseAsync(userId, sessionId, activeNodeId); + } + + private async Task EnsureSessionExistsAsync(int userId, int sessionId) + { + var session = await _chatSessionRepository.GetSessionAsync(userId, sessionId); + return session ?? throw new ChatSessionOperationException("Chat session not found.", StatusCodes.Status404NotFound); + } + + private async Task GetRequiredNodeAsync(int userId, int sessionId, int nodeId) + { + if (nodeId <= 0) + throw new ChatSessionOperationException("A valid node is required.", StatusCodes.Status400BadRequest); + + var node = await _chatSessionRepository.GetNodeAsync(userId, sessionId, nodeId); + return node ?? throw new ChatSessionOperationException("Chat node not found.", StatusCodes.Status404NotFound); + } + + private async Task BuildStateResponseAsync( + int userId, + int sessionId, + int nodeId, + IReadOnlyList? providers = null) + { + var graph = await BuildGraphResponseAsync(userId, sessionId, nodeId, providers); + var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); + return new ChatSessionStateResponse + { + Graph = graph, + Node = await BuildNodeDetailAsync(userId, sessionId, node) + }; + } + + private async Task BuildGraphResponseAsync( + int userId, + int sessionId, + int? activeNodeId = null, + IReadOnlyList? providers = null) + { + var session = await EnsureSessionExistsAsync(userId, sessionId); + var nodes = await _chatSessionRepository.GetNodesAsync(userId, sessionId); + var rootNode = nodes.FirstOrDefault(item => item.ParentNodeId == null) + ?? throw new ChatSessionOperationException("Chat root node not found.", StatusCodes.Status500InternalServerError); + var activeId = activeNodeId ?? rootNode.Id; + if (nodes.All(item => item.Id != activeId)) + activeId = rootNode.Id; + + var configuredProviders = providers ?? await _llmRepository.GetProviderKeysAsync(userId); + return new ChatSessionGraphResponse + { + Session = MapSession(session), + DefaultProvider = configuredProviders.FirstOrDefault(item => item.IsDefault)?.Provider, + AvailableProviders = configuredProviders.Select(item => item.Provider).ToList(), + RootNodeId = rootNode.Id, + ActiveNodeId = activeId, + Nodes = nodes.Select(MapNode).ToList() + }; + } + + private async Task BuildNodeDetailAsync(int userId, int sessionId, ChatNode node) + { + var localMessages = await _chatSessionRepository.GetNodeMessagesAsync(userId, sessionId, node.Id); + var resolvedMessages = await BuildResolvedMessagesAsync(userId, sessionId, node); + + return new ChatCanvasNodeDetailResponse + { + SessionId = sessionId, + NodeId = node.Id, + ParentNodeId = node.ParentNodeId, + BranchFromMessageId = node.BranchFromMessageId, + NodeType = node.NodeType, + Title = node.Title, + Preview = node.Preview, + Content = node.Content, + PositionX = node.PositionX, + PositionY = node.PositionY, + Width = node.Width, + Height = node.Height, + IsCollapsed = node.IsCollapsed, + ExpandedWidth = node.ExpandedWidth, + ExpandedHeight = node.ExpandedHeight, + PreferredProvider = node.PreferredProvider, + PreferredModel = node.PreferredModel, + CreatedAt = node.CreatedAt, + UpdatedAt = node.UpdatedAt, + LocalMessages = localMessages.Select(message => MapMessage(message, message.Role == "assistant")).ToList(), + ResolvedMessages = resolvedMessages.Select(message => MapMessage(message, message.NodeId == node.Id && message.Role == "assistant")).ToList() + }; + } + + private async Task> BuildResolvedMessagesAsync(int userId, int sessionId, ChatNode node) + { + var localMessages = (await _chatSessionRepository.GetNodeMessagesAsync(userId, sessionId, node.Id)).ToList(); + if (node.ParentNodeId == null || !node.BranchFromMessageId.HasValue) + return localMessages; + + var parentNode = await GetRequiredNodeAsync(userId, sessionId, node.ParentNodeId.Value); + var parentHistory = await BuildResolvedMessagesAsync(userId, sessionId, parentNode); + var truncatedParentHistory = TakeHistoryUntil(parentHistory, node.BranchFromMessageId.Value); + + return truncatedParentHistory + .Concat(localMessages) + .ToList(); + } + + private static List TakeHistoryUntil(IReadOnlyList messages, int messageId) + { + var targetIndex = messages.ToList().FindIndex(message => message.Id == messageId); + if (targetIndex < 0) + { + throw new ChatSessionOperationException( + "The selected branch message does not exist in this thread.", + StatusCodes.Status400BadRequest); + } + + return messages.Take(targetIndex + 1).ToList(); + } + + private async Task ResolveProviderAsync(int userId, string? preferredProvider, string? providerOverride) + { + var provider = NormalizeNullable(providerOverride) ?? NormalizeNullable(preferredProvider); + return await _providerResolver.ResolveAsync(userId, provider); + } + + private async Task PersistNodeContextSnapshotAsync(int userId, int sessionId, int nodeId, ChatNodeContextSnapshot snapshot) + { + var snapshotJson = JsonSerializer.Serialize(snapshot); + await _chatSessionRepository.UpdateNodeContextSnapshotAsync( + userId, + sessionId, + nodeId, + snapshot.BoundaryMessageId, + snapshotJson); + } + + private async Task SendConversationAsync( + string provider, + ILlmProviderClient providerClient, + string apiKey, + IReadOnlyList messages, + string? modelOverride, + string? systemPrompt = null) + { + if (messages.Any(message => !string.IsNullOrWhiteSpace(message.ImageUrl))) + return await SendMultimodalConversationAsync(provider, apiKey, messages, modelOverride, systemPrompt); + + return await providerClient.SendChatAsync( + apiKey, + messages.Select(message => new LlmProviderChatMessage + { + Role = message.Role, + Content = message.Content + }).ToList(), + modelOverride, + systemPrompt); + } + + private async Task SendMultimodalConversationAsync( + string provider, + string apiKey, + IReadOnlyList messages, + string? modelOverride, + string? systemPrompt = null) + { + return provider switch + { + LlmProviderNames.OpenAi => await SendOpenAiMultimodalAsync(apiKey, messages, modelOverride, systemPrompt), + LlmProviderNames.LmStudio => await SendOpenAiMultimodalAsync(apiKey, messages, modelOverride, systemPrompt), + LlmProviderNames.Gemini => await SendGeminiMultimodalAsync(apiKey, messages, modelOverride, systemPrompt), + LlmProviderNames.Claude => await SendClaudeMultimodalAsync(apiKey, messages, modelOverride, systemPrompt), + _ => throw new LlmChatException( + "That provider is not supported yet.", + LlmProviderErrorCodes.ProviderError, + StatusCodes.Status400BadRequest) + }; + } + + private static async Task SendOpenAiMultimodalAsync( + string apiKey, + IReadOnlyList messages, + string? modelOverride, + string? systemPrompt = null) + { + var model = NormalizeModelOverride(LlmProviderNames.OpenAi, modelOverride) ?? "gpt-4o-mini"; + using var httpClient = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + var chatMessages = messages + .Select(message => new + { + role = message.Role == "assistant" ? "assistant" : "user", + content = BuildOpenAiMessageContent(message) + }) + .ToList(); + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + chatMessages.Insert(0, new { role = "system", content = (object)systemPrompt }); + } + request.Content = JsonContent.Create(new + { + model, + messages = chatMessages + }); + + using var response = await SendProviderRequestAsync(httpClient, request, model); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var messageContent = document.RootElement + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content"); + + var content = ExtractTextContent(messageContent); + if (string.IsNullOrWhiteSpace(content)) + throw new LlmChatException("Provider returned an empty response.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway); + + return new LlmProviderChatResult + { + Content = content.Trim(), + Model = model + }; + } + + private static object BuildOpenAiMessageContent(CanvasConversationMessage message) + { + if (string.IsNullOrWhiteSpace(message.ImageUrl) || message.Role == "assistant") + return message.Content; + + return new object[] + { + new { type = "text", text = message.Content }, + new { type = "image_url", image_url = new { url = message.ImageUrl } } + }; + } + + private static async Task SendGeminiMultimodalAsync( + string apiKey, + IReadOnlyList messages, + string? modelOverride, + string? systemPrompt = null) + { + var model = NormalizeModelOverride(LlmProviderNames.Gemini, modelOverride) ?? "gemini-1.5-flash"; + using var httpClient = CreateHttpClient(); + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={Uri.EscapeDataString(apiKey)}"); + request.Content = JsonContent.Create(new + { + systemInstruction = string.IsNullOrWhiteSpace(systemPrompt) + ? null + : new + { + parts = new[] + { + new { text = systemPrompt } + } + }, + contents = messages.Select(message => new + { + role = message.Role == "assistant" ? "model" : "user", + parts = BuildGeminiParts(message) + }) + }); + + using var response = await SendProviderRequestAsync(httpClient, request, model); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var parts = document.RootElement + .GetProperty("candidates")[0] + .GetProperty("content") + .GetProperty("parts"); + + var content = string.Join( + "\n", + parts.EnumerateArray() + .Where(part => part.TryGetProperty("text", out _)) + .Select(part => part.GetProperty("text").GetString()) + .Where(text => !string.IsNullOrWhiteSpace(text))); + + if (string.IsNullOrWhiteSpace(content)) + throw new LlmChatException("Provider returned an empty response.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway); + + return new LlmProviderChatResult + { + Content = content.Trim(), + Model = model + }; + } + + private static IReadOnlyList BuildGeminiParts(CanvasConversationMessage message) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(message.Content)) + parts.Add(new { text = message.Content }); + + if (!string.IsNullOrWhiteSpace(message.ImageUrl) && message.Role != "assistant") + { + var image = ParseDataUrl(message.ImageUrl); + parts.Add(new + { + inlineData = new + { + mimeType = image.MimeType, + data = image.Base64Data + } + }); + } + + return parts; + } + + private static async Task SendClaudeMultimodalAsync( + string apiKey, + IReadOnlyList messages, + string? modelOverride, + string? systemPrompt = null) + { + var model = NormalizeModelOverride(LlmProviderNames.Claude, modelOverride) ?? "claude-3-5-sonnet-latest"; + using var httpClient = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); + request.Headers.Add("x-api-key", apiKey); + request.Headers.Add("anthropic-version", "2023-06-01"); + request.Content = JsonContent.Create(new + { + model, + max_tokens = 1024, + system = systemPrompt, + messages = messages.Select(message => new + { + role = message.Role == "assistant" ? "assistant" : "user", + content = BuildClaudeContent(message) + }) + }); + + using var response = await SendProviderRequestAsync(httpClient, request, model); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var content = string.Join( + "\n", + document.RootElement + .GetProperty("content") + .EnumerateArray() + .Where(item => item.TryGetProperty("type", out var typeProperty) && typeProperty.GetString() == "text") + .Select(item => item.GetProperty("text").GetString()) + .Where(text => !string.IsNullOrWhiteSpace(text))); + + if (string.IsNullOrWhiteSpace(content)) + throw new LlmChatException("Provider returned an empty response.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway); + + return new LlmProviderChatResult + { + Content = content.Trim(), + Model = model + }; + } + + private static IReadOnlyList BuildClaudeContent(CanvasConversationMessage message) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(message.Content)) + parts.Add(new { type = "text", text = message.Content }); + + if (!string.IsNullOrWhiteSpace(message.ImageUrl) && message.Role != "assistant") + { + var image = ParseDataUrl(message.ImageUrl); + parts.Add(new + { + type = "image", + source = new + { + type = "base64", + media_type = image.MimeType, + data = image.Base64Data + } + }); + } + + return parts; + } + + private static async Task SendProviderRequestAsync(HttpClient httpClient, HttpRequestMessage request, string model) + { + try + { + var response = await httpClient.SendAsync(request); + if (response.IsSuccessStatusCode) + return response; + + await ThrowProviderFailureAsync(response, model); + throw new InvalidOperationException("Provider failure did not throw as expected."); + } + catch (TaskCanceledException ex) + { + throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); + } + catch (HttpRequestException ex) + { + throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, ex); + } + } + + private static async Task ThrowProviderFailureAsync(HttpResponseMessage response, string model) + { + var body = await response.Content.ReadAsStringAsync(); + var code = response.StatusCode switch + { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => LlmProviderErrorCodes.InvalidKey, + HttpStatusCode.TooManyRequests => LlmProviderErrorCodes.RateLimited, + _ => LlmProviderErrorCodes.ProviderError + }; + + var message = response.StatusCode switch + { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => "The saved API key is invalid.", + HttpStatusCode.TooManyRequests => "The provider is rate limiting requests right now.", + _ => "The provider returned an unexpected error." + }; + + throw new LlmChatException( + message, + code, + response.StatusCode switch + { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => StatusCodes.Status400BadRequest, + HttpStatusCode.TooManyRequests => StatusCodes.Status429TooManyRequests, + _ => StatusCodes.Status502BadGateway + }, + new InvalidOperationException($"Model: {model}\n{body}")); + } + + private static string ExtractTextContent(JsonElement contentElement) + { + if (contentElement.ValueKind == JsonValueKind.String) + return contentElement.GetString() ?? string.Empty; + + if (contentElement.ValueKind != JsonValueKind.Array) + return string.Empty; + + return string.Join( + "\n", + contentElement.EnumerateArray() + .Where(item => item.TryGetProperty("type", out var typeProperty) && typeProperty.GetString() == "text") + .Select(item => item.GetProperty("text").GetString()) + .Where(text => !string.IsNullOrWhiteSpace(text))); + } + + private static string ResolveImageModel(string provider, string? explicitModel) + { + var normalizedModel = NormalizeModelOverride(provider, explicitModel); + if (!string.IsNullOrWhiteSpace(normalizedModel)) + { + if (!SupportsImageGeneration(provider, normalizedModel)) + { + throw new LlmChatException( + "The selected provider or model does not support image generation.", + LlmProviderErrorCodes.UnsupportedCapability, + StatusCodes.Status400BadRequest); + } + + return normalizedModel; + } + + return provider switch + { + LlmProviderNames.OpenAi => "gpt-image-1", + LlmProviderNames.Gemini => "gemini-2.0-flash-preview-image-generation", + _ => throw new LlmChatException( + "The selected provider or model does not support image generation.", + LlmProviderErrorCodes.UnsupportedCapability, + StatusCodes.Status400BadRequest) + }; + } + + private static bool SupportsImageGeneration(string provider, string model) + { + var normalizedProvider = LlmProviderNames.Normalize(provider); + var normalizedModel = model.Trim().ToLowerInvariant(); + + return normalizedProvider switch + { + LlmProviderNames.OpenAi => normalizedModel.Contains("image", StringComparison.Ordinal), + LlmProviderNames.Gemini => normalizedModel.Contains("image", StringComparison.Ordinal), + _ => false + }; + } + + private static async Task GenerateImageForProviderAsync(string provider, string apiKey, string prompt, string model) + { + return provider switch + { + LlmProviderNames.OpenAi => await GenerateOpenAiImageAsync(apiKey, prompt, model), + LlmProviderNames.Gemini => await GenerateGeminiImageAsync(apiKey, prompt, model), + _ => throw new LlmChatException( + "The selected provider or model does not support image generation.", + LlmProviderErrorCodes.UnsupportedCapability, + StatusCodes.Status400BadRequest) + }; + } + + private static async Task GenerateOpenAiImageAsync(string apiKey, string prompt, string model) + { + using var httpClient = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/images/generations"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Content = JsonContent.Create(new + { + model, + prompt, + size = "1024x1024", + response_format = "b64_json" + }); + + using var response = await SendProviderRequestAsync(httpClient, request, model); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var data = document.RootElement.GetProperty("data")[0]; + + if (data.TryGetProperty("b64_json", out var b64Element) && !string.IsNullOrWhiteSpace(b64Element.GetString())) + return $"data:image/png;base64,{b64Element.GetString()}"; + + if (data.TryGetProperty("url", out var urlElement) && !string.IsNullOrWhiteSpace(urlElement.GetString())) + return urlElement.GetString()!; + + throw new LlmChatException("Provider returned an empty image response.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway); + } + + private static async Task GenerateGeminiImageAsync(string apiKey, string prompt, string model) + { + using var httpClient = CreateHttpClient(); + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={Uri.EscapeDataString(apiKey)}"); + request.Content = JsonContent.Create(new + { + contents = new[] + { + new + { + role = "user", + parts = new[] + { + new { text = prompt } + } + } + }, + generationConfig = new + { + responseModalities = new[] { "TEXT", "IMAGE" } + } + }); + + using var response = await SendProviderRequestAsync(httpClient, request, model); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var parts = document.RootElement + .GetProperty("candidates")[0] + .GetProperty("content") + .GetProperty("parts"); + + foreach (var part in parts.EnumerateArray()) + { + if (!part.TryGetProperty("inlineData", out var inlineData)) + continue; + + var mimeType = inlineData.GetProperty("mimeType").GetString() ?? "image/png"; + var base64Data = inlineData.GetProperty("data").GetString(); + if (!string.IsNullOrWhiteSpace(base64Data)) + return $"data:{mimeType};base64,{base64Data}"; + } + + throw new LlmChatException("Provider returned an empty image response.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway); + } + + private static HttpClient CreateHttpClient() + { + return new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + } + + private static DataUrlParts ParseDataUrl(string dataUrl) + { + var match = DataUrlRegex().Match(dataUrl); + if (!match.Success) + { + throw new LlmChatException( + "Image attachments must be sent as data URLs.", + LlmProviderErrorCodes.UnsupportedCapability, + StatusCodes.Status400BadRequest); + } + + return new DataUrlParts(match.Groups["mime"].Value, match.Groups["data"].Value); + } + + private static string BuildNotePrompt(ChatNode node, IReadOnlyList messages) + { + var history = string.Join( + "\n", + messages.Select(message => $"{message.Role.ToUpperInvariant()}: {message.Content}")); + + return + """ + Synthesize the following node into a professional, elegant note. + Use structured plain text only. + Do not use markdown symbols such as #, *, or bullet markers. + Treat the first line as the note title. + Keep the result substantial and easy to read in a sticky-note style layout. + """ + + $"\n\nNODE TITLE:\n{node.Title}\n" + + (string.IsNullOrWhiteSpace(node.Content) ? string.Empty : $"\nNODE CONTENT:\n{node.Content}\n") + + (string.IsNullOrWhiteSpace(history) ? string.Empty : $"\nLOCAL CONVERSATION:\n{history}\n"); + } + + private async Task PreviewGuestChatAsync( + IReadOnlyList messages, + string? prompt, + string? imageUrl, + string provider, + ILlmProviderClient? providerClient, + string? apiKey, + string? modelOverride) + { + var normalizedPrompt = prompt ?? "Continue this thought."; + if (providerClient != null && !string.IsNullOrWhiteSpace(apiKey)) + { + var conversation = messages + .Select(message => new CanvasConversationMessage(message.Role, message.Content, message.ImageUrl)) + .ToList(); + conversation.Add(new CanvasConversationMessage( + "user", + imageUrl is null ? ApplyRichDocumentInstruction(normalizedPrompt) : normalizedPrompt, + imageUrl)); + + var providerReply = await SendConversationAsync(provider, providerClient, apiKey, conversation, modelOverride); + return new GuestChatPreviewResponse + { + ActionType = "chat", + Message = providerReply.Content, + Provider = provider, + Model = providerReply.Model + }; + } + + return new GuestChatPreviewResponse + { + ActionType = "chat", + Message = BuildGuestFallbackRichDocument(normalizedPrompt), + Provider = provider, + Model = modelOverride + }; + } + + private async Task PreviewGuestNoteAsync( + IReadOnlyList messages, + string? content, + string provider, + ILlmProviderClient? providerClient, + string? apiKey, + string? modelOverride) + { + var noteSource = string.Join( + "\n", + messages + .Where(message => !string.IsNullOrWhiteSpace(message.Content)) + .Select(message => $"{message.Role.ToUpperInvariant()}: {message.Content}")); + + if (!string.IsNullOrWhiteSpace(content)) + noteSource = string.IsNullOrWhiteSpace(noteSource) ? content : $"{noteSource}\nRAW CONTENT: {content}"; + + if (string.IsNullOrWhiteSpace(noteSource)) + noteSource = "Thought summary"; + + if (providerClient != null && !string.IsNullOrWhiteSpace(apiKey)) + { + var providerReply = await providerClient.SendChatAsync( + apiKey, + [new LlmProviderChatMessage + { + Role = "user", + Content = + """ + Synthesize the following node into a professional, elegant note. + Use structured plain text only. + Do not use markdown symbols such as #, *, or bullet markers. + Treat the first line as the note title. + Keep the result substantial and easy to read in a sticky-note style layout. + """ + + $"\n\nSOURCE:\n{noteSource}" + }], + modelOverride); + + var lines = providerReply.Content + .Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + return new GuestChatPreviewResponse + { + ActionType = "note", + NoteTitle = lines.FirstOrDefault() ?? GeneratedNoteTitle, + NoteContent = providerReply.Content, + Provider = provider, + Model = providerReply.Model + }; + } + + var fallbackNote = BuildGuestFallbackNote(noteSource); + return new GuestChatPreviewResponse + { + ActionType = "note", + NoteTitle = fallbackNote.Split('\n', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? GeneratedNoteTitle, + NoteContent = fallbackNote, + Provider = provider, + Model = modelOverride + }; + } + + private async Task PreviewGuestImageAsync( + string? prompt, + string provider, + string? apiKey, + string? modelOverride) + { + var normalizedPrompt = NormalizeTitle(prompt ?? "Abstract visual composition", "Image prompt is required.", 4000); + if (!string.IsNullOrWhiteSpace(apiKey)) + { + var imageModel = ResolveImageModel(provider, modelOverride); + var imageUrl = await GenerateImageForProviderAsync(provider, apiKey, normalizedPrompt, imageModel); + return new GuestChatPreviewResponse + { + ActionType = "image", + Message = $"Here is the visual manifestation for: **{normalizedPrompt}**", + ImageUrl = imageUrl, + Provider = provider, + Model = imageModel + }; + } + + return new GuestChatPreviewResponse + { + ActionType = "image", + Message = $"Here is the visual manifestation for: **{normalizedPrompt}**", + ImageUrl = BuildGuestFallbackImageDataUrl(normalizedPrompt), + Provider = provider, + Model = modelOverride + }; + } + + private string? ResolveGuestPreviewApiKey(string provider) + { + return NormalizeNullable( + _configuration[$"Llm:GuestPreview:{provider}:ApiKey"] + ?? _configuration["Llm:GuestPreview:ApiKey"]); + } + + private string? ResolveGuestPreviewModel(string provider, string requestedModel) + { + var configuredModel = NormalizeNullable( + _configuration[$"Llm:GuestPreview:{provider}:Model"] + ?? _configuration["Llm:GuestPreview:Model"]); + + return NormalizeModelOverride(provider, requestedModel) + ?? NormalizeModelOverride(provider, configuredModel) + ?? GetDefaultGuestModel(provider); + } + + private static string NormalizeGuestActionType(string? actionType) + { + var normalized = NormalizeNullable(actionType)?.ToLowerInvariant(); + return normalized switch + { + "chat" or "response" => "chat", + "note" => "note", + "image" => "image", + _ => "chat" + }; + } + + private static string InferProviderFromModel(string model) + { + var normalized = model.Trim().ToLowerInvariant(); + if (normalized == "local-model" || normalized.StartsWith("lmstudio", StringComparison.Ordinal)) + return LlmProviderNames.LmStudio; + if (normalized.Contains("claude", StringComparison.Ordinal)) + return LlmProviderNames.Claude; + if (normalized.StartsWith("gpt", StringComparison.Ordinal) || normalized.Contains("gpt-", StringComparison.Ordinal) || normalized.StartsWith("o1", StringComparison.Ordinal) || normalized.StartsWith("o3", StringComparison.Ordinal)) + return LlmProviderNames.OpenAi; + return LlmProviderNames.Gemini; + } + + private static string GetDefaultGuestModel(string provider) + { + return LlmProviderNames.Normalize(provider) switch + { + LlmProviderNames.OpenAi => "gpt-5.2", + LlmProviderNames.Claude => "claude-sonnet-4-20250514", + LlmProviderNames.LmStudio => "local-model", + _ => "gemini-3.1-flash-lite-preview" + }; + } + + private static string BuildGuestFallbackRichDocument(string prompt) + { + var title = BuildDisplayTitle(prompt, 48); + return + $"# {title}\n\n" + + "## Overview\n" + + $"The guest preview interpreted your prompt as: **{prompt}**.\n\n" + + "---\n\n" + + "## Next Angles\n" + + "1. Clarify the core objective.\n" + + "2. Identify the strongest supporting evidence.\n" + + "3. Decide what should become a branch, note, or follow-up prompt.\n\n" + + "---\n\n" + + "## Recommended Next Step\n" + + "Turn this into a deeper node conversation after signing in so the canvas can persist across sessions."; + } + + private static string BuildGuestFallbackNote(string content) + { + var title = BuildDisplayTitle(content, 42); + return + $"{title}\n\n" + + "This preview note captures the current thought in plain text.\n\n" + + $"Source focus: {content}\n\n" + + "Convert this into a saved canvas after sign-in to keep the full structure and AI history."; + } + + private static string BuildGuestFallbackImageDataUrl(string prompt) + { + var escapedPrompt = WebUtility.HtmlEncode(prompt); + var svg = + """ + + + + + + + + + + Guest Preview + + """ + + escapedPrompt + + """ + + + """; + + return $"data:image/svg+xml;base64,{Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(svg))}"; + } + + private static string NormalizeImportedRole(string role) + { + return role.Trim().ToLowerInvariant() switch + { + "assistant" or "model" => "assistant", + _ => "user" + }; + } + + private static string ApplyRichDocumentInstruction(string content) + { + return $"{content}\n\n[FORMATTING INSTRUCTION: {RichDocumentInstruction}]"; + } + + private static (double Width, double Height) ResolveNodeSize(string nodeType, double? width, double? height) + { + var defaultWidth = nodeType == ChatNodeTypes.Note ? DefaultNoteWidth : DefaultNodeWidth; + var defaultHeight = nodeType == ChatNodeTypes.Note ? DefaultNoteHeight : DefaultNodeHeight; + + return (width ?? defaultWidth, height ?? defaultHeight); + } + + private static (double PositionX, double PositionY) ResolveNodePosition( + ChatNode parentNode, + string nodeType, + IReadOnlyList existingNodes, + double? requestedX, + double? requestedY) + { + if (requestedX.HasValue && requestedY.HasValue) + return (requestedX.Value, requestedY.Value); + + const double collisionWidth = 600; + const double collisionHeight = 520; + + static bool IsColliding(IReadOnlyList nodes, double candidateX, double candidateY) + { + return nodes.Any(node => + { + var width = node.Width > 0 ? node.Width : 340; + var height = node.Height > 0 ? node.Height : 420; + + return !(candidateX + collisionWidth < node.PositionX + || candidateX > node.PositionX + width + 60 + || candidateY + collisionHeight < node.PositionY + || candidateY > node.PositionY + height + 60); + }); + } + + if (nodeType == ChatNodeTypes.Note) + { + var candidateX = requestedX ?? (parentNode.PositionX + (parentNode.Width > 0 ? parentNode.Width : 340) + 40); + var candidateY = requestedY ?? parentNode.PositionY; + + if (!IsColliding(existingNodes, candidateX, candidateY)) + return (candidateX, candidateY); + + return ( + candidateX, + candidateY + 100); + } + + var nextX = requestedX ?? (parentNode.PositionX + 20); + var nextY = requestedY ?? (parentNode.PositionY + 450); + var tier = 0; + + while (IsColliding(existingNodes, nextX, nextY) && tier < 10) + { + foreach (var offset in new[] { -1d, 1d }) + { + var candidateX = nextX + (offset * collisionWidth); + if (!IsColliding(existingNodes, candidateX, nextY)) + return (candidateX, nextY); + } + + nextY += collisionHeight - 100; + tier++; + } + + return (nextX, nextY); + } + + private static (double Width, double Height) CalculateGeneratedNoteSize(string content) + { + var lines = Math.Max(4, Math.Ceiling(content.Length / 70d)); + return (DefaultNodeWidth, lines * 37.8 + 160); + } + + private static ChatNodeUpdateInput BuildUpdatedNodeInput( + ChatNode node, + string? title = null, + string? preview = null, + string? content = null, + double? positionX = null, + double? positionY = null, + double? width = null, + double? height = null, + string? preferredProvider = null, + string? preferredModel = null) + { + return new ChatNodeUpdateInput + { + NodeType = node.ParentNodeId == null ? ChatNodeTypes.Question : node.NodeType, + Title = title ?? node.Title, + Preview = preview ?? node.Preview, + Content = content ?? node.Content, + PositionX = positionX ?? node.PositionX, + PositionY = positionY ?? node.PositionY, + Width = width ?? node.Width, + Height = height ?? node.Height, + IsCollapsed = node.IsCollapsed, + ExpandedWidth = node.ExpandedWidth ?? node.Width, + ExpandedHeight = node.ExpandedHeight ?? node.Height, + PreferredProvider = preferredProvider ?? node.PreferredProvider, + PreferredModel = preferredModel ?? node.PreferredModel + }; + } + + private static string GetDefaultNodeTitle(string nodeType) + { + return nodeType switch + { + ChatNodeTypes.Response => "AI response...", + ChatNodeTypes.Note => "Note...", + _ => "New question..." + }; + } + + private static string NormalizeNodeTitle(string? title, string defaultTitle, int maxLength) + { + var normalized = NormalizeOptionalContent(title); + if (normalized is null) + return defaultTitle; + + return normalized.Length <= maxLength + ? normalized + : $"{normalized[..(maxLength - 3)].TrimEnd()}..."; + } + + private static string NormalizeTitle(string value, string errorMessage, int maxLength) + { + var normalized = NormalizeOptionalContent(value); + if (normalized is null) + throw new ChatSessionOperationException(errorMessage, StatusCodes.Status400BadRequest); + + return normalized.Length <= maxLength + ? normalized + : $"{normalized[..(maxLength - 3)].TrimEnd()}..."; + } + + private static string? NormalizeOptionalContent(string? content) + { + if (string.IsNullOrWhiteSpace(content)) + return null; + + return WhitespaceRegex().Replace(content, " ").Trim(); + } + + private static string? NormalizeRichContent(string? content) + { + if (string.IsNullOrWhiteSpace(content)) + return null; + + return content.Trim(); + } + + private static string? NormalizeSessionTitle(string? title) + { + var normalized = NormalizeOptionalContent(title); + if (normalized is null) + return null; + + return normalized.Length <= SessionTitleMaxLength + ? normalized + : $"{normalized[..(SessionTitleMaxLength - 3)].TrimEnd()}..."; + } + + private static string? NormalizeNullable(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? null + : value.Trim(); + } + + private static string? NormalizeModelOverride(string provider, string? modelOverride) + { + if (string.IsNullOrWhiteSpace(modelOverride)) + return null; + + var normalizedProvider = LlmProviderNames.Normalize(provider); + var normalizedModel = modelOverride.Trim(); + + return (normalizedProvider, normalizedModel.ToLowerInvariant()) switch + { + (LlmProviderNames.Claude, "claude-3-5-sonnet") => "claude-sonnet-4-20250514", + (LlmProviderNames.Claude, "claude-3-5-sonnet-latest") => "claude-sonnet-4-20250514", + _ => normalizedModel + }; + } + + private static ChatSessionSummaryResponse MapSession(ChatSession session) + { + return new ChatSessionSummaryResponse + { + Id = session.Id, + Title = session.Title, + CreatedAt = session.CreatedAt, + UpdatedAt = session.UpdatedAt + }; + } + + private static ChatCanvasNodeSummaryResponse MapNode(ChatNode node) + { + return new ChatCanvasNodeSummaryResponse + { + Id = node.Id, + ParentNodeId = node.ParentNodeId, + BranchFromMessageId = node.BranchFromMessageId, + NodeType = node.NodeType, + Title = node.Title, + Preview = node.Preview, + Content = node.Content, + PositionX = node.PositionX, + PositionY = node.PositionY, + Width = node.Width, + Height = node.Height, + IsCollapsed = node.IsCollapsed, + ExpandedWidth = node.ExpandedWidth, + ExpandedHeight = node.ExpandedHeight, + PreferredProvider = node.PreferredProvider, + PreferredModel = node.PreferredModel, + CreatedAt = node.CreatedAt, + UpdatedAt = node.UpdatedAt + }; + } + + private static ChatCanvasMessageResponse MapMessage(ChatMessage message, bool canBranch) + { + return new ChatCanvasMessageResponse + { + Id = message.Id, + NodeId = message.NodeId, + Role = message.Role, + Content = message.Content, + ImageUrl = message.ImageUrl, + Provider = message.Provider, + Model = message.Model, + CreatedAt = message.CreatedAt, + CanBranch = canBranch + }; + } + + private ILlmProviderClient GetProviderClient(string provider) + { + if (_providerClients.TryGetValue(provider, out var client)) + return client; + + throw new LlmChatException( + "That provider is not supported yet.", + LlmProviderErrorCodes.ProviderError, + StatusCodes.Status400BadRequest); + } + + private static string BuildDisplayTitle(string content, int maxLength) + { + var normalized = WhitespaceRegex().Replace(content, " ").Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + return BlankSessionTitle; + + return normalized.Length <= maxLength + ? normalized + : $"{normalized[..(maxLength - 3)].TrimEnd()}..."; + } + + private static string BuildPreview(string content) + { + var normalized = WhitespaceRegex().Replace(content, " ").Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + return string.Empty; + + return normalized.Length <= PreviewMaxLength + ? normalized + : $"{normalized[..(PreviewMaxLength - 3)].TrimEnd()}..."; + } + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); + + [GeneratedRegex(@"^data:(?[-\w.+/]+);base64,(?.+)$", RegexOptions.IgnoreCase)] + private static partial Regex DataUrlRegex(); + + private sealed record ConversationMessage(string Role, string Content, string? ImageUrl); + private sealed record DataUrlParts(string MimeType, string Base64Data); +} diff --git a/backend/Coliee.API/Services/ClaudeProviderClient.cs b/backend/Coliee.API/Services/ClaudeProviderClient.cs index 219a8c1..6bf2915 100644 --- a/backend/Coliee.API/Services/ClaudeProviderClient.cs +++ b/backend/Coliee.API/Services/ClaudeProviderClient.cs @@ -28,21 +28,81 @@ public Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = return SendChatAsync( apiKey, [new LlmProviderChatMessage { Role = "user", Content = "Reply with OK." }], - cancellationToken); + cancellationToken: cancellationToken); + } + + public async Task> GetModelsAsync(string apiKey, CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/models?limit=1000"); + request.Headers.Add("x-api-key", apiKey); + request.Headers.Add("anthropic-version", _anthropicVersion); + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCts.CancelAfter(TimeSpan.FromSeconds(30)); + + HttpResponseMessage response; + + try + { + response = await _httpClient.SendAsync(request, linkedCts.Token); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(ex, "Claude model list request timed out."); + throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Claude model list request failed."); + throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + await ThrowForFailureAsync(response, _model, linkedCts.Token); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(linkedCts.Token)); + if (!document.RootElement.TryGetProperty("data", out var dataElement) || dataElement.ValueKind != JsonValueKind.Array) + return []; + + return dataElement + .EnumerateArray() + .Select(item => + { + var id = TryGetString(item, "id"); + if (string.IsNullOrWhiteSpace(id)) + return null; + + return new LlmProviderModel + { + Id = id, + DisplayName = TryGetString(item, "display_name") ?? id + }; + }) + .Where(item => item != null) + .Cast() + .OrderBy(item => item.DisplayName ?? item.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + } } public async Task SendChatAsync( string apiKey, IReadOnlyList messages, + string? modelOverride = null, + string? systemPrompt = null, CancellationToken cancellationToken = default) { + var model = ResolveModel(modelOverride); using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/messages"); request.Headers.Add("x-api-key", apiKey); request.Headers.Add("anthropic-version", _anthropicVersion); request.Content = JsonContent.Create(new { - model = _model, + model, max_tokens = 1024, + system = systemPrompt, messages = messages.Select(message => new { role = message.Role == "assistant" ? "assistant" : "user", @@ -61,19 +121,19 @@ public async Task SendChatAsync( } catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { - _logger.LogWarning(ex, "Claude request timed out for model {Model}.", _model); + _logger.LogWarning(ex, "Claude request timed out for model {Model}.", model); throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); } catch (HttpRequestException ex) { - _logger.LogWarning(ex, "Claude request failed for model {Model}.", _model); + _logger.LogWarning(ex, "Claude request failed for model {Model}.", model); throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, ex); } using (response) { if (!response.IsSuccessStatusCode) - await ThrowForFailureAsync(response, linkedCts.Token); + await ThrowForFailureAsync(response, model, linkedCts.Token); using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(linkedCts.Token)); var contentItems = document.RootElement.GetProperty("content"); @@ -81,19 +141,113 @@ public async Task SendChatAsync( if (string.IsNullOrWhiteSpace(content)) { - _logger.LogWarning("Claude returned an empty response for model {Model}.", _model); + _logger.LogWarning("Claude returned an empty response for model {Model}.", model); throw new LlmChatException("Provider returned an empty response.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway); } return new LlmProviderChatResult { Content = content.Trim(), - Model = _model + Model = model }; } } - private async Task ThrowForFailureAsync(HttpResponseMessage response, CancellationToken cancellationToken) + public async Task SendStructuredJsonAsync( + string apiKey, + LlmProviderStructuredRequest structuredRequest, + string? modelOverride = null, + CancellationToken cancellationToken = default) + { + var model = ResolveModel(modelOverride); + using var schemaDocument = JsonDocument.Parse(structuredRequest.SchemaJson); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/messages"); + request.Headers.Add("x-api-key", apiKey); + request.Headers.Add("anthropic-version", _anthropicVersion); + request.Content = JsonContent.Create(new + { + model, + max_tokens = structuredRequest.MaxOutputTokens, + system = structuredRequest.Instructions, + messages = structuredRequest.Messages.Select(message => new + { + role = message.Role == "assistant" ? "assistant" : "user", + content = message.Content + }), + tools = new[] + { + new + { + name = structuredRequest.SchemaName, + description = "Return the requested structured JSON.", + input_schema = schemaDocument.RootElement.Clone() + } + }, + tool_choice = new + { + type = "tool", + name = structuredRequest.SchemaName + } + }); + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCts.CancelAfter(TimeSpan.FromSeconds(30)); + + HttpResponseMessage response; + + try + { + response = await _httpClient.SendAsync(request, linkedCts.Token); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(ex, "Claude request timed out for model {Model}.", model); + throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Claude request failed for model {Model}.", model); + throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + await ThrowForFailureAsync(response, model, linkedCts.Token); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(linkedCts.Token)); + foreach (var item in document.RootElement.GetProperty("content").EnumerateArray()) + { + if (item.TryGetProperty("type", out var typeProperty) + && typeProperty.GetString() == "tool_use" + && item.TryGetProperty("input", out var inputProperty)) + { + return new LlmProviderChatResult + { + Content = JsonSerializer.Serialize(inputProperty), + Model = model + }; + } + } + + var contentItems = document.RootElement.GetProperty("content"); + var content = contentItems[0].GetProperty("text").GetString(); + + if (string.IsNullOrWhiteSpace(content)) + { + _logger.LogWarning("Claude returned an empty structured response for model {Model}.", model); + throw new LlmChatException("Provider returned an empty response.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway); + } + + return new LlmProviderChatResult + { + Content = content.Trim(), + Model = model + }; + } + } + + private async Task ThrowForFailureAsync(HttpResponseMessage response, string model, CancellationToken cancellationToken) { var body = await response.Content.ReadAsStringAsync(cancellationToken); var code = GetCode(response.StatusCode); @@ -101,7 +255,7 @@ private async Task ThrowForFailureAsync(HttpResponseMessage response, Cancellati "Claude returned failure status {StatusCode} with error code {ErrorCode} for model {Model}.", (int)response.StatusCode, code, - _model); + model); throw new LlmChatException( GetMessage(response.StatusCode), code, @@ -125,6 +279,7 @@ private static int GetStatusCode(HttpStatusCode statusCode) { HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => StatusCodes.Status400BadRequest, HttpStatusCode.TooManyRequests => StatusCodes.Status429TooManyRequests, + HttpStatusCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, _ => StatusCodes.Status502BadGateway }; } @@ -138,4 +293,18 @@ private static string GetMessage(HttpStatusCode statusCode) _ => "The provider returned an unexpected error." }; } + + private static string? TryGetString(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var property) + ? property.GetString() + : null; + } + + private string ResolveModel(string? modelOverride) + { + return string.IsNullOrWhiteSpace(modelOverride) + ? _model + : modelOverride.Trim(); + } } diff --git a/backend/Coliee.API/Services/GeminiProviderClient.cs b/backend/Coliee.API/Services/GeminiProviderClient.cs index 69103ea..32c1a7f 100644 --- a/backend/Coliee.API/Services/GeminiProviderClient.cs +++ b/backend/Coliee.API/Services/GeminiProviderClient.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; +using System.Text.RegularExpressions; namespace Coliee.API.Services; @@ -26,18 +27,99 @@ public Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = return SendChatAsync( apiKey, [new LlmProviderChatMessage { Role = "user", Content = "Reply with OK." }], - cancellationToken); + cancellationToken: cancellationToken); + } + + public async Task> GetModelsAsync(string apiKey, CancellationToken cancellationToken = default) + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCts.CancelAfter(TimeSpan.FromSeconds(30)); + + var models = new List(); + string? pageToken = null; + + do + { + var url = $"{_baseUrl}/models?pageSize=1000&key={Uri.EscapeDataString(apiKey)}"; + if (!string.IsNullOrWhiteSpace(pageToken)) + url += $"&pageToken={Uri.EscapeDataString(pageToken)}"; + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + + HttpResponseMessage response; + + try + { + response = await _httpClient.SendAsync(request, linkedCts.Token); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(ex, "Gemini model list request timed out."); + throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Gemini model list request failed."); + throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + await ThrowForFailureAsync(response, linkedCts.Token); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(linkedCts.Token)); + if (document.RootElement.TryGetProperty("models", out var modelsElement) && modelsElement.ValueKind == JsonValueKind.Array) + { + foreach (var modelElement in modelsElement.EnumerateArray()) + { + if (!SupportsGenerateContent(modelElement)) + continue; + + var id = GetModelId(modelElement); + if (string.IsNullOrWhiteSpace(id)) + continue; + + models.Add(new LlmProviderModel + { + Id = id, + DisplayName = TryGetString(modelElement, "displayName") ?? id + }); + } + } + + pageToken = TryGetString(document.RootElement, "nextPageToken"); + } + } + while (!string.IsNullOrWhiteSpace(pageToken)); + + return models + .DistinctBy(item => item.Id, StringComparer.OrdinalIgnoreCase) + .OrderBy(item => item.DisplayName ?? item.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); } public async Task SendChatAsync( string apiKey, IReadOnlyList messages, + string? modelOverride = null, + string? systemPrompt = null, CancellationToken cancellationToken = default) { - var url = $"{_baseUrl}/models/{_model}:generateContent?key={Uri.EscapeDataString(apiKey)}"; + var model = ResolveModel(modelOverride); + var url = $"{_baseUrl}/models/{model}:generateContent?key={Uri.EscapeDataString(apiKey)}"; using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Content = JsonContent.Create(new { + systemInstruction = string.IsNullOrWhiteSpace(systemPrompt) + ? null + : new + { + parts = new[] + { + new { text = systemPrompt } + } + }, contents = messages.Select(message => new { role = message.Role == "assistant" ? "model" : "user", @@ -59,12 +141,12 @@ public async Task SendChatAsync( } catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { - _logger.LogWarning(ex, "Gemini request timed out for model {Model}.", _model); + _logger.LogWarning(ex, "Gemini request timed out for model {Model}.", model); throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); } catch (HttpRequestException ex) { - _logger.LogWarning(ex, "Gemini request failed for model {Model}.", _model); + _logger.LogWarning(ex, "Gemini request failed for model {Model}.", model); throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, ex); } @@ -83,14 +165,96 @@ public async Task SendChatAsync( if (string.IsNullOrWhiteSpace(content)) { - _logger.LogWarning("Gemini returned an empty response for model {Model}.", _model); + _logger.LogWarning("Gemini returned an empty response for model {Model}.", model); throw new LlmChatException("Provider returned an empty response.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway); } return new LlmProviderChatResult { Content = content.Trim(), - Model = _model + Model = model + }; + } + } + + public async Task SendStructuredJsonAsync( + string apiKey, + LlmProviderStructuredRequest structuredRequest, + string? modelOverride = null, + CancellationToken cancellationToken = default) + { + using var schemaDocument = JsonDocument.Parse(structuredRequest.SchemaJson); + var model = ResolveModel(modelOverride); + var url = $"{_baseUrl}/models/{model}:generateContent?key={Uri.EscapeDataString(apiKey)}"; + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Content = JsonContent.Create(new + { + systemInstruction = new + { + parts = new[] + { + new { text = structuredRequest.Instructions } + } + }, + contents = structuredRequest.Messages.Select(message => new + { + role = message.Role == "assistant" ? "model" : "user", + parts = new[] + { + new { text = message.Content } + } + }), + generationConfig = new + { + responseMimeType = "application/json", + responseJsonSchema = schemaDocument.RootElement.Clone(), + maxOutputTokens = structuredRequest.MaxOutputTokens + } + }); + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCts.CancelAfter(TimeSpan.FromSeconds(30)); + + HttpResponseMessage response; + + try + { + response = await _httpClient.SendAsync(request, linkedCts.Token); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(ex, "Gemini request timed out for model {Model}.", model); + throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Gemini request failed for model {Model}.", model); + throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + await ThrowForFailureAsync(response, linkedCts.Token); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(linkedCts.Token)); + var content = document.RootElement + .GetProperty("candidates")[0] + .GetProperty("content") + .GetProperty("parts")[0] + .GetProperty("text") + .GetString(); + + if (string.IsNullOrWhiteSpace(content)) + { + _logger.LogWarning("Gemini returned an empty structured response for model {Model}.", model); + throw new LlmChatException("Provider returned an empty response.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway); + } + + return new LlmProviderChatResult + { + Content = content.Trim(), + Model = model }; } } @@ -98,24 +262,27 @@ public async Task SendChatAsync( private async Task ThrowForFailureAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var body = await response.Content.ReadAsStringAsync(cancellationToken); - var code = GetCode(response.StatusCode); + var details = ParseFailureDetails(body); + var code = GetCode(response.StatusCode, details); _logger.LogWarning( - "Gemini returned failure status {StatusCode} with error code {ErrorCode} for model {Model}.", + "Gemini returned failure status {StatusCode} with error code {ErrorCode} for model {Model}. Body: {Body}", (int)response.StatusCode, code, - _model); + _model, + body); throw new LlmChatException( - GetMessage(response.StatusCode), + GetMessage(response.StatusCode, code, details), code, GetStatusCode(response.StatusCode), new InvalidOperationException(body)); } - private static string GetCode(HttpStatusCode statusCode) + private static string GetCode(HttpStatusCode statusCode, GeminiFailureDetails details) { return statusCode switch { - HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => LlmProviderErrorCodes.InvalidKey, + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => LlmProviderErrorCodes.InvalidKey, + HttpStatusCode.BadRequest when LooksLikeInvalidKey(details.Message) => LlmProviderErrorCodes.InvalidKey, HttpStatusCode.TooManyRequests => LlmProviderErrorCodes.RateLimited, _ => LlmProviderErrorCodes.ProviderError }; @@ -127,17 +294,105 @@ private static int GetStatusCode(HttpStatusCode statusCode) { HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => StatusCodes.Status400BadRequest, HttpStatusCode.TooManyRequests => StatusCodes.Status429TooManyRequests, + HttpStatusCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, _ => StatusCodes.Status502BadGateway }; } - private static string GetMessage(HttpStatusCode statusCode) + private static string GetMessage(HttpStatusCode statusCode, string code, GeminiFailureDetails details) { + if (code == LlmProviderErrorCodes.InvalidKey) + return "The saved API key is invalid."; + + if (code == LlmProviderErrorCodes.RateLimited) + return "The provider is rate limiting requests right now."; + + if (!string.IsNullOrWhiteSpace(details.Message)) + return details.Message; + return statusCode switch { - HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => "The saved API key is invalid.", - HttpStatusCode.TooManyRequests => "The provider is rate limiting requests right now.", _ => "The provider returned an unexpected error." }; } + + private static GeminiFailureDetails ParseFailureDetails(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return new GeminiFailureDetails(); + + try + { + using var document = JsonDocument.Parse(body); + if (!document.RootElement.TryGetProperty("error", out var errorElement)) + return new GeminiFailureDetails(); + + return new GeminiFailureDetails + { + Status = errorElement.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null, + Message = errorElement.TryGetProperty("message", out var messageElement) ? messageElement.GetString() : null + }; + } + catch (JsonException) + { + return new GeminiFailureDetails(); + } + } + + private static bool LooksLikeInvalidKey(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + return false; + + return Regex.IsMatch( + message, + "(api key|credential).*(invalid|not valid|missing)|invalid.*(api key|credential)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + private static bool SupportsGenerateContent(JsonElement modelElement) + { + if (!modelElement.TryGetProperty("supportedGenerationMethods", out var methodsElement) || methodsElement.ValueKind != JsonValueKind.Array) + return false; + + return methodsElement + .EnumerateArray() + .Any(item => string.Equals(item.GetString(), "generateContent", StringComparison.Ordinal)); + } + + private static string? GetModelId(JsonElement modelElement) + { + var baseModelId = TryGetString(modelElement, "baseModelId"); + if (!string.IsNullOrWhiteSpace(baseModelId)) + return baseModelId; + + var name = TryGetString(modelElement, "name"); + if (string.IsNullOrWhiteSpace(name)) + return null; + + const string prefix = "models/"; + return name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? name[prefix.Length..] + : name; + } + + private static string? TryGetString(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var property) + ? property.GetString() + : null; + } + + private string ResolveModel(string? modelOverride) + { + return string.IsNullOrWhiteSpace(modelOverride) + ? _model + : modelOverride.Trim(); + } + + private sealed class GeminiFailureDetails + { + public string? Status { get; init; } + public string? Message { get; init; } + } } diff --git a/backend/Coliee.API/Services/ICanvasCreationService.cs b/backend/Coliee.API/Services/ICanvasCreationService.cs new file mode 100644 index 0000000..92bb835 --- /dev/null +++ b/backend/Coliee.API/Services/ICanvasCreationService.cs @@ -0,0 +1,8 @@ +using Coliee.API.DTOs; + +namespace Coliee.API.Services; + +public interface ICanvasCreationService +{ + Task CreateCanvasAsync(int userId, string firstPrompt, string? generationMode = null); +} diff --git a/backend/Coliee.API/Services/ICanvasGraphService.cs b/backend/Coliee.API/Services/ICanvasGraphService.cs index 2e58547..bf7f66a 100644 --- a/backend/Coliee.API/Services/ICanvasGraphService.cs +++ b/backend/Coliee.API/Services/ICanvasGraphService.cs @@ -5,5 +5,5 @@ namespace Coliee.API.Services; public interface ICanvasGraphService { Task GetCanvasGraphAsync(int userId, int canvasId); - Task ExpandCanvasNodeAsync(int userId, int canvasId, int nodeId, string prompt, string? providerOverride); + Task ExpandCanvasNodeAsync(int userId, int canvasId, int nodeId, string prompt, string? providerOverride, string? generationMode = null); } diff --git a/backend/Coliee.API/Services/ICanvasProviderResolver.cs b/backend/Coliee.API/Services/ICanvasProviderResolver.cs new file mode 100644 index 0000000..8d743f6 --- /dev/null +++ b/backend/Coliee.API/Services/ICanvasProviderResolver.cs @@ -0,0 +1,6 @@ +namespace Coliee.API.Services; + +public interface ICanvasProviderResolver +{ + Task ResolveAsync(int userId, string? providerOverride); +} diff --git a/backend/Coliee.API/Services/ICanvasService.cs b/backend/Coliee.API/Services/ICanvasService.cs index 8d0ff9d..8aee3d1 100644 --- a/backend/Coliee.API/Services/ICanvasService.cs +++ b/backend/Coliee.API/Services/ICanvasService.cs @@ -4,7 +4,6 @@ namespace Coliee.API.Services; public interface ICanvasService { - Task CreateCanvasAsync(int userId, string firstPrompt); Task> GetCanvasesAsync(int userId); Task RenameCanvasAsync(int userId, int canvasId, string title); Task DeleteCanvasAsync(int userId, int canvasId); diff --git a/backend/Coliee.API/Services/IChatNodeContextCompactionService.cs b/backend/Coliee.API/Services/IChatNodeContextCompactionService.cs new file mode 100644 index 0000000..0f54922 --- /dev/null +++ b/backend/Coliee.API/Services/IChatNodeContextCompactionService.cs @@ -0,0 +1,20 @@ +using Coliee.API.Models; + +namespace Coliee.API.Services; + +public interface IChatNodeContextCompactionService +{ + Task BuildPromptContextAsync( + int userId, + int sessionId, + ChatNode node, + IReadOnlyList history, + CanvasConversationMessage nextMessage, + ILlmProviderClient providerClient, + string apiKey, + string? modelOverride, + bool includeFormattingInstruction, + bool compactAllHistory = false, + ChatNodeContextSnapshot? initialSnapshot = null, + CancellationToken cancellationToken = default); +} diff --git a/backend/Coliee.API/Services/IChatSessionService.cs b/backend/Coliee.API/Services/IChatSessionService.cs new file mode 100644 index 0000000..e768fc4 --- /dev/null +++ b/backend/Coliee.API/Services/IChatSessionService.cs @@ -0,0 +1,25 @@ +using Coliee.API.DTOs; + +namespace Coliee.API.Services; + +public interface IChatSessionService +{ + Task> GetSessionsAsync(int userId); + Task CreateSessionAsync(int userId, string? title, string? content, string? providerOverride, string? modelOverride = null); + Task PreviewGuestAsync(GuestChatPreviewRequest request); + Task ImportGuestCanvasesAsync(int userId, ImportGuestCanvasesRequest request); + Task RenameSessionAsync(int userId, int sessionId, string title); + Task DeleteSessionAsync(int userId, int sessionId); + Task GetSessionAsync(int userId, int sessionId, int? activeNodeId = null); + Task GetNodeAsync(int userId, int sessionId, int nodeId); + Task CreateNodeAsync(int userId, int sessionId, DTOs.CreateChatNodeRequest request); + Task UpdateNodeAsync(int userId, int sessionId, int nodeId, DTOs.UpdateChatNodeRequest request); + Task SendNodeMessageAsync(int userId, int sessionId, int nodeId, string content, string? imageUrl, string? providerOverride, string? modelOverride = null); + Task CreateBranchAsync(int userId, int sessionId, int nodeId, int branchFromMessageId, string content, string? providerOverride, string? modelOverride = null); + Task MoveMessagesToBranchAsync(int userId, int sessionId, int nodeId, IReadOnlyList messageIds, string? transferMode = null); + Task UpdateMessageAsync(int userId, int sessionId, int nodeId, int messageId, DTOs.UpdateChatCanvasMessageRequest request); + Task GenerateNoteAsync(int userId, int sessionId, int nodeId, string? providerOverride, string? modelOverride = null); + Task GenerateImageAsync(int userId, int sessionId, int nodeId, string prompt, string? providerOverride, string? modelOverride = null); + Task DeleteNodeAsync(int userId, int sessionId, int nodeId, int? newActiveNodeId = null); + Task SaveLayoutAsync(int userId, int sessionId, IReadOnlyList nodes, int? activeNodeId = null); +} diff --git a/backend/Coliee.API/Services/ILlmProviderClient.cs b/backend/Coliee.API/Services/ILlmProviderClient.cs index ce4ea93..5a194c9 100644 --- a/backend/Coliee.API/Services/ILlmProviderClient.cs +++ b/backend/Coliee.API/Services/ILlmProviderClient.cs @@ -3,9 +3,17 @@ namespace Coliee.API.Services; public interface ILlmProviderClient { string Provider { get; } + Task> GetModelsAsync(string apiKey, CancellationToken cancellationToken = default); Task SendChatAsync( string apiKey, IReadOnlyList messages, + string? modelOverride = null, + string? systemPrompt = null, + CancellationToken cancellationToken = default); + Task SendStructuredJsonAsync( + string apiKey, + LlmProviderStructuredRequest request, + string? modelOverride = null, CancellationToken cancellationToken = default); Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = default); diff --git a/backend/Coliee.API/Services/ILlmService.cs b/backend/Coliee.API/Services/ILlmService.cs index c6e2985..6c1d494 100644 --- a/backend/Coliee.API/Services/ILlmService.cs +++ b/backend/Coliee.API/Services/ILlmService.cs @@ -5,10 +5,12 @@ namespace Coliee.API.Services; public interface ILlmService { Task GetProvidersAsync(int userId); + Task GetProviderModelsAsync(int userId, string provider); Task UpsertProviderKeyAsync(int userId, string provider, string apiKey); Task DeleteProviderKeyAsync(int userId, string provider); Task SetDefaultProviderAsync(int userId, string provider); Task TestProviderAsync(int userId, string provider); + Task SendStandaloneChatMessageAsync(int userId, IReadOnlyList history, string content, string? providerOverride); Task GetMainNodeChatAsync(int userId, int canvasId); Task SendMainNodeMessageAsync(int userId, int canvasId, string content, string? providerOverride); } diff --git a/backend/Coliee.API/Services/LlmProviderErrorCodes.cs b/backend/Coliee.API/Services/LlmProviderErrorCodes.cs index 97376fb..4ac09b0 100644 --- a/backend/Coliee.API/Services/LlmProviderErrorCodes.cs +++ b/backend/Coliee.API/Services/LlmProviderErrorCodes.cs @@ -6,5 +6,6 @@ public static class LlmProviderErrorCodes public const string InvalidKey = "invalid_key"; public const string RateLimited = "rate_limited"; public const string Timeout = "timeout"; + public const string UnsupportedCapability = "unsupported_capability"; public const string ProviderError = "provider_error"; } diff --git a/backend/Coliee.API/Services/LlmProviderModel.cs b/backend/Coliee.API/Services/LlmProviderModel.cs new file mode 100644 index 0000000..dd22306 --- /dev/null +++ b/backend/Coliee.API/Services/LlmProviderModel.cs @@ -0,0 +1,7 @@ +namespace Coliee.API.Services; + +public sealed class LlmProviderModel +{ + public string Id { get; init; } = string.Empty; + public string? DisplayName { get; init; } +} diff --git a/backend/Coliee.API/Services/LlmProviderNames.cs b/backend/Coliee.API/Services/LlmProviderNames.cs index 49fb5dc..5c1d82f 100644 --- a/backend/Coliee.API/Services/LlmProviderNames.cs +++ b/backend/Coliee.API/Services/LlmProviderNames.cs @@ -5,8 +5,9 @@ public static class LlmProviderNames public const string OpenAi = "openai"; public const string Claude = "claude"; public const string Gemini = "gemini"; + public const string LmStudio = "lmstudio"; - public static readonly IReadOnlyList Ordered = [OpenAi, Claude, Gemini]; + public static readonly IReadOnlyList Ordered = [OpenAi, Claude, Gemini, LmStudio]; public static string Normalize(string provider) => provider.Trim().ToLowerInvariant(); diff --git a/backend/Coliee.API/Services/LlmProviderStructuredRequest.cs b/backend/Coliee.API/Services/LlmProviderStructuredRequest.cs new file mode 100644 index 0000000..79131d7 --- /dev/null +++ b/backend/Coliee.API/Services/LlmProviderStructuredRequest.cs @@ -0,0 +1,10 @@ +namespace Coliee.API.Services; + +public class LlmProviderStructuredRequest +{ + public string Instructions { get; set; } = string.Empty; + public string SchemaName { get; set; } = string.Empty; + public string SchemaJson { get; set; } = string.Empty; + public IReadOnlyList Messages { get; set; } = []; + public int MaxOutputTokens { get; set; } = 2048; +} diff --git a/backend/Coliee.API/Services/LlmService.cs b/backend/Coliee.API/Services/LlmService.cs index 9e50d97..4cac119 100644 --- a/backend/Coliee.API/Services/LlmService.cs +++ b/backend/Coliee.API/Services/LlmService.cs @@ -31,6 +31,33 @@ public LlmService( public Task GetProvidersAsync(int userId) => BuildProviderListResponseAsync(userId); + public async Task GetProviderModelsAsync(int userId, string provider) + { + var normalizedProvider = NormalizeProvider(provider); + var providerRecord = await _llmRepository.GetProviderKeyAsync(userId, normalizedProvider); + if (providerRecord == null) + { + _logger.LogWarning("Rejected provider model lookup for user {UserId}; provider {Provider} is not configured.", userId, normalizedProvider); + throw new LlmChatException("Configure that provider before loading models.", LlmProviderErrorCodes.MissingKey, StatusCodes.Status400BadRequest); + } + + var client = GetProviderClient(normalizedProvider); + var decryptedApiKey = _encryptionService.Decrypt(providerRecord.EncryptedApiKey); + var models = await client.GetModelsAsync(decryptedApiKey); + + _logger.LogInformation("Loaded {ModelCount} models for user {UserId} and provider {Provider}.", models.Count, userId, normalizedProvider); + + return new ProviderModelsResponse + { + Provider = normalizedProvider, + Models = models.Select(item => new ProviderModelResponse + { + Id = item.Id, + DisplayName = item.DisplayName + }).ToList() + }; + } + public async Task UpsertProviderKeyAsync(int userId, string provider, string apiKey) { var normalizedProvider = NormalizeProvider(provider); @@ -112,6 +139,46 @@ public async Task TestProviderAsync(int userId, string pro return await BuildProviderListResponseAsync(userId); } + public async Task SendStandaloneChatMessageAsync(int userId, IReadOnlyList history, string content, string? providerOverride) + { + var trimmedContent = NormalizeStandaloneMessageContent(content, "Message content is required."); + var providers = await _llmRepository.GetProviderKeysAsync(userId); + var selectedProvider = ResolveProvider(providers, providerOverride); + var providerRecord = providers.First(item => item.Provider == selectedProvider); + var decryptedApiKey = _encryptionService.Decrypt(providerRecord.EncryptedApiKey); + var providerClient = GetProviderClient(selectedProvider); + + var providerMessages = history + .Select(item => new LlmProviderChatMessage + { + Role = NormalizeStandaloneMessageRole(item.Role), + Content = NormalizeStandaloneMessageContent(item.Content, "Message history cannot contain empty items.") + }) + .Append(new LlmProviderChatMessage + { + Role = "user", + Content = trimmedContent + }) + .ToList(); + + var providerReply = await providerClient.SendChatAsync(decryptedApiKey, providerMessages); + var availableProviders = providers.Select(item => item.Provider).ToList(); + + return new StandaloneChatResponse + { + DefaultProvider = providers.FirstOrDefault(item => item.IsDefault)?.Provider, + AvailableProviders = availableProviders, + Message = new StandaloneChatMessageResponse + { + Role = "assistant", + Content = providerReply.Content, + Provider = selectedProvider, + Model = providerReply.Model, + CreatedAt = _timeProvider.GetUtcNow().UtcDateTime + } + }; + } + public async Task GetMainNodeChatAsync(int userId, int canvasId) { await EnsureCanvasExistsAsync(userId, canvasId); @@ -242,6 +309,24 @@ private ILlmProviderClient GetProviderClient(string provider) throw new InvalidOperationException($"No provider client is registered for '{provider}'."); } + private static string NormalizeStandaloneMessageRole(string role) + { + var normalizedRole = role.Trim().ToLowerInvariant(); + if (normalizedRole is "user" or "assistant") + return normalizedRole; + + throw new LlmChatException("Only user and assistant messages are supported.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status400BadRequest); + } + + private static string NormalizeStandaloneMessageContent(string content, string errorMessage) + { + var trimmedContent = content.Trim(); + if (string.IsNullOrWhiteSpace(trimmedContent)) + throw new LlmChatException(errorMessage, LlmProviderErrorCodes.ProviderError, StatusCodes.Status400BadRequest); + + return trimmedContent; + } + private string ResolveProvider(IReadOnlyList providers, string? providerOverride) { if (providers.Count == 0) diff --git a/backend/Coliee.API/Services/LmStudioProviderClient.cs b/backend/Coliee.API/Services/LmStudioProviderClient.cs new file mode 100644 index 0000000..7c800e5 --- /dev/null +++ b/backend/Coliee.API/Services/LmStudioProviderClient.cs @@ -0,0 +1,274 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Coliee.API.Services; + +public class LmStudioProviderClient : ILlmProviderClient +{ + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private readonly string _model; + private readonly ILogger _logger; + + public LmStudioProviderClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) + { + _httpClient = httpClient; + _baseUrl = (configuration["Llm:Providers:LmStudio:BaseUrl"] ?? "http://localhost:1234/v1").TrimEnd('/'); + _model = configuration["Llm:Providers:LmStudio:Model"] ?? "local-model"; + _logger = logger; + } + + public string Provider => LlmProviderNames.LmStudio; + + public Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) + { + return SendChatAsync( + apiKey, + [new LlmProviderChatMessage { Role = "user", Content = "Reply with OK." }], + cancellationToken: cancellationToken); + } + + public async Task> GetModelsAsync(string apiKey, CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/models"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCts.CancelAfter(TimeSpan.FromSeconds(30)); + + HttpResponseMessage response; + + try + { + response = await _httpClient.SendAsync(request, linkedCts.Token); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(ex, "LM Studio model list request timed out."); + throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "LM Studio model list request failed."); + throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + await ThrowForFailureAsync(response, _model, linkedCts.Token); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(linkedCts.Token)); + if (!document.RootElement.TryGetProperty("data", out var dataElement) || dataElement.ValueKind != JsonValueKind.Array) + return []; + + return dataElement + .EnumerateArray() + .Select(item => TryGetString(item, "id")) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => new LlmProviderModel + { + Id = item!, + DisplayName = item + }) + .OrderBy(item => item.DisplayName ?? item.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } + + public async Task SendChatAsync( + string apiKey, + IReadOnlyList messages, + string? modelOverride = null, + string? systemPrompt = null, + CancellationToken cancellationToken = default) + { + var model = ResolveModel(modelOverride); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/chat/completions"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Content = JsonContent.Create(new + { + model, + messages = BuildChatMessages(messages, systemPrompt) + }); + + return await SendRequestAsync(request, model, cancellationToken); + } + + public async Task SendStructuredJsonAsync( + string apiKey, + LlmProviderStructuredRequest structuredRequest, + string? modelOverride = null, + CancellationToken cancellationToken = default) + { + var model = ResolveModel(modelOverride); + using var schemaDocument = JsonDocument.Parse(structuredRequest.SchemaJson); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/chat/completions"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Content = JsonContent.Create(new + { + model, + max_tokens = structuredRequest.MaxOutputTokens, + messages = BuildStructuredMessages(structuredRequest), + response_format = new + { + type = "json_schema", + json_schema = new + { + name = structuredRequest.SchemaName, + strict = true, + schema = schemaDocument.RootElement.Clone() + } + } + }); + + return await SendRequestAsync(request, model, cancellationToken); + } + + private IEnumerable BuildChatMessages(IReadOnlyList messages, string? systemPrompt) + { + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + yield return new + { + role = "system", + content = systemPrompt + }; + } + + foreach (var message in messages) + { + yield return new + { + role = message.Role, + content = message.Content + }; + } + } + + private IEnumerable BuildStructuredMessages(LlmProviderStructuredRequest structuredRequest) + { + yield return new + { + role = "system", + content = structuredRequest.Instructions + }; + + foreach (var message in structuredRequest.Messages) + { + yield return new + { + role = message.Role, + content = message.Content + }; + } + } + + private async Task SendRequestAsync(HttpRequestMessage request, string model, CancellationToken cancellationToken) + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCts.CancelAfter(TimeSpan.FromSeconds(30)); + + HttpResponseMessage response; + + try + { + response = await _httpClient.SendAsync(request, linkedCts.Token); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(ex, "LM Studio request timed out for model {Model}.", model); + throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "LM Studio request failed for model {Model}.", model); + throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + await ThrowForFailureAsync(response, model, linkedCts.Token); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(linkedCts.Token)); + var content = document.RootElement + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString(); + + if (string.IsNullOrWhiteSpace(content)) + { + _logger.LogWarning("LM Studio returned an empty response for model {Model}.", model); + throw new LlmChatException("Provider returned an empty response.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway); + } + + return new LlmProviderChatResult + { + Content = content.Trim(), + Model = model + }; + } + } + + private async Task ThrowForFailureAsync(HttpResponseMessage response, string model, CancellationToken cancellationToken) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + var code = GetCode(response.StatusCode); + _logger.LogWarning( + "LM Studio returned failure status {StatusCode} with error code {ErrorCode} for model {Model}.", + (int)response.StatusCode, + code, + model); + throw new LlmChatException( + GetMessage(response.StatusCode), + code, + GetStatusCode(response.StatusCode), + new InvalidOperationException(body)); + } + + private static string GetCode(HttpStatusCode statusCode) + { + return statusCode switch + { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => LlmProviderErrorCodes.InvalidKey, + HttpStatusCode.TooManyRequests => LlmProviderErrorCodes.RateLimited, + _ => LlmProviderErrorCodes.ProviderError + }; + } + + private static int GetStatusCode(HttpStatusCode statusCode) + { + return statusCode switch + { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => StatusCodes.Status400BadRequest, + HttpStatusCode.TooManyRequests => StatusCodes.Status429TooManyRequests, + _ => StatusCodes.Status502BadGateway + }; + } + + private static string GetMessage(HttpStatusCode statusCode) + { + return statusCode switch + { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => "The saved API key is invalid.", + HttpStatusCode.TooManyRequests => "The provider is rate limiting requests right now.", + _ => "The provider returned an unexpected error." + }; + } + + private static string? TryGetString(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var property) + ? property.GetString() + : null; + } + + private string ResolveModel(string? modelOverride) + { + return string.IsNullOrWhiteSpace(modelOverride) ? _model : modelOverride.Trim(); + } +} diff --git a/backend/Coliee.API/Services/OpenAiProviderClient.cs b/backend/Coliee.API/Services/OpenAiProviderClient.cs index c6addf7..bd7a5b4 100644 --- a/backend/Coliee.API/Services/OpenAiProviderClient.cs +++ b/backend/Coliee.API/Services/OpenAiProviderClient.cs @@ -27,26 +27,137 @@ public Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = return SendChatAsync( apiKey, [new LlmProviderChatMessage { Role = "user", Content = "Reply with OK." }], - cancellationToken); + cancellationToken: cancellationToken); + } + + public async Task> GetModelsAsync(string apiKey, CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/models"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCts.CancelAfter(TimeSpan.FromSeconds(30)); + + HttpResponseMessage response; + + try + { + response = await _httpClient.SendAsync(request, linkedCts.Token); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(ex, "OpenAI model list request timed out."); + throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "OpenAI model list request failed."); + throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + await ThrowForFailureAsync(response, _model, linkedCts.Token); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(linkedCts.Token)); + if (!document.RootElement.TryGetProperty("data", out var dataElement) || dataElement.ValueKind != JsonValueKind.Array) + return []; + + return dataElement + .EnumerateArray() + .Select(item => TryGetString(item, "id")) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => new LlmProviderModel + { + Id = item!, + DisplayName = item + }) + .OrderBy(item => item.DisplayName ?? item.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + } } public async Task SendChatAsync( string apiKey, IReadOnlyList messages, + string? modelOverride = null, + string? systemPrompt = null, CancellationToken cancellationToken = default) { + var model = ResolveModel(modelOverride); using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/chat/completions"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); - request.Content = JsonContent.Create(new - { - model = _model, - messages = messages.Select(message => new + var chatMessages = messages + .Select(message => new { role = message.Role, content = message.Content }) + .ToList(); + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + chatMessages.Insert(0, new { role = "system", content = systemPrompt }); + } + request.Content = JsonContent.Create(new + { + model, + messages = chatMessages + }); + + return await SendRequestAsync(request, model, cancellationToken); + } + + public async Task SendStructuredJsonAsync( + string apiKey, + LlmProviderStructuredRequest structuredRequest, + string? modelOverride = null, + CancellationToken cancellationToken = default) + { + var model = ResolveModel(modelOverride); + using var schemaDocument = JsonDocument.Parse(structuredRequest.SchemaJson); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/chat/completions"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Content = JsonContent.Create(new + { + model, + max_tokens = structuredRequest.MaxOutputTokens, + messages = BuildStructuredMessages(structuredRequest), + response_format = new + { + type = "json_schema", + json_schema = new + { + name = structuredRequest.SchemaName, + strict = true, + schema = schemaDocument.RootElement.Clone() + } + } }); + return await SendRequestAsync(request, model, cancellationToken); + } + + private IEnumerable BuildStructuredMessages(LlmProviderStructuredRequest structuredRequest) + { + yield return new + { + role = "system", + content = structuredRequest.Instructions + }; + + foreach (var message in structuredRequest.Messages) + { + yield return new + { + role = message.Role, + content = message.Content + }; + } + } + + private async Task SendRequestAsync(HttpRequestMessage request, string model, CancellationToken cancellationToken) + { using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); linkedCts.CancelAfter(TimeSpan.FromSeconds(30)); @@ -58,19 +169,19 @@ public async Task SendChatAsync( } catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { - _logger.LogWarning(ex, "OpenAI request timed out for model {Model}.", _model); + _logger.LogWarning(ex, "OpenAI request timed out for model {Model}.", model); throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); } catch (HttpRequestException ex) { - _logger.LogWarning(ex, "OpenAI request failed for model {Model}.", _model); + _logger.LogWarning(ex, "OpenAI request failed for model {Model}.", model); throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, ex); } using (response) { if (!response.IsSuccessStatusCode) - await ThrowForFailureAsync(response, linkedCts.Token); + await ThrowForFailureAsync(response, model, linkedCts.Token); using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(linkedCts.Token)); var content = document.RootElement @@ -81,19 +192,19 @@ public async Task SendChatAsync( if (string.IsNullOrWhiteSpace(content)) { - _logger.LogWarning("OpenAI returned an empty response for model {Model}.", _model); + _logger.LogWarning("OpenAI returned an empty response for model {Model}.", model); throw new LlmChatException("Provider returned an empty response.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway); } return new LlmProviderChatResult { Content = content.Trim(), - Model = _model + Model = model }; } } - private async Task ThrowForFailureAsync(HttpResponseMessage response, CancellationToken cancellationToken) + private async Task ThrowForFailureAsync(HttpResponseMessage response, string model, CancellationToken cancellationToken) { var body = await response.Content.ReadAsStringAsync(cancellationToken); var code = GetCode(response.StatusCode); @@ -101,7 +212,7 @@ private async Task ThrowForFailureAsync(HttpResponseMessage response, Cancellati "OpenAI returned failure status {StatusCode} with error code {ErrorCode} for model {Model}.", (int)response.StatusCode, code, - _model); + model); throw new LlmChatException( GetMessage(response.StatusCode), code, @@ -125,6 +236,7 @@ private static int GetStatusCode(HttpStatusCode statusCode) { HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => StatusCodes.Status400BadRequest, HttpStatusCode.TooManyRequests => StatusCodes.Status429TooManyRequests, + HttpStatusCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, _ => StatusCodes.Status502BadGateway }; } @@ -138,4 +250,18 @@ private static string GetMessage(HttpStatusCode statusCode) _ => "The provider returned an unexpected error." }; } + + private static string? TryGetString(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var property) + ? property.GetString() + : null; + } + + private string ResolveModel(string? modelOverride) + { + return string.IsNullOrWhiteSpace(modelOverride) + ? _model + : modelOverride.Trim(); + } } diff --git a/backend/Coliee.API/appsettings.json b/backend/Coliee.API/appsettings.json index c090de5..7e71a73 100644 --- a/backend/Coliee.API/appsettings.json +++ b/backend/Coliee.API/appsettings.json @@ -34,6 +34,10 @@ "Gemini": { "BaseUrl": "https://generativelanguage.googleapis.com/v1beta", "Model": "gemini-1.5-flash" + }, + "LmStudio": { + "BaseUrl": "http://localhost:1234/v1", + "Model": "local-model" } } }, diff --git a/backend/test_output.log b/backend/test_output.log deleted file mode 100644 index 68ca27a..0000000 Binary files a/backend/test_output.log and /dev/null differ diff --git a/backend/test_output2.log b/backend/test_output2.log deleted file mode 100644 index 2b5e27a..0000000 --- a/backend/test_output2.log +++ /dev/null @@ -1,10 +0,0 @@ - Determining projects to restore... - Restored /app/Coliee.API/Coliee.API.csproj (in 57.55 sec). - Restored /app/Coliee.API.Tests/Coliee.API.Tests.csproj (in 57.55 sec). - Coliee.API -> /app/Coliee.API/bin/Debug/net8.0/Coliee.API.dll -/app/Coliee.API.Tests/AuthControllerTests.cs(60,9): error CS7036: There is no argument given that corresponds to the required parameter 'googleAuthService' of 'AuthController.AuthController(IUserRepository, TokenService, IPasswordResetOtpService, IEmailVerificationOtpService, IPasswordPolicyService, IGoogleAuthService)' [/app/Coliee.API.Tests/Coliee.API.Tests.csproj] -/app/Coliee.API.Tests/Controllers/AuthControllerTests.cs(114,13): warning CS0219: The variable 'callCount' is assigned but its value is never used [/app/Coliee.API.Tests/Coliee.API.Tests.csproj] -/app/Coliee.API.Tests/PasswordResetOtpServiceTests.cs(23,9): warning xUnit2013: Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013) [/app/Coliee.API.Tests/Coliee.API.Tests.csproj] -/app/Coliee.API.Tests/PasswordResetOtpServiceTests.cs(45,9): warning xUnit2013: Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013) [/app/Coliee.API.Tests/Coliee.API.Tests.csproj] -/app/Coliee.API.Tests/PasswordResetOtpServiceTests.cs(65,9): warning xUnit2013: Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013) [/app/Coliee.API.Tests/Coliee.API.Tests.csproj] -/app/Coliee.API.Tests/PasswordResetOtpServiceTests.cs(66,9): warning xUnit2013: Do not use Assert.Equal() to check for collection size. Use Assert.Single instead. (https://xunit.net/xunit.analyzers/rules/xUnit2013) [/app/Coliee.API.Tests/Coliee.API.Tests.csproj] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4e82c22..8d6ca0f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -41,6 +41,12 @@ services: - Llm__Providers__Claude__Version=${CLAUDE_VERSION:-2023-06-01} - Llm__Providers__Gemini__Model=${GEMINI_MODEL:-gemini-1.5-flash} - Llm__Providers__Gemini__ApiKey=${GEMINI_API_KEY} + - Llm__GuestPreview__OpenAI__ApiKey=${Llm__GuestPreview__OpenAI__ApiKey} + - Llm__GuestPreview__OpenAI__Model=${Llm__GuestPreview__OpenAI__Model} + - Llm__GuestPreview__Claude__ApiKey=${Llm__GuestPreview__Claude__ApiKey} + - Llm__GuestPreview__Claude__Model=${Llm__GuestPreview__Claude__Model} + - Llm__GuestPreview__Gemini__ApiKey=${Llm__GuestPreview__Gemini__ApiKey} + - Llm__GuestPreview__Gemini__Model=${Llm__GuestPreview__Gemini__Model} frontend: image: ${DOCKERHUB_USERNAME}/coliee-frontend:latest diff --git a/docker-compose.yml b/docker-compose.yml index e734f8f..d1ca6fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,9 @@ services: dockerfile: Dockerfile.dev ports: - "8080:8080" + dns: + - 8.8.8.8 + - 1.1.1.1 volumes: - ./backend/Coliee.API:/app depends_on: @@ -45,6 +48,12 @@ services: - Llm__Providers__Claude__Model=${Llm__Providers__Claude__Model} - Llm__Providers__Claude__Version=${Llm__Providers__Claude__Version} - Llm__Providers__Gemini__Model=${Llm__Providers__Gemini__Model} + - Llm__GuestPreview__OpenAI__ApiKey=${Llm__GuestPreview__OpenAI__ApiKey} + - Llm__GuestPreview__OpenAI__Model=${Llm__GuestPreview__OpenAI__Model} + - Llm__GuestPreview__Claude__ApiKey=${Llm__GuestPreview__Claude__ApiKey} + - Llm__GuestPreview__Claude__Model=${Llm__GuestPreview__Claude__Model} + - Llm__GuestPreview__Gemini__ApiKey=${Llm__GuestPreview__Gemini__ApiKey} + - Llm__GuestPreview__Gemini__Model=${Llm__GuestPreview__Gemini__Model} frontend: build: @@ -54,7 +63,7 @@ services: - "3000:5173" volumes: - ./frontend:/app - - /app/node_modules + - frontend-node-modules:/app/node_modules environment: - CHOKIDAR_USEPOLLING=true - API_URL=http://backend:8080 @@ -64,3 +73,4 @@ services: volumes: mysql-data: + frontend-node-modules: diff --git a/frontend/frontend_test_result.txt b/frontend/frontend_test_result.txt deleted file mode 100644 index 777366d..0000000 Binary files a/frontend/frontend_test_result.txt and /dev/null differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 02461de..8de2df2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,11 +11,18 @@ "@react-oauth/google": "^0.13.4", "@xyflow/react": "^12.9.0", "axios": "^1.13.5", + "jspdf": "^4.2.1", "jwt-decode": "^4.0.0", + "lucide-react": "^0.542.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hot-toast": "^2.6.0", - "react-router-dom": "^7.13.1" + "react-markdown": "^10.1.0", + "react-router-dom": "^7.13.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -321,7 +328,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1834,6 +1840,15 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1845,9 +1860,26 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1928,6 +1960,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.3.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", @@ -1938,11 +1985,23 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1965,6 +2024,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2277,6 +2349,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2644,6 +2722,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2651,6 +2739,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -2799,6 +2897,36 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2833,6 +2961,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -2935,6 +3103,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2972,6 +3150,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2987,6 +3177,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -3150,7 +3350,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3171,6 +3370,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3201,12 +3413,24 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3229,6 +3453,16 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3254,7 +3488,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -3551,6 +3784,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3599,6 +3842,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3650,6 +3899,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -3678,6 +3938,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3966,6 +4232,155 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -3996,6 +4411,40 @@ "node": ">=18" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -4084,6 +4533,42 @@ "node": ">=8" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4113,6 +4598,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4136,6 +4631,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4146,12 +4651,24 @@ "node": ">=0.12.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", @@ -4468,6 +4985,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jwt-decode": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", @@ -4544,6 +5078,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -4561,6 +5105,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.542.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", + "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -4582,6 +5135,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4591,16 +5154,875 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8" + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4676,7 +6098,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -4811,6 +6232,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4824,11 +6251,35 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -4881,6 +6332,13 @@ "node": ">= 14.16" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5124,6 +6582,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -5161,6 +6629,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -5207,6 +6685,33 @@ "license": "MIT", "peer": true }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5305,6 +6810,123 @@ "node": ">=8" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5347,6 +6969,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -5515,6 +7147,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -5545,6 +7187,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -5552,6 +7204,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -5598,6 +7264,24 @@ "dev": true, "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -5647,6 +7331,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -5702,6 +7396,16 @@ "jiti": "bin/jiti.js" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -5845,6 +7549,26 @@ "node": ">=18" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -5923,6 +7647,93 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -5980,6 +7791,58 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -6149,6 +8012,16 @@ "node": ">=18" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -6349,6 +8222,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 0fc284d..aaa1716 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,11 +14,18 @@ "@react-oauth/google": "^0.13.4", "@xyflow/react": "^12.9.0", "axios": "^1.13.5", + "jspdf": "^4.2.1", "jwt-decode": "^4.0.0", + "lucide-react": "^0.542.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hot-toast": "^2.6.0", - "react-router-dom": "^7.13.1" + "react-markdown": "^10.1.0", + "react-router-dom": "^7.13.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/public/pdf-fonts/Arial-Unicode.ttf b/frontend/public/pdf-fonts/Arial-Unicode.ttf new file mode 100644 index 0000000..1537c5b Binary files /dev/null and b/frontend/public/pdf-fonts/Arial-Unicode.ttf differ diff --git a/frontend/public/pdf-fonts/Courier-New.ttf b/frontend/public/pdf-fonts/Courier-New.ttf new file mode 100644 index 0000000..633899d Binary files /dev/null and b/frontend/public/pdf-fonts/Courier-New.ttf differ diff --git a/frontend/public/pdf-fonts/NotoSans-Regular.ttf b/frontend/public/pdf-fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000..7557504 Binary files /dev/null and b/frontend/public/pdf-fonts/NotoSans-Regular.ttf differ diff --git a/frontend/public/pdf-fonts/NotoSansMono-Regular.ttf b/frontend/public/pdf-fonts/NotoSansMono-Regular.ttf new file mode 100644 index 0000000..07ecc66 Binary files /dev/null and b/frontend/public/pdf-fonts/NotoSansMono-Regular.ttf differ diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 71455d1..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,218 +0,0 @@ -:root { - --primary: #3b82f6; - --primary-hover: #2563eb; - --bg-color: #0f172a; - --card-bg: #1e293b; - --text-main: #f8fafc; - --text-muted: #94a3b8; - --border-color: #334155; - --danger: #ef4444; - --danger-hover: #dc2626; - font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - color-scheme: dark; - background-color: var(--bg-color); - color: var(--text-main); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -body { - margin: 0; - min-width: 320px; - min-height: 100vh; -} - -#root { - width: 100%; -} - -* { - box-sizing: border-box; -} - -.app-container { - max-width: 1200px; - margin: 0 auto; - padding: 2rem; - width: 100%; -} - -.header { - text-align: center; - margin-bottom: 3rem; - padding-bottom: 2rem; - border-bottom: 1px solid var(--border-color); -} - -.header h1 { - font-size: 2.5rem; - margin: 0 0 0.5rem 0; - background: linear-gradient(135deg, #60a5fa, #3b82f6); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.header p { - color: var(--text-muted); - margin: 0; - font-size: 1.1rem; -} - -.main-content { - display: grid; - grid-template-columns: 1fr 2fr; - gap: 2rem; - align-items: start; -} - -@media (max-width: 768px) { - .main-content { - grid-template-columns: 1fr; - } -} - -.form-card, .list-card { - background: var(--card-bg); - padding: 2rem; - border-radius: 16px; - box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5); - border: 1px solid var(--border-color); -} - -.form-card h2, .list-card h2 { - margin-top: 0; - margin-bottom: 1.5rem; - font-size: 1.25rem; - color: var(--text-main); -} - -.input-group { - margin-bottom: 1.5rem; -} - -.input-group label { - display: block; - margin-bottom: 0.5rem; - font-size: 0.875rem; - color: var(--text-muted); - font-weight: 500; -} - -.input-group input { - width: 100%; - padding: 0.75rem 1rem; - border-radius: 8px; - border: 1px solid var(--border-color); - background: rgba(15, 23, 42, 0.5); - color: var(--text-main); - font-size: 1rem; - transition: all 0.2s; -} - -.input-group input:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); -} - -.form-actions { - display: flex; - gap: 1rem; -} - -button { - cursor: pointer; - border: none; - border-radius: 8px; - font-weight: 600; - font-size: 0.875rem; - transition: all 0.2s; - font-family: inherit; -} - -.btn-primary { - background: var(--primary); - color: white; - padding: 0.75rem 1.5rem; - flex: 1; -} - -.btn-primary:hover { - background: var(--primary-hover); - transform: translateY(-1px); -} - -.btn-secondary { - background: transparent; - color: var(--text-muted); - border: 1px solid var(--border-color); - padding: 0.75rem 1.5rem; -} - -.btn-secondary:hover { - background: var(--border-color); - color: var(--text-main); -} - -.table-wrapper { - overflow-x: auto; -} - -table { - width: 100%; - border-collapse: collapse; -} - -th, td { - padding: 1rem; - text-align: left; - border-bottom: 1px solid var(--border-color); -} - -th { - color: var(--text-muted); - font-weight: 500; - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.text-right { - text-align: right; -} - -.font-semibold { - font-weight: 600; -} - -.actions { - display: flex; - gap: 0.5rem; - justify-content: flex-end; -} - -.btn-icon { - background: rgba(255, 255, 255, 0.05); - color: var(--text-main); - padding: 0.4rem 0.8rem; - font-size: 0.75rem; -} - -.btn-icon:hover { - background: rgba(255, 255, 255, 0.1); -} - -.btn-icon.delete:hover { - background: var(--danger); - color: white; -} - -.loading, .empty-state { - text-align: center; - padding: 3rem; - color: var(--text-muted); -} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..622d988 --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import App from './App' + +const authState = vi.hoisted(() => ({ + isAuthenticated: false, +})) + +vi.mock('./context/AuthContext', () => ({ + useAuth: () => authState, +})) + +vi.mock('./pages/Index', () => ({ + default: () =>
Index Page
, +})) + +vi.mock('./pages/ForgotPasswordPage', () => ({ + default: () =>
Forgot Password
, +})) + +vi.mock('./pages/VerifyEmailPage', () => ({ + default: () =>
Verify Email
, +})) + +vi.mock('./pages/VerifyOtpPage', () => ({ + default: () =>
Verify OTP
, +})) + +vi.mock('./pages/ResetPasswordPage', () => ({ + default: () =>
Reset Password
, +})) + +vi.mock('./pages/SettingsPage', () => ({ + default: () =>
Settings
, +})) + +describe('App routes', () => { + beforeEach(() => { + authState.isAuthenticated = false + }) + + it('redirects legacy chat route to the canonical workspace route', () => { + render( + + + , + ) + + expect(screen.getByTestId('index-page')).toBeInTheDocument() + }) + + it('redirects legacy mindmap route to the canonical workspace route', () => { + render( + + + , + ) + + expect(screen.getByTestId('index-page')).toBeInTheDocument() + }) + + it('keeps the settings route protected', () => { + render( + + + , + ) + + expect(screen.getByTestId('index-page')).toBeInTheDocument() + }) + + it('renders settings for authenticated users', () => { + authState.isAuthenticated = true + + render( + + + , + ) + + expect(screen.getByTestId('settings-page')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cd07610..ec358f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,52 +1,39 @@ -import { Routes, Route, Navigate } from 'react-router-dom' -import WelcomePage from './pages/WelcomePage' -import LoginPage from './pages/LoginPage' -import RegisterPage from './pages/RegisterPage' +import { Navigate, Route, Routes, useLocation } from 'react-router-dom' import VerifyEmailPage from './pages/VerifyEmailPage' import ForgotPasswordPage from './pages/ForgotPasswordPage' import VerifyOtpPage from './pages/VerifyOtpPage' import ResetPasswordPage from './pages/ResetPasswordPage' import SettingsPage from './pages/SettingsPage' -import HomePage from './pages/HomePage' -import PublicRoute from './components/PublicRoute' -import ProtectedRoute from './components/ProtectedRoute' -import Layout from './components/Layout' +import IndexPage from './pages/Index' +import { useAuth } from './context/AuthContext' -function App() { - return ( - - {/* Public (Only accessible if NOT logged in) */} - - } /> - - } /> - - } /> - - } /> - - } /> +function ProtectedSettingsRoute() { + const { isAuthenticated } = useAuth() + const location = useLocation() - {/* OTP and Email Verification can sometimes be accessed publicly if carrying a token, but usually mid-auth flow */} - } /> - } /> + if (!isAuthenticated) + return - {/* Protected (Only accessible IF logged in) - Wrapped in Layout */} - }> - } /> - } /> - } /> - + return +} - {/* Fallback */} - } /> - - ) +function App() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) } export default App diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx deleted file mode 100644 index 40fcaa5..0000000 --- a/frontend/src/components/Layout.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Outlet, useLocation, useNavigate } from 'react-router-dom'; -import { useAuth } from '../context/AuthContext'; - -export default function Layout() { - const { user, logout } = useAuth(); - const location = useLocation(); - const navigate = useNavigate(); - const isCanvasWorkspace = location.pathname === '/home'; - - const handleLogout = async () => { - await logout(); - navigate('/'); - }; - - return ( -
-
-
-
navigate('/home')}> - Coliee AI -
- -
- - {user?.displayName || user?.email} - - - - - -
-
- -
- -
-
-
- ); -} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx deleted file mode 100644 index b81fd53..0000000 --- a/frontend/src/components/ProtectedRoute.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Navigate, useLocation } from 'react-router-dom'; -import { useAuth } from '../context/AuthContext'; - -interface ProtectedRouteProps { - children: React.ReactNode; -} - -export default function ProtectedRoute({ children }: ProtectedRouteProps) { - const { isAuthenticated } = useAuth(); - const location = useLocation(); - - // If not logged in, redirect to login page with the return url - return isAuthenticated ? <>{children} : ; -} diff --git a/frontend/src/components/PublicRoute.tsx b/frontend/src/components/PublicRoute.tsx deleted file mode 100644 index c566070..0000000 --- a/frontend/src/components/PublicRoute.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Navigate } from 'react-router-dom'; -import { useAuth } from '../context/AuthContext'; - -interface PublicRouteProps { - children: React.ReactNode; -} - -export default function PublicRoute({ children }: PublicRouteProps) { - const { isAuthenticated } = useAuth(); - - // If user is already logged in, redirect them away from public pages (like login/register) - return isAuthenticated ? : <>{children}; -} diff --git a/frontend/src/components/auth/AuthCard.tsx b/frontend/src/components/auth/AuthCard.tsx new file mode 100644 index 0000000..85842b5 --- /dev/null +++ b/frontend/src/components/auth/AuthCard.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' + +interface AuthCardProps { + children: ReactNode + className?: string +} + +export default function AuthCard({ children, className = '' }: AuthCardProps) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/components/auth/AuthIcons.tsx b/frontend/src/components/auth/AuthIcons.tsx new file mode 100644 index 0000000..1cb40e8 --- /dev/null +++ b/frontend/src/components/auth/AuthIcons.tsx @@ -0,0 +1,24 @@ +export function Eye() { + return ( + + + + ) +} + +export function EyeOff() { + return ( + + + + + + + ) +} + +export function RabbitSVG() { + return ( + C + ) +} diff --git a/frontend/src/components/auth/AuthModal.tsx b/frontend/src/components/auth/AuthModal.tsx new file mode 100644 index 0000000..69bfa1a --- /dev/null +++ b/frontend/src/components/auth/AuthModal.tsx @@ -0,0 +1,77 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import AuthCard from './AuthCard' +import LoginForm from './LoginForm' +import RegisterForm from './RegisterForm' + +export type AuthModalMode = 'login' | 'register' + +interface AuthModalProps { + mode: AuthModalMode + onClose: () => void + onChangeMode: (mode: AuthModalMode) => void +} + +export default function AuthModal({ mode, onClose, onChangeMode }: AuthModalProps) { + const navigate = useNavigate() + const title = mode === 'login' + ? 'Sign in to your canvas workspace' + : 'Create a canvas workspace account' + const copy = mode === 'login' + ? 'Continue in the same workspace used on the main canvas, with your sessions, branches, and saved canvases in one place.' + : 'Start with the same workspace design language as the main canvas, then pick up your canvases anywhere.' + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') onClose() + } + + document.body.classList.add('auth-modal-open') + window.addEventListener('keydown', handleKeyDown) + return () => { + document.body.classList.remove('auth-modal-open') + window.removeEventListener('keydown', handleKeyDown) + } + }, [onClose]) + + return ( +
+ +
event.stopPropagation()}> +
+
+ Coliee AI +

{title}

+

{copy}

+
+ +
+ + {mode === 'login' ? ( + onChangeMode('register')} + onForgotPassword={() => { + onClose() + navigate('/forgot-password') + }} + onSuccess={onClose} + /> + ) : ( + onChangeMode('login')} + onSuccess={onClose} + /> + )} +
+
+
+ ) +} diff --git a/frontend/src/components/auth/AuthNavbar.tsx b/frontend/src/components/auth/AuthNavbar.tsx new file mode 100644 index 0000000..2360861 --- /dev/null +++ b/frontend/src/components/auth/AuthNavbar.tsx @@ -0,0 +1,14 @@ +import { useLocation } from 'react-router-dom' +import PublicAuthNavbar from './PublicAuthNavbar' + +export default function AuthNavbar() { + const location = useLocation() + const isRegister = location.pathname === '/register' + + return ( + + ) +} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..aee410c --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -0,0 +1,185 @@ +import { useState, type FormEvent } from 'react' +import { AxiosError } from 'axios' +import { GoogleLogin, type CredentialResponse } from '@react-oauth/google' +import { useNavigate } from 'react-router-dom' +import { login, loginWithGoogle, getEmailVerificationRequired, getErrorMessage } from '../../services/authService' +import { useAuth } from '../../context/AuthContext' +import { Eye, EyeOff } from './AuthIcons' + +interface FieldErrors { + email?: string + password?: string +} + +interface LockoutData { + message: string + lockedUntil: string +} + +interface LoginFormProps { + onSwitchToRegister: () => void + onForgotPassword?: () => void + onSuccess?: () => void +} + +export default function LoginForm({ onSwitchToRegister, onForgotPassword, onSuccess }: LoginFormProps) { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [showPw, setShowPw] = useState(false) + const [fieldErrors, setFieldErrors] = useState({}) + const [apiError, setApiError] = useState('') + const [lockedUntil, setLockedUntil] = useState(null) + const [loading, setLoading] = useState(false) + + const navigate = useNavigate() + const { login: authLogin } = useAuth() + + function validate(): boolean { + const errors: FieldErrors = {} + if (!email.trim()) errors.email = 'Email is required' + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) errors.email = 'Enter a valid email address' + if (!password) errors.password = 'Password is required' + setFieldErrors(errors) + return !Object.keys(errors).length + } + + async function handleGoogleSuccess(credentialResponse: CredentialResponse) { + setApiError('') + setLockedUntil(null) + if (!credentialResponse.credential) return + + setLoading(true) + try { + const data = await loginWithGoogle(credentialResponse.credential) + authLogin(data.token, data.refreshToken) + onSuccess?.() + navigate('/') + } catch (err: unknown) { + setApiError(getErrorMessage(err)) + } finally { + setLoading(false) + } + } + + async function handleSubmit(ev: FormEvent) { + ev.preventDefault() + setApiError('') + setLockedUntil(null) + if (!validate()) return + + setLoading(true) + try { + const data = await login(email.trim(), password) + authLogin(data.token, data.refreshToken) + onSuccess?.() + navigate('/') + } catch (err: unknown) { + const verificationRequired = getEmailVerificationRequired(err) + if (verificationRequired) { + navigate('/verify-email', { + state: { + email: verificationRequired.email, + nextResendAt: verificationRequired.nextResendAt, + cooldownSeconds: verificationRequired.cooldownSeconds, + resendAttemptsRemaining: verificationRequired.resendAttemptsRemaining, + cooldownSecondsRemaining: verificationRequired.cooldownSecondsRemaining, + message: verificationRequired.message, + } + }) + return + } + + if (err instanceof AxiosError && err.response?.status === 429) { + const body = err.response.data as LockoutData + setLockedUntil(body.lockedUntil ?? null) + setApiError('Account locked due to too many failed attempts.') + } else { + setApiError(getErrorMessage(err)) + } + } finally { + setLoading(false) + } + } + + return ( + <> +
+

Welcome back

+
+ + {apiError && ( +
+ {apiError} +
+ )} + {lockedUntil && ( +
+ 🔒 Try again after {new Date(lockedUntil).toLocaleTimeString()} +
+ )} + +
+ setApiError('Google Sign-In failed or was cancelled.')} + /> +
+ + +
+
+ { setEmail(e.target.value); setFieldErrors(p => ({ ...p, email: undefined })) }} + placeholder="Email address" + className={`auth-input${fieldErrors.email ? ' auth-input--error' : ''}`} + autoComplete="email" + disabled={loading} + /> + {fieldErrors.email &&

{fieldErrors.email}

} +
+ +
+ { setPassword(e.target.value); setFieldErrors(p => ({ ...p, password: undefined })) }} + placeholder="Password" + className={`auth-input${fieldErrors.password ? ' auth-input--error' : ''}`} + autoComplete="current-password" + disabled={loading} + /> + + {fieldErrors.password &&

{fieldErrors.password}

} +
+ + +
+ +
+ Don't have an account?{' '} + +
+ +
+ + ) +} diff --git a/frontend/src/components/auth/PublicAuthNavbar.tsx b/frontend/src/components/auth/PublicAuthNavbar.tsx new file mode 100644 index 0000000..4519b7c --- /dev/null +++ b/frontend/src/components/auth/PublicAuthNavbar.tsx @@ -0,0 +1,26 @@ +import { useNavigate } from 'react-router-dom' + +interface PublicAuthNavbarProps { + actionLabel?: string + actionHref?: string +} + +export default function PublicAuthNavbar({ actionLabel, actionHref }: PublicAuthNavbarProps) { + const navigate = useNavigate() + + return ( + + ) +} diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..6d026fe --- /dev/null +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -0,0 +1,224 @@ +import { useState, type FormEvent } from 'react' +import { GoogleLogin, type CredentialResponse } from '@react-oauth/google' +import { useNavigate } from 'react-router-dom' +import { register, loginWithGoogle, getErrorMessage } from '../../services/authService' +import { useAuth } from '../../context/AuthContext' +import type { EmailVerificationOtpResponse } from '../../types' +import { Eye, EyeOff } from './AuthIcons' + +interface FieldErrors { + email?: string + password?: string + confirmPassword?: string +} + +interface RegisterFormProps { + onSwitchToLogin: () => void + onSuccess?: () => void +} + +export default function RegisterForm({ onSwitchToLogin, onSuccess }: RegisterFormProps) { + const [displayName, setDisplayName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showPw, setShowPw] = useState(false) + const [showCpw, setShowCpw] = useState(false) + const [fieldErrors, setFieldErrors] = useState({}) + const [apiError, setApiError] = useState('') + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState(false) + + const navigate = useNavigate() + const { login: authLogin } = useAuth() + + function validate(): boolean { + const errors: FieldErrors = {} + if (!email.trim()) { + errors.email = 'Email is required' + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + errors.email = 'Enter a valid email address' + } + + if (!password) { + errors.password = 'Password is required' + } else { + if (password.length < 8) errors.password = 'At least 8 characters' + else if (!/[A-Z]/.test(password)) errors.password = 'Needs uppercase' + else if (!/[a-z]/.test(password)) errors.password = 'Needs lowercase' + else if (!/[0-9]/.test(password)) errors.password = 'Needs a number' + else if (!/[^A-Za-z0-9]/.test(password)) errors.password = 'Needs a special character' + } + + if (!confirmPassword) errors.confirmPassword = 'Please confirm your password' + else if (password !== confirmPassword) errors.confirmPassword = 'Passwords do not match' + + setFieldErrors(errors) + return !Object.keys(errors).length + } + + async function handleSubmit(ev: FormEvent) { + ev.preventDefault() + setApiError('') + if (!validate()) return + + setLoading(true) + try { + const data: EmailVerificationOtpResponse = await register(email.trim(), password, displayName.trim() || undefined) + setSuccess(true) + onSuccess?.() + setTimeout(() => navigate('/verify-email', { + state: { + email: email.trim(), + nextResendAt: data.nextResendAt, + cooldownSeconds: data.cooldownSeconds, + resendAttemptsRemaining: data.resendAttemptsRemaining, + message: data.message, + } + }), 1400) + } catch (err: unknown) { + setApiError(getErrorMessage(err)) + } finally { + setLoading(false) + } + } + + async function handleGoogleSuccess(credentialResponse: CredentialResponse) { + setApiError('') + if (!credentialResponse.credential) return + + setLoading(true) + try { + const data = await loginWithGoogle(credentialResponse.credential) + authLogin(data.token, data.refreshToken) + onSuccess?.() + navigate('/') + } catch (err: unknown) { + setApiError(getErrorMessage(err)) + } finally { + setLoading(false) + } + } + + return ( + <> +
+

Create an account

+
+ + {apiError && ( +
+ {apiError} +
+ )} + {success && ( +
+ Account created! Check your email for a verification code… +
+ )} + +
+ setApiError('Google Sign-Up failed or was cancelled.')} + /> +
+ + +
+
+ setDisplayName(e.target.value)} + placeholder="Your Name" + className="auth-input" + autoComplete="nickname" + disabled={loading || success} + /> +
+ +
+ { setEmail(e.target.value); setFieldErrors(p => ({ ...p, email: undefined })) }} + placeholder="Email Address" + className={`auth-input${fieldErrors.email ? ' auth-input--error' : ''}`} + autoComplete="email" + disabled={loading || success} + /> + {fieldErrors.email &&

{fieldErrors.email}

} +
+ +
+ { setPassword(e.target.value); setFieldErrors(p => ({ ...p, password: undefined })) }} + placeholder="Password" + className={`auth-input${fieldErrors.password ? ' auth-input--error' : ''}`} + autoComplete="new-password" + disabled={loading || success} + /> + + {fieldErrors.password &&

{fieldErrors.password}

} + + {password.length > 0 && ( +
+
= 8 ? 'met' : ''}`}> + {password.length >= 8 ? '✓' : '○'} 8+ characters +
+
+ {/[A-Z]/.test(password) ? '✓' : '○'} Uppercase letter +
+
+ {/[a-z]/.test(password) ? '✓' : '○'} Lowercase letter +
+
+ {/[0-9]/.test(password) ? '✓' : '○'} Number +
+
+ {/[^A-Za-z0-9]/.test(password) ? '✓' : '○'} Special character +
+
+ )} +
+ +
+ { setConfirmPassword(e.target.value); setFieldErrors(p => ({ ...p, confirmPassword: undefined })) }} + placeholder="Re-enter Password" + className={`auth-input${fieldErrors.confirmPassword ? ' auth-input--error' : ''}`} + autoComplete="new-password" + disabled={loading || success} + /> + + {fieldErrors.confirmPassword &&

{fieldErrors.confirmPassword}

} +
+ + +
+ +
+ Already have an account?{' '} + +
+ + ) +} diff --git a/frontend/src/components/canvas/CanvasGraphView.tsx b/frontend/src/components/canvas/CanvasGraphView.tsx deleted file mode 100644 index b8205a0..0000000 --- a/frontend/src/components/canvas/CanvasGraphView.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { Background, Controls, Handle, Position, ReactFlow, ReactFlowProvider, type Edge, type Node, type NodeProps } from '@xyflow/react' -import type { CanvasGraphNode, CanvasGraphResponse, LlmProviderId } from '../../types' - -interface CanvasGraphViewProps { - graph: CanvasGraphResponse - selectedNodeId: number | null - draft: string - providerOverride: LlmProviderId | '' - sending: boolean - noProvidersConfigured: boolean - onSelectNode: (nodeId: number) => void - onDraftChange: (value: string) => void - onProviderOverrideChange: (value: LlmProviderId | '') => void - onSubmit: (nodeId: number) => void - onOpenSettings: () => void -} - -interface GraphNodeData { - node: CanvasGraphNode - childCount: number - draft: string - providerOverride: LlmProviderId | '' - sending: boolean - selected: boolean - noProvidersConfigured: boolean - defaultProvider: LlmProviderId | null - availableProviders: LlmProviderId[] - onDraftChange: (value: string) => void - onProviderOverrideChange: (value: LlmProviderId | '') => void - onSubmit: () => void - onOpenSettings: () => void -} - -function getProviderLabel(provider: LlmProviderId): string { - switch (provider) { - case 'openai': return 'OpenAI' - case 'claude': return 'Claude' - case 'gemini': return 'Gemini' - } - - return provider -} - -function summarizeContent(content: string): string { - const normalized = content.replace(/\s+/g, ' ').trim() - if (normalized.length <= 160) return normalized - return `${normalized.slice(0, 157).trimEnd()}...` -} - -function GraphNodeCard({ data }: NodeProps>) { - const isRoot = data.node.kind === 'main' - const isEmptyMainNode = isRoot && !data.node.content.trim() && data.childCount === 0 - const showComposer = data.selected || isEmptyMainNode - const preview = summarizeContent(data.node.content) - - return ( -
- {!isRoot && } - - -
- {isRoot ? 'Main Node' : `Node ${data.node.depth}`} - {data.childCount > 0 && {data.childCount} branch{data.childCount === 1 ? '' : 'es'}} -
-

{data.node.title}

- - {data.node.content && ( -

- {showComposer ? data.node.content : preview} -

- )} - - {!showComposer && ( -
Click to continue from this node.
- )} - - {showComposer && ( -
event.stopPropagation()} - onSubmit={event => { - event.preventDefault() - data.onSubmit() - }} - > - -