From 4b7b3d93815638effc8c1021be4761588d2dfc42 Mon Sep 17 00:00:00 2001 From: Chaturna Kasturiratna Date: Mon, 20 Apr 2026 13:21:28 +0530 Subject: [PATCH 1/2] feat [SCRUM-28]: enhance Docker setup and API with environment variable support, improved error handling, and context usage tracking --- .../Services/CanvasGraphServiceTests.cs | 38 ++- .../Services/ChatSessionServiceTests.cs | 146 ++++++++- .../Services/LlmProviderClientTests.cs | 113 ++++++- .../Services/LlmServiceTests.cs | 90 +++++- .../Coliee.API/Controllers/AuthController.cs | 2 +- .../DTOs/ChatCanvasNodeDetailResponse.cs | 1 + .../DTOs/ChatCanvasNodeSummaryResponse.cs | 1 + .../DTOs/ChatNodeContextUsageResponse.cs | 9 + .../Coliee.API/Data/ChatSessionRepository.cs | 217 +++++++++++-- .../Coliee.API/Data/IChatSessionRepository.cs | 1 + backend/Coliee.API/Dockerfile.dev | 13 +- backend/Coliee.API/Models/ChatNode.cs | 4 + backend/Coliee.API/Program.cs | 38 +++ .../ChatNodeContextCompactionService.cs | 131 +++++++- .../Services/ChatNodeContextUsage.cs | 9 + .../Services/ChatNodePromptContextResult.cs | 1 + .../Coliee.API/Services/ChatSessionService.cs | 115 ++++++- .../Services/ClaudeProviderClient.cs | 21 +- .../Services/GeminiProviderClient.cs | 22 +- .../IChatNodeContextCompactionService.cs | 8 + .../Services/LlmProviderChatResult.cs | 1 + backend/Coliee.API/Services/LlmService.cs | 40 ++- .../Services/LmStudioProviderClient.cs | 224 ++++++++----- .../Services/OpenAiProviderClient.cs | 18 +- backend/Coliee.API/appsettings.json | 3 +- docker-compose.yml | 5 + frontend/package-lock.json | 33 +- frontend/src/App.test.tsx | 3 + frontend/src/App.tsx | 5 +- .../components/chat-canvas/IdeaNode.test.tsx | 80 ++++- .../src/components/chat-canvas/IdeaNode.tsx | 175 +++++++++- frontend/src/components/chat-canvas/types.ts | 4 +- frontend/src/context/AuthContext.tsx | 75 ++++- frontend/src/pages/Index.test.tsx | 298 ++++++++++++++++- frontend/src/pages/Index.tsx | 302 ++++++++++++++---- frontend/src/pages/SettingsPage.test.tsx | 1 + frontend/src/pages/SettingsPage.tsx | 31 +- frontend/src/services/authService.test.ts | 55 ++++ frontend/src/services/authService.ts | 27 +- frontend/src/types/index.ts | 9 + frontend/vite.config.ts | 4 + 41 files changed, 2109 insertions(+), 264 deletions(-) create mode 100644 backend/Coliee.API/DTOs/ChatNodeContextUsageResponse.cs create mode 100644 backend/Coliee.API/Services/ChatNodeContextUsage.cs create mode 100644 frontend/src/services/authService.test.ts diff --git a/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs b/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs index a7eb189..729a596 100644 --- a/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs +++ b/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs @@ -423,6 +423,7 @@ public TestCanvasGraphService( _llmService = new LlmService( canvasRepository, llmRepository, + (IUserRepository)canvasRepository, encryptionService, providerClients, TimeProvider.System, @@ -515,7 +516,7 @@ public Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = } } - private sealed class InMemoryGraphStore : ICanvasRepository, ILlmRepository + private sealed class InMemoryGraphStore : ICanvasRepository, ILlmRepository, IUserRepository { private readonly List _canvases = []; private readonly List _nodes = []; @@ -527,6 +528,41 @@ private sealed class InMemoryGraphStore : ICanvasRepository, ILlmRepository private int _nextConversationId = 1; private int _nextMessageId = 1; + public Task GetByIdAsync(int id) + { + if (id <= 0) + return Task.FromResult(null); + + return Task.FromResult(new User + { + Id = id, + Email = $"user{id}@example.com", + AuthProvider = "local", + CreatedAt = DateTime.UtcNow + }); + } + + public Task EmailExistsAsync(string email) => throw new NotImplementedException(); + public Task CreateUserAsync(string email, string passwordHash, string? displayName) => throw new NotImplementedException(); + public Task CreateGoogleUserAsync(string email, string displayName) => throw new NotImplementedException(); + public Task GetByEmailAsync(string email) => throw new NotImplementedException(); + public Task GetProfileAsync(int userId) => throw new NotImplementedException(); + public Task UpdateDisplayNameAsync(int userId, string? displayName) => throw new NotImplementedException(); + public Task StoreRefreshTokenAsync(int userId, string token, DateTime expiresAt) => throw new NotImplementedException(); + public Task ValidateRefreshTokenAsync(string token) => throw new NotImplementedException(); + public Task DeleteRefreshTokenAsync(string token) => throw new NotImplementedException(); + public Task DeleteAllRefreshTokensAsync(int userId) => throw new NotImplementedException(); + public Task RotateRefreshTokenAsync(int userId, string oldToken) => throw new NotImplementedException(); + public Task RecordFailedLoginAsync(int userId) => throw new NotImplementedException(); + public Task ResetFailedLoginAsync(int userId) => throw new NotImplementedException(); + public Task UpdatePasswordHashAsync(int userId, string newHash) => throw new NotImplementedException(); + public Task CreateOtpAsync(int userId, string otpCode, string purpose, DateTime expiresAtUtc) => throw new NotImplementedException(); + public Task CountOtpsCreatedSinceAsync(int userId, string purpose, DateTime sinceUtc) => throw new NotImplementedException(); + public Task ValidateLatestOtpAsync(int userId, string otpCode, string purpose, DateTime utcNow) => throw new NotImplementedException(); + public Task MarkEmailVerifiedAsync(int userId) => throw new NotImplementedException(); + public Task CreatePasswordResetTokenAsync(int userId) => throw new NotImplementedException(); + public Task ValidatePasswordResetTokenAsync(string token) => throw new NotImplementedException(); + public Task CreateCanvasAsync(int userId, string title) { var now = DateTime.UtcNow; diff --git a/backend/Coliee.API.Tests/Services/ChatSessionServiceTests.cs b/backend/Coliee.API.Tests/Services/ChatSessionServiceTests.cs index e38bc6b..8c6bfaa 100644 --- a/backend/Coliee.API.Tests/Services/ChatSessionServiceTests.cs +++ b/backend/Coliee.API.Tests/Services/ChatSessionServiceTests.cs @@ -32,6 +32,57 @@ static async Task serviceDependencies(InMemoryChatSessionStore repository, strin } } + [Fact] + public async Task CreateSession_PersistsProviderContextUsage() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService(repository, new QueueProviderClient(LlmProviderNames.Gemini, [ + new LlmProviderChatResult { Content = "First reply", Model = "gemini-3.1-flash-lite-preview", UsedTokens = 123 } + ])); + await ConfigureGeminiAsync(repository); + + var response = await service.CreateSessionAsync(1, null, "Plan the launch", null); + + response.Node.ContextUsage.Should().NotBeNull(); + response.Node.ContextUsage!.Source.Should().Be("provider"); + response.Node.ContextUsage.UsedTokens.Should().Be(123); + response.Node.ContextUsage.TotalTokens.Should().BeGreaterThan(response.Node.ContextUsage.UsedTokens); + } + + [Fact] + public async Task CreateSession_FallsBackToEstimatedContextUsageWhenProviderOmitsUsage() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService(repository, new QueueProviderClient(LlmProviderNames.Gemini, [ + new LlmProviderChatResult { Content = "First reply", Model = "gemini-3.1-flash-lite-preview" } + ])); + await ConfigureGeminiAsync(repository); + + var response = await service.CreateSessionAsync(1, null, "Plan the launch", null); + + response.Node.ContextUsage.Should().NotBeNull(); + response.Node.ContextUsage!.Source.Should().Be("estimated"); + response.Node.ContextUsage.UsedTokens.Should().BeGreaterThan(0); + } + + [Fact] + public async Task CreateSession_WhenUserMissing_ThrowsUnauthorized() + { + var repository = new InMemoryChatSessionStore(); + var service = CreateService( + repository, + new MissingUserRepository(), + new QueueProviderClient(LlmProviderNames.Gemini, [ + new LlmProviderChatResult { Content = "First reply", Model = "gemini-3.1-flash-lite-preview" } + ])); + + var action = async () => await service.CreateSessionAsync(1, null, "Plan the launch", null); + + var exception = await action.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(401); + exception.Which.Message.Should().Contain("sign in again"); + } + [Fact] public async Task SendNodeMessage_AppendsOnlyToSelectedNode() { @@ -203,6 +254,7 @@ public async Task SaveLayout_PersistsNodePositions() await ConfigureGeminiAsync(repository); var created = await service.CreateSessionAsync(1, null, "Plan the launch", null); + var originalUpdatedAt = created.Graph.Session.UpdatedAt; var graph = await service.SaveLayoutAsync(1, created.Graph.Session.Id, [ new ChatNodeLayoutUpdateInput { @@ -214,6 +266,7 @@ public async Task SaveLayout_PersistsNodePositions() 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); + graph.Session.UpdatedAt.Should().BeAfter(originalUpdatedAt); } [Fact] @@ -385,6 +438,13 @@ private static ChatSessionService CreateService( InMemoryChatSessionStore repository, ILlmProviderClient providerClient, IReadOnlyDictionary? configurationValues = null) + => CreateService(repository, repository, providerClient, configurationValues); + + private static ChatSessionService CreateService( + InMemoryChatSessionStore repository, + IUserRepository userRepository, + ILlmProviderClient providerClient, + IReadOnlyDictionary? configurationValues = null) { var configurationData = new Dictionary { @@ -405,6 +465,7 @@ private static ChatSessionService CreateService( return new ChatSessionService( repository, repository, + userRepository, new CanvasProviderResolver(repository, NullLogger.Instance), new StubEncryptionService(), new ChatNodeContextCompactionService(configuration, NullLogger.Instance), @@ -413,6 +474,31 @@ private static ChatSessionService CreateService( NullLogger.Instance); } + private sealed class MissingUserRepository : IUserRepository + { + public Task EmailExistsAsync(string email) => throw new NotImplementedException(); + public Task CreateUserAsync(string email, string passwordHash, string? displayName) => throw new NotImplementedException(); + public Task CreateGoogleUserAsync(string email, string displayName) => throw new NotImplementedException(); + public Task GetByEmailAsync(string email) => throw new NotImplementedException(); + public Task GetByIdAsync(int id) => Task.FromResult(null); + public Task GetProfileAsync(int userId) => throw new NotImplementedException(); + public Task UpdateDisplayNameAsync(int userId, string? displayName) => throw new NotImplementedException(); + public Task StoreRefreshTokenAsync(int userId, string token, DateTime expiresAt) => throw new NotImplementedException(); + public Task ValidateRefreshTokenAsync(string token) => throw new NotImplementedException(); + public Task DeleteRefreshTokenAsync(string token) => throw new NotImplementedException(); + public Task DeleteAllRefreshTokensAsync(int userId) => throw new NotImplementedException(); + public Task RotateRefreshTokenAsync(int userId, string oldToken) => throw new NotImplementedException(); + public Task RecordFailedLoginAsync(int userId) => throw new NotImplementedException(); + public Task ResetFailedLoginAsync(int userId) => throw new NotImplementedException(); + public Task UpdatePasswordHashAsync(int userId, string newHash) => throw new NotImplementedException(); + public Task CreateOtpAsync(int userId, string otpCode, string purpose, DateTime expiresAtUtc) => throw new NotImplementedException(); + public Task CountOtpsCreatedSinceAsync(int userId, string purpose, DateTime sinceUtc) => throw new NotImplementedException(); + public Task ValidateLatestOtpAsync(int userId, string otpCode, string purpose, DateTime utcNow) => throw new NotImplementedException(); + public Task MarkEmailVerifiedAsync(int userId) => throw new NotImplementedException(); + public Task CreatePasswordResetTokenAsync(int userId) => throw new NotImplementedException(); + public Task ValidatePasswordResetTokenAsync(string token) => throw new NotImplementedException(); + } + private sealed class StubEncryptionService : ILlmEncryptionService { public string Encrypt(string plaintext) => $"enc::{plaintext}"; @@ -574,7 +660,7 @@ public Task SendStructuredJsonAsync(string apiKey, LlmPro public Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) => Task.CompletedTask; } - private sealed class InMemoryChatSessionStore : IChatSessionRepository, ILlmRepository + private sealed class InMemoryChatSessionStore : IChatSessionRepository, ILlmRepository, IUserRepository { private readonly List _sessions = []; private readonly List _nodes = []; @@ -587,6 +673,41 @@ private sealed class InMemoryChatSessionStore : IChatSessionRepository, ILlmRepo private int _nextMessageId = 1; private int _nextConversationId = 1; + public Task GetByIdAsync(int id) + { + if (id <= 0) + return Task.FromResult(null); + + return Task.FromResult(new User + { + Id = id, + Email = $"user{id}@example.com", + AuthProvider = "local", + CreatedAt = DateTime.UtcNow + }); + } + + public Task EmailExistsAsync(string email) => throw new NotImplementedException(); + public Task CreateUserAsync(string email, string passwordHash, string? displayName) => throw new NotImplementedException(); + public Task CreateGoogleUserAsync(string email, string displayName) => throw new NotImplementedException(); + public Task GetByEmailAsync(string email) => throw new NotImplementedException(); + public Task GetProfileAsync(int userId) => throw new NotImplementedException(); + public Task UpdateDisplayNameAsync(int userId, string? displayName) => throw new NotImplementedException(); + public Task StoreRefreshTokenAsync(int userId, string token, DateTime expiresAt) => throw new NotImplementedException(); + public Task ValidateRefreshTokenAsync(string token) => throw new NotImplementedException(); + public Task DeleteRefreshTokenAsync(string token) => throw new NotImplementedException(); + public Task DeleteAllRefreshTokensAsync(int userId) => throw new NotImplementedException(); + public Task RotateRefreshTokenAsync(int userId, string oldToken) => throw new NotImplementedException(); + public Task RecordFailedLoginAsync(int userId) => throw new NotImplementedException(); + public Task ResetFailedLoginAsync(int userId) => throw new NotImplementedException(); + public Task UpdatePasswordHashAsync(int userId, string newHash) => throw new NotImplementedException(); + public Task CreateOtpAsync(int userId, string otpCode, string purpose, DateTime expiresAtUtc) => throw new NotImplementedException(); + public Task CountOtpsCreatedSinceAsync(int userId, string purpose, DateTime sinceUtc) => throw new NotImplementedException(); + public Task ValidateLatestOtpAsync(int userId, string otpCode, string purpose, DateTime utcNow) => throw new NotImplementedException(); + public Task MarkEmailVerifiedAsync(int userId) => throw new NotImplementedException(); + public Task CreatePasswordResetTokenAsync(int userId) => throw new NotImplementedException(); + public Task ValidatePasswordResetTokenAsync(string token) => throw new NotImplementedException(); + public Task CreateSessionAsync(int userId, string title) { var session = new ChatSession @@ -658,6 +779,10 @@ public Task EnsureRootNodeAsync(int userId, int sessionId, string titl ExpandedHeight = 520, PreferredProvider = null, PreferredModel = null, + ContextUsageUsedTokens = null, + ContextUsageTotalTokens = null, + ContextUsageSource = null, + ContextUsageUpdatedAt = null, ContextBoundaryMessageId = null, ContextSnapshotJson = null, ContextSnapshotUpdatedAt = null, @@ -712,6 +837,10 @@ public Task CreateNodeAsync(int userId, int sessionId, ChatNodeCreateI ExpandedHeight = node.ExpandedHeight, PreferredProvider = node.PreferredProvider, PreferredModel = node.PreferredModel, + ContextUsageUsedTokens = null, + ContextUsageTotalTokens = null, + ContextUsageSource = null, + ContextUsageUpdatedAt = null, ContextBoundaryMessageId = null, ContextSnapshotJson = null, ContextSnapshotUpdatedAt = null, @@ -745,6 +874,19 @@ public Task UpdateNodeAsync(int userId, int sessionId, int nodeId, Cha return Task.FromResult(node); } + public Task UpdateNodeContextUsageAsync(int userId, int sessionId, int nodeId, ChatNodeContextUsage usage) + { + var node = _nodes.First(item => item.SessionId == sessionId && item.Id == nodeId); + node.ContextUsageUsedTokens = usage.UsedTokens; + node.ContextUsageTotalTokens = usage.TotalTokens; + node.ContextUsageSource = usage.Source; + node.ContextUsageUpdatedAt = usage.UpdatedAt; + 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); @@ -775,6 +917,8 @@ public Task SaveNodeLayoutsAsync(int userId, int sessionId, IReadOnlyList item.Id == sessionId && item.UserId == userId); + session.UpdatedAt = DateTime.UtcNow; return Task.CompletedTask; } diff --git a/backend/Coliee.API.Tests/Services/LlmProviderClientTests.cs b/backend/Coliee.API.Tests/Services/LlmProviderClientTests.cs index b37122b..2abdb1a 100644 --- a/backend/Coliee.API.Tests/Services/LlmProviderClientTests.cs +++ b/backend/Coliee.API.Tests/Services/LlmProviderClientTests.cs @@ -18,7 +18,7 @@ public async Task OpenAiClient_ParsesAssistantReply() new HttpClient(new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent("""{"choices":[{"message":{"content":"OpenAI says hi"}}]}""", Encoding.UTF8, "application/json") + Content = new StringContent("""{"choices":[{"message":{"content":"OpenAI says hi"}}],"usage":{"prompt_tokens":123,"completion_tokens":45,"total_tokens":168}}""", Encoding.UTF8, "application/json") })), CreateConfiguration(), NullLogger.Instance); @@ -26,6 +26,7 @@ public async Task OpenAiClient_ParsesAssistantReply() var response = await client.SendChatAsync("sk-test", [new LlmProviderChatMessage { Role = "user", Content = "Hello" }]); response.Content.Should().Be("OpenAI says hi"); + response.UsedTokens.Should().Be(123); } [Fact] @@ -111,6 +112,53 @@ public async Task LmStudioClient_ParsesAssistantReply() response.Content.Should().Be("LM Studio says hi"); } + [Fact] + public async Task LmStudioClient_NormalizesRootUrlAndFetchesModels() + { + string? requestedUrl = null; + var client = new LmStudioProviderClient( + new HttpClient(new StubHttpMessageHandler(request => + { + requestedUrl = request.RequestUri?.ToString(); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"data":[{"id":"local-model"}]}""", Encoding.UTF8, "application/json") + }; + })), + CreateConfiguration(), + NullLogger.Instance); + + var models = await client.GetModelsAsync("http://127.0.0.1:1234"); + + requestedUrl.Should().Be("http://127.0.0.1:1234/v1/models"); + models.Select(item => item.Id).Should().ContainSingle().Which.Should().Be("local-model"); + } + + [Fact] + public async Task LmStudioClient_FallsBackToHostDockerInternalAfterLoopbackFailure() + { + var requestedHosts = new List(); + var client = new LmStudioProviderClient( + new HttpClient(new StubHttpMessageHandler(request => + { + requestedHosts.Add(request.RequestUri!.Host); + if (request.RequestUri.Host is "localhost" or "127.0.0.1") + throw new HttpRequestException("Connection refused"); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"data":[{"id":"local-model"}]}""", Encoding.UTF8, "application/json") + }; + })), + CreateConfiguration(), + NullLogger.Instance); + + var models = await client.GetModelsAsync("http://127.0.0.1:1234"); + + requestedHosts.Should().ContainInOrder("127.0.0.1", "host.docker.internal"); + models.Should().ContainSingle(item => item.Id == "local-model"); + } + [Fact] public async Task LmStudioClient_GetModels_ReturnsAvailableModels() { @@ -128,6 +176,32 @@ public async Task LmStudioClient_GetModels_ReturnsAvailableModels() models.Select(item => item.Id).Should().ContainInOrder("local-model", "qwen/qwen3-4b-2507"); } + [Fact] + public async Task LmStudioClient_UsesConfiguredTimeoutForSlowLocalModels() + { + CancellationToken observedToken = default; + var client = new LmStudioProviderClient( + new HttpClient(new StubHttpMessageHandler(async (_, cancellationToken) => + { + observedToken = cancellationToken; + await Task.Delay(200, cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"choices":[{"message":{"content":"LM Studio says hi"}}]}""", Encoding.UTF8, "application/json") + }; + })), + CreateConfiguration(new Dictionary + { + ["Llm:Providers:LmStudio:TimeoutSeconds"] = "1" + }), + NullLogger.Instance); + + var response = await client.SendChatAsync("lmstudio", [new LlmProviderChatMessage { Role = "user", Content = "Hello" }]); + + response.Content.Should().Be("LM Studio says hi"); + observedToken.CanBeCanceled.Should().BeTrue(); + } + [Fact] public async Task GeminiClient_InvalidArgument_DoesNotMapToInvalidKey() { @@ -208,32 +282,47 @@ public async Task OpenAiClient_InvalidKey_MapsToLlmChatException() exception.Which.Code.Should().Be(LlmProviderErrorCodes.InvalidKey); } - private static IConfiguration CreateConfiguration() + private static IConfiguration CreateConfiguration(IDictionary? overrides = null) { - return new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary + var settings = new Dictionary + { + ["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", + }; + + if (overrides != null) + { + foreach (var entry in overrides) { - ["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", - }) + settings[entry.Key] = entry.Value; + } + } + + return new ConfigurationBuilder() + .AddInMemoryCollection(settings) .Build(); } private sealed class StubHttpMessageHandler : HttpMessageHandler { - private readonly Func _handler; + private readonly Func> _handler; public StubHttpMessageHandler(Func handler) + : this((request, _) => Task.FromResult(handler(request))) + { + } + + public StubHttpMessageHandler(Func> handler) { _handler = handler; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - return Task.FromResult(_handler(request)); + return _handler(request, cancellationToken); } } } diff --git a/backend/Coliee.API.Tests/Services/LlmServiceTests.cs b/backend/Coliee.API.Tests/Services/LlmServiceTests.cs index 899ba6c..9debbac 100644 --- a/backend/Coliee.API.Tests/Services/LlmServiceTests.cs +++ b/backend/Coliee.API.Tests/Services/LlmServiceTests.cs @@ -53,6 +53,17 @@ public async Task DeleteProviderKey_DefaultProviderPromotesLmStudioWhenItIsTheOn response.DefaultProvider.Should().Be("lmstudio"); } + [Fact] + public async Task UpsertProviderKey_LmStudioStoresReadableEndpointLabel() + { + var repository = new InMemoryDataStore(); + var service = CreateService(repository, new StubProviderClient(LlmProviderNames.LmStudio)); + + var response = await service.UpsertProviderKeyAsync(1, "lmstudio", "http://127.0.0.1:1234"); + + response.Providers.Single(item => item.Provider == "lmstudio").MaskedLast4.Should().Be("127.0.0.1:1234"); + } + [Fact] public async Task GetProviderModels_ConfiguredProvider_ReturnsDiscoveredModels() { @@ -106,6 +117,19 @@ public async Task GetProviderModels_WithoutConfiguredProvider_ThrowsMissingKey() exception.Which.StatusCode.Should().Be(400); } + [Fact] + public async Task UpsertProviderKey_WhenUserMissing_ThrowsUnauthorized() + { + var repository = new InMemoryDataStore(); + var service = CreateService(repository, new MissingUserRepository(), new StubProviderClient(LlmProviderNames.OpenAi)); + + var action = async () => await service.UpsertProviderKeyAsync(1, "openai", "sk-test-1234"); + + var exception = await action.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(401); + exception.Which.Message.Should().Contain("sign in again"); + } + [Fact] public async Task SendStandaloneChatMessage_WithoutConfiguredProviders_ThrowsMissingKey() { @@ -237,10 +261,14 @@ public async Task GetMainNodeChat_UnknownCanvas_ThrowsNotFound() } private static LlmService CreateService(InMemoryDataStore repository, params StubProviderClient[] clients) + => CreateService(repository, repository, clients); + + private static LlmService CreateService(InMemoryDataStore repository, IUserRepository userRepository, params StubProviderClient[] clients) { return new LlmService( repository, repository, + userRepository, new StubEncryptionService(), clients, TimeProvider.System, @@ -254,6 +282,31 @@ private sealed class StubEncryptionService : ILlmEncryptionService public string Decrypt(string ciphertext) => ciphertext.Replace("enc::", string.Empty, StringComparison.Ordinal); } + private sealed class MissingUserRepository : IUserRepository + { + public Task GetByIdAsync(int id) => Task.FromResult(null); + public Task EmailExistsAsync(string email) => throw new NotImplementedException(); + public Task CreateUserAsync(string email, string passwordHash, string? displayName) => throw new NotImplementedException(); + public Task CreateGoogleUserAsync(string email, string displayName) => throw new NotImplementedException(); + public Task GetByEmailAsync(string email) => throw new NotImplementedException(); + public Task StoreRefreshTokenAsync(int userId, string token, DateTime expiresAt) => throw new NotImplementedException(); + public Task ValidateRefreshTokenAsync(string token) => throw new NotImplementedException(); + public Task GetProfileAsync(int userId) => throw new NotImplementedException(); + public Task UpdateDisplayNameAsync(int userId, string? displayName) => throw new NotImplementedException(); + public Task DeleteRefreshTokenAsync(string token) => throw new NotImplementedException(); + public Task DeleteAllRefreshTokensAsync(int userId) => throw new NotImplementedException(); + public Task RotateRefreshTokenAsync(int userId, string oldToken) => throw new NotImplementedException(); + public Task RecordFailedLoginAsync(int userId) => throw new NotImplementedException(); + public Task ResetFailedLoginAsync(int userId) => throw new NotImplementedException(); + public Task UpdatePasswordHashAsync(int userId, string newHash) => throw new NotImplementedException(); + public Task CreateOtpAsync(int userId, string otpCode, string purpose, DateTime expiresAtUtc) => throw new NotImplementedException(); + public Task CountOtpsCreatedSinceAsync(int userId, string purpose, DateTime sinceUtc) => throw new NotImplementedException(); + public Task ValidateLatestOtpAsync(int userId, string otpCode, string purpose, DateTime utcNow) => throw new NotImplementedException(); + public Task MarkEmailVerifiedAsync(int userId) => throw new NotImplementedException(); + public Task CreatePasswordResetTokenAsync(int userId) => throw new NotImplementedException(); + public Task ValidatePasswordResetTokenAsync(string token) => throw new NotImplementedException(); + } + private sealed class StubProviderClient : ILlmProviderClient { private readonly string _content; @@ -310,7 +363,7 @@ public Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = } } - private sealed class InMemoryDataStore : ICanvasRepository, ILlmRepository + private sealed class InMemoryDataStore : ICanvasRepository, ILlmRepository, IUserRepository { private readonly List _canvases = []; private readonly List _nodes = []; @@ -322,6 +375,41 @@ private sealed class InMemoryDataStore : ICanvasRepository, ILlmRepository private int _nextMessageId = 1; private int _nextNodeId = 1; + public Task GetByIdAsync(int id) + { + if (id <= 0) + return Task.FromResult(null); + + return Task.FromResult(new User + { + Id = id, + Email = $"user{id}@example.com", + AuthProvider = "local", + CreatedAt = DateTime.UtcNow + }); + } + + public Task EmailExistsAsync(string email) => throw new NotImplementedException(); + public Task CreateUserAsync(string email, string passwordHash, string? displayName) => throw new NotImplementedException(); + public Task CreateGoogleUserAsync(string email, string displayName) => throw new NotImplementedException(); + public Task GetByEmailAsync(string email) => throw new NotImplementedException(); + public Task GetProfileAsync(int userId) => throw new NotImplementedException(); + public Task UpdateDisplayNameAsync(int userId, string? displayName) => throw new NotImplementedException(); + public Task StoreRefreshTokenAsync(int userId, string token, DateTime expiresAt) => throw new NotImplementedException(); + public Task ValidateRefreshTokenAsync(string token) => throw new NotImplementedException(); + public Task DeleteRefreshTokenAsync(string token) => throw new NotImplementedException(); + public Task DeleteAllRefreshTokensAsync(int userId) => throw new NotImplementedException(); + public Task RotateRefreshTokenAsync(int userId, string oldToken) => throw new NotImplementedException(); + public Task RecordFailedLoginAsync(int userId) => throw new NotImplementedException(); + public Task ResetFailedLoginAsync(int userId) => throw new NotImplementedException(); + public Task UpdatePasswordHashAsync(int userId, string newHash) => throw new NotImplementedException(); + public Task CreateOtpAsync(int userId, string otpCode, string purpose, DateTime expiresAtUtc) => throw new NotImplementedException(); + public Task CountOtpsCreatedSinceAsync(int userId, string purpose, DateTime sinceUtc) => throw new NotImplementedException(); + public Task ValidateLatestOtpAsync(int userId, string otpCode, string purpose, DateTime utcNow) => throw new NotImplementedException(); + public Task MarkEmailVerifiedAsync(int userId) => throw new NotImplementedException(); + public Task CreatePasswordResetTokenAsync(int userId) => throw new NotImplementedException(); + public Task ValidatePasswordResetTokenAsync(string token) => throw new NotImplementedException(); + public Task CreateCanvasAsync(int userId, string title) { var canvas = new Canvas diff --git a/backend/Coliee.API/Controllers/AuthController.cs b/backend/Coliee.API/Controllers/AuthController.cs index 6d13065..6493d0d 100644 --- a/backend/Coliee.API/Controllers/AuthController.cs +++ b/backend/Coliee.API/Controllers/AuthController.cs @@ -341,7 +341,7 @@ public async Task GoogleLogin([FromBody] GoogleLoginRequest req) catch (Exception ex) { _logger.LogError(ex, "Google login failed for email {Email}.", payload.Email); - return StatusCode(500, new { message = "Google sign-in failed while linking your account." }); + return StatusCode(500, new { message = $"Google sign-in failed: {ex.Message}" }); } } diff --git a/backend/Coliee.API/DTOs/ChatCanvasNodeDetailResponse.cs b/backend/Coliee.API/DTOs/ChatCanvasNodeDetailResponse.cs index dbb7649..e04576f 100644 --- a/backend/Coliee.API/DTOs/ChatCanvasNodeDetailResponse.cs +++ b/backend/Coliee.API/DTOs/ChatCanvasNodeDetailResponse.cs @@ -19,6 +19,7 @@ public class ChatCanvasNodeDetailResponse public double? ExpandedHeight { get; set; } public string? PreferredProvider { get; set; } public string? PreferredModel { get; set; } + public ChatNodeContextUsageResponse? ContextUsage { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } public List LocalMessages { get; set; } = []; diff --git a/backend/Coliee.API/DTOs/ChatCanvasNodeSummaryResponse.cs b/backend/Coliee.API/DTOs/ChatCanvasNodeSummaryResponse.cs index cfe009b..a3d9e30 100644 --- a/backend/Coliee.API/DTOs/ChatCanvasNodeSummaryResponse.cs +++ b/backend/Coliee.API/DTOs/ChatCanvasNodeSummaryResponse.cs @@ -18,6 +18,7 @@ public class ChatCanvasNodeSummaryResponse public double? ExpandedHeight { get; set; } public string? PreferredProvider { get; set; } public string? PreferredModel { get; set; } + public ChatNodeContextUsageResponse? ContextUsage { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } diff --git a/backend/Coliee.API/DTOs/ChatNodeContextUsageResponse.cs b/backend/Coliee.API/DTOs/ChatNodeContextUsageResponse.cs new file mode 100644 index 0000000..109097b --- /dev/null +++ b/backend/Coliee.API/DTOs/ChatNodeContextUsageResponse.cs @@ -0,0 +1,9 @@ +namespace Coliee.API.DTOs; + +public class ChatNodeContextUsageResponse +{ + public int UsedTokens { get; set; } + public int TotalTokens { get; set; } + public string Source { get; set; } = "estimated"; + public DateTime UpdatedAt { get; set; } +} diff --git a/backend/Coliee.API/Data/ChatSessionRepository.cs b/backend/Coliee.API/Data/ChatSessionRepository.cs index 24ba1a1..ef89341 100644 --- a/backend/Coliee.API/Data/ChatSessionRepository.cs +++ b/backend/Coliee.API/Data/ChatSessionRepository.cs @@ -172,7 +172,7 @@ public async Task> GetNodesAsync(int userId, int session 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 + 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_usage_used_tokens, node.context_usage_total_tokens, node.context_usage_source, node.context_usage_updated_at, 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 @@ -196,7 +196,7 @@ FROM chat_nodes node 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 + 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_usage_used_tokens, node.context_usage_total_tokens, node.context_usage_source, node.context_usage_updated_at, 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 @@ -475,6 +475,61 @@ UPDATE chat_sessions } } + public async Task UpdateNodeContextUsageAsync(int userId, int sessionId, int nodeId, ChatNodeContextUsage usage) + { + 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_usage_used_tokens = @usedTokens, + node.context_usage_total_tokens = @totalTokens, + node.context_usage_source = @source, + node.context_usage_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("@usedTokens", usage.UsedTokens); + cmd.Parameters.AddWithValue("@totalTokens", usage.TotalTokens); + cmd.Parameters.AddWithValue("@source", usage.Source); + 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 usage could not be reloaded."); + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + public async Task SaveNodeLayoutsAsync(int userId, int sessionId, IReadOnlyList nodes) { if (nodes.Count == 0) @@ -506,9 +561,23 @@ UPDATE chat_nodes current_node cmd.Parameters.AddWithValue("@nodeId", node.NodeId); cmd.Parameters.AddWithValue("@positionX", node.PositionX); cmd.Parameters.AddWithValue("@positionY", node.PositionY); - await cmd.ExecuteNonQueryAsync(); + var rowsUpdated = await cmd.ExecuteNonQueryAsync(); + if (rowsUpdated == 0) + throw new InvalidOperationException("Chat node layout update 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(); } catch @@ -830,7 +899,7 @@ UPDATE chat_sessions 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 + 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_usage_used_tokens, node.context_usage_total_tokens, node.context_usage_source, node.context_usage_updated_at, 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 @@ -863,23 +932,27 @@ private static ChatSession MapSession(MySqlDataReader reader) 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"); + var parentNodeOrdinal = TryGetOrdinal(reader, "parent_node_id"); + var branchFromMessageOrdinal = TryGetOrdinal(reader, "branch_from_message_id"); + var isCollapsedOrdinal = TryGetOrdinal(reader, "is_collapsed"); + var expandedWidthOrdinal = TryGetOrdinal(reader, "expanded_width"); + var expandedHeightOrdinal = TryGetOrdinal(reader, "expanded_height"); + var preferredProviderOrdinal = TryGetOrdinal(reader, "preferred_provider"); + var preferredModelOrdinal = TryGetOrdinal(reader, "preferred_model"); + var usageUsedOrdinal = TryGetOrdinal(reader, "context_usage_used_tokens"); + var usageTotalOrdinal = TryGetOrdinal(reader, "context_usage_total_tokens"); + var usageSourceOrdinal = TryGetOrdinal(reader, "context_usage_source"); + var usageUpdatedOrdinal = TryGetOrdinal(reader, "context_usage_updated_at"); + var contextBoundaryOrdinal = TryGetOrdinal(reader, "context_boundary_message_id"); + var contextSnapshotOrdinal = TryGetOrdinal(reader, "context_snapshot_json"); + var contextSnapshotUpdatedOrdinal = TryGetOrdinal(reader, "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), + ParentNodeId = GetNullableInt32(reader, parentNodeOrdinal), + BranchFromMessageId = GetNullableInt32(reader, branchFromMessageOrdinal), NodeType = reader.GetString("node_type"), Title = reader.GetString("title"), Preview = reader.GetString("preview"), @@ -888,20 +961,76 @@ private static ChatNode MapNode(MySqlDataReader reader) 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), + IsCollapsed = GetBoolean(reader, isCollapsedOrdinal), + ExpandedWidth = GetNullableDouble(reader, expandedWidthOrdinal), + ExpandedHeight = GetNullableDouble(reader, expandedHeightOrdinal), + PreferredProvider = GetNullableString(reader, preferredProviderOrdinal), + PreferredModel = GetNullableString(reader, preferredModelOrdinal), + ContextUsageUsedTokens = GetNullableInt32(reader, usageUsedOrdinal), + ContextUsageTotalTokens = GetNullableInt32(reader, usageTotalOrdinal), + ContextUsageSource = GetNullableString(reader, usageSourceOrdinal), + ContextUsageUpdatedAt = GetNullableDateTime(reader, usageUpdatedOrdinal), + ContextBoundaryMessageId = GetNullableInt32(reader, contextBoundaryOrdinal), + ContextSnapshotJson = GetNullableString(reader, contextSnapshotOrdinal), + ContextSnapshotUpdatedAt = GetNullableDateTime(reader, contextSnapshotUpdatedOrdinal), SiblingOrder = reader.GetInt32("sibling_order"), CreatedAt = reader.GetDateTime("created_at"), UpdatedAt = reader.GetDateTime("updated_at") }; } + private static int? TryGetOrdinal(MySqlDataReader reader, string columnName) + { + try + { + return reader.GetOrdinal(columnName); + } + catch (IndexOutOfRangeException) + { + return null; + } + } + + private static string? GetNullableString(MySqlDataReader reader, int? ordinal) + { + if (!ordinal.HasValue || reader.IsDBNull(ordinal.Value)) + return null; + + return reader.GetString(ordinal.Value); + } + + private static int? GetNullableInt32(MySqlDataReader reader, int? ordinal) + { + if (!ordinal.HasValue || reader.IsDBNull(ordinal.Value)) + return null; + + return reader.GetInt32(ordinal.Value); + } + + private static double? GetNullableDouble(MySqlDataReader reader, int? ordinal) + { + if (!ordinal.HasValue || reader.IsDBNull(ordinal.Value)) + return null; + + return reader.GetDouble(ordinal.Value); + } + + private static DateTime? GetNullableDateTime(MySqlDataReader reader, int? ordinal) + { + if (!ordinal.HasValue || reader.IsDBNull(ordinal.Value)) + return null; + + return reader.GetDateTime(ordinal.Value); + } + + private static bool GetBoolean(MySqlDataReader reader, int? ordinal) + { + if (!ordinal.HasValue || reader.IsDBNull(ordinal.Value)) + return false; + + return reader.GetBoolean(ordinal.Value); + } + private static ChatMessage MapMessage(MySqlDataReader reader) { var imageUrlOrdinal = reader.GetOrdinal("image_url"); @@ -959,6 +1088,10 @@ is_collapsed TINYINT(1) NOT NULL DEFAULT 0, expanded_height DOUBLE NULL, preferred_provider VARCHAR(32) NULL, preferred_model VARCHAR(120) NULL, + context_usage_used_tokens INT NULL, + context_usage_total_tokens INT NULL, + context_usage_source VARCHAR(16) NULL, + context_usage_updated_at TIMESTAMP NULL, context_boundary_message_id INT NULL, context_snapshot_json MEDIUMTEXT NULL, context_snapshot_updated_at TIMESTAMP NULL, @@ -1055,6 +1188,42 @@ ALTER TABLE chat_nodes ADD COLUMN preferred_model VARCHAR(120) NULL AFTER preferred_provider """); + await EnsureColumnAsync( + conn, + "chat_nodes", + "context_usage_used_tokens", + """ + ALTER TABLE chat_nodes + ADD COLUMN context_usage_used_tokens INT NULL AFTER preferred_model + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "context_usage_total_tokens", + """ + ALTER TABLE chat_nodes + ADD COLUMN context_usage_total_tokens INT NULL AFTER context_usage_used_tokens + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "context_usage_source", + """ + ALTER TABLE chat_nodes + ADD COLUMN context_usage_source VARCHAR(16) NULL AFTER context_usage_total_tokens + """); + + await EnsureColumnAsync( + conn, + "chat_nodes", + "context_usage_updated_at", + """ + ALTER TABLE chat_nodes + ADD COLUMN context_usage_updated_at TIMESTAMP NULL AFTER context_usage_source + """); + await EnsureColumnAsync( conn, "chat_nodes", diff --git a/backend/Coliee.API/Data/IChatSessionRepository.cs b/backend/Coliee.API/Data/IChatSessionRepository.cs index b1b552b..c3fb44a 100644 --- a/backend/Coliee.API/Data/IChatSessionRepository.cs +++ b/backend/Coliee.API/Data/IChatSessionRepository.cs @@ -15,6 +15,7 @@ public interface IChatSessionRepository 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 UpdateNodeContextUsageAsync(int userId, int sessionId, int nodeId, ChatNodeContextUsage usage); 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); diff --git a/backend/Coliee.API/Dockerfile.dev b/backend/Coliee.API/Dockerfile.dev index 2ff2005..2c4d040 100644 --- a/backend/Coliee.API/Dockerfile.dev +++ b/backend/Coliee.API/Dockerfile.dev @@ -1,12 +1,17 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 + +WORKDIR /src +COPY Coliee.API.csproj ./ +RUN dotnet restore ./Coliee.API.csproj + WORKDIR /app # Expose the port the app runs on EXPOSE 8080 ENV ASPNETCORE_HTTP_PORTS=8080 -# Essential for Windows/WSL setups to ensure file changes are detected across the mount -ENV DOTNET_USE_POLLING_FILE_WATCHER=1 +# Essential for Docker Desktop bind mounts so file changes are detected reliably. +ENV DOTNET_USE_POLLING_FILE_WATCHER=1 -# Start the watcher -ENTRYPOINT ["dotnet", "watch", "run", "--no-launch-profile"] +# Use the restored packages baked into the image instead of restoring on every boot. +ENTRYPOINT ["dotnet", "watch", "run", "--project", "/app/Coliee.API.csproj", "--no-launch-profile", "--no-restore"] diff --git a/backend/Coliee.API/Models/ChatNode.cs b/backend/Coliee.API/Models/ChatNode.cs index 46a92e7..9e5cdee 100644 --- a/backend/Coliee.API/Models/ChatNode.cs +++ b/backend/Coliee.API/Models/ChatNode.cs @@ -19,6 +19,10 @@ public class ChatNode public double? ExpandedHeight { get; set; } public string? PreferredProvider { get; set; } public string? PreferredModel { get; set; } + public int? ContextUsageUsedTokens { get; set; } + public int? ContextUsageTotalTokens { get; set; } + public string? ContextUsageSource { get; set; } + public DateTime? ContextUsageUpdatedAt { get; set; } public int? ContextBoundaryMessageId { get; set; } public string? ContextSnapshotJson { get; set; } public DateTime? ContextSnapshotUpdatedAt { get; set; } diff --git a/backend/Coliee.API/Program.cs b/backend/Coliee.API/Program.cs index c9e443e..4e95e84 100644 --- a/backend/Coliee.API/Program.cs +++ b/backend/Coliee.API/Program.cs @@ -5,7 +5,9 @@ using System.Text; using System.Threading.RateLimiting; using Microsoft.AspNetCore.RateLimiting; +using System.IO; +LoadRootEnvFile(); var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); @@ -85,6 +87,11 @@ var app = builder.Build(); +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} + // Swagger enabled in all environments — Docker runs as Production by default app.UseSwagger(); app.UseSwaggerUI(); @@ -98,3 +105,34 @@ app.MapControllers(); app.Run(); + +static void LoadRootEnvFile() +{ + var current = new DirectoryInfo(Directory.GetCurrentDirectory()); + for (var depth = 0; depth < 6 && current != null; depth++, current = current.Parent) + { + var envPath = Path.Combine(current.FullName, ".env"); + if (!File.Exists(envPath)) + continue; + + foreach (var line in File.ReadAllLines(envPath)) + { + var trimmed = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith('#') || !trimmed.Contains('=')) + continue; + + var separatorIndex = trimmed.IndexOf('='); + if (separatorIndex <= 0) + continue; + + var key = trimmed[..separatorIndex].Trim(); + var value = trimmed[(separatorIndex + 1)..].Trim(); + if (string.IsNullOrWhiteSpace(key) || Environment.GetEnvironmentVariable(key) is not null) + continue; + + Environment.SetEnvironmentVariable(key, value); + } + + break; + } +} diff --git a/backend/Coliee.API/Services/ChatNodeContextCompactionService.cs b/backend/Coliee.API/Services/ChatNodeContextCompactionService.cs index 85d2e79..7fec557 100644 --- a/backend/Coliee.API/Services/ChatNodeContextCompactionService.cs +++ b/backend/Coliee.API/Services/ChatNodeContextCompactionService.cs @@ -79,6 +79,8 @@ public async Task BuildPromptContextAsync( snapshot, rawHistory, nextMessage, + providerClient.Provider, + modelOverride, includeFormattingInstruction, limit, snapshotChanged || compactAllHistory); @@ -106,10 +108,22 @@ public async Task BuildPromptContextAsync( { SystemPrompt = systemPrompt, Messages = messages, - Snapshot = snapshotChanged || compactAllHistory ? snapshot : null + Snapshot = snapshotChanged || compactAllHistory ? snapshot : null, + Usage = BuildUsageEstimate(systemPrompt, rawHistory, nextMessage, providerClient.Provider, modelOverride) }; } + public ChatNodeContextUsage EstimateCurrentContextUsage( + ChatNode node, + IReadOnlyList history, + ChatNodeContextSnapshot? snapshot, + bool includeFormattingInstruction) + { + var systemPrompt = BuildSystemPrompt(node, snapshot, includeFormattingInstruction); + var provider = node.PreferredProvider ?? InferProviderFromModel(node.PreferredModel); + return BuildUsageEstimate(systemPrompt, history, provider, node.PreferredModel); + } + private async Task CompactHistoryAsync( ILlmProviderClient providerClient, string apiKey, @@ -179,6 +193,8 @@ private ChatNodePromptContextResult BuildFallbackContext( ChatNodeContextSnapshot? snapshot, IReadOnlyList rawHistory, CanvasConversationMessage nextMessage, + string provider, + string? modelOverride, bool includeFormattingInstruction, ContextLimit limit, bool snapshotChanged) @@ -197,7 +213,8 @@ private ChatNodePromptContextResult BuildFallbackContext( .Select(message => new CanvasConversationMessage(message.Role, message.Content, message.ImageUrl)) .Append(nextMessage) .ToList(), - Snapshot = snapshotChanged ? snapshot : null + Snapshot = snapshotChanged ? snapshot : null, + Usage = BuildUsageEstimate(BuildSystemPrompt(node, snapshot, includeFormattingInstruction), retained, nextMessage, provider, modelOverride) }; } @@ -400,6 +417,116 @@ private static int EstimateRequestChars(string? systemPrompt, IReadOnlyList history) + { + 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; + } + + return total; + } + + private ChatNodeContextUsage BuildUsageEstimate(string? systemPrompt, IReadOnlyList history, CanvasConversationMessage nextMessage, string provider, string? modelOverride) + { + return new ChatNodeContextUsage + { + UsedTokens = EstimateTokens(EstimateRequestChars(systemPrompt, history, nextMessage)), + TotalTokens = ResolveContextWindowTokens(provider, modelOverride), + Source = "estimated", + UpdatedAt = DateTime.UtcNow + }; + } + + private ChatNodeContextUsage BuildUsageEstimate(string? systemPrompt, IReadOnlyList history, string provider, string? modelOverride) + { + return new ChatNodeContextUsage + { + UsedTokens = EstimateTokens(EstimateRequestChars(systemPrompt, history)), + TotalTokens = ResolveContextWindowTokens(provider, modelOverride), + Source = "estimated", + UpdatedAt = DateTime.UtcNow + }; + } + + private static int EstimateTokens(int estimatedChars) + { + return Math.Max(0, (int)Math.Ceiling(estimatedChars / 4d)); + } + + public int ResolveContextWindowTokens(string provider, string? modelOverride) + { + var normalizedProvider = provider.Trim().ToLowerInvariant(); + var normalizedModel = string.IsNullOrWhiteSpace(modelOverride) + ? null + : modelOverride.Trim().ToLowerInvariant(); + var providerPath = $"Llm:ContextWindows:Providers:{normalizedProvider}"; + var modelPath = normalizedModel == null ? null : $"{providerPath}:Models:{normalizedModel}"; + + var configuredWindow = + GetInt(modelPath is null ? null : $"{modelPath}:TotalTokens") + ?? GetInt($"{providerPath}:TotalTokens") + ?? GetInt("Llm:ContextWindows:Default:TotalTokens"); + if (configuredWindow.HasValue) + return configuredWindow.Value; + + return normalizedProvider switch + { + LlmProviderNames.OpenAi => ResolveOpenAiWindow(normalizedModel), + LlmProviderNames.Claude => ResolveClaudeWindow(normalizedModel), + LlmProviderNames.Gemini => ResolveGeminiWindow(normalizedModel), + LlmProviderNames.LmStudio => 128000, + _ => 128000 + }; + } + + private static int ResolveOpenAiWindow(string? normalizedModel) + { + return normalizedModel switch + { + "gpt-5.2" or "gpt-5.1" => 256000, + "gpt-4.1-mini" => 128000, + _ => 128000 + }; + } + + private static int ResolveClaudeWindow(string? normalizedModel) + { + return normalizedModel switch + { + "claude-opus-4-1-20250805" or "claude-sonnet-4-20250514" or "claude-3-5-haiku-20241022" => 200000, + _ => 200000 + }; + } + + private static int ResolveGeminiWindow(string? normalizedModel) + { + return normalizedModel switch + { + "gemini-3.1-flash-lite-preview" => 258000, + "gemini-2.5-flash" or "gemini-2.5-pro" => 1000000, + _ => 258000 + }; + } + + private static string InferProviderFromModel(string? model) + { + if (string.IsNullOrWhiteSpace(model)) + return LlmProviderNames.Gemini; + + var normalized = model.Trim().ToLowerInvariant(); + if (normalized == "local-model" || normalized.StartsWith("lmstudio", StringComparison.Ordinal)) + return LlmProviderNames.LmStudio; + if (normalized.StartsWith("claude", StringComparison.Ordinal) || 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 List TakeOldestCompactableChunk(IReadOnlyList messages) { var assistantIndex = messages diff --git a/backend/Coliee.API/Services/ChatNodeContextUsage.cs b/backend/Coliee.API/Services/ChatNodeContextUsage.cs new file mode 100644 index 0000000..1943d07 --- /dev/null +++ b/backend/Coliee.API/Services/ChatNodeContextUsage.cs @@ -0,0 +1,9 @@ +namespace Coliee.API.Services; + +public sealed class ChatNodeContextUsage +{ + public int UsedTokens { get; set; } + public int TotalTokens { get; set; } + public string Source { get; set; } = "estimated"; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/Coliee.API/Services/ChatNodePromptContextResult.cs b/backend/Coliee.API/Services/ChatNodePromptContextResult.cs index 80ebc1e..bca13ba 100644 --- a/backend/Coliee.API/Services/ChatNodePromptContextResult.cs +++ b/backend/Coliee.API/Services/ChatNodePromptContextResult.cs @@ -5,4 +5,5 @@ public sealed class ChatNodePromptContextResult public string? SystemPrompt { get; set; } public IReadOnlyList Messages { get; set; } = []; public ChatNodeContextSnapshot? Snapshot { get; set; } + public ChatNodeContextUsage? Usage { get; set; } } diff --git a/backend/Coliee.API/Services/ChatSessionService.cs b/backend/Coliee.API/Services/ChatSessionService.cs index 49bef9d..b2d6dc4 100644 --- a/backend/Coliee.API/Services/ChatSessionService.cs +++ b/backend/Coliee.API/Services/ChatSessionService.cs @@ -28,6 +28,7 @@ public partial class ChatSessionService : IChatSessionService private const double DefaultNoteHeight = 400; private readonly IChatSessionRepository _chatSessionRepository; private readonly ILlmRepository _llmRepository; + private readonly IUserRepository _userRepository; private readonly ICanvasProviderResolver _providerResolver; private readonly ILlmEncryptionService _encryptionService; private readonly IChatNodeContextCompactionService _contextCompactionService; @@ -38,6 +39,7 @@ public partial class ChatSessionService : IChatSessionService public ChatSessionService( IChatSessionRepository chatSessionRepository, ILlmRepository llmRepository, + IUserRepository userRepository, ICanvasProviderResolver providerResolver, ILlmEncryptionService encryptionService, IChatNodeContextCompactionService contextCompactionService, @@ -47,6 +49,7 @@ public ChatSessionService( { _chatSessionRepository = chatSessionRepository; _llmRepository = llmRepository; + _userRepository = userRepository; _providerResolver = providerResolver; _encryptionService = encryptionService; _contextCompactionService = contextCompactionService; @@ -57,12 +60,14 @@ public ChatSessionService( public async Task> GetSessionsAsync(int userId) { + await EnsureUserExistsAsync(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) { + await EnsureUserExistsAsync(userId); var normalizedTitle = NormalizeSessionTitle(title); var trimmedContent = NormalizeOptionalContent(content); var sessionTitle = normalizedTitle @@ -128,7 +133,12 @@ await _chatSessionRepository.UpdateNodeAsync( 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); + return await BuildStateResponseAsync( + userId, + session.Id, + rootNode.Id, + resolution.Providers, + BuildProviderContextUsage(selectedProvider, providerReply.Model, providerReply.UsedTokens) ?? promptContext.Usage); } catch (Exception ex) when (ex is ChatSessionOperationException or LlmChatException) { @@ -177,6 +187,7 @@ public async Task PreviewGuestAsync(GuestChatPreviewRe public async Task ImportGuestCanvasesAsync(int userId, ImportGuestCanvasesRequest request) { + await EnsureUserExistsAsync(userId); if (request.Canvases.Count == 0) throw new ChatSessionOperationException("At least one guest canvas is required.", StatusCodes.Status400BadRequest); @@ -302,6 +313,7 @@ await _chatSessionRepository.AddMessageAsync( public async Task RenameSessionAsync(int userId, int sessionId, string title) { + await EnsureUserExistsAsync(userId); await EnsureSessionExistsAsync(userId, sessionId); var normalizedTitle = NormalizeTitle(title, "Canvas title is required.", SessionTitleMaxLength); var session = await _chatSessionRepository.RenameSessionAsync(userId, sessionId, normalizedTitle); @@ -310,6 +322,7 @@ public async Task RenameSessionAsync(int userId, int public async Task DeleteSessionAsync(int userId, int sessionId) { + await EnsureUserExistsAsync(userId); await EnsureSessionExistsAsync(userId, sessionId); var sessions = await _chatSessionRepository.GetSessionsAsync(userId); if (sessions.Count <= 1) @@ -324,12 +337,14 @@ public async Task DeleteSessionAsync(int userId, int sessionId) public async Task GetSessionAsync(int userId, int sessionId, int? activeNodeId = null) { + await EnsureUserExistsAsync(userId); await EnsureSessionExistsAsync(userId, sessionId); return await BuildGraphResponseAsync(userId, sessionId, activeNodeId); } public async Task GetNodeAsync(int userId, int sessionId, int nodeId) { + await EnsureUserExistsAsync(userId); await EnsureSessionExistsAsync(userId, sessionId); var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); return await BuildNodeDetailAsync(userId, sessionId, node); @@ -337,6 +352,7 @@ public async Task GetNodeAsync(int userId, int ses public async Task CreateNodeAsync(int userId, int sessionId, CreateChatNodeRequest request) { + await EnsureUserExistsAsync(userId); await EnsureSessionExistsAsync(userId, sessionId); if (request.ParentNodeId <= 0) throw new ChatSessionOperationException("A valid parent node is required.", StatusCodes.Status400BadRequest); @@ -374,6 +390,7 @@ public async Task CreateNodeAsync(int userId, int sess public async Task UpdateNodeAsync(int userId, int sessionId, int nodeId, UpdateChatNodeRequest request) { + await EnsureUserExistsAsync(userId); await EnsureSessionExistsAsync(userId, sessionId); var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); var nodeType = node.ParentNodeId == null ? ChatNodeTypes.Question : node.NodeType; @@ -422,6 +439,7 @@ public async Task SendNodeMessageAsync( string? providerOverride, string? modelOverride = null) { + await EnsureUserExistsAsync(userId); var session = await EnsureSessionExistsAsync(userId, sessionId); var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); if (node.NodeType == ChatNodeTypes.Note) @@ -492,11 +510,17 @@ await _chatSessionRepository.UpdateNodeAsync( node.Id, BuildUpdatedNodeInput(node, preview: BuildPreview(providerReply.Content))); - return await BuildStateResponseAsync(userId, session.Id, node.Id, resolution.Providers); + return await BuildStateResponseAsync( + userId, + session.Id, + node.Id, + resolution.Providers, + BuildProviderContextUsage(selectedProvider, providerReply.Model, providerReply.UsedTokens) ?? promptContext.Usage); } public async Task UpdateMessageAsync(int userId, int sessionId, int nodeId, int messageId, UpdateChatCanvasMessageRequest request) { + await EnsureUserExistsAsync(userId); await EnsureSessionExistsAsync(userId, sessionId); var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); var messages = await _chatSessionRepository.GetNodeMessagesAsync(userId, sessionId, nodeId); @@ -525,6 +549,7 @@ await _chatSessionRepository.UpdateNodeAsync( public async Task CreateBranchAsync(int userId, int sessionId, int nodeId, int branchFromMessageId, string content, string? providerOverride, string? modelOverride = null) { + await EnsureUserExistsAsync(userId); var session = await EnsureSessionExistsAsync(userId, sessionId); var parentNode = await GetRequiredNodeAsync(userId, sessionId, nodeId); var branchFromMessage = await _chatSessionRepository.GetNodeMessageAsync(userId, sessionId, nodeId, branchFromMessageId); @@ -596,7 +621,12 @@ public async Task CreateBranchAsync(int userId, int se await PersistNodeContextSnapshotAsync(userId, session.Id, childNode.Id, promptContext.Snapshot); } - return await BuildStateResponseAsync(userId, session.Id, childNode.Id, resolution.Providers); + return await BuildStateResponseAsync( + userId, + session.Id, + childNode.Id, + resolution.Providers, + BuildProviderContextUsage(selectedProvider, providerReply.Model, providerReply.UsedTokens) ?? promptContext.Usage); } public async Task MoveMessagesToBranchAsync( @@ -606,6 +636,7 @@ public async Task MoveMessagesToBranchAsync( IReadOnlyList messageIds, string? transferMode = null) { + await EnsureUserExistsAsync(userId); var session = await EnsureSessionExistsAsync(userId, sessionId); var sourceNode = await GetRequiredNodeAsync(userId, sessionId, nodeId); if (sourceNode.NodeType == ChatNodeTypes.Note) @@ -723,6 +754,9 @@ await _chatSessionRepository.UpdateNodeAsync( BuildUpdatedNodeInput( sourceNode, preview: finalRemainingMessage == null ? string.Empty : BuildPreview(finalRemainingMessage.Content))); + + sourceNode = await GetRequiredNodeAsync(userId, sessionId, sourceNode.Id); + await RefreshNodeContextUsageAsync(userId, sessionId, sourceNode); } else { @@ -751,6 +785,7 @@ await _chatSessionRepository.UpdateNodeAsync( public async Task GenerateNoteAsync(int userId, int sessionId, int nodeId, string? providerOverride, string? modelOverride = null) { + await EnsureUserExistsAsync(userId); await EnsureSessionExistsAsync(userId, sessionId); var sourceNode = await GetRequiredNodeAsync(userId, sessionId, nodeId); var localMessages = (await _chatSessionRepository.GetNodeMessagesAsync(userId, sessionId, nodeId)).ToList(); @@ -801,6 +836,7 @@ public async Task GenerateNoteAsync(int userId, int se public async Task GenerateImageAsync(int userId, int sessionId, int nodeId, string prompt, string? providerOverride, string? modelOverride = null) { + await EnsureUserExistsAsync(userId); var session = await EnsureSessionExistsAsync(userId, sessionId); var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); if (node.NodeType == ChatNodeTypes.Note) @@ -834,6 +870,7 @@ await _chatSessionRepository.UpdateNodeAsync( public async Task DeleteNodeAsync(int userId, int sessionId, int nodeId, int? newActiveNodeId = null) { + await EnsureUserExistsAsync(userId); await EnsureSessionExistsAsync(userId, sessionId); var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); @@ -858,6 +895,7 @@ public async Task DeleteNodeAsync(int userId, int sess public async Task SaveLayoutAsync(int userId, int sessionId, IReadOnlyList nodes, int? activeNodeId = null) { + await EnsureUserExistsAsync(userId); await EnsureSessionExistsAsync(userId, sessionId); if (nodes.Any(node => node.NodeId <= 0)) { @@ -874,6 +912,12 @@ private async Task EnsureSessionExistsAsync(int userId, int session return session ?? throw new ChatSessionOperationException("Chat session not found.", StatusCodes.Status404NotFound); } + private async Task EnsureUserExistsAsync(int userId) + { + if (await _userRepository.GetByIdAsync(userId) == null) + throw new ChatSessionOperationException("User account not found. Please sign in again.", StatusCodes.Status401Unauthorized); + } + private async Task GetRequiredNodeAsync(int userId, int sessionId, int nodeId) { if (nodeId <= 0) @@ -887,14 +931,16 @@ private async Task BuildStateResponseAsync( int userId, int sessionId, int nodeId, - IReadOnlyList? providers = null) + IReadOnlyList? providers = null, + ChatNodeContextUsage? preferredUsage = null) { - var graph = await BuildGraphResponseAsync(userId, sessionId, nodeId, providers); var node = await GetRequiredNodeAsync(userId, sessionId, nodeId); + var refreshedNode = await RefreshNodeContextUsageAsync(userId, sessionId, node, preferredUsage); + var graph = await BuildGraphResponseAsync(userId, sessionId, refreshedNode.Id, providers); return new ChatSessionStateResponse { Graph = graph, - Node = await BuildNodeDetailAsync(userId, sessionId, node) + Node = await BuildNodeDetailAsync(userId, sessionId, refreshedNode) }; } @@ -948,6 +994,7 @@ private async Task BuildNodeDetailAsync(int userId ExpandedHeight = node.ExpandedHeight, PreferredProvider = node.PreferredProvider, PreferredModel = node.PreferredModel, + ContextUsage = MapContextUsage(node), CreatedAt = node.CreatedAt, UpdatedAt = node.UpdatedAt, LocalMessages = localMessages.Select(message => MapMessage(message, message.Role == "assistant")).ToList(), @@ -1000,6 +1047,47 @@ await _chatSessionRepository.UpdateNodeContextSnapshotAsync( snapshotJson); } + private async Task RefreshNodeContextUsageAsync( + int userId, + int sessionId, + ChatNode node, + ChatNodeContextUsage? preferredUsage = null) + { + if (node.NodeType == ChatNodeTypes.Note) + return node; + + var usage = preferredUsage ?? await EstimateNodeContextUsageAsync(userId, sessionId, node); + if (usage == null) + return node; + + return await _chatSessionRepository.UpdateNodeContextUsageAsync(userId, sessionId, node.Id, usage); + } + + private ChatNodeContextUsage? BuildProviderContextUsage(string provider, string model, int? usedTokens) + { + if (!usedTokens.HasValue) + return null; + + return new ChatNodeContextUsage + { + UsedTokens = usedTokens.Value, + TotalTokens = _contextCompactionService.ResolveContextWindowTokens(provider, model), + Source = "provider", + UpdatedAt = DateTime.UtcNow + }; + } + + private async Task EstimateNodeContextUsageAsync(int userId, int sessionId, ChatNode node) + { + var localMessages = await _chatSessionRepository.GetNodeMessagesAsync(userId, sessionId, node.Id); + var snapshot = await _chatSessionRepository.GetNodeContextSnapshotAsync(userId, sessionId, node.Id); + return _contextCompactionService.EstimateCurrentContextUsage( + node, + localMessages, + snapshot, + includeFormattingInstruction: true); + } + private async Task SendConversationAsync( string provider, ILlmProviderClient providerClient, @@ -1943,11 +2031,26 @@ private static ChatCanvasNodeSummaryResponse MapNode(ChatNode node) ExpandedHeight = node.ExpandedHeight, PreferredProvider = node.PreferredProvider, PreferredModel = node.PreferredModel, + ContextUsage = MapContextUsage(node), CreatedAt = node.CreatedAt, UpdatedAt = node.UpdatedAt }; } + private static ChatNodeContextUsageResponse? MapContextUsage(ChatNode node) + { + if (!node.ContextUsageUsedTokens.HasValue || !node.ContextUsageTotalTokens.HasValue || string.IsNullOrWhiteSpace(node.ContextUsageSource)) + return null; + + return new ChatNodeContextUsageResponse + { + UsedTokens = node.ContextUsageUsedTokens.Value, + TotalTokens = node.ContextUsageTotalTokens.Value, + Source = node.ContextUsageSource, + UpdatedAt = node.ContextUsageUpdatedAt ?? node.UpdatedAt + }; + } + private static ChatCanvasMessageResponse MapMessage(ChatMessage message, bool canBranch) { return new ChatCanvasMessageResponse diff --git a/backend/Coliee.API/Services/ClaudeProviderClient.cs b/backend/Coliee.API/Services/ClaudeProviderClient.cs index 6bf2915..1111e17 100644 --- a/backend/Coliee.API/Services/ClaudeProviderClient.cs +++ b/backend/Coliee.API/Services/ClaudeProviderClient.cs @@ -225,13 +225,15 @@ public async Task SendStructuredJsonAsync( return new LlmProviderChatResult { Content = JsonSerializer.Serialize(inputProperty), - Model = model + Model = model, + UsedTokens = TryGetInt(document.RootElement, "usage", "input_tokens") }; } } var contentItems = document.RootElement.GetProperty("content"); var content = contentItems[0].GetProperty("text").GetString(); + var usedTokens = TryGetInt(document.RootElement, "usage", "input_tokens"); if (string.IsNullOrWhiteSpace(content)) { @@ -242,7 +244,8 @@ public async Task SendStructuredJsonAsync( return new LlmProviderChatResult { Content = content.Trim(), - Model = model + Model = model, + UsedTokens = usedTokens }; } } @@ -301,6 +304,20 @@ private static string GetMessage(HttpStatusCode statusCode) : null; } + private static int? TryGetInt(JsonElement element, params string[] path) + { + var current = element; + foreach (var segment in path) + { + if (!current.TryGetProperty(segment, out current)) + return null; + } + + return current.ValueKind == JsonValueKind.Number && current.TryGetInt32(out var value) + ? value + : null; + } + private string ResolveModel(string? modelOverride) { return string.IsNullOrWhiteSpace(modelOverride) diff --git a/backend/Coliee.API/Services/GeminiProviderClient.cs b/backend/Coliee.API/Services/GeminiProviderClient.cs index 32c1a7f..964bda9 100644 --- a/backend/Coliee.API/Services/GeminiProviderClient.cs +++ b/backend/Coliee.API/Services/GeminiProviderClient.cs @@ -162,6 +162,7 @@ public async Task SendChatAsync( .GetProperty("parts")[0] .GetProperty("text") .GetString(); + var usedTokens = TryGetInt(document.RootElement, "usageMetadata", "promptTokenCount"); if (string.IsNullOrWhiteSpace(content)) { @@ -172,7 +173,8 @@ public async Task SendChatAsync( return new LlmProviderChatResult { Content = content.Trim(), - Model = model + Model = model, + UsedTokens = usedTokens }; } } @@ -244,6 +246,7 @@ public async Task SendStructuredJsonAsync( .GetProperty("parts")[0] .GetProperty("text") .GetString(); + var usedTokens = TryGetInt(document.RootElement, "usageMetadata", "promptTokenCount"); if (string.IsNullOrWhiteSpace(content)) { @@ -254,7 +257,8 @@ public async Task SendStructuredJsonAsync( return new LlmProviderChatResult { Content = content.Trim(), - Model = model + Model = model, + UsedTokens = usedTokens }; } } @@ -383,6 +387,20 @@ private static bool SupportsGenerateContent(JsonElement modelElement) : null; } + private static int? TryGetInt(JsonElement element, params string[] path) + { + var current = element; + foreach (var segment in path) + { + if (!current.TryGetProperty(segment, out current)) + return null; + } + + return current.ValueKind == JsonValueKind.Number && current.TryGetInt32(out var value) + ? value + : null; + } + private string ResolveModel(string? modelOverride) { return string.IsNullOrWhiteSpace(modelOverride) diff --git a/backend/Coliee.API/Services/IChatNodeContextCompactionService.cs b/backend/Coliee.API/Services/IChatNodeContextCompactionService.cs index 0f54922..636e1fc 100644 --- a/backend/Coliee.API/Services/IChatNodeContextCompactionService.cs +++ b/backend/Coliee.API/Services/IChatNodeContextCompactionService.cs @@ -17,4 +17,12 @@ Task BuildPromptContextAsync( bool compactAllHistory = false, ChatNodeContextSnapshot? initialSnapshot = null, CancellationToken cancellationToken = default); + + ChatNodeContextUsage EstimateCurrentContextUsage( + ChatNode node, + IReadOnlyList history, + ChatNodeContextSnapshot? snapshot, + bool includeFormattingInstruction); + + int ResolveContextWindowTokens(string provider, string? modelOverride); } diff --git a/backend/Coliee.API/Services/LlmProviderChatResult.cs b/backend/Coliee.API/Services/LlmProviderChatResult.cs index fcad268..1aff163 100644 --- a/backend/Coliee.API/Services/LlmProviderChatResult.cs +++ b/backend/Coliee.API/Services/LlmProviderChatResult.cs @@ -4,4 +4,5 @@ public class LlmProviderChatResult { public string Content { get; set; } = string.Empty; public string Model { get; set; } = string.Empty; + public int? UsedTokens { get; set; } } diff --git a/backend/Coliee.API/Services/LlmService.cs b/backend/Coliee.API/Services/LlmService.cs index 4cac119..662d365 100644 --- a/backend/Coliee.API/Services/LlmService.cs +++ b/backend/Coliee.API/Services/LlmService.cs @@ -8,6 +8,7 @@ public class LlmService : ILlmService { private readonly ICanvasRepository _canvasRepository; private readonly ILlmRepository _llmRepository; + private readonly IUserRepository _userRepository; private readonly ILlmEncryptionService _encryptionService; private readonly IReadOnlyDictionary _providerClients; private readonly TimeProvider _timeProvider; @@ -16,6 +17,7 @@ public class LlmService : ILlmService public LlmService( ICanvasRepository canvasRepository, ILlmRepository llmRepository, + IUserRepository userRepository, ILlmEncryptionService encryptionService, IEnumerable providerClients, TimeProvider timeProvider, @@ -23,16 +25,22 @@ public LlmService( { _canvasRepository = canvasRepository; _llmRepository = llmRepository; + _userRepository = userRepository; _encryptionService = encryptionService; _providerClients = providerClients.ToDictionary(client => client.Provider, StringComparer.Ordinal); _timeProvider = timeProvider; _logger = logger; } - public Task GetProvidersAsync(int userId) => BuildProviderListResponseAsync(userId); + public async Task GetProvidersAsync(int userId) + { + await EnsureUserExistsAsync(userId); + return await BuildProviderListResponseAsync(userId); + } public async Task GetProviderModelsAsync(int userId, string provider) { + await EnsureUserExistsAsync(userId); var normalizedProvider = NormalizeProvider(provider); var providerRecord = await _llmRepository.GetProviderKeyAsync(userId, normalizedProvider); if (providerRecord == null) @@ -60,13 +68,14 @@ public async Task GetProviderModelsAsync(int userId, str public async Task UpsertProviderKeyAsync(int userId, string provider, string apiKey) { + await EnsureUserExistsAsync(userId); var normalizedProvider = NormalizeProvider(provider); var trimmedApiKey = apiKey.Trim(); if (string.IsNullOrWhiteSpace(trimmedApiKey)) throw new LlmChatException("API key is required.", LlmProviderErrorCodes.InvalidKey, StatusCodes.Status400BadRequest); var encryptedApiKey = _encryptionService.Encrypt(trimmedApiKey); - await _llmRepository.UpsertProviderKeyAsync(userId, normalizedProvider, encryptedApiKey, MaskKey(trimmedApiKey)); + await _llmRepository.UpsertProviderKeyAsync(userId, normalizedProvider, encryptedApiKey, MaskKey(normalizedProvider, trimmedApiKey)); _logger.LogInformation("Saved provider key for user {UserId} and provider {Provider}.", userId, normalizedProvider); var providers = await _llmRepository.GetProviderKeysAsync(userId); @@ -81,6 +90,7 @@ public async Task UpsertProviderKeyAsync(int userId, strin public async Task DeleteProviderKeyAsync(int userId, string provider) { + await EnsureUserExistsAsync(userId); var normalizedProvider = NormalizeProvider(provider); var providers = await _llmRepository.GetProviderKeysAsync(userId); var existing = providers.FirstOrDefault(item => item.Provider == normalizedProvider); @@ -107,6 +117,7 @@ public async Task DeleteProviderKeyAsync(int userId, strin public async Task SetDefaultProviderAsync(int userId, string provider) { + await EnsureUserExistsAsync(userId); var normalizedProvider = NormalizeProvider(provider); var providers = await _llmRepository.GetProviderKeysAsync(userId); if (!providers.Any(item => item.Provider == normalizedProvider)) @@ -122,6 +133,7 @@ public async Task SetDefaultProviderAsync(int userId, stri public async Task TestProviderAsync(int userId, string provider) { + await EnsureUserExistsAsync(userId); var normalizedProvider = NormalizeProvider(provider); var providerRecord = await _llmRepository.GetProviderKeyAsync(userId, normalizedProvider); if (providerRecord == null) @@ -141,6 +153,7 @@ public async Task TestProviderAsync(int userId, string pro public async Task SendStandaloneChatMessageAsync(int userId, IReadOnlyList history, string content, string? providerOverride) { + await EnsureUserExistsAsync(userId); var trimmedContent = NormalizeStandaloneMessageContent(content, "Message content is required."); var providers = await _llmRepository.GetProviderKeysAsync(userId); var selectedProvider = ResolveProvider(providers, providerOverride); @@ -181,12 +194,14 @@ public async Task SendStandaloneChatMessageAsync(int use public async Task GetMainNodeChatAsync(int userId, int canvasId) { + await EnsureUserExistsAsync(userId); await EnsureCanvasExistsAsync(userId, canvasId); return await BuildChatResponseAsync(userId, canvasId); } public async Task SendMainNodeMessageAsync(int userId, int canvasId, string content, string? providerOverride) { + await EnsureUserExistsAsync(userId); await EnsureCanvasExistsAsync(userId, canvasId); var trimmedContent = content.Trim(); @@ -301,6 +316,15 @@ private async Task EnsureCanvasExistsAsync(int userId, int canvasId) } } + private async Task EnsureUserExistsAsync(int userId) + { + if (await _userRepository.GetByIdAsync(userId) == null) + { + _logger.LogWarning("Rejected LLM request for missing user {UserId}.", userId); + throw new LlmChatException("User account not found. Please sign in again.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status401Unauthorized); + } + } + private ILlmProviderClient GetProviderClient(string provider) { if (_providerClients.TryGetValue(provider, out var client)) @@ -369,9 +393,17 @@ private string NormalizeProvider(string provider) return normalizedProvider; } - private static string MaskKey(string apiKey) + private static string MaskKey(string provider, string value) { - var last4 = apiKey.Length >= 4 ? apiKey[^4..] : apiKey; + if (provider == LlmProviderNames.LmStudio && Uri.TryCreate(value.Trim(), UriKind.Absolute, out var uri)) + { + var path = uri.AbsolutePath.TrimEnd('/'); + return string.IsNullOrWhiteSpace(path) || path == "/" + ? $"{uri.Host}:{uri.Port}" + : $"{uri.Host}:{uri.Port}{path}"; + } + + var last4 = value.Length >= 4 ? value[^4..] : value; return $"****{last4}"; } } diff --git a/backend/Coliee.API/Services/LmStudioProviderClient.cs b/backend/Coliee.API/Services/LmStudioProviderClient.cs index 7c800e5..3f80b31 100644 --- a/backend/Coliee.API/Services/LmStudioProviderClient.cs +++ b/backend/Coliee.API/Services/LmStudioProviderClient.cs @@ -7,60 +7,38 @@ namespace Coliee.API.Services; public class LmStudioProviderClient : ILlmProviderClient { + private const int DefaultTimeoutSeconds = 120; private readonly HttpClient _httpClient; - private readonly string _baseUrl; - private readonly string _model; + private readonly string _configuredBaseUrl; + private readonly string _configuredModel; + private readonly TimeSpan _requestTimeout; 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"; + _configuredBaseUrl = NormalizeBaseUrl(configuration["Llm:Providers:LmStudio:BaseUrl"] ?? "http://localhost:1234"); + _configuredModel = configuration["Llm:Providers:LmStudio:Model"] ?? "local-model"; + _requestTimeout = ResolveTimeout(configuration["Llm:Providers:LmStudio:TimeoutSeconds"]); _logger = logger; } public string Provider => LlmProviderNames.LmStudio; - public Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) + public async Task TestApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) { - return SendChatAsync( - apiKey, - [new LlmProviderChatMessage { Role = "user", Content = "Reply with OK." }], - cancellationToken: cancellationToken); + await GetModelsAsync(apiKey, 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); - } - + var response = await SendWithFallbackAsync(apiKey, endpoint => new HttpRequestMessage(HttpMethod.Get, $"{endpoint}/models"), cancellationToken); using (response) { if (!response.IsSuccessStatusCode) - await ThrowForFailureAsync(response, _model, linkedCts.Token); + await ThrowForFailureAsync(response, ResolveBaseUrl(apiKey), cancellationToken); - using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(linkedCts.Token)); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); if (!document.RootElement.TryGetProperty("data", out var dataElement) || dataElement.ValueKind != JsonValueKind.Array) return []; @@ -86,15 +64,21 @@ public async Task SendChatAsync( 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); + var response = await SendWithFallbackAsync( + apiKey, + endpoint => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{endpoint}/chat/completions"); + request.Content = JsonContent.Create(new + { + model, + messages = BuildChatMessages(messages, systemPrompt) + }); + return request; + }, + cancellationToken); + + return await ParseChatResponseAsync(response, model, cancellationToken); } public async Task SendStructuredJsonAsync( @@ -105,26 +89,32 @@ public async Task SendStructuredJsonAsync( { 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 + var response = await SendWithFallbackAsync( + apiKey, + endpoint => { - type = "json_schema", - json_schema = new + var request = new HttpRequestMessage(HttpMethod.Post, $"{endpoint}/chat/completions"); + request.Content = JsonContent.Create(new { - name = structuredRequest.SchemaName, - strict = true, - schema = schemaDocument.RootElement.Clone() - } - } - }); - - return await SendRequestAsync(request, model, cancellationToken); + 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 request; + }, + cancellationToken); + + return await ParseChatResponseAsync(response, model, cancellationToken); } private IEnumerable BuildChatMessages(IReadOnlyList messages, string? systemPrompt) @@ -166,39 +156,54 @@ private IEnumerable BuildStructuredMessages(LlmProviderStructuredRequest } } - private async Task SendRequestAsync(HttpRequestMessage request, string model, CancellationToken cancellationToken) + private async Task SendWithFallbackAsync( + string apiKeyOrEndpoint, + Func requestFactory, + CancellationToken cancellationToken) { using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - linkedCts.CancelAfter(TimeSpan.FromSeconds(30)); + linkedCts.CancelAfter(_requestTimeout); - HttpResponseMessage response; + var endpoints = GetCandidateBaseUrls(apiKeyOrEndpoint).ToList(); + Exception? lastException = null; - try - { - response = await _httpClient.SendAsync(request, linkedCts.Token); - } - catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + foreach (var endpoint in endpoints) { - _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 var request = requestFactory(endpoint); + try + { + var response = await _httpClient.SendAsync(request, linkedCts.Token); + return response; + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(ex, "LM Studio request timed out for endpoint {Endpoint}.", endpoint); + throw new LlmChatException("Provider request timed out.", LlmProviderErrorCodes.Timeout, StatusCodes.Status504GatewayTimeout, ex); + } + catch (HttpRequestException ex) + { + lastException = ex; + _logger.LogWarning(ex, "LM Studio request failed for endpoint {Endpoint}.", endpoint); + } } + throw new LlmChatException("Provider request failed.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status502BadGateway, lastException); + } + + private async Task ParseChatResponseAsync(HttpResponseMessage response, string model, CancellationToken cancellationToken) + { using (response) { if (!response.IsSuccessStatusCode) - await ThrowForFailureAsync(response, model, linkedCts.Token); + await ThrowForFailureAsync(response, model, cancellationToken); - using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(linkedCts.Token)); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); var content = document.RootElement .GetProperty("choices")[0] .GetProperty("message") .GetProperty("content") .GetString(); + var usedTokens = TryGetInt(document.RootElement, "usage", "prompt_tokens"); if (string.IsNullOrWhiteSpace(content)) { @@ -209,7 +214,8 @@ private async Task SendRequestAsync(HttpRequestMessage re return new LlmProviderChatResult { Content = content.Trim(), - Model = model + Model = model, + UsedTokens = usedTokens }; } } @@ -230,6 +236,42 @@ private async Task ThrowForFailureAsync(HttpResponseMessage response, string mod new InvalidOperationException(body)); } + private string ResolveBaseUrl(string? apiKeyOrEndpoint) + { + if (string.IsNullOrWhiteSpace(apiKeyOrEndpoint)) + return _configuredBaseUrl; + + var trimmed = apiKeyOrEndpoint.Trim(); + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + return _configuredBaseUrl; + + return NormalizeBaseUrl(uri.ToString()); + } + + private IEnumerable GetCandidateBaseUrls(string? apiKeyOrEndpoint) + { + var primary = ResolveBaseUrl(apiKeyOrEndpoint); + yield return primary; + + if (Uri.TryCreate(primary, UriKind.Absolute, out var uri) && IsLoopbackHost(uri)) + yield return NormalizeBaseUrl(new UriBuilder(uri) { Host = "host.docker.internal" }.Uri.ToString()); + } + + private static bool IsLoopbackHost(Uri uri) + { + return uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) + || uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeBaseUrl(string rawBaseUrl) + { + var trimmed = rawBaseUrl.Trim().TrimEnd('/'); + if (trimmed.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + return trimmed; + + return $"{trimmed}/v1"; + } + private static string GetCode(HttpStatusCode statusCode) { return statusCode switch @@ -267,8 +309,30 @@ private static string GetMessage(HttpStatusCode statusCode) : null; } + private static int? TryGetInt(JsonElement element, params string[] path) + { + var current = element; + foreach (var segment in path) + { + if (!current.TryGetProperty(segment, out current)) + return null; + } + + return current.ValueKind == JsonValueKind.Number && current.TryGetInt32(out var value) + ? value + : null; + } + private string ResolveModel(string? modelOverride) { - return string.IsNullOrWhiteSpace(modelOverride) ? _model : modelOverride.Trim(); + return string.IsNullOrWhiteSpace(modelOverride) ? _configuredModel : modelOverride.Trim(); + } + + private static TimeSpan ResolveTimeout(string? rawTimeoutSeconds) + { + if (int.TryParse(rawTimeoutSeconds, out var timeoutSeconds) && timeoutSeconds > 0) + return TimeSpan.FromSeconds(timeoutSeconds); + + return TimeSpan.FromSeconds(DefaultTimeoutSeconds); } } diff --git a/backend/Coliee.API/Services/OpenAiProviderClient.cs b/backend/Coliee.API/Services/OpenAiProviderClient.cs index bd7a5b4..d2158aa 100644 --- a/backend/Coliee.API/Services/OpenAiProviderClient.cs +++ b/backend/Coliee.API/Services/OpenAiProviderClient.cs @@ -189,6 +189,7 @@ private async Task SendRequestAsync(HttpRequestMessage re .GetProperty("message") .GetProperty("content") .GetString(); + var usedTokens = TryGetInt(document.RootElement, "usage", "prompt_tokens"); if (string.IsNullOrWhiteSpace(content)) { @@ -199,7 +200,8 @@ private async Task SendRequestAsync(HttpRequestMessage re return new LlmProviderChatResult { Content = content.Trim(), - Model = model + Model = model, + UsedTokens = usedTokens }; } } @@ -258,6 +260,20 @@ private static string GetMessage(HttpStatusCode statusCode) : null; } + private static int? TryGetInt(JsonElement element, params string[] path) + { + var current = element; + foreach (var segment in path) + { + if (!current.TryGetProperty(segment, out current)) + return null; + } + + return current.ValueKind == JsonValueKind.Number && current.TryGetInt32(out var value) + ? value + : null; + } + private string ResolveModel(string? modelOverride) { return string.IsNullOrWhiteSpace(modelOverride) diff --git a/backend/Coliee.API/appsettings.json b/backend/Coliee.API/appsettings.json index 7e71a73..8f29abc 100644 --- a/backend/Coliee.API/appsettings.json +++ b/backend/Coliee.API/appsettings.json @@ -37,7 +37,8 @@ }, "LmStudio": { "BaseUrl": "http://localhost:1234/v1", - "Model": "local-model" + "Model": "local-model", + "TimeoutSeconds": 120 } } }, diff --git a/docker-compose.yml b/docker-compose.yml index a558e8e..150a542 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ services: image: mysql:8 env_file: .env environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} MYSQL_USER: ${DB_USER} MYSQL_PASSWORD: ${DB_PASSWORD} ports: @@ -24,6 +26,8 @@ services: dockerfile: Dockerfile.dev ports: - "8080:8080" + extra_hosts: + - "host.docker.internal:host-gateway" dns: - 8.8.8.8 - 1.1.1.1 @@ -33,6 +37,7 @@ services: mysql: condition: service_healthy environment: + - ASPNETCORE_ENVIRONMENT=Development - ConnectionStrings__Default=server=${DB_HOST};port=${DB_PORT};database=${MYSQL_DATABASE};user=${DB_USER};password=${DB_PASSWORD}; - Jwt__Key=${JWT_KEY} - Jwt__Issuer=ColieeAPI diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b041b47..8de2df2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -121,7 +121,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -470,7 +469,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -494,7 +492,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1735,7 +1732,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2005,7 +2003,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2016,7 +2013,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2103,7 +2099,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2534,7 +2529,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2585,6 +2579,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2824,7 +2819,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3231,8 +3225,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3-color": { "version": "3.1.0", @@ -3291,7 +3284,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3458,7 +3450,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.0", @@ -3623,7 +3616,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4912,7 +4904,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -5129,6 +5120,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6407,7 +6399,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6567,6 +6558,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6582,6 +6574,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6651,7 +6644,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6661,7 +6653,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6691,7 +6682,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -7616,7 +7608,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7858,7 +7849,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -8188,7 +8178,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 622d988..5fa7e80 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -5,6 +5,7 @@ import App from './App' const authState = vi.hoisted(() => ({ isAuthenticated: false, + isSessionReady: true, })) vi.mock('./context/AuthContext', () => ({ @@ -38,6 +39,7 @@ vi.mock('./pages/SettingsPage', () => ({ describe('App routes', () => { beforeEach(() => { authState.isAuthenticated = false + authState.isSessionReady = true }) it('redirects legacy chat route to the canonical workspace route', () => { @@ -72,6 +74,7 @@ describe('App routes', () => { it('renders settings for authenticated users', () => { authState.isAuthenticated = true + authState.isSessionReady = true render( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ec358f0..01201a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,9 +8,12 @@ import IndexPage from './pages/Index' import { useAuth } from './context/AuthContext' function ProtectedSettingsRoute() { - const { isAuthenticated } = useAuth() + const { isAuthenticated, isSessionReady } = useAuth() const location = useLocation() + if (!isSessionReady) + return null + if (!isAuthenticated) return diff --git a/frontend/src/components/chat-canvas/IdeaNode.test.tsx b/frontend/src/components/chat-canvas/IdeaNode.test.tsx index 5207427..b7533fa 100644 --- a/frontend/src/components/chat-canvas/IdeaNode.test.tsx +++ b/frontend/src/components/chat-canvas/IdeaNode.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import type { NodeProps } from '@xyflow/react' @@ -15,7 +15,11 @@ vi.mock('@xyflow/react', () => ({ }, })) -function renderNode(modelOptions: CanvasModelOption[]) { +function renderNode( + modelOptions: CanvasModelOption[], + contextUsage: ChatCanvasFlowNode['data']['contextUsage'] = null, + overrides: Partial = {}, +) { const onProviderModelChange = vi.fn() const data: ChatCanvasFlowNode['data'] = { nodeId: 'node-1', @@ -31,6 +35,7 @@ function renderNode(modelOptions: CanvasModelOption[]) { preferredProvider: 'gemini', preferredModel: 'gemini-3.1-flash-lite-preview', modelOptions, + contextUsage, canDelete: true, showFloatingLabel: true, onCreateChild: vi.fn(), @@ -45,6 +50,7 @@ function renderNode(modelOptions: CanvasModelOption[]) { onEditMessage: vi.fn().mockResolvedValue(true), onMoveMessagesToBranch: vi.fn().mockResolvedValue(true), onResize: vi.fn(), + ...overrides, } const nodeProps = { @@ -116,4 +122,74 @@ describe('IdeaNode', () => { await user.click(screen.getByRole('dialog', { name: /select ai model/i }).parentElement as HTMLElement) expect(screen.queryByRole('dialog', { name: /select ai model/i })).not.toBeInTheDocument() }) + + it('renders provider-sourced context usage in the node footer', () => { + renderNode([ + { provider: 'gemini', modelId: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash-Lite Preview', hint: 'Low-latency and lightweight', badge: 'GEMINI' }, + ], { + usedTokens: 83000, + totalTokens: 258000, + source: 'provider', + updatedAt: new Date().toISOString(), + }) + + expect(screen.getByLabelText('32% used (Provider)')).toBeInTheDocument() + }) + + it('renders estimated context usage when no provider usage is present', () => { + renderNode([ + { provider: 'gemini', modelId: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash-Lite Preview', hint: 'Low-latency and lightweight', badge: 'GEMINI' }, + ], { + usedTokens: 12450, + totalTokens: 258000, + source: 'estimated', + updatedAt: new Date().toISOString(), + }) + + expect(screen.getByLabelText('5% used (Estimated)')).toBeInTheDocument() + }) + + it('shows a hover tooltip for context usage', async () => { + const user = userEvent.setup() + + renderNode([ + { provider: 'gemini', modelId: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash-Lite Preview', hint: 'Low-latency and lightweight', badge: 'GEMINI' }, + ], { + usedTokens: 83000, + totalTokens: 258000, + source: 'provider', + updatedAt: new Date().toISOString(), + }) + + await user.hover(screen.getByLabelText('32% used (Provider)')) + + expect(screen.getByRole('tooltip')).toHaveTextContent('32% used') + expect(screen.getByRole('tooltip')).toHaveTextContent('Provider usage') + }) + + it('does not render a context badge when no usage is available', () => { + renderNode([ + { provider: 'gemini', modelId: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash-Lite Preview', hint: 'Low-latency and lightweight', badge: 'GEMINI' }, + ]) + + expect(screen.queryByLabelText(/used/i)).not.toBeInTheDocument() + }) + + it('commits note edits when the textarea blurs', () => { + const onCommitContent = vi.fn() + + renderNode([ + { provider: 'gemini', modelId: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash-Lite Preview', hint: 'Low-latency and lightweight', badge: 'GEMINI' }, + ], null, { + nodeType: 'note', + content: 'Draft note', + isCollapsed: false, + onCommitContent, + }) + + const textarea = screen.getByPlaceholderText('Type your note here...') + fireEvent.blur(textarea) + + expect(onCommitContent).toHaveBeenCalledTimes(1) + }) }) diff --git a/frontend/src/components/chat-canvas/IdeaNode.tsx b/frontend/src/components/chat-canvas/IdeaNode.tsx index 79e4ef0..3c820cd 100644 --- a/frontend/src/components/chat-canvas/IdeaNode.tsx +++ b/frontend/src/components/chat-canvas/IdeaNode.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import ReactMarkdown from 'react-markdown' import { Handle, NodeResizer, Position, type NodeProps } from '@xyflow/react' import { createPortal } from 'react-dom' @@ -36,6 +36,156 @@ const TYPE_LABELS = { note: 'Note', } as const +function formatCompactTokens(tokens: number) { + if (tokens >= 1_000_000) + return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}m` + + if (tokens >= 1_000) + return `${Math.round(tokens / 1_000)}k` + + return String(tokens) +} + +function ContextUsageBadge({ usage }: { usage: NonNullable }) { + const badgeRef = useRef(null) + const popupRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [popupStyle, setPopupStyle] = useState({ top: 0, left: 0 }) + const used = Math.max(0, usage.usedTokens) + const total = Math.max(0, usage.totalTokens) + const safeTotal = Math.max(total, used) + const usedPercent = safeTotal === 0 ? 0 : Math.min(100, Math.round((used / safeTotal) * 100)) + const sourceLabel = usage.source === 'provider' ? 'Provider' : 'Estimated' + const accent = usage.source === 'provider' + ? 'hsl(152 72% 42%)' + : 'hsl(40 92% 56%)' + const tooltipLabel = `${usedPercent}% used` + const tooltipSource = usage.source === 'provider' ? 'Provider usage' : 'Estimated usage' + const ringSize = 12 + const ringStroke = 1.25 + const ringRadius = (ringSize - ringStroke) / 2 + const ringCircumference = 2 * Math.PI * ringRadius + const ringProgress = ringCircumference * (usedPercent / 100) + const popupRingRadius = 5.2 + const popupRingCircumference = 2 * Math.PI * popupRingRadius + const popupRingProgress = popupRingCircumference * (usedPercent / 100) + + const updatePopupPosition = useCallback(() => { + const rect = badgeRef.current?.getBoundingClientRect() + if (!rect) + return + + const popupWidth = popupRef.current?.offsetWidth ?? 180 + const left = Math.max(12, Math.min(window.innerWidth - popupWidth - 12, rect.left + (rect.width / 2) - (popupWidth / 2))) + const top = Math.max(12, rect.top - 12) + setPopupStyle({ left, top }) + }, []) + + useLayoutEffect(() => { + if (!isOpen) + return + + updatePopupPosition() + window.addEventListener('resize', updatePopupPosition) + window.addEventListener('scroll', updatePopupPosition, true) + + return () => { + window.removeEventListener('resize', updatePopupPosition) + window.removeEventListener('scroll', updatePopupPosition, true) + } + }, [isOpen, updatePopupPosition]) + + return ( + + ) +} + function getProviderIcon(provider: string) { switch (provider) { case 'claude': @@ -729,6 +879,7 @@ function IdeaNode({ id, data, selected }: NodeProps) { placeholder="Type your note here..." value={data.content} onChange={event => data.onUpdateContent(event.target.value)} + onBlur={() => data.onCommitContent?.()} /> )} @@ -1317,16 +1468,20 @@ function IdeaNode({ id, data, selected }: NodeProps) { ) : null} -
+
+ {data.nodeType !== 'note' && data.contextUsage ? ( + + ) : null} - {data.canDelete ? ( - } - tooltip="Delete" - onClick={data.onDelete} - hoverClass={data.nodeType === 'note' ? 'hover:text-slate-900' : 'hover:text-red-300'} - /> - ) : null} + {data.canDelete ? ( + } + tooltip="Delete" + onClick={data.onDelete} + hoverClass={data.nodeType === 'note' ? 'hover:text-slate-900' : 'hover:text-red-300'} + /> + ) : null} +
{ preferredProvider: LlmProviderId | null preferredModel: string | null modelOptions: CanvasModelOption[] + contextUsage?: NodeContextUsage | null canDelete: boolean showFloatingLabel: boolean onCreateChild: (type: ChatNodeType, seedContent?: string) => void onDelete: () => void onUpdateTitle: (title: string) => void onUpdateContent: (content: string) => void + onCommitContent?: () => void onSendMessage: (text: string, imageUrl?: string) => Promise onSendImagePrompt: (prompt: string) => Promise onGenerateNote: () => void diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 20aae75..3ab414d 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, ReactNode, useContext, createContext } from 'react' import { jwtDecode } from 'jwt-decode' +import { AxiosError } from 'axios' import { AuthPayload, User } from '../types' import { logoutService, getProfile } from '../services/authService' @@ -7,6 +8,7 @@ export interface AuthState { token: string | null refreshToken: string | null isAuthenticated: boolean + isSessionReady: boolean user: User | null } @@ -24,9 +26,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { token: null, refreshToken: null, isAuthenticated: false, + isSessionReady: false, user: null, }) + const clearLocalAuthState = () => { + sessionStorage.clear() + setAuthState({ + token: null, + refreshToken: null, + isAuthenticated: false, + isSessionReady: true, + user: null, + }) + } + // Rehydrate from sessionStorage on page load useEffect(() => { const token = sessionStorage.getItem('token') @@ -41,6 +55,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { token, refreshToken, isAuthenticated: true, + isSessionReady: false, user: { id: parseInt(decoded.sub), email: decoded.email, @@ -48,15 +63,65 @@ export function AuthProvider({ children }: { children: ReactNode }) { displayName, }, }) + return } else { - sessionStorage.clear() + clearLocalAuthState() } } catch { - sessionStorage.clear() + clearLocalAuthState() } } + else { + setAuthState(prev => ({ + ...prev, + isSessionReady: true, + })) + } }, []) + useEffect(() => { + if (!authState.isAuthenticated || authState.isSessionReady) + return + + let isActive = true + + void (async () => { + try { + const profile = await getProfile() + if (!isActive) + return + + setAuthState(prev => ({ + ...prev, + isSessionReady: true, + user: prev.user + ? { + ...prev.user, + displayName: profile.displayName ?? prev.user.displayName, + } + : prev.user, + })) + } catch (error) { + if (!isActive) + return + + if (error instanceof AxiosError && [401, 403, 404].includes(error.response?.status ?? 0)) { + clearLocalAuthState() + return + } + + setAuthState(prev => ({ + ...prev, + isSessionReady: true, + })) + } + })() + + return () => { + isActive = false + } + }, [authState.isAuthenticated, authState.isSessionReady]) + const refreshProfile = async () => { if (!authState.token) return try { @@ -69,6 +134,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { })) } } catch (err) { + if (err instanceof AxiosError && [401, 403, 404].includes(err.response?.status ?? 0)) { + clearLocalAuthState() + return + } console.error('Failed to fetch profile', err) } } @@ -96,6 +165,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { token, refreshToken, isAuthenticated: true, + isSessionReady: true, user: { id: parseInt(decoded.sub), email: decoded.email, @@ -123,6 +193,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { token: null, refreshToken: null, isAuthenticated: false, + isSessionReady: true, user: null, }) } diff --git a/frontend/src/pages/Index.test.tsx b/frontend/src/pages/Index.test.tsx index d9efe94..011b719 100644 --- a/frontend/src/pages/Index.test.tsx +++ b/frontend/src/pages/Index.test.tsx @@ -1,8 +1,9 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { AxiosError } from 'axios' import { MemoryRouter } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' -import IndexPage from './Index' +import IndexPage, { syncNodeDetails } from './Index' import type { ChatCanvasFlowEdge, ChatCanvasFlowNode } from '@/components/chat-canvas/types' import type { ChatCanvasNodeDetail, @@ -29,10 +30,41 @@ const workspaceMock = vi.hoisted(() => ({ }, })) +const sidebarMock = vi.hoisted(() => ({ + latestProps: null as null | { + sessions: Array<{ id: string; title: string }> + activeSessionId: string | null + isLoading: boolean + isCreating: boolean + onAdd: () => void + }, +})) + const mockUseLocation = vi.fn() const mockNavigate = vi.fn() const mockLogout = vi.fn() const mockSetTheme = vi.fn() +let storageState = new Map() +let authState = { + user: { id: 1, email: 'tester@example.com', isEmailVerified: true, displayName: 'Tester' }, + isAuthenticated: true, + isSessionReady: true, +} + +function createStorageMock() { + return { + getItem: (key: string) => storageState.get(key) ?? null, + setItem: (key: string, value: string) => { + storageState.set(key, value) + }, + removeItem: (key: string) => { + storageState.delete(key) + }, + clear: () => { + storageState.clear() + }, + } +} vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom') @@ -45,15 +77,16 @@ vi.mock('react-router-dom', async () => { vi.mock('@/context/AuthContext', async () => { const actual = await vi.importActual('@/context/AuthContext') - return { - ...actual, - useAuth: () => ({ - user: { id: 1, email: 'tester@example.com', isEmailVerified: true, displayName: 'Tester' }, - logout: mockLogout, - isAuthenticated: true, - }), - } -}) + return { + ...actual, + useAuth: () => ({ + user: authState.user, + logout: mockLogout, + isAuthenticated: authState.isAuthenticated, + isSessionReady: authState.isSessionReady, + }), + } + }) vi.mock('@/context/AppThemeContext', async () => { const actual = await vi.importActual('@/context/AppThemeContext') @@ -97,13 +130,22 @@ vi.mock('@/components/chat-canvas/CanvasWorkspace', () => ({ })) vi.mock('@/components/chat-canvas/CanvasSidebar', () => ({ - default: (props: { onAdd: () => void }) => ( + default: (props: { + sessions: Array<{ id: string; title: string }> + activeSessionId: string | null + isLoading: boolean + isCreating: boolean + onAdd: () => void + }) => { + sidebarMock.latestProps = props + return (
- ), + ) + }, })) vi.mock('@/components/auth/AuthModal', () => ({ @@ -223,6 +265,18 @@ function createState(sessionId: number, nodeId: number, provider: LlmProviderId describe('IndexPage authenticated canvas bootstrap', () => { beforeEach(() => { vi.clearAllMocks() + storageState = new Map() + workspaceMock.latestProps = null + sidebarMock.latestProps = null + authState = { + user: { id: 1, email: 'tester@example.com', isEmailVerified: true, displayName: 'Tester' }, + isAuthenticated: true, + isSessionReady: true, + } + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: createStorageMock(), + }) mockUseLocation.mockReturnValue({ pathname: '/', state: null }) vi.mocked(chatSessionService.getChatSessions).mockResolvedValue([]) vi.mocked(llmService.getProviders).mockResolvedValue({ @@ -236,6 +290,54 @@ describe('IndexPage authenticated canvas bootstrap', () => { vi.mocked(llmService.getProviderModels).mockResolvedValue({ provider: 'openai', models: [] }) }) + it('does not import a title-only guest canvas after login', async () => { + window.localStorage.setItem('coliee-guest-state-v2', JSON.stringify({ + canvases: [{ + id: 'guest-canvas-1', + title: 'New Canvas', + createdAt: '2026-04-13T00:00:00.000Z', + updatedAt: '2026-04-13T00:00:00.000Z', + rootNodeId: 'guest-node-1', + activeNodeId: 'guest-node-1', + nodes: [{ + id: 'guest-node-1', + parentNodeId: null, + nodeType: 'question', + title: 'Main Node', + preview: '', + content: '', + positionX: 400, + positionY: 300, + width: 600, + height: 520, + isCollapsed: false, + expandedWidth: 600, + expandedHeight: 520, + preferredProvider: 'gemini', + preferredModel: 'gemini-3.1-flash-lite-preview', + createdAt: '2026-04-13T00:00:00.000Z', + updatedAt: '2026-04-13T00:00:00.000Z', + messages: [], + }], + }], + activeCanvasId: 'guest-canvas-1', + guestPreviewUsed: false, + })) + + render( + + + , + ) + + await screen.findByTestId('canvas-workspace') + + await waitFor(() => { + expect(chatSessionService.getChatSessions).toHaveBeenCalledTimes(1) + }) + expect(chatSessionService.importGuestCanvases).not.toHaveBeenCalled() + }) + it('creates a real session when the empty authenticated canvas receives the first message', async () => { const user = userEvent.setup() const nextState = createState(101, 201) @@ -260,6 +362,89 @@ describe('IndexPage authenticated canvas bootstrap', () => { expect(chatSessionService.sendChatNodeMessage).not.toHaveBeenCalled() }) + it('loads the empty authenticated canvas once without re-fetching sessions on rerender', async () => { + vi.mocked(chatSessionService.getChatSessions).mockResolvedValue([]) + + render( + + + , + ) + + await screen.findByTestId('canvas-workspace') + + await waitFor(() => { + expect(chatSessionService.getChatSessions).toHaveBeenCalledTimes(1) + }) + expect(chatSessionService.createChatSession).not.toHaveBeenCalled() + }) + + it('stops showing sidebar loading after resolving to the empty authenticated draft canvas', async () => { + vi.mocked(chatSessionService.getChatSessions).mockResolvedValue([]) + + render( + + + , + ) + + await screen.findByTestId('canvas-workspace') + + await waitFor(() => { + expect(sidebarMock.latestProps?.isLoading).toBe(false) + }) + expect(sidebarMock.latestProps?.sessions).toEqual([]) + }) + + it('renders the blank authenticated workspace instead of the loading shell when there are no sessions', async () => { + vi.mocked(chatSessionService.getChatSessions).mockResolvedValue([]) + + render( + + + , + ) + + await screen.findByTestId('canvas-workspace') + expect(screen.queryByText('Loading canvas workspace...')).not.toBeInTheDocument() + expect(screen.getByText('Untitled Canvas')).toBeInTheDocument() + }) + + it('recovers to a blank authenticated canvas after a guest-to-login transition with no sessions', async () => { + authState = { + user: null, + isAuthenticated: false, + isSessionReady: true, + } + + const view = render( + + + , + ) + + await screen.findByTestId('canvas-workspace') + + authState = { + user: { id: 1, email: 'tester@example.com', isEmailVerified: true, displayName: 'Tester' }, + isAuthenticated: true, + isSessionReady: true, + } + + view.rerender( + + + , + ) + + await screen.findByTestId('canvas-workspace') + + await waitFor(() => { + expect(chatSessionService.getChatSessions).toHaveBeenCalled() + }) + expect(screen.getByText('Untitled Canvas')).toBeInTheDocument() + }) + it('boots a blank authenticated canvas and names it from the first prompt', async () => { const user = userEvent.setup() const session = { @@ -358,6 +543,40 @@ describe('IndexPage authenticated canvas bootstrap', () => { expect(chatSessionService.createChatSession).not.toHaveBeenCalled() }) + it('uses LM Studio for the blank authenticated canvas when it is the only configured provider', async () => { + const user = userEvent.setup() + const nextState = createState(101, 201, 'lmstudio') + + vi.mocked(llmService.getProviders).mockResolvedValue({ + defaultProvider: 'lmstudio', + providers: [ + { provider: 'lmstudio', configured: true, maskedLast4: '1234', isDefault: true, lastTestedAt: null, updatedAt: null }, + ], + }) + vi.mocked(llmService.getProviderModels).mockResolvedValue({ + provider: 'lmstudio', + models: [{ id: 'qwen2.5-0.5b-instruct-mlx', displayName: 'Qwen2.5 0.5B Instruct MLX' }], + }) + vi.mocked(chatSessionService.createChatSession).mockResolvedValue(nextState) + + render( + + + , + ) + + await screen.findByTestId('canvas-workspace') + await user.click(screen.getByTestId('send-root-message')) + + await waitFor(() => { + expect(chatSessionService.createChatSession).toHaveBeenCalledWith({ + content: 'Build the first idea', + providerOverride: 'lmstudio', + modelOverride: 'local-model', + }) + }) + }) + it('loads model options from all configured providers even when the current graph uses one provider', async () => { const user = userEvent.setup() const session = { @@ -420,4 +639,59 @@ describe('IndexPage authenticated canvas bootstrap', () => { ) }) }) + + it('preserves pending node edits when syncing a server graph refresh', () => { + const graph = createGraph({ + id: 301, + title: 'Existing session', + createdAt: '2026-04-13T00:00:00.000Z', + updatedAt: '2026-04-13T00:00:00.000Z', + }, 401) + const detail = createNodeDetail(301, 401) + + const synced = syncNodeDetails(graph, { + 401: detail, + }, { + 401: { + content: 'Draft note', + positionX: 111, + positionY: 222, + }, + }) + + expect(synced[401].content).toBe('Draft note') + expect(synced[401].positionX).toBe(111) + expect(synced[401].positionY).toBe(222) + }) + + it('retries loading authenticated sessions after a transient backend error', async () => { + const session = { + id: 401, + title: 'Retry session', + createdAt: '2026-04-13T00:00:00.000Z', + updatedAt: '2026-04-13T00:00:00.000Z', + } satisfies ChatSessionSummary + + vi.mocked(chatSessionService.getChatSessions) + .mockRejectedValueOnce(new AxiosError('Network Error')) + .mockResolvedValueOnce([session]) + + render( + + + , + ) + + await waitFor(() => { + expect(chatSessionService.getChatSessions).toHaveBeenCalledTimes(1) + }) + + await waitFor(() => { + expect(chatSessionService.getChatSessions).toHaveBeenCalledTimes(2) + }, { timeout: 4000 }) + + expect(sidebarMock.latestProps?.sessions).toEqual([ + { id: '401', title: 'Retry session' }, + ]) + }) }) diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index 0eda0b3..b0797ce 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -1,4 +1,5 @@ import { startTransition, useEffect, useMemo, useRef, useState } from 'react' +import { AxiosError } from 'axios' import { LogOut, Settings2 } from 'lucide-react' import { useLocation, useNavigate } from 'react-router-dom' import toast from 'react-hot-toast' @@ -16,6 +17,7 @@ import type { ChatSessionState, ChatSessionSummary, LlmProviderId, + NodeContextUsage, UpdateChatNodeInput, } from '@/types' import type { @@ -85,6 +87,7 @@ interface GuestCanvasNode { expandedHeight: number | null preferredProvider: LlmProviderId | null preferredModel: string | null + contextUsage?: NodeContextUsage | null createdAt: string updatedAt: string messages: GuestCanvasMessage[] @@ -106,6 +109,21 @@ interface StoredGuestState { guestPreviewUsed: boolean } +type PendingNodePatch = Partial> + function createId(prefix: string): string { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return `${prefix}-${crypto.randomUUID()}` @@ -200,21 +218,36 @@ function mergeSummaryIntoDetail(detail: ChatCanvasNodeDetail, summary: ChatCanva expandedHeight: summary.expandedHeight, preferredProvider: summary.preferredProvider, preferredModel: summary.preferredModel, + contextUsage: summary.contextUsage ?? detail.contextUsage, createdAt: summary.createdAt, updatedAt: summary.updatedAt, } } -function syncNodeDetails( +function applyPendingPatchToDetail(detail: ChatCanvasNodeDetail, patch?: PendingNodePatch): ChatCanvasNodeDetail { + if (!patch) + return detail + + const next = { ...detail, ...patch } + if ('content' in patch) + next.preview = buildPreview(patch.content ?? '') + + return next +} + +export function syncNodeDetails( graph: ChatSessionGraph, current: Record, + pendingPatches: Record = {}, ): Record { const next: Record = {} for (const summary of graph.nodes) { const existing = current[summary.id] - if (existing) - next[summary.id] = mergeSummaryIntoDetail(existing, summary) + if (existing) { + const merged = mergeSummaryIntoDetail(existing, summary) + next[summary.id] = applyPendingPatchToDetail(merged, pendingPatches[summary.id]) + } } return next @@ -228,23 +261,41 @@ function buildNodeDetailMap(graph: ChatSessionGraph, details: ChatCanvasNodeDeta return syncNodeDetails(graph, next) } -function resolveNodeSelection(node: { preferredProvider: LlmProviderId | null; preferredModel: string | null }): { provider: LlmProviderId; model: string } { - if (node.preferredProvider && node.preferredModel) +function isRecoverableSessionLoadError(error: unknown): boolean { + if (!(error instanceof AxiosError)) + return false + + if (!error.response) + return true + + return [502, 503, 504].includes(error.response.status) +} + +function resolveNodeSelection( + node: { preferredProvider: LlmProviderId | null; preferredModel: string | null }, + availableProviders: LlmProviderId[] = [], +): { provider: LlmProviderId; model: string } { + if (node.preferredProvider && node.preferredModel && (availableProviders.length === 0 || availableProviders.includes(node.preferredProvider))) return { provider: node.preferredProvider, model: node.preferredModel } if (node.preferredModel) { const provider = inferProviderFromModel(node.preferredModel) - return { provider, model: node.preferredModel } + if (availableProviders.length === 0 || availableProviders.includes(provider)) + return { provider, model: node.preferredModel } } - if (node.preferredProvider) { + if (node.preferredProvider && (availableProviders.length === 0 || availableProviders.includes(node.preferredProvider))) { return { provider: node.preferredProvider, model: defaultModelForProvider(node.preferredProvider), } } - return DEFAULT_CANVAS_MODEL_SELECTION + const provider = availableProviders[0] ?? DEFAULT_CANVAS_MODEL_SELECTION.provider + return { + provider, + model: defaultModelForProvider(provider), + } } function createGuestMessage( @@ -466,20 +517,13 @@ function hasMeaningfulGuestState(canvases: GuestCanvas[]): boolean { if (!firstCanvas) return false - if (firstCanvas.title !== DEFAULT_SESSION_TITLE) - return true - if (firstCanvas.nodes.length > 1) return true const [rootNode] = firstCanvas.nodes return Boolean( rootNode - && ( - rootNode.content.trim() - || rootNode.messages.length > 0 - || rootNode.title !== ROOT_NODE_TITLE - ), + && (rootNode.content.trim() || rootNode.messages.length > 0), ) } @@ -618,7 +662,7 @@ function createLoadingNoteNode( export default function IndexPage() { const navigate = useNavigate() const location = useLocation() - const { user, logout, isAuthenticated } = useAuth() + const { user, logout, isAuthenticated, isSessionReady } = useAuth() const { theme, setTheme } = useAppTheme() const initialGuestStateRef = useRef(null) if (!initialGuestStateRef.current) @@ -647,11 +691,17 @@ export default function IndexPage() { const [pageError, setPageError] = useState('') const [pendingNodeIds, setPendingNodeIds] = useState([]) const [chatReplyPendingNodeIds, setChatReplyPendingNodeIds] = useState([]) + const [initialAuthLoadRetryNonce, setInitialAuthLoadRetryNonce] = useState(0) const notePatchTimersRef = useRef>({}) const notePatchPayloadsRef = useRef>({}) + const pendingNodePatchesRef = useRef>({}) + const nodeDetailsRef = useRef>({}) + const initialAuthLoadRetryTimerRef = useRef(null) + const initialAuthLoadRequestIdRef = useRef(0) const nextTemporaryNodeIdRef = useRef(-1) const optimisticAuthMessageIdRef = useRef(-500_000_000) const guestImportAttemptedRef = useRef(false) + const initialAuthSessionsLoadedRef = useRef(false) const pendingNodeSet = useMemo(() => new Set(pendingNodeIds), [pendingNodeIds]) const chatReplyPendingSet = useMemo(() => new Set(chatReplyPendingNodeIds), [chatReplyPendingNodeIds]) @@ -668,6 +718,16 @@ export default function IndexPage() { [guestActiveCanvasId, guestCanvases], ) + useEffect(() => { + nodeDetailsRef.current = nodeDetails + }, [nodeDetails]) + + function getAvailableProvidersForSelection(): LlmProviderId[] { + return configuredProviders.length > 0 + ? configuredProviders + : graph?.availableProviders ?? [] + } + useEffect(() => { if (isAuthenticated) return @@ -689,6 +749,7 @@ export default function IndexPage() { useEffect(() => { if (!isAuthenticated) { guestImportAttemptedRef.current = false + initialAuthSessionsLoadedRef.current = false setSessions([]) setActiveSessionId(null) setGraph(null) @@ -741,7 +802,7 @@ export default function IndexPage() { }, [isAuthenticated]) useEffect(() => { - if (!isAuthenticated) + if (!isAuthenticated || !isSessionReady) return const providersToLoad = configuredProviders.length > 0 @@ -777,13 +838,67 @@ export default function IndexPage() { return () => { active = false } - }, [configuredProviders, graph, isAuthenticated]) + }, [configuredProviders, graph, isAuthenticated, isSessionReady]) useEffect(() => () => { for (const timer of Object.values(notePatchTimersRef.current)) window.clearTimeout(timer) }, []) + function queuePendingNodePatch(nodeId: number, patch: PendingNodePatch) { + pendingNodePatchesRef.current[nodeId] = { + ...pendingNodePatchesRef.current[nodeId], + ...patch, + } + } + + function clearPendingNodePatch(nodeId: number, patch: PendingNodePatch) { + const currentPatch = pendingNodePatchesRef.current[nodeId] + if (!currentPatch) + return + + const currentDetail = nodeDetailsRef.current[nodeId] + const nextPatch: PendingNodePatch = { ...currentPatch } + + for (const [key, value] of Object.entries(patch) as Array<[keyof PendingNodePatch, PendingNodePatch[keyof PendingNodePatch]]>) { + if (!Object.prototype.hasOwnProperty.call(nextPatch, key)) + continue + + if (currentDetail && Object.is(currentDetail[key], value)) + delete nextPatch[key] + } + + if (Object.keys(nextPatch).length === 0) { + delete pendingNodePatchesRef.current[nodeId] + return + } + + pendingNodePatchesRef.current[nodeId] = nextPatch + } + + function commitPendingNotePatch(sessionId: number, nodeId: number) { + const timer = notePatchTimersRef.current[nodeId] + if (timer) + window.clearTimeout(timer) + + const payload = notePatchPayloadsRef.current[nodeId] + if (!payload) + return + + delete notePatchTimersRef.current[nodeId] + delete notePatchPayloadsRef.current[nodeId] + + void (async () => { + try { + const response = await updateChatNode(sessionId, nodeId, payload) + applyStateResponse(response) + clearPendingNodePatch(nodeId, payload) + } catch (error) { + toast.error(getLlmError(error).message) + } + })() + } + function markNodePending(nodeId: string, isPending: boolean) { setPendingNodeIds(current => { if (isPending) @@ -865,7 +980,7 @@ export default function IndexPage() { function applyGraphOnly(nextGraph: ChatSessionGraph) { setGraph(nextGraph) setSessions(current => upsertSession(current, nextGraph.session)) - setNodeDetails(current => syncNodeDetails(nextGraph, current)) + setNodeDetails(current => syncNodeDetails(nextGraph, current, pendingNodePatchesRef.current)) setSelectedNodeId(current => ( current !== null && nextGraph.nodes.some(node => String(node.id) === current) ? current @@ -879,8 +994,8 @@ export default function IndexPage() { setGraph(response.graph) setSessions(current => upsertSession(current, response.graph.session)) setNodeDetails(current => { - const next = syncNodeDetails(response.graph, current) - next[response.node.nodeId] = response.node + const next = syncNodeDetails(response.graph, current, pendingNodePatchesRef.current) + next[response.node.nodeId] = applyPendingPatchToDetail(response.node, pendingNodePatchesRef.current[response.node.nodeId]) return next }) setSelectedNodeId(String(response.node.nodeId)) @@ -965,6 +1080,7 @@ export default function IndexPage() { ...notePatchPayloadsRef.current[nodeId], ...patch, } + queuePendingNodePatch(nodeId, patch) const currentTimer = notePatchTimersRef.current[nodeId] if (currentTimer) @@ -972,15 +1088,10 @@ export default function IndexPage() { notePatchTimersRef.current[nodeId] = window.setTimeout(async () => { const payload = notePatchPayloadsRef.current[nodeId] - delete notePatchPayloadsRef.current[nodeId] - delete notePatchTimersRef.current[nodeId] + if (!payload) + return - try { - const response = await updateChatNode(sessionId, nodeId, payload) - applyStateResponse(response) - } catch (error) { - toast.error(getLlmError(error).message) - } + commitPendingNotePatch(sessionId, nodeId) }, 500) } @@ -997,13 +1108,11 @@ export default function IndexPage() { const blankGraph = createEmptyAuthenticatedGraph() const blankNode = createEmptyAuthenticatedNodeDetail() - startTransition(() => { - setGraph(blankGraph) - setNodeDetails({ [blankNode.nodeId]: blankNode }) - setSelectedNodeId(String(blankNode.nodeId)) - setActiveSessionId(null) - setPageError('') - }) + setGraph(blankGraph) + setNodeDetails({ [blankNode.nodeId]: blankNode }) + setSelectedNodeId(String(blankNode.nodeId)) + setActiveSessionId(null) + setPageError('') } async function bootstrapEmptyAuthenticatedSession(nodeId: string, text: string): Promise { @@ -1018,7 +1127,7 @@ export default function IndexPage() { if (!nodeSource) return false - const selection = resolveNodeSelection(nodeSource) + const selection = resolveNodeSelection(nodeSource, getAvailableProvidersForSelection()) let succeeded = false appendOptimisticAuthUserMessage(numericNodeId, text.trim(), null) @@ -1046,7 +1155,7 @@ export default function IndexPage() { } useEffect(() => { - if (!isAuthenticated || guestImportAttemptedRef.current) + if (!isAuthenticated || !isSessionReady || guestImportAttemptedRef.current) return if (!hasMeaningfulGuestState(guestCanvases)) @@ -1094,16 +1203,14 @@ export default function IndexPage() { return () => { active = false } - }, [guestActiveCanvasId, guestCanvases, isAuthenticated]) + }, [guestActiveCanvasId, guestCanvases, isAuthenticated, isSessionReady]) useEffect(() => { - if (!isAuthenticated || importingGuest) + if (!isAuthenticated || !isSessionReady || importingGuest || initialAuthSessionsLoadedRef.current) return - if (graph?.session.id === EMPTY_AUTH_SESSION_ID && activeSessionId === null && sessions.length > 0) - return - - let isActive = true + const requestId = ++initialAuthLoadRequestIdRef.current + let cancelled = false async function loadInitialData() { setLoadingInitial(true) @@ -1111,7 +1218,7 @@ export default function IndexPage() { try { const sessionsResponse = await getChatSessions() - if (!isActive) + if (cancelled || initialAuthLoadRequestIdRef.current !== requestId) return const sortedSessions = sortSessions(sessionsResponse) @@ -1130,13 +1237,26 @@ export default function IndexPage() { ) { setActiveSessionId(sortedSessions[0].id) } + + initialAuthSessionsLoadedRef.current = true } catch (error) { - if (!isActive) + if (cancelled || initialAuthLoadRequestIdRef.current !== requestId) return - setPageError(getLlmError(error).message) + if (isRecoverableSessionLoadError(error)) { + if (initialAuthLoadRetryTimerRef.current !== null) + window.clearTimeout(initialAuthLoadRetryTimerRef.current) + + initialAuthLoadRetryTimerRef.current = window.setTimeout(() => { + initialAuthLoadRetryTimerRef.current = null + if (!cancelled && initialAuthLoadRequestIdRef.current === requestId) + setInitialAuthLoadRetryNonce(current => current + 1) + }, 1500) + } else { + setPageError(getLlmError(error).message) + } } finally { - if (isActive) + if (!cancelled && initialAuthLoadRequestIdRef.current === requestId) setLoadingInitial(false) } } @@ -1144,12 +1264,16 @@ export default function IndexPage() { void loadInitialData() return () => { - isActive = false + cancelled = true + if (initialAuthLoadRetryTimerRef.current !== null) { + window.clearTimeout(initialAuthLoadRetryTimerRef.current) + initialAuthLoadRetryTimerRef.current = null + } } - }, [activeSessionId, graph?.session.id, importingGuest, isAuthenticated, sessions.length]) + }, [activeSessionId, graph?.session.id, importingGuest, isAuthenticated, isSessionReady, sessions.length, initialAuthLoadRetryNonce]) useEffect(() => { - if (!isAuthenticated || !activeSessionId) + if (!isAuthenticated || !isSessionReady || !activeSessionId) return if (graph?.session.id === activeSessionId && Object.keys(nodeDetails).length > 0) @@ -1206,7 +1330,31 @@ export default function IndexPage() { return () => { isActive = false } - }, [activeSessionId, graph?.session.id, isAuthenticated, nodeDetails, sessions]) + }, [activeSessionId, graph?.session.id, isAuthenticated, isSessionReady, nodeDetails, sessions]) + + useEffect(() => { + if (!isAuthenticated || !isSessionReady || importingGuest) + return + + if (loadingInitial || loadingCanvas || creatingSession || pageError) + return + + if (graph || activeSessionId !== null || sessions.length > 0) + return + + createBlankAuthenticatedCanvas() + }, [ + activeSessionId, + creatingSession, + graph, + importingGuest, + isAuthenticated, + isSessionReady, + loadingCanvas, + loadingInitial, + pageError, + sessions.length, + ]) function openAuthModal(mode: AuthModalMode) { navigate(mode === 'login' ? '/login' : '/register') @@ -1332,7 +1480,7 @@ export default function IndexPage() { if (!parentSource) return - const modelSelection = resolveNodeSelection(parentSource) + const modelSelection = resolveNodeSelection(parentSource, getAvailableProvidersForSelection()) await runNodeAction(parentNodeId, async () => { try { @@ -1360,7 +1508,7 @@ export default function IndexPage() { return const now = new Date().toISOString() - const selection = resolveNodeSelection(parentNode) + const selection = resolveNodeSelection(parentNode, getAvailableProvidersForSelection()) const position = resolveGuestNodePosition(parentNode, nodeType, activeGuestCanvas.nodes) const content = nodeType === 'note' ? seedContent ?? '' : '' const width = nodeType === 'note' ? NOTE_EXPANDED_WIDTH : 600 @@ -1406,10 +1554,12 @@ export default function IndexPage() { return updateNodeLocally(numericNodeId, { title }) + queuePendingNodePatch(numericNodeId, { title }) try { const response = await updateChatNode(activeSessionId, numericNodeId, { title }) applyStateResponse(response) + clearPendingNodePatch(numericNodeId, { title }) } catch (error) { toast.error(getLlmError(error).message) } @@ -1433,6 +1583,7 @@ export default function IndexPage() { return updateNodeLocally(numericNodeId, { content }) + queuePendingNodePatch(numericNodeId, { content }) scheduleNotePatch(activeSessionId, numericNodeId, { content }) return } @@ -1458,6 +1609,10 @@ export default function IndexPage() { preferredProvider: provider, preferredModel: model, }) + queuePendingNodePatch(numericNodeId, { + preferredProvider: provider, + preferredModel: model, + }) try { const response = await updateChatNode(activeSessionId, numericNodeId, { @@ -1465,6 +1620,10 @@ export default function IndexPage() { preferredModel: model, }) applyStateResponse(response) + clearPendingNodePatch(numericNodeId, { + preferredProvider: provider, + preferredModel: model, + }) } catch (error) { toast.error(getLlmError(error).message) } @@ -1496,10 +1655,12 @@ export default function IndexPage() { } updateNodeLocally(numericNodeId, nextPatch) + queuePendingNodePatch(numericNodeId, nextPatch) try { const response = await updateChatNode(activeSessionId, numericNodeId, nextPatch) applyStateResponse(response) + clearPendingNodePatch(numericNodeId, nextPatch) } catch (error) { toast.error(getLlmError(error).message) } @@ -1545,10 +1706,12 @@ export default function IndexPage() { } updateNodeLocally(numericNodeId, nextPatch) + queuePendingNodePatch(numericNodeId, nextPatch) try { const response = await updateChatNode(activeSessionId, numericNodeId, nextPatch) applyStateResponse(response) + clearPendingNodePatch(numericNodeId, nextPatch) } catch (error) { toast.error(getLlmError(error).message) } @@ -1583,6 +1746,7 @@ export default function IndexPage() { return updateNodeLocally(numericNodeId, { positionX, positionY }) + queuePendingNodePatch(numericNodeId, { positionX, positionY }) try { const response = await saveChatLayout( @@ -1591,6 +1755,7 @@ export default function IndexPage() { extractNumericId(selectedNodeId) ?? graph.activeNodeId, ) applyGraphOnly(response) + clearPendingNodePatch(numericNodeId, { positionX, positionY }) } catch (error) { toast.error(getLlmError(error).message) } @@ -1621,7 +1786,7 @@ export default function IndexPage() { if (!nodeSource) return false - const selection = resolveNodeSelection(nodeSource) + const selection = resolveNodeSelection(nodeSource, getAvailableProvidersForSelection()) let succeeded = false appendOptimisticAuthUserMessage(numericNodeId, text.trim(), imageUrl ?? null) @@ -1658,7 +1823,7 @@ export default function IndexPage() { if (!guestNode) return false - const selection = resolveNodeSelection(guestNode) + const selection = resolveNodeSelection(guestNode, []) const existingMessages = guestNode.messages const userMessage = createGuestMessage('user', text, imageUrl, null, null) updateGuestNode(nodeId, node => ({ @@ -1722,7 +1887,7 @@ export default function IndexPage() { if (!nodeSource) return false - const selection = resolveNodeSelection(nodeSource) + const selection = resolveNodeSelection(nodeSource, getAvailableProvidersForSelection()) let succeeded = false await runNodeAction(nodeId, async () => { @@ -1909,7 +2074,7 @@ export default function IndexPage() { if (!nodeSource) return - const selection = resolveNodeSelection(nodeSource) + const selection = resolveNodeSelection(nodeSource, getAvailableProvidersForSelection()) const tempNodeId = allocateTemporaryNodeId() const position = resolveGuestNodePosition( { @@ -2019,7 +2184,7 @@ export default function IndexPage() { if (!sourceNode) return - const selection = resolveNodeSelection(sourceNode) + const selection = resolveNodeSelection(sourceNode, getAvailableProvidersForSelection()) const tempNodeId = createId('guest-node') const position = resolveGuestNodePosition(sourceNode, 'note', activeGuestCanvas.nodes) const now = new Date().toISOString() @@ -2201,7 +2366,10 @@ export default function IndexPage() { return graph.nodes.map(summary => { const detail = nodeDetails[summary.id] - const source = detail ? mergeSummaryIntoDetail(detail, summary) : summary + const pendingPatch = pendingNodePatchesRef.current[summary.id] + const source = detail + ? applyPendingPatchToDetail(mergeSummaryIntoDetail(detail, summary), pendingPatch) + : summary const rawNodeId = String(summary.id) const summaryNote = graph.nodes.find(node => ( node.parentNodeId === summary.id @@ -2229,6 +2397,7 @@ export default function IndexPage() { messages: detail?.localMessages ?? [], preferredProvider: source.preferredProvider, preferredModel: source.preferredModel, + contextUsage: source.contextUsage, modelOptions: canvasModelOptions, canDelete: summary.parentNodeId !== null, showFloatingLabel: graph.nodes.length > 1, @@ -2246,6 +2415,10 @@ export default function IndexPage() { onUpdateContent: content => { handleUpdateNodeContent(rawNodeId, content) }, + onCommitContent: () => { + if (isAuthenticated) + commitPendingNotePatch(graph.session.id, summary.id) + }, onSendMessage: (text, image) => handleSendNodeMessage(rawNodeId, text, image), onSendImagePrompt: prompt => handleGenerateNodeImage(rawNodeId, prompt), onGenerateNote: () => { @@ -2301,6 +2474,7 @@ export default function IndexPage() { messages: node.messages, preferredProvider: node.preferredProvider, preferredModel: node.preferredModel, + contextUsage: node.contextUsage, modelOptions: canvasModelOptions, canDelete: node.parentNodeId !== null, showFloatingLabel: activeGuestCanvas.nodes.length > 1, @@ -2359,6 +2533,7 @@ export default function IndexPage() { handleMoveMessagesToBranch, isAuthenticated, nodeDetails, + pendingNodePatchesRef, chatReplyPendingSet, pendingNodeSet, selectedNodeId, @@ -2380,13 +2555,16 @@ export default function IndexPage() { const showWorkspace = isAuthenticated ? Boolean(graph) : Boolean(activeGuestCanvas) const showLoadingState = importingGuest || loadingInitial || loadingCanvas || creatingSession + const sidebarIsLoading = isAuthenticated + && loadingInitial + && !(sessions.length === 0 && graph?.session.id === EMPTY_AUTH_SESSION_ID) return (
{ if (isAuthenticated) { diff --git a/frontend/src/pages/SettingsPage.test.tsx b/frontend/src/pages/SettingsPage.test.tsx index db1f698..47b7abb 100644 --- a/frontend/src/pages/SettingsPage.test.tsx +++ b/frontend/src/pages/SettingsPage.test.tsx @@ -53,6 +53,7 @@ vi.mock('../context/AuthContext', async () => { useAuth: () => ({ user: { id: 1, email: 'test@example.com', isEmailVerified: true, displayName: 'Test' }, logout: mockLogout, + isSessionReady: true, }), } }) diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c100cd9..d19fc2b 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -56,11 +56,12 @@ const PROVIDERS: Array<{ label: string helper: string placeholder: string + inputType?: 'password' | 'text' }> = [ { id: 'openai', label: 'OpenAI', helper: 'GPT models via OpenAI API', placeholder: 'sk-...' }, { id: 'claude', label: 'Claude', helper: 'Anthropic Claude models', placeholder: 'sk-ant-...' }, { id: 'gemini', label: 'Gemini', helper: 'Google Gemini models', placeholder: 'AIza...' }, - { id: 'lmstudio', label: 'LM Studio', helper: 'Local OpenAI-compatible server at http://localhost:1234/v1', placeholder: 'lmstudio' }, + { id: 'lmstudio', label: 'LM Studio', helper: 'OpenAI-compatible local server. Paste http://127.0.0.1:1234 or your host-reachable URL.', placeholder: 'http://127.0.0.1:1234', inputType: 'text' }, ] const WORKSPACE_PREFS_STORAGE_KEY = 'coliee-settings-workspace-prefs' @@ -225,6 +226,17 @@ function formatAuthMethod(authProvider?: string | null): string { return authProvider } +function getProviderCredentialLabel(providerId: LlmProviderId): string { + return providerId === 'lmstudio' ? 'Saved URL' : 'Saved key' +} + +function getProviderActionLabel(providerId: LlmProviderId, action: 'save' | 'remove' | 'test', isConfigured: boolean): string { + if (providerId !== 'lmstudio') + return action === 'save' ? (isConfigured ? 'Update key' : 'Save key') : action === 'test' ? 'Test key' : 'Remove' + + return action === 'save' ? (isConfigured ? 'Update URL' : 'Save URL') : action === 'test' ? 'Test URL' : 'Remove' +} + function downloadJson(filename: string, data: unknown) { if (typeof window === 'undefined') return @@ -329,7 +341,7 @@ function NoticeIcon({ kind }: { kind: LocalStatus['kind'] }) { export default function SettingsPage() { const location = useLocation() const navigate = useNavigate() - const { user, logout } = useAuth() + const { user, logout, isSessionReady } = useAuth() const { theme, setTheme } = useAppTheme() const profileSectionRef = useRef(null) @@ -376,6 +388,9 @@ export default function SettingsPage() { const [dataNotice, setDataNotice] = useState(null) useEffect(() => { + if (!isSessionReady) + return + let isActive = true async function loadPage() { @@ -416,7 +431,7 @@ export default function SettingsPage() { return () => { isActive = false } - }, []) + }, [isSessionReady]) useEffect(() => { if (typeof IntersectionObserver === 'undefined') @@ -1399,7 +1414,7 @@ export default function SettingsPage() { refProp={providersSectionRef} eyebrow="Integrations" title="Provider / API configuration" - description="Save keys, test them, and select the default provider that the app should prefer." + description="Save provider URLs or keys, test them, and select the default provider that the app should prefer." chips={( <> {configuredProvidersCount} configured @@ -1460,7 +1475,7 @@ export default function SettingsPage() {
- Saved key + {getProviderCredentialLabel(provider.id)} {summary?.maskedLast4 ?? 'None'}
@@ -1496,7 +1511,7 @@ export default function SettingsPage() {
setProviderInput(provider.id, event.target.value)} placeholder={provider.placeholder} @@ -1511,7 +1526,7 @@ export default function SettingsPage() { onClick={() => handleProviderSave(provider.id)} disabled={isBusy} > - {providerActions[provider.id] === 'save' ? 'Saving…' : (isConfigured ? 'Update key' : 'Save key')} + {providerActions[provider.id] === 'save' ? 'Saving…' : getProviderActionLabel(provider.id, 'save', isConfigured)}